Files
isletmem-kovakcrm/src/app/(dashboard)/finance/loans/components/loans-client.tsx
T
kovakmedya b632ae8a73 feat(banking B): bank loans + amortization schedule
Step 2 of banking. Loan creation auto-generates the full installment
schedule using standard amortization (eşit taksitli kredi):
  monthlyPayment = P × r × (1+r)^n / ((1+r)^n − 1)

Schema:
- bank_loans: bankAccountId (optional FK), bankName, loanName, loanType
  enum (consumer/vehicle/housing/commercial/kmh/other), principal,
  interestRate (monthly nominal %), termMonths, monthlyPayment, startDate,
  paymentDay (1-28, clamped per month), status (active/closed/defaulted).
- loan_installments: loanId, installmentNo, dueDate, amount, principalPart,
  interestPart, paid, paidAt, financeEntryId.
- Indexes on bank_loans(tenantId, status) and loan_installments(tenantId,
  loanId) and (tenantId, paid, dueDate).

Server (lib/appwrite/loan-actions.ts):
- createLoanAction: validates with Zod, computes amortization including
  rounding-drift handling on the last installment, persists loan + N
  installments, audit-logs. Atomic rollback on failure (deletes any
  partially-created installments and the loan).
- payInstallmentAction: atomically creates a finance_entry (expense,
  bankAccountId carried over from the loan), updates installment with
  paid=true + financeEntryId. If it was the last unpaid installment,
  marks loan status='closed'.
- unpayInstallmentAction: deletes the linked finance_entry, clears paid
  fields, reopens the loan if it was closed.
- deleteLoanAction: cascade-deletes all installments first, then the loan.

UI (/finance/loans):
- 3 stat cards: aktif kredi sayısı, toplam çekilen, kalan ödeme.
- Loan card per loan with bank/name/type/status badges, anapara/aylık
  taksit/faiz/sonraki ödeme grid, progress bar (paid/total), expandable
  installment table.
- Installment row: # / vade (red if overdue) / anapara / faiz / toplam /
  Ödendi-Geri al toggle.
- LoanFormSheet: live preview of monthly payment, total payment, total
  interest as user changes principal/rate/term. paymentDay clamped 1-28
  to avoid month-length issues.
2026-04-30 07:29:24 +03:00

358 lines
14 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 {
Banknote,
Check,
ChevronDown,
ChevronUp,
Loader2,
Plus,
RotateCcw,
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
deleteLoanAction,
payInstallmentAction,
unpayInstallmentAction,
} from "@/lib/appwrite/loan-actions";
import { formatDate, formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
import { LoanFormSheet } from "./loan-form-sheet";
import {
type BankAccountOption,
type InstallmentRow,
LOAN_STATUS_LABEL,
LOAN_TYPE_LABEL,
type LoanRow,
} from "./types";
type Props = {
loans: LoanRow[];
installments: InstallmentRow[];
bankAccounts: BankAccountOption[];
};
export function LoansClient({ loans, installments, bankAccounts }: Props) {
const [formOpen, setFormOpen] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
const [deleting, setDeleting] = useState<LoanRow | null>(null);
const [busy, startTransition] = useTransition();
const totalPrincipal = loans
.filter((l) => l.status === "active")
.reduce((s, l) => s + l.principal, 0);
const totalRemaining = loans
.filter((l) => l.status === "active")
.reduce((s, l) => s + (l.totalAmount - l.paidAmount), 0);
const installmentsByLoan = new Map<string, InstallmentRow[]>();
for (const i of installments) {
const arr = installmentsByLoan.get(i.loanId) ?? [];
arr.push(i);
installmentsByLoan.set(i.loanId, arr);
}
const togglePay = (inst: InstallmentRow) => {
startTransition(async () => {
const fd = new FormData();
fd.set("id", inst.id);
const result = inst.paid ? await unpayInstallmentAction(fd) : await payInstallmentAction(fd);
if (result.ok) {
toast.success(inst.paid ? "Taksit ödenmedi olarak işaretlendi." : "Taksit ödendi olarak işaretlendi.");
} else {
toast.error(result.error ?? "İşlem başarısız.");
}
});
};
const handleDelete = () => {
if (!deleting) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deleting.id);
const result = await deleteLoanAction(fd);
if (result.ok) {
toast.success("Kredi silindi.");
setDeleting(null);
} else {
toast.error(result.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 kredi sayısı</p>
<p className="mt-1 text-2xl font-semibold">
{loans.filter((l) => l.status === "active").length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Toplam çekilen</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatTRY(totalPrincipal)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Kalan ödeme</p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-amber-600 dark:text-amber-400">
{formatTRY(totalRemaining)}
</p>
</CardContent>
</Card>
</div>
<div className="flex justify-end">
<Button onClick={() => setFormOpen(true)}>
<Plus className="size-4" />
Yeni kredi
</Button>
</div>
{loans.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-12 text-center">
<Banknote className="text-muted-foreground size-8" />
<p className="text-sm">Henüz kredi tanımlanmamış.</p>
<Button variant="outline" size="sm" onClick={() => setFormOpen(true)}>
<Plus className="size-3.5" />
İlk krediyi ekle
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{loans.map((loan) => {
const isOpen = expanded === loan.id;
const items = installmentsByLoan.get(loan.id) ?? [];
const progressPct =
loan.totalAmount > 0 ? (loan.paidAmount / loan.totalAmount) * 100 : 0;
return (
<Card key={loan.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">
<h3 className="font-semibold">{loan.bankName}</h3>
<span className="text-muted-foreground">·</span>
<span className="text-sm">{loan.loanName}</span>
<Badge variant="outline" className="text-[10px]">
{LOAN_TYPE_LABEL[loan.loanType]}
</Badge>
<Badge
variant="outline"
className={cn(
"text-[10px]",
loan.status === "active"
? "border-blue-500/30 bg-blue-500/15 text-blue-700 dark:text-blue-300"
: loan.status === "closed"
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300"
: "border-red-500/30 bg-red-500/15 text-red-700 dark:text-red-300",
)}
>
{LOAN_STATUS_LABEL[loan.status]}
</Badge>
</div>
{loan.bankAccountLabel && (
<p className="text-muted-foreground mt-1 text-xs">
Hesap: {loan.bankAccountLabel}
</p>
)}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded(isOpen ? null : loan.id)}
>
{isOpen ? (
<>
<ChevronUp className="size-3.5" />
Kapat
</>
) : (
<>
<ChevronDown className="size-3.5" />
Taksitler ({items.length})
</>
)}
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setDeleting(loan)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
<div className="grid gap-2 md:grid-cols-4">
<Stat label="Anapara" value={formatTRY(loan.principal)} />
<Stat label="Aylık taksit" value={formatTRY(loan.monthlyPayment)} />
<Stat label="Aylık faiz" value={`%${loan.interestRate}`} />
<Stat
label="Sonraki ödeme"
value={loan.nextDue ? formatDate(loan.nextDue) : "—"}
/>
</div>
<div>
<div className="text-muted-foreground flex items-center justify-between text-xs">
<span>
{loan.remainingCount === 0
? "Tüm taksitler ödendi"
: `${loan.remainingCount} taksit kaldı`}
</span>
<span>
{formatTRY(loan.paidAmount)} / {formatTRY(loan.totalAmount)}
</span>
</div>
<div className="bg-muted mt-1 h-1.5 overflow-hidden rounded-full">
<div
className="bg-emerald-500 h-full"
style={{ width: `${Math.min(100, progressPct)}%` }}
/>
</div>
</div>
{isOpen && (
<div className="border-t pt-3">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">#</TableHead>
<TableHead>Vade</TableHead>
<TableHead className="text-right">Anapara</TableHead>
<TableHead className="text-right">Faiz</TableHead>
<TableHead className="text-right">Toplam</TableHead>
<TableHead className="text-right">Durum</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((it) => {
const overdue =
!it.paid && new Date(it.dueDate) < new Date();
return (
<TableRow
key={it.id}
className={cn(it.paid && "opacity-60")}
>
<TableCell className="font-mono">{it.installmentNo}</TableCell>
<TableCell
className={cn(
"text-muted-foreground text-sm",
overdue && "text-destructive font-medium",
)}
>
{formatDate(it.dueDate)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(it.principalPart)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(it.interestPart)}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatTRY(it.amount)}
</TableCell>
<TableCell className="text-right">
<Button
variant={it.paid ? "ghost" : "outline"}
size="sm"
disabled={busy}
onClick={() => togglePay(it)}
>
{busy ? (
<Loader2 className="size-3.5 animate-spin" />
) : it.paid ? (
<>
<RotateCcw className="size-3.5" />
Geri al
</>
) : (
<>
<Check className="size-3.5" />
Ödendi
</>
)}
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
)}
<LoanFormSheet open={formOpen} onOpenChange={setFormOpen} bankAccounts={bankAccounts} />
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Krediyi sil</DialogTitle>
<DialogDescription>
<strong>{deleting?.bankName} {deleting?.loanName}</strong> ve tüm taksitleri silinecek.
Bu işlem geri alınamaz.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-muted-foreground text-[10px] uppercase tracking-wide">{label}</p>
<p className="mt-0.5 text-sm font-medium tabular-nums">{value}</p>
</div>
);
}