feat(banking C): credit cards + monthly statements
Final banking step. User-managed: each month, paste your bank statement totals into the form. We don't auto-compute interest — bank's number is authoritative. Schema: - credit_cards: bankName, cardName, last4, creditLimit, statementDay, dueDay, interestRate (monthly nominal %), bankAccountId (optional FK, payments expense from this account), archived, notes. - credit_card_statements: cardId, period (YYYY-MM), statementDate, dueDate, totalDebt, minimumPayment, paidAmount, status (pending/partial/paid/overdue), financeEntryId (link to last payment). - Indexes on (tenantId, archived) for cards and (tenantId, cardId) + (tenantId, status, dueDate) for statements. Server (lib/appwrite/credit-card-actions.ts): - create/update/archive(toggle)/delete for cards. Card delete cascades through statements + their finance_entries. - createStatementAction: computes status from dueDate + paidAmount. - payStatementAction: partial-payment friendly. Creates a single finance_entry expense for the pay amount, capped at remaining balance, bankAccountId carried from the card. Recomputes status: paid (full), partial (some + pre-due), overdue (past due with anything < total). - deleteStatementAction: removes linked finance_entry too. - All audit-logged. UI (/finance/cards): - 3 stat cards: aktif kart sayısı, bekleyen toplam, vadesi geçmiş ekstre. - Per-card panel: bank/name/last4, limit + statement/due day + monthly interest, current outstanding. Statement table inside card with status badges, Öde button (opens partial-payment dialog), delete button. - CardFormSheet for card CRUD, StatementFormSheet for statement creation with default period/dates derived from card's statementDay/dueDay. Sidebar Finans submenu now functional: Banka hesapları → /finance/banks, Krediler → /finance/loans, Kredi kartları → /finance/cards.
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
createCreditCardAction,
|
||||
updateCreditCardAction,
|
||||
} from "@/lib/appwrite/credit-card-actions";
|
||||
import { initialCreditCardState } from "@/lib/appwrite/credit-card-types";
|
||||
|
||||
import type { BankAccountOption, CreditCardRow } from "./types";
|
||||
|
||||
const NONE = "__none__";
|
||||
|
||||
export function CardFormSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
card,
|
||||
bankAccounts,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
card?: CreditCardRow | null;
|
||||
bankAccounts: BankAccountOption[];
|
||||
}) {
|
||||
const isEdit = Boolean(card);
|
||||
const action = isEdit ? updateCreditCardAction : createCreditCardAction;
|
||||
const [state, formAction, isPending] = useActionState(action, initialCreditCardState);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success(isEdit ? "Kart güncellendi." : "Kart eklendi.");
|
||||
onOpenChange(false);
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
|
||||
<SheetHeader className="border-b px-6 py-4">
|
||||
<SheetTitle>{isEdit ? "Kartı düzenle" : "Yeni kredi kartı"}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Hesap kesim ve son ödeme günleri her ay otomatik kullanılır. Ekstreler kart başına manuel girilir.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form
|
||||
action={(fd) => {
|
||||
if (fd.get("bankAccountId") === NONE) fd.set("bankAccountId", "");
|
||||
formAction(fd);
|
||||
}}
|
||||
className="flex flex-1 flex-col"
|
||||
>
|
||||
{isEdit && card && <input type="hidden" name="id" value={card.id} />}
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="bankName">Banka *</Label>
|
||||
<Input
|
||||
id="bankName"
|
||||
name="bankName"
|
||||
defaultValue={card?.bankName ?? ""}
|
||||
required
|
||||
placeholder="Garanti BBVA"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cardName">Kart adı *</Label>
|
||||
<Input
|
||||
id="cardName"
|
||||
name="cardName"
|
||||
defaultValue={card?.cardName ?? ""}
|
||||
required
|
||||
placeholder="Bonus / Maximum / World"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="last4">Son 4 hane</Label>
|
||||
<Input
|
||||
id="last4"
|
||||
name="last4"
|
||||
defaultValue={card?.last4 ?? ""}
|
||||
maxLength={4}
|
||||
inputMode="numeric"
|
||||
placeholder="1234"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="creditLimit">Kredi limiti (₺)</Label>
|
||||
<Input
|
||||
id="creditLimit"
|
||||
name="creditLimit"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={card?.creditLimit ?? 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="statementDay">Hesap kesim günü</Label>
|
||||
<Input
|
||||
id="statementDay"
|
||||
name="statementDay"
|
||||
type="number"
|
||||
min="1"
|
||||
max="28"
|
||||
defaultValue={card?.statementDay ?? 1}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dueDay">Son ödeme günü</Label>
|
||||
<Input
|
||||
id="dueDay"
|
||||
name="dueDay"
|
||||
type="number"
|
||||
min="1"
|
||||
max="28"
|
||||
defaultValue={card?.dueDay ?? 10}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="interestRate">Aylık faiz %</Label>
|
||||
<Input
|
||||
id="interestRate"
|
||||
name="interestRate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={card?.interestRate ?? 4.25}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="bankAccountId">Bağlı hesap</Label>
|
||||
<Select
|
||||
name="bankAccountId"
|
||||
defaultValue={card?.bankAccountId || NONE}
|
||||
disabled={bankAccounts.length === 0}
|
||||
>
|
||||
<SelectTrigger id="bankAccountId">
|
||||
<SelectValue placeholder={bankAccounts.length === 0 ? "Önce hesap ekleyin" : "Yok"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>Yok</SelectItem>
|
||||
{bankAccounts.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Ekstre ödemeleri seçilen hesaba expense olarak yazılır.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="notes">Notlar</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows={3}
|
||||
defaultValue={card?.notes ?? ""}
|
||||
placeholder="Sadakat puanı, kampanya, vb."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
Vazgeç
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Kaydediliyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="size-4" />
|
||||
{isEdit ? "Güncelle" : "Kaydet"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
Check,
|
||||
CreditCard as CreditCardIcon,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
archiveCreditCardAction,
|
||||
deleteCreditCardAction,
|
||||
deleteStatementAction,
|
||||
payStatementAction,
|
||||
} from "@/lib/appwrite/credit-card-actions";
|
||||
import { formatDate, formatTRY } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CardFormSheet } from "./card-form-sheet";
|
||||
import { StatementFormSheet } from "./statement-form-sheet";
|
||||
import {
|
||||
type BankAccountOption,
|
||||
type CreditCardRow,
|
||||
STATEMENT_STATUS_COLOR,
|
||||
STATEMENT_STATUS_LABEL,
|
||||
type StatementRow,
|
||||
} from "./types";
|
||||
|
||||
type Props = {
|
||||
cards: CreditCardRow[];
|
||||
statements: StatementRow[];
|
||||
bankAccounts: BankAccountOption[];
|
||||
};
|
||||
|
||||
export function CardsClient({ cards, statements, bankAccounts }: Props) {
|
||||
const [cardFormOpen, setCardFormOpen] = useState(false);
|
||||
const [editingCard, setEditingCard] = useState<CreditCardRow | null>(null);
|
||||
const [deletingCard, setDeletingCard] = useState<CreditCardRow | null>(null);
|
||||
const [stmtFormOpen, setStmtFormOpen] = useState(false);
|
||||
const [stmtCard, setStmtCard] = useState<CreditCardRow | null>(null);
|
||||
const [payDialog, setPayDialog] = useState<StatementRow | null>(null);
|
||||
const [payAmount, setPayAmount] = useState("");
|
||||
const [deletingStmt, setDeletingStmt] = useState<StatementRow | null>(null);
|
||||
const [busy, startTransition] = useTransition();
|
||||
|
||||
const active = cards.filter((c) => !c.archived);
|
||||
const archived = cards.filter((c) => c.archived);
|
||||
|
||||
const stmtsByCard = new Map<string, StatementRow[]>();
|
||||
for (const s of statements) {
|
||||
const arr = stmtsByCard.get(s.cardId) ?? [];
|
||||
arr.push(s);
|
||||
stmtsByCard.set(s.cardId, arr);
|
||||
}
|
||||
|
||||
const totalOutstanding = statements
|
||||
.filter((s) => s.status !== "paid")
|
||||
.reduce((sum, s) => sum + (s.totalDebt - s.paidAmount), 0);
|
||||
|
||||
const overdueCount = statements.filter((s) => s.status === "overdue").length;
|
||||
|
||||
const toggleArchive = (c: CreditCardRow) => {
|
||||
startTransition(async () => {
|
||||
const fd = new FormData();
|
||||
fd.set("id", c.id);
|
||||
const r = await archiveCreditCardAction(fd);
|
||||
if (r.ok) toast.success(c.archived ? "Kart geri açıldı." : "Kart arşivlendi.");
|
||||
else toast.error(r.error ?? "İşlem başarısız.");
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteCard = () => {
|
||||
if (!deletingCard) return;
|
||||
startTransition(async () => {
|
||||
const fd = new FormData();
|
||||
fd.set("id", deletingCard.id);
|
||||
const r = await deleteCreditCardAction(fd);
|
||||
if (r.ok) {
|
||||
toast.success("Kart silindi.");
|
||||
setDeletingCard(null);
|
||||
} else {
|
||||
toast.error(r.error ?? "Silme başarısız.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handlePay = () => {
|
||||
if (!payDialog) return;
|
||||
startTransition(async () => {
|
||||
const fd = new FormData();
|
||||
fd.set("id", payDialog.id);
|
||||
if (payAmount.trim()) fd.set("amount", payAmount);
|
||||
const r = await payStatementAction(fd);
|
||||
if (r.ok) {
|
||||
toast.success("Ödeme kaydedildi.");
|
||||
setPayDialog(null);
|
||||
setPayAmount("");
|
||||
} else {
|
||||
toast.error(r.error ?? "Ödeme başarısız.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteStmt = () => {
|
||||
if (!deletingStmt) return;
|
||||
startTransition(async () => {
|
||||
const fd = new FormData();
|
||||
fd.set("id", deletingStmt.id);
|
||||
const r = await deleteStatementAction(fd);
|
||||
if (r.ok) {
|
||||
toast.success("Ekstre silindi.");
|
||||
setDeletingStmt(null);
|
||||
} else {
|
||||
toast.error(r.error ?? "Silme başarısız.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs">Aktif kart</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{active.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs">Bekleyen toplam borç</p>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums text-amber-600 dark:text-amber-400">
|
||||
{formatTRY(totalOutstanding)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs">Vadesi geçmiş ekstre</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 text-2xl font-semibold",
|
||||
overdueCount > 0 && "text-red-600 dark:text-red-400",
|
||||
)}
|
||||
>
|
||||
{overdueCount}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStmtCard(null);
|
||||
setStmtFormOpen(true);
|
||||
}}
|
||||
disabled={cards.length === 0}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Yeni ekstre
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingCard(null);
|
||||
setCardFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Yeni kart
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{cards.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-2 py-12 text-center">
|
||||
<CreditCardIcon className="text-muted-foreground size-8" />
|
||||
<p className="text-sm">Henüz kredi kartı eklenmemiş.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setCardFormOpen(true)}>
|
||||
<Plus className="size-3.5" />
|
||||
İlk kartı ekle
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{active.map((c) => {
|
||||
const items = stmtsByCard.get(c.id) ?? [];
|
||||
const totalDebt = items
|
||||
.filter((s) => s.status !== "paid")
|
||||
.reduce((sum, s) => sum + (s.totalDebt - s.paidAmount), 0);
|
||||
return (
|
||||
<Card key={c.id}>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CreditCardIcon className="text-muted-foreground size-4" />
|
||||
<h3 className="font-semibold">{c.bankName}</h3>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span>{c.cardName}</span>
|
||||
{c.last4 && (
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
**{c.last4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-xs">
|
||||
<span>Limit {formatTRY(c.creditLimit)}</span>
|
||||
<span>Kesim: ayın {c.statementDay}'i</span>
|
||||
<span>Vade: ayın {c.dueDay}'i</span>
|
||||
<span>Aylık faiz: %{c.interestRate}</span>
|
||||
</div>
|
||||
{c.bankAccountLabel && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Hesap: {c.bankAccountLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-right">
|
||||
<p className="text-muted-foreground text-xs">Bekleyen</p>
|
||||
<p className="font-semibold tabular-nums">{formatTRY(totalDebt)}</p>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8" disabled={busy}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setStmtCard(c);
|
||||
setStmtFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Ekstre ekle
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setEditingCard(c);
|
||||
setCardFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
Düzenle
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => toggleArchive(c)}>
|
||||
<Archive className="size-3.5" />
|
||||
Arşivle
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setDeletingCard(c)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Sil
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="border-t pt-3">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Dönem</TableHead>
|
||||
<TableHead>Son ödeme</TableHead>
|
||||
<TableHead className="text-right">Toplam</TableHead>
|
||||
<TableHead className="text-right">Asgari</TableHead>
|
||||
<TableHead className="text-right">Ödenen</TableHead>
|
||||
<TableHead>Durum</TableHead>
|
||||
<TableHead className="text-right">İşlem</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((s) => {
|
||||
const remaining = s.totalDebt - s.paidAmount;
|
||||
return (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="font-mono text-sm">{s.period}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{formatDate(s.dueDate)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatTRY(s.totalDebt)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatTRY(s.minimumPayment)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatTRY(s.paidAmount)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("border-0", STATEMENT_STATUS_COLOR[s.status])}
|
||||
>
|
||||
{STATEMENT_STATUS_LABEL[s.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{remaining > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPayDialog(s);
|
||||
setPayAmount(remaining.toFixed(2));
|
||||
}}
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
Öde
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => setDeletingStmt(s)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{archived.length > 0 && (
|
||||
<details className="group">
|
||||
<summary className="text-muted-foreground hover:text-foreground cursor-pointer text-sm">
|
||||
Arşivlenmiş kartlar ({archived.length})
|
||||
</summary>
|
||||
<div className="mt-4 space-y-3">
|
||||
{archived.map((c) => (
|
||||
<Card key={c.id} className="opacity-70">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{c.bankName} — {c.cardName}{" "}
|
||||
{c.last4 && (
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
**{c.last4}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">Arşivli</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => toggleArchive(c)}>
|
||||
<ArchiveRestore className="size-3.5" />
|
||||
Geri aç
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardFormSheet
|
||||
open={cardFormOpen}
|
||||
onOpenChange={(v) => {
|
||||
setCardFormOpen(v);
|
||||
if (!v) setEditingCard(null);
|
||||
}}
|
||||
card={editingCard}
|
||||
bankAccounts={bankAccounts}
|
||||
/>
|
||||
|
||||
<StatementFormSheet
|
||||
open={stmtFormOpen}
|
||||
onOpenChange={(v) => {
|
||||
setStmtFormOpen(v);
|
||||
if (!v) setStmtCard(null);
|
||||
}}
|
||||
card={stmtCard}
|
||||
cards={cards}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deletingCard)} onOpenChange={(v) => !v && setDeletingCard(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kartı sil</DialogTitle>
|
||||
<DialogDescription>
|
||||
<strong>
|
||||
{deletingCard?.bankName} — {deletingCard?.cardName}
|
||||
</strong>{" "}
|
||||
ve tüm ekstreleri silinecek.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeletingCard(null)} disabled={busy}>
|
||||
Vazgeç
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteCard} disabled={busy}>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||||
Sil
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(payDialog)}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) {
|
||||
setPayDialog(null);
|
||||
setPayAmount("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ekstre ödemesi</DialogTitle>
|
||||
<DialogDescription>
|
||||
{payDialog && (
|
||||
<>
|
||||
<strong>{payDialog.period}</strong> dönemi — kalan{" "}
|
||||
{formatTRY(payDialog.totalDebt - payDialog.paidAmount)}.
|
||||
<br />
|
||||
Tutarı boş bırakırsanız tamamı ödenir.
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-2 py-2">
|
||||
<label className="text-sm font-medium">Ödenen tutar (₺)</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={payAmount}
|
||||
onChange={(e) => setPayAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPayDialog(null)} disabled={busy}>
|
||||
Vazgeç
|
||||
</Button>
|
||||
<Button onClick={handlePay} disabled={busy}>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||||
Ödemeyi kaydet
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(deletingStmt)} onOpenChange={(v) => !v && setDeletingStmt(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ekstreyi sil</DialogTitle>
|
||||
<DialogDescription>
|
||||
<strong>{deletingStmt?.period}</strong> ekstresi silinecek. Bağlı gider kaydı varsa
|
||||
o da silinir.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeletingStmt(null)} disabled={busy}>
|
||||
Vazgeç
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteStmt} disabled={busy}>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||||
Sil
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useMemo } from "react";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { createStatementAction } from "@/lib/appwrite/credit-card-actions";
|
||||
import { initialCreditCardState } from "@/lib/appwrite/credit-card-types";
|
||||
|
||||
import type { CreditCardRow } from "./types";
|
||||
|
||||
function pad(n: number) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function defaultDates(card?: CreditCardRow | null) {
|
||||
const now = new Date();
|
||||
const sd = card?.statementDay ?? 1;
|
||||
const dd = card?.dueDay ?? 10;
|
||||
const statement = new Date(now.getFullYear(), now.getMonth(), Math.min(sd, 28));
|
||||
const due = new Date(now.getFullYear(), now.getMonth(), Math.min(dd, 28));
|
||||
if (due.getTime() < statement.getTime()) due.setMonth(due.getMonth() + 1);
|
||||
const period = `${statement.getFullYear()}-${pad(statement.getMonth() + 1)}`;
|
||||
const ymd = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
return { period, statementDate: ymd(statement), dueDate: ymd(due) };
|
||||
}
|
||||
|
||||
export function StatementFormSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
card,
|
||||
cards,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
card?: CreditCardRow | null;
|
||||
cards: CreditCardRow[];
|
||||
}) {
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
createStatementAction,
|
||||
initialCreditCardState,
|
||||
);
|
||||
const defaults = useMemo(() => defaultDates(card), [card]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("Ekstre kaydedildi.");
|
||||
onOpenChange(false);
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
|
||||
<SheetHeader className="border-b px-6 py-4">
|
||||
<SheetTitle>Yeni ekstre</SheetTitle>
|
||||
<SheetDescription>
|
||||
Banka ekstrenizdeki dönem, son ödeme tarihi, toplam borç ve asgari ödeme tutarını girin.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form action={formAction} className="flex flex-1 flex-col">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cardId">Kart *</Label>
|
||||
<Select name="cardId" defaultValue={card?.id ?? ""}>
|
||||
<SelectTrigger id="cardId">
|
||||
<SelectValue placeholder="Kart seçin" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cards.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.bankName} — {c.cardName} {c.last4 ? `**${c.last4}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{state.fieldErrors?.cardId && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.cardId}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="period">Dönem (YYYY-AA) *</Label>
|
||||
<Input
|
||||
id="period"
|
||||
name="period"
|
||||
defaultValue={defaults.period}
|
||||
pattern="\d{4}-\d{2}"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="statementDate">Hesap kesim *</Label>
|
||||
<Input
|
||||
id="statementDate"
|
||||
name="statementDate"
|
||||
type="date"
|
||||
defaultValue={defaults.statementDate}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dueDate">Son ödeme *</Label>
|
||||
<Input
|
||||
id="dueDate"
|
||||
name="dueDate"
|
||||
type="date"
|
||||
defaultValue={defaults.dueDate}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="totalDebt">Toplam borç (₺) *</Label>
|
||||
<Input
|
||||
id="totalDebt"
|
||||
name="totalDebt"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{state.fieldErrors?.totalDebt && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.totalDebt}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="minimumPayment">Asgari ödeme (₺)</Label>
|
||||
<Input
|
||||
id="minimumPayment"
|
||||
name="minimumPayment"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="notes">Notlar</Label>
|
||||
<Textarea id="notes" name="notes" rows={3} placeholder="Önemli ekstre notları" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
Vazgeç
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Kaydediliyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="size-4" />
|
||||
Kaydet
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export type CreditCardRow = {
|
||||
id: string;
|
||||
bankName: string;
|
||||
cardName: string;
|
||||
last4: string;
|
||||
creditLimit: number;
|
||||
statementDay: number;
|
||||
dueDay: number;
|
||||
interestRate: number;
|
||||
bankAccountId: string;
|
||||
bankAccountLabel: string;
|
||||
archived: boolean;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export type StatementRow = {
|
||||
id: string;
|
||||
cardId: string;
|
||||
period: string; // YYYY-MM
|
||||
statementDate: string;
|
||||
dueDate: string;
|
||||
totalDebt: number;
|
||||
minimumPayment: number;
|
||||
paidAmount: number;
|
||||
status: "pending" | "partial" | "paid" | "overdue";
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export type BankAccountOption = { id: string; label: string };
|
||||
|
||||
export const STATEMENT_STATUS_LABEL: Record<StatementRow["status"], string> = {
|
||||
pending: "Bekliyor",
|
||||
partial: "Kısmi ödendi",
|
||||
paid: "Ödendi",
|
||||
overdue: "Gecikti",
|
||||
};
|
||||
|
||||
export const STATEMENT_STATUS_COLOR: Record<StatementRow["status"], string> = {
|
||||
pending: "bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30",
|
||||
partial: "bg-amber-500/15 text-amber-800 dark:text-amber-300 border-amber-500/30",
|
||||
paid: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
|
||||
overdue: "bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30",
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { listBankAccounts } from "@/lib/appwrite/bank-account-queries";
|
||||
import {
|
||||
listCreditCards,
|
||||
listStatements,
|
||||
} from "@/lib/appwrite/credit-card-queries";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { CardsClient } from "./components/cards-client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "İşletmem — Kredi kartları",
|
||||
};
|
||||
|
||||
export default async function CardsPage() {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
const [cards, statements, bankAccounts] = await Promise.all([
|
||||
listCreditCards(ctx.tenantId),
|
||||
listStatements(ctx.tenantId),
|
||||
listBankAccounts(ctx.tenantId),
|
||||
]);
|
||||
|
||||
const bankMap = new Map(
|
||||
bankAccounts.map((b) => [b.$id, `${b.bankName} — ${b.accountName}`]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Kredi kartları</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Kartlarınızı ve aylık ekstrelerinizi takip edin. Ekstre ödendiğinde otomatik gider kaydı oluşur.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CardsClient
|
||||
cards={cards.map((c) => ({
|
||||
id: c.$id,
|
||||
bankName: c.bankName,
|
||||
cardName: c.cardName,
|
||||
last4: c.last4 ?? "",
|
||||
creditLimit: c.creditLimit ?? 0,
|
||||
statementDay: c.statementDay ?? 1,
|
||||
dueDay: c.dueDay ?? 10,
|
||||
interestRate: c.interestRate ?? 4.25,
|
||||
bankAccountId: c.bankAccountId ?? "",
|
||||
bankAccountLabel: c.bankAccountId ? bankMap.get(c.bankAccountId) ?? "" : "",
|
||||
archived: Boolean(c.archived),
|
||||
notes: c.notes ?? "",
|
||||
}))}
|
||||
statements={statements.map((s) => ({
|
||||
id: s.$id,
|
||||
cardId: s.cardId,
|
||||
period: s.period,
|
||||
statementDate: s.statementDate,
|
||||
dueDate: s.dueDate,
|
||||
totalDebt: s.totalDebt,
|
||||
minimumPayment: s.minimumPayment ?? 0,
|
||||
paidAmount: s.paidAmount ?? 0,
|
||||
status: s.status ?? "pending",
|
||||
notes: s.notes ?? "",
|
||||
}))}
|
||||
bankAccounts={bankAccounts
|
||||
.filter((b) => !b.archived)
|
||||
.map((b) => ({
|
||||
id: b.$id,
|
||||
label: `${b.bankName} — ${b.accountName}`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type CreditCard,
|
||||
type CreditCardStatement,
|
||||
} from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
import type { CreditCardActionState } from "./credit-card-types";
|
||||
import { creditCardSchema, statementSchema } from "@/lib/validation/credit-cards";
|
||||
|
||||
function appwriteError(e: unknown): string {
|
||||
if (e instanceof AppwriteException) return e.message || "Beklenmeyen hata.";
|
||||
return "Bağlantı hatası. Tekrar deneyin.";
|
||||
}
|
||||
|
||||
function flattenErrors(err: z.ZodError): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const issue of err.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (key && !out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function teamRowPermissions(tenantId: string) {
|
||||
return [
|
||||
Permission.read(Role.team(tenantId)),
|
||||
Permission.update(Role.team(tenantId)),
|
||||
Permission.delete(Role.team(tenantId, "owner")),
|
||||
Permission.delete(Role.team(tenantId, "admin")),
|
||||
];
|
||||
}
|
||||
|
||||
function toIso(v: string): string {
|
||||
if (!v) return v;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return `${v}T00:00:00.000+00:00`;
|
||||
return v;
|
||||
}
|
||||
|
||||
// ---------------- Cards ----------------
|
||||
|
||||
function pickCardFields(formData: FormData) {
|
||||
return {
|
||||
bankName: String(formData.get("bankName") ?? "").trim(),
|
||||
cardName: String(formData.get("cardName") ?? "").trim(),
|
||||
last4: String(formData.get("last4") ?? "").trim(),
|
||||
creditLimit: String(formData.get("creditLimit") ?? "0"),
|
||||
statementDay: String(formData.get("statementDay") ?? "1"),
|
||||
dueDay: String(formData.get("dueDay") ?? "10"),
|
||||
interestRate: String(formData.get("interestRate") ?? "4.25"),
|
||||
bankAccountId: String(formData.get("bankAccountId") ?? ""),
|
||||
notes: String(formData.get("notes") ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createCreditCardAction(
|
||||
_prev: CreditCardActionState,
|
||||
formData: FormData,
|
||||
): Promise<CreditCardActionState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = creditCardSchema.safeParse(pickCardFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.creditCards,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
...parsed.data,
|
||||
},
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "credit_card",
|
||||
entityId: row.$id,
|
||||
changes: parsed.data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/cards");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateCreditCardAction(
|
||||
_prev: CreditCardActionState,
|
||||
formData: FormData,
|
||||
): Promise<CreditCardActionState> {
|
||||
const id = String(formData.get("id") ?? "");
|
||||
if (!id) return { ok: false, error: "ID eksik." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = creditCardSchema.safeParse(pickCardFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const existing = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.creditCards,
|
||||
id,
|
||||
)) as unknown as CreditCard;
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.creditCards, id, parsed.data);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "credit_card",
|
||||
entityId: id,
|
||||
changes: parsed.data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/cards");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function archiveCreditCardAction(
|
||||
formData: FormData,
|
||||
): Promise<CreditCardActionState> {
|
||||
const id = String(formData.get("id") ?? "");
|
||||
if (!id) return { ok: false, error: "ID eksik." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const existing = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.creditCards,
|
||||
id,
|
||||
)) as unknown as CreditCard;
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
const newArchivedState = !existing.archived;
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.creditCards, id, {
|
||||
archived: newArchivedState,
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "credit_card",
|
||||
entityId: id,
|
||||
changes: { archived: newArchivedState },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/cards");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteCreditCardAction(
|
||||
formData: FormData,
|
||||
): Promise<CreditCardActionState> {
|
||||
const id = String(formData.get("id") ?? "");
|
||||
if (!id) return { ok: false, error: "ID eksik." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const existing = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.creditCards,
|
||||
id,
|
||||
)) as unknown as CreditCard;
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
// Cascade delete statements + their finance entries
|
||||
const statements = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.creditCardStatements,
|
||||
queries: [
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.equal("cardId", id),
|
||||
Query.limit(500),
|
||||
],
|
||||
});
|
||||
for (const s of statements.rows as unknown as CreditCardStatement[]) {
|
||||
if (s.financeEntryId) {
|
||||
try {
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.financeEntries, s.financeEntryId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.creditCardStatements, s.$id);
|
||||
}
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.creditCards, id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "credit_card",
|
||||
entityId: id,
|
||||
changes: { bankName: existing.bankName, cardName: existing.cardName, statements: statements.rows.length },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/cards");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ---------------- Statements ----------------
|
||||
|
||||
function pickStatementFields(formData: FormData) {
|
||||
return {
|
||||
cardId: String(formData.get("cardId") ?? ""),
|
||||
period: String(formData.get("period") ?? "").trim(),
|
||||
statementDate: String(formData.get("statementDate") ?? ""),
|
||||
dueDate: String(formData.get("dueDate") ?? ""),
|
||||
totalDebt: String(formData.get("totalDebt") ?? "0"),
|
||||
minimumPayment: String(formData.get("minimumPayment") ?? "0"),
|
||||
notes: String(formData.get("notes") ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function computeStatus(
|
||||
totalDebt: number,
|
||||
paidAmount: number,
|
||||
dueDate: string,
|
||||
): "pending" | "partial" | "paid" | "overdue" {
|
||||
if (paidAmount >= totalDebt) return "paid";
|
||||
const past = new Date(dueDate).getTime() < Date.now();
|
||||
if (paidAmount > 0) return past ? "overdue" : "partial";
|
||||
return past ? "overdue" : "pending";
|
||||
}
|
||||
|
||||
export async function createStatementAction(
|
||||
_prev: CreditCardActionState,
|
||||
formData: FormData,
|
||||
): Promise<CreditCardActionState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = statementSchema.safeParse(pickStatementFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const card = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.creditCards,
|
||||
parsed.data.cardId,
|
||||
)) as unknown as CreditCard;
|
||||
if (card.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
const status = computeStatus(parsed.data.totalDebt, 0, parsed.data.dueDate);
|
||||
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.creditCardStatements,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
cardId: parsed.data.cardId,
|
||||
period: parsed.data.period,
|
||||
statementDate: toIso(parsed.data.statementDate),
|
||||
dueDate: toIso(parsed.data.dueDate),
|
||||
totalDebt: parsed.data.totalDebt,
|
||||
minimumPayment: parsed.data.minimumPayment,
|
||||
paidAmount: 0,
|
||||
status,
|
||||
notes: parsed.data.notes,
|
||||
},
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "credit_card_statement",
|
||||
entityId: row.$id,
|
||||
changes: { cardId: parsed.data.cardId, period: parsed.data.period, totalDebt: parsed.data.totalDebt },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/cards");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function payStatementAction(formData: FormData): Promise<CreditCardActionState> {
|
||||
const id = String(formData.get("id") ?? "");
|
||||
const amountStr = String(formData.get("amount") ?? "");
|
||||
if (!id) return { ok: false, error: "ID eksik." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const existing = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.creditCardStatements,
|
||||
id,
|
||||
)) as unknown as CreditCardStatement;
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
const remaining = (existing.totalDebt ?? 0) - (existing.paidAmount ?? 0);
|
||||
if (remaining <= 0) {
|
||||
return { ok: false, error: "Bu ekstrenin bakiyesi kalmamış." };
|
||||
}
|
||||
|
||||
const amount = amountStr
|
||||
? Number(amountStr.replace(",", "."))
|
||||
: remaining;
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
return { ok: false, error: "Tutar geçersiz.", fieldErrors: { amount: "Geçersiz" } };
|
||||
}
|
||||
const payAmount = Math.min(amount, remaining);
|
||||
|
||||
const card = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.creditCards,
|
||||
existing.cardId,
|
||||
)) as unknown as CreditCard;
|
||||
|
||||
const fe = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.financeEntries,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
type: "expense",
|
||||
amount: payAmount,
|
||||
date: new Date().toISOString(),
|
||||
description: `${card.bankName} ${card.cardName} ${existing.period} ekstre ödemesi`,
|
||||
bankAccountId: card.bankAccountId,
|
||||
},
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
const newPaid = (existing.paidAmount ?? 0) + payAmount;
|
||||
const newStatus = computeStatus(existing.totalDebt ?? 0, newPaid, existing.dueDate);
|
||||
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.creditCardStatements, id, {
|
||||
paidAmount: Number(newPaid.toFixed(2)),
|
||||
status: newStatus,
|
||||
financeEntryId: newStatus === "paid" ? fe.$id : existing.financeEntryId ?? fe.$id,
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "credit_card_statement",
|
||||
entityId: id,
|
||||
changes: { paidAmount: newPaid, status: newStatus, payAmount },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/cards");
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteStatementAction(
|
||||
formData: FormData,
|
||||
): Promise<CreditCardActionState> {
|
||||
const id = String(formData.get("id") ?? "");
|
||||
if (!id) return { ok: false, error: "ID eksik." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const existing = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.creditCardStatements,
|
||||
id,
|
||||
)) as unknown as CreditCardStatement;
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
if (existing.financeEntryId) {
|
||||
try {
|
||||
await tablesDB.deleteRow(
|
||||
DATABASE_ID,
|
||||
TABLES.financeEntries,
|
||||
existing.financeEntryId,
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.creditCardStatements, id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "credit_card_statement",
|
||||
entityId: id,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/cards");
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { createAdminClient } from "./server";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type CreditCard,
|
||||
type CreditCardStatement,
|
||||
} from "./schema";
|
||||
|
||||
export async function listCreditCards(tenantId: string): Promise<CreditCard[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.creditCards,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderAsc("bankName"),
|
||||
Query.limit(200),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as CreditCard[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function listStatements(
|
||||
tenantId: string,
|
||||
): Promise<CreditCardStatement[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.creditCardStatements,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderDesc("statementDate"),
|
||||
Query.limit(500),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as CreditCardStatement[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type CreditCardActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const initialCreditCardState: CreditCardActionState = { ok: false };
|
||||
@@ -16,6 +16,8 @@ export const TABLES = {
|
||||
bankAccounts: "bank_accounts",
|
||||
bankLoans: "bank_loans",
|
||||
loanInstallments: "loan_installments",
|
||||
creditCards: "credit_cards",
|
||||
creditCardStatements: "credit_card_statements",
|
||||
} as const;
|
||||
|
||||
export type TableId = (typeof TABLES)[keyof typeof TABLES];
|
||||
@@ -223,6 +225,38 @@ export interface LoanInstallment extends Row {
|
||||
financeEntryId?: string;
|
||||
}
|
||||
|
||||
export interface CreditCard extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
bankName: string;
|
||||
cardName: string;
|
||||
last4?: string;
|
||||
creditLimit?: number;
|
||||
statementDay?: number;
|
||||
dueDay?: number;
|
||||
interestRate?: number;
|
||||
bankAccountId?: string;
|
||||
archived?: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export type StatementStatus = "pending" | "partial" | "paid" | "overdue";
|
||||
|
||||
export interface CreditCardStatement extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
cardId: string;
|
||||
period: string; // YYYY-MM
|
||||
statementDate: string;
|
||||
dueDate: string;
|
||||
totalDebt: number;
|
||||
minimumPayment?: number;
|
||||
paidAmount?: number;
|
||||
status?: StatementStatus;
|
||||
financeEntryId?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export type InviteRole = "admin" | "member";
|
||||
export type InviteStatus = "pending" | "accepted" | "cancelled" | "expired";
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const creditCardSchema = z.object({
|
||||
bankName: z.string().trim().min(1, "Banka adı zorunlu.").max(100),
|
||||
cardName: z.string().trim().min(1, "Kart adı zorunlu.").max(100),
|
||||
last4: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(4)
|
||||
.optional()
|
||||
.transform((v) => (v ? v.replace(/\D/g, "").slice(-4) : undefined)),
|
||||
creditLimit: z
|
||||
.union([z.number(), z.string()])
|
||||
.optional()
|
||||
.transform((v) => {
|
||||
if (v === undefined || v === "") return 0;
|
||||
const n = typeof v === "string" ? Number(v.replace(",", ".")) : v;
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}),
|
||||
statementDay: z
|
||||
.union([z.number(), z.string()])
|
||||
.optional()
|
||||
.transform((v) => {
|
||||
if (v === undefined || v === "") return 1;
|
||||
const n = typeof v === "string" ? parseInt(v, 10) : v;
|
||||
return Number.isFinite(n) ? Math.min(28, Math.max(1, n)) : 1;
|
||||
}),
|
||||
dueDay: z
|
||||
.union([z.number(), z.string()])
|
||||
.optional()
|
||||
.transform((v) => {
|
||||
if (v === undefined || v === "") return 10;
|
||||
const n = typeof v === "string" ? parseInt(v, 10) : v;
|
||||
return Number.isFinite(n) ? Math.min(28, Math.max(1, n)) : 10;
|
||||
}),
|
||||
interestRate: z
|
||||
.union([z.number(), z.string()])
|
||||
.optional()
|
||||
.transform((v) => {
|
||||
if (v === undefined || v === "") return 4.25;
|
||||
const n = typeof v === "string" ? Number(v.replace(",", ".")) : v;
|
||||
return Number.isFinite(n) ? n : 4.25;
|
||||
}),
|
||||
bankAccountId: z.string().optional().transform((v) => (v ? v : undefined)),
|
||||
notes: z.string().trim().max(500).optional().transform((v) => (v ? v : undefined)),
|
||||
});
|
||||
|
||||
export type CreditCardInput = z.infer<typeof creditCardSchema>;
|
||||
|
||||
export const statementSchema = z.object({
|
||||
cardId: z.string().min(1, "Kart seçin."),
|
||||
period: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}$/, "Dönem YYYY-AA formatında olmalı.")
|
||||
.max(7),
|
||||
statementDate: z.string().min(1, "Hesap kesim tarihi zorunlu."),
|
||||
dueDate: z.string().min(1, "Son ödeme tarihi zorunlu."),
|
||||
totalDebt: z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((v) => (typeof v === "string" ? Number(v.replace(",", ".")) : v))
|
||||
.pipe(z.number().nonnegative("Negatif olamaz.")),
|
||||
minimumPayment: z
|
||||
.union([z.number(), z.string()])
|
||||
.optional()
|
||||
.transform((v) => {
|
||||
if (v === undefined || v === "") return 0;
|
||||
const n = typeof v === "string" ? Number(v.replace(",", ".")) : v;
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}),
|
||||
notes: z.string().trim().max(500).optional().transform((v) => (v ? v : undefined)),
|
||||
});
|
||||
|
||||
export type StatementInput = z.infer<typeof statementSchema>;
|
||||
Reference in New Issue
Block a user