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:
egecankomur
2026-05-05 04:37:04 +03:00
commit 37679e83e6
383 changed files with 53525 additions and 0 deletions
@@ -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
</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>
);
}