Files
kovakemlak-crm/src/app/(dashboard)/finance/cards/components/cards-client.tsx
T
egecankomur 37679e83e6 init: kovakemlak-crm project scaffold
- Next.js 16 + Appwrite multi-tenant emlak CRM
- Database: kovakemlak-db (properties, customers, customer_searches, property_matches, presentations, investors, activities, tenant_settings)
- Same stack as isletmem-kovakcrm (shadcn/ui template base)
- Modules: portföy, müşteri takibi, arama kriterleri, otomatik eşleştirme, sunum linki, yatırımcı portalı
2026-05-05 04:37:04 +03:00

523 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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
</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>
);
}