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ı
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user