37679e83e6
- 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ı
523 lines
19 KiB
TypeScript
523 lines
19 KiB
TypeScript
"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>
|
||
);
|
||
}
|