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,228 @@
|
||||
"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 { ScopeToggle } from "@/components/finance/scope-toggle";
|
||||
|
||||
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">
|
||||
<ScopeToggle defaultValue={card?.scope ?? "company"} />
|
||||
|
||||
<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 pt-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 pt-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,44 @@
|
||||
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;
|
||||
scope: "company" | "personal";
|
||||
};
|
||||
|
||||
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",
|
||||
};
|
||||
Reference in New Issue
Block a user