From 98ab73235f841c57e50c78678d2b17d4fedbfcc2 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 30 Apr 2026 06:04:46 +0300 Subject: [PATCH] feat(finance): income/expense/debt/receivable tracking + summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-tenant cash flow tracker. All amounts in TRY, decimals preserved. Schema/validation: - lib/validation/finance.ts: financeEntrySchema with type enum, positive amount, date required, optional customer/invoice link, optional payment method. - lib/appwrite/finance-actions.ts: create/update/delete with audit; date HTML input normalized to ISO before write. - lib/appwrite/finance-queries.ts: listFinanceEntries ordered by date desc. UI: - /finance server page passes entries + customers to FinanceClient. - 5 stat cards: Gelir / Gider / Net (income-expense, color-coded by sign) / Alacaklar / Borçlar. - Type filter dropdown (Tümü/Gelir/Gider/Alacaklar/Borçlar) + global search (description/customer/amount). - 4 quick-add buttons let users start a new entry pre-filled with the desired type. Single FinanceFormSheet handles all 4 types via a Select. - Table: type badge (color-coded), signed amount (+ for income/receivable, − for expense/debt), date, customer, payment method label, description preview. Row dropdown: Edit / Delete. - Inline destructive Sil button in Sheet footer when editing. --- .../finance/components/finance-client.tsx | 429 ++++++++++++++++++ .../finance/components/finance-form-sheet.tsx | 236 ++++++++++ .../(dashboard)/finance/components/types.ts | 38 ++ src/app/(dashboard)/finance/page.tsx | 53 +++ src/lib/appwrite/finance-actions.ts | 197 ++++++++ src/lib/appwrite/finance-queries.ts | 24 + src/lib/appwrite/finance-types.ts | 7 + src/lib/validation/finance.ts | 19 + 8 files changed, 1003 insertions(+) create mode 100644 src/app/(dashboard)/finance/components/finance-client.tsx create mode 100644 src/app/(dashboard)/finance/components/finance-form-sheet.tsx create mode 100644 src/app/(dashboard)/finance/components/types.ts create mode 100644 src/app/(dashboard)/finance/page.tsx create mode 100644 src/lib/appwrite/finance-actions.ts create mode 100644 src/lib/appwrite/finance-queries.ts create mode 100644 src/lib/appwrite/finance-types.ts create mode 100644 src/lib/validation/finance.ts diff --git a/src/app/(dashboard)/finance/components/finance-client.tsx b/src/app/(dashboard)/finance/components/finance-client.tsx new file mode 100644 index 0000000..e622c88 --- /dev/null +++ b/src/app/(dashboard)/finance/components/finance-client.tsx @@ -0,0 +1,429 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + type SortingState, +} from "@tanstack/react-table"; +import { + ArrowDownCircle, + ArrowUpCircle, + CircleAlert, + CircleDollarSign, + Loader2, + MoreHorizontal, + Pencil, + Plus, + Search, + Trash2, + Wallet, +} 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, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { deleteFinanceEntryAction } from "@/lib/appwrite/finance-actions"; +import { formatDate, formatTRY } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +import { FinanceFormSheet } from "./finance-form-sheet"; +import { + type Customer, + type FinanceRow, + type FinanceType, + PAYMENT_METHOD_LABEL, + TYPE_COLOR, + TYPE_LABEL, +} from "./types"; + +type Props = { + entries: FinanceRow[]; + customers: Customer[]; +}; + +function StatCard({ + label, + amount, + icon: Icon, + tone, +}: { + label: string; + amount: number; + icon: typeof Wallet; + tone: "income" | "expense" | "receivable" | "debt" | "net"; +}) { + const toneClass = { + income: "text-emerald-600 dark:text-emerald-400", + expense: "text-red-600 dark:text-red-400", + receivable: "text-blue-600 dark:text-blue-400", + debt: "text-amber-600 dark:text-amber-400", + net: amount >= 0 ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400", + }[tone]; + return ( + + +
+

{label}

+

{formatTRY(amount)}

+
+ +
+
+ ); +} + +export function FinanceClient({ entries, customers }: Props) { + const [tab, setTab] = useState("all"); + const [search, setSearch] = useState(""); + const [sorting, setSorting] = useState([]); + const [formOpen, setFormOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [defaultType, setDefaultType] = useState("income"); + const [deleting, setDeleting] = useState(null); + const [busy, startTransition] = useTransition(); + + const stats = useMemo(() => { + let income = 0, + expense = 0, + receivable = 0, + debt = 0; + for (const e of entries) { + if (e.type === "income") income += e.amount; + else if (e.type === "expense") expense += e.amount; + else if (e.type === "receivable") receivable += e.amount; + else if (e.type === "debt") debt += e.amount; + } + return { income, expense, receivable, debt, net: income - expense }; + }, [entries]); + + const filtered = useMemo( + () => (tab === "all" ? entries : entries.filter((e) => e.type === tab)), + [entries, tab], + ); + + const columns = useMemo[]>( + () => [ + { + accessorKey: "type", + header: "Tür", + cell: ({ row }) => ( + + {TYPE_LABEL[row.original.type]} + + ), + }, + { + accessorKey: "amount", + header: "Tutar", + cell: ({ row }) => { + const sign = + row.original.type === "income" || row.original.type === "receivable" ? "+" : "−"; + return ( + + {sign} {formatTRY(row.original.amount)} + + ); + }, + }, + { + accessorKey: "date", + header: "Tarih", + cell: ({ row }) => ( + {formatDate(row.original.date)} + ), + }, + { + accessorKey: "customerName", + header: "Müşteri", + cell: ({ row }) => + row.original.customerName ? ( + {row.original.customerName} + ) : ( + + ), + }, + { + accessorKey: "paymentMethod", + header: "Ödeme", + cell: ({ row }) => ( + + {PAYMENT_METHOD_LABEL[row.original.paymentMethod]} + + ), + }, + { + accessorKey: "description", + header: "Açıklama", + cell: ({ row }) => + row.original.description ? ( + + {row.original.description} + + ) : ( + + ), + }, + { + id: "actions", + cell: ({ row }) => ( +
+ + + + + + { + setEditing(row.original); + setFormOpen(true); + }} + > + + Düzenle + + setDeleting(row.original)} + > + + Sil + + + +
+ ), + }, + ], + [], + ); + + const table = useReactTable({ + data: filtered, + columns, + state: { globalFilter: search, sorting }, + onGlobalFilterChange: setSearch, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + initialState: { pagination: { pageSize: 25 } }, + globalFilterFn: (row, _id, fv) => { + const v = String(fv).toLowerCase(); + return [row.original.description, row.original.customerName, row.original.amount.toString()] + .join(" ") + .toLowerCase() + .includes(v); + }, + }); + + const handleDelete = () => { + if (!deleting) return; + startTransition(async () => { + const fd = new FormData(); + fd.set("id", deleting.id); + const result = await deleteFinanceEntryAction(fd); + if (result.ok) { + toast.success("Kayıt silindi."); + setDeleting(null); + } else { + toast.error(result.error ?? "Silme başarısız."); + } + }); + }; + + const openCreate = (type: FinanceType) => { + setEditing(null); + setDefaultType(type); + setFormOpen(true); + }; + + return ( + <> +
+ + + + + +
+ + + +
+
+ +
+ + setSearch(e.target.value)} + placeholder="Açıklama, müşteri, tutar..." + className="pl-9" + /> +
+
+
+ + + + +
+
+ + + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((h) => ( + + {h.isPlaceholder + ? null + : flexRender(h.column.columnDef.header, h.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((r) => ( + + {r.getVisibleCells().map((c) => ( + + {flexRender(c.column.columnDef.cell, c.getContext())} + + ))} + + )) + ) : ( + + +

Kayıt yok.

+
+
+ )} +
+
+
+
+ + { + setFormOpen(v); + if (!v) setEditing(null); + }} + entry={editing} + defaultType={defaultType} + customers={customers} + onRequestDelete={(e) => { + setFormOpen(false); + setDeleting(e); + }} + /> + + !v && setDeleting(null)}> + + + Kaydı sil + + {deleting && ( + <> + {TYPE_LABEL[deleting.type]} — {formatTRY(deleting.amount)} ( + {formatDate(deleting.date)}) silinecek. + + )} + + + + + + + + + + ); +} diff --git a/src/app/(dashboard)/finance/components/finance-form-sheet.tsx b/src/app/(dashboard)/finance/components/finance-form-sheet.tsx new file mode 100644 index 0000000..eeeae18 --- /dev/null +++ b/src/app/(dashboard)/finance/components/finance-form-sheet.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { useActionState, useEffect } from "react"; +import { Loader2, Save, Trash2 } 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 { + createFinanceEntryAction, + updateFinanceEntryAction, +} from "@/lib/appwrite/finance-actions"; +import { initialFinanceState } from "@/lib/appwrite/finance-types"; + +import type { Customer, FinanceRow, FinanceType } from "./types"; + +const NONE = "__none__"; + +type Props = { + open: boolean; + onOpenChange: (v: boolean) => void; + entry?: FinanceRow | null; + defaultType?: FinanceType; + customers: Customer[]; + onRequestDelete?: (entry: FinanceRow) => void; +}; + +function isoToDate(iso: string): string { + if (!iso) return ""; + return iso.slice(0, 10); +} + +export function FinanceFormSheet({ + open, + onOpenChange, + entry, + defaultType = "income", + customers, + onRequestDelete, +}: Props) { + const isEdit = Boolean(entry); + const action = isEdit ? updateFinanceEntryAction : createFinanceEntryAction; + const [state, formAction, isPending] = useActionState(action, initialFinanceState); + + useEffect(() => { + if (state.ok) { + toast.success(isEdit ? "Kayıt güncellendi." : "Kayıt eklendi."); + onOpenChange(false); + } else if (state.error) { + toast.error(state.error); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state]); + + const today = new Date().toISOString().slice(0, 10); + + return ( + + + + {isEdit ? "Kaydı düzenle" : "Yeni kayıt"} + + Gelir, gider, borç veya alacak girişi. Borç = ödeyeceğiniz, Alacak = tahsil edeceğiniz. + + + +
{ + ["customerId", "paymentMethod"].forEach((k) => { + if (fd.get(k) === NONE) fd.set(k, ""); + }); + formAction(fd); + }} + className="flex flex-1 flex-col" + > + {isEdit && entry && } + +
+
+
+ + +
+
+ + + {state.fieldErrors?.amount && ( +

{state.fieldErrors.amount}

+ )} +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +