Files
isletmem-kovakcrm/src/app/(dashboard)/finance/banks/components/banks-client.tsx
T
kovakmedya 7b6be623ae feat(banking A): bank accounts module + finance integration
First of 3-step banking expansion. Banks tracked separately from
customer/supplier debts so we can compute real cash position later.

Schema:
- New bank_accounts table: bankName, accountName, iban, openingBalance,
  notes, archived. Indexes on (tenantId, archived).
- New column finance_entries.bankAccountId (FK, optional). Index on
  (tenantId, bankAccountId).
- schema.ts: TABLES.bankAccounts, BankAccount type, FinanceEntry gains
  bankAccountId.

Server side:
- lib/validation/bank-accounts.ts (Zod): IBAN normalized to upper-case
  no-spaces; openingBalance defaults to 0.
- lib/appwrite/bank-account-actions.ts: create/update/archive(toggle)/
  delete with audit. Delete refuses if any finance_entry still references
  the account; archive toggle replaces it for safe disable.
- lib/appwrite/bank-account-queries.ts:
  * listBankAccounts
  * getBankAccountBalances — computes opening + Σ(income) − Σ(expense)
    per account by scanning up to 5000 entries with bankAccountId set.
    Pure cash flow; debt/receivable don't move balance.
  * listEntriesForAccount

UI:
- /finance/banks server page renders BanksClient with computed balances.
- BanksClient: card grid for active accounts, collapsed details for
  archived. Sum card on top showing total active balance (color-coded by
  sign). Each card shows bank, account name, formatted IBAN, current
  balance + opening (if drifted). Dropdown: Düzenle / Arşivle / Sil.
- BankFormSheet: bank/account/IBAN/openingBalance/notes form.
- Finance form gets a bank-account Select (sentinel-stripped). Existing
  finance entries get a 'bankAccountLabel' subtitle in their row.

Sidebar: Finans group expanded with Bankalar submenu (Banka hesapları
/ Krediler / Kredi kartları). The latter two land in B and C.
2026-04-30 07:22:51 +03:00

293 lines
9.1 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 {
Archive,
ArchiveRestore,
Building2,
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 {
archiveBankAccountAction,
deleteBankAccountAction,
} from "@/lib/appwrite/bank-account-actions";
import { formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
import { BankFormSheet } from "./bank-form-sheet";
import type { BankAccountRow } from "./types";
type Props = { accounts: BankAccountRow[] };
export function BanksClient({ accounts }: Props) {
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<BankAccountRow | null>(null);
const [deleting, setDeleting] = useState<BankAccountRow | null>(null);
const [busy, startTransition] = useTransition();
const active = accounts.filter((a) => !a.archived);
const archived = accounts.filter((a) => a.archived);
const totalBalance = active.reduce((s, a) => s + a.balance, 0);
const toggleArchive = (acc: BankAccountRow) => {
startTransition(async () => {
const fd = new FormData();
fd.set("id", acc.id);
const result = await archiveBankAccountAction(fd);
if (result.ok) {
toast.success(acc.archived ? "Hesap geri açıldı." : "Hesap arşivlendi.");
} 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 deleteBankAccountAction(fd);
if (result.ok) {
toast.success("Hesap silindi.");
setDeleting(null);
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<Card className="flex-1">
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Toplam bakiye (aktif hesaplar)</p>
<p
className={cn(
"mt-1 text-2xl font-semibold tabular-nums",
totalBalance >= 0 ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400",
)}
>
{formatTRY(totalBalance)}
</p>
</CardContent>
</Card>
<Button
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
>
<Plus className="size-4" />
Yeni hesap
</Button>
</div>
{active.length === 0 && archived.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-12 text-center">
<Building2 className="text-muted-foreground size-8" />
<p className="text-sm">Henüz banka hesabı eklenmemiş.</p>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
>
<Plus className="size-3.5" />
İlk hesabı ekle
</Button>
</CardContent>
</Card>
) : (
<>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{active.map((a) => (
<AccountCard
key={a.id}
account={a}
onEdit={() => {
setEditing(a);
setFormOpen(true);
}}
onArchiveToggle={() => toggleArchive(a)}
onDelete={() => setDeleting(a)}
busy={busy}
/>
))}
</div>
{archived.length > 0 && (
<details className="group">
<summary className="text-muted-foreground hover:text-foreground cursor-pointer text-sm">
Arşivlenmiş hesaplar ({archived.length})
</summary>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{archived.map((a) => (
<AccountCard
key={a.id}
account={a}
onEdit={() => {
setEditing(a);
setFormOpen(true);
}}
onArchiveToggle={() => toggleArchive(a)}
onDelete={() => setDeleting(a)}
busy={busy}
/>
))}
</div>
</details>
)}
</>
)}
<BankFormSheet
open={formOpen}
onOpenChange={(v) => {
setFormOpen(v);
if (!v) setEditing(null);
}}
account={editing}
/>
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Hesabı sil</DialogTitle>
<DialogDescription>
<strong>{deleting?.bankName} {deleting?.accountName}</strong> kalıcı olarak silinecek.
Bağlı finans hareketi varsa silme reddedilir; o durumda arşivlemeyi tercih edin.
</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 AccountCard({
account,
onEdit,
onArchiveToggle,
onDelete,
busy,
}: {
account: BankAccountRow;
onEdit: () => void;
onArchiveToggle: () => void;
onDelete: () => void;
busy: boolean;
}) {
const positive = account.balance >= 0;
return (
<Card className={cn(account.archived && "opacity-60")}>
<CardContent className="space-y-3 p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Building2 className="text-muted-foreground size-4 shrink-0" />
<h3 className="truncate font-medium">{account.bankName}</h3>
{account.archived && (
<Badge variant="outline" className="text-[10px]">
Arşivli
</Badge>
)}
</div>
<p className="text-muted-foreground mt-0.5 truncate text-sm">{account.accountName}</p>
{account.iban && (
<p className="text-muted-foreground mt-1 truncate font-mono text-[11px]">
{account.iban.replace(/(.{4})/g, "$1 ").trim()}
</p>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8 shrink-0" disabled={busy}>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>
<Pencil className="size-3.5" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem onClick={onArchiveToggle}>
{account.archived ? (
<>
<ArchiveRestore className="size-3.5" />
Arşivden çıkar
</>
) : (
<>
<Archive className="size-3.5" />
Arşivle
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="size-3.5" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div>
<p className="text-muted-foreground text-xs">Güncel bakiye</p>
<p
className={cn(
"text-xl font-semibold tabular-nums",
positive ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400",
)}
>
{formatTRY(account.balance)}
</p>
{account.balance !== account.openingBalance && (
<p className="text-muted-foreground mt-0.5 text-[11px]">
Açılış: {formatTRY(account.openingBalance)}
</p>
)}
</div>
</CardContent>
</Card>
);
}