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 ( + <> + + + + + + + + + + + + + setTab(v as typeof tab)}> + + + + + Tümü + Gelir + Gider + Alacaklar + Borçlar + + + + + setSearch(e.target.value)} + placeholder="Açıklama, müşteri, tutar..." + className="pl-9" + /> + + + + openCreate("income")}> + + Gelir + + openCreate("expense")}> + + Gider + + openCreate("receivable")}> + + Alacak + + openCreate("debt")}> + + Borç + + + + + + + {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. + > + )} + + + + setDeleting(null)} disabled={busy}> + Vazgeç + + + {busy ? : } + Sil + + + + + > + ); +} 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 && } + + + + + Tür * + + + + + + Gelir + Gider + Alacak + Borç + + + + + Tutar (₺) * + + {state.fieldErrors?.amount && ( + {state.fieldErrors.amount} + )} + + + + + + Tarih * + + + + Ödeme yöntemi + + + + + + Belirtilmemiş + Nakit + Havale / EFT + Kart + Çek + Diğer + + + + + + + Müşteri (opsiyonel) + + + + + + Yok + {customers.map((c) => ( + + {c.name} + + ))} + + + + + + Açıklama + + + + + + + + {isEdit && entry && onRequestDelete && ( + onRequestDelete(entry)} + disabled={isPending} + > + + Sil + + )} + + + onOpenChange(false)} + disabled={isPending} + > + Vazgeç + + + {isPending ? ( + <> + + Kaydediliyor... + > + ) : ( + <> + + {isEdit ? "Güncelle" : "Kaydet"} + > + )} + + + + + + + + ); +} diff --git a/src/app/(dashboard)/finance/components/types.ts b/src/app/(dashboard)/finance/components/types.ts new file mode 100644 index 0000000..9ef7567 --- /dev/null +++ b/src/app/(dashboard)/finance/components/types.ts @@ -0,0 +1,38 @@ +export type FinanceType = "income" | "expense" | "debt" | "receivable"; +export type PaymentMethod = "cash" | "transfer" | "card" | "check" | "other" | ""; + +export type FinanceRow = { + id: string; + type: FinanceType; + amount: number; + date: string; + description: string; + customerId: string; + customerName: string; + paymentMethod: PaymentMethod; +}; + +export type Customer = { id: string; name: string }; + +export const TYPE_LABEL: Record = { + income: "Gelir", + expense: "Gider", + debt: "Borç", + receivable: "Alacak", +}; + +export const TYPE_COLOR: Record = { + income: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30", + expense: "bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30", + debt: "bg-amber-500/15 text-amber-800 dark:text-amber-300 border-amber-500/30", + receivable: "bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30", +}; + +export const PAYMENT_METHOD_LABEL: Record = { + cash: "Nakit", + transfer: "Havale / EFT", + card: "Kart", + check: "Çek", + other: "Diğer", + "": "—", +}; diff --git a/src/app/(dashboard)/finance/page.tsx b/src/app/(dashboard)/finance/page.tsx new file mode 100644 index 0000000..b3a2ec5 --- /dev/null +++ b/src/app/(dashboard)/finance/page.tsx @@ -0,0 +1,53 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { listCustomers } from "@/lib/appwrite/customer-queries"; +import { listFinanceEntries } from "@/lib/appwrite/finance-queries"; +import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import { FinanceClient } from "./components/finance-client"; + +export const metadata: Metadata = { + title: "İşletmem — Gelir / Gider", +}; + +export default async function FinancePage() { + let ctx; + try { + ctx = await requireTenant(); + } catch { + redirect("/onboarding"); + } + + const [entries, customers] = await Promise.all([ + listFinanceEntries(ctx.tenantId), + listCustomers(ctx.tenantId), + ]); + + const customerMap = new Map(customers.map((c) => [c.$id, c.name])); + + return ( + + + {ctx.settings?.companyName ?? "Çalışma alanı"} + Gelir / Gider + + Nakit hareketleri, borç ve alacaklarınızı tek yerden takip edin. + + + + ({ + id: e.$id, + type: e.type, + amount: e.amount, + date: e.date, + description: e.description ?? "", + customerId: e.customerId ?? "", + customerName: e.customerId ? customerMap.get(e.customerId) ?? "" : "", + paymentMethod: e.paymentMethod ?? "", + }))} + customers={customers.map((c) => ({ id: c.$id, name: c.name }))} + /> + + ); +} diff --git a/src/lib/appwrite/finance-actions.ts b/src/lib/appwrite/finance-actions.ts new file mode 100644 index 0000000..cc66f63 --- /dev/null +++ b/src/lib/appwrite/finance-actions.ts @@ -0,0 +1,197 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { AppwriteException, ID, Permission, Role } from "node-appwrite"; +import { z } from "zod"; + +import { logAudit } from "./audit"; +import { DATABASE_ID, TABLES, type FinanceEntry } from "./schema"; +import { createAdminClient } from "./server"; +import { requireTenant } from "./tenant-guard"; +import type { FinanceActionState } from "./finance-types"; +import { financeEntrySchema } from "@/lib/validation/finance"; + +function appwriteError(e: unknown): string { + if (e instanceof AppwriteException) return e.message || "Beklenmeyen hata."; + return "Bağlantı hatası. Tekrar deneyin."; +} + +function flattenErrors(err: z.ZodError): Record { + const out: Record = {}; + for (const issue of err.issues) { + const key = issue.path.join("."); + if (key && !out[key]) out[key] = issue.message; + } + return out; +} + +function teamRowPermissions(tenantId: string) { + return [ + Permission.read(Role.team(tenantId)), + Permission.update(Role.team(tenantId)), + Permission.delete(Role.team(tenantId, "owner")), + Permission.delete(Role.team(tenantId, "admin")), + ]; +} + +function pickFormFields(formData: FormData) { + return { + type: formData.get("type") as "income" | "expense" | "debt" | "receivable", + amount: String(formData.get("amount") ?? "0"), + date: String(formData.get("date") ?? ""), + description: String(formData.get("description") ?? "").trim(), + customerId: String(formData.get("customerId") ?? ""), + invoiceId: String(formData.get("invoiceId") ?? ""), + paymentMethod: formData.get("paymentMethod") as + | "cash" + | "transfer" + | "card" + | "check" + | "other" + | null, + }; +} + +function toIso(v: string): string { + if (!v) return v; + if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return `${v}T00:00:00.000+00:00`; + return v; +} + +export async function createFinanceEntryAction( + _prev: FinanceActionState, + formData: FormData, +): Promise { + let ctx; + try { + ctx = await requireTenant(); + } catch { + return { ok: false, error: "Yetkiniz yok." }; + } + + const parsed = financeEntrySchema.safeParse(pickFormFields(formData)); + if (!parsed.success) { + return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) }; + } + + try { + const { tablesDB } = createAdminClient(); + const data = { ...parsed.data, date: toIso(parsed.data.date) }; + const row = await tablesDB.createRow( + DATABASE_ID, + TABLES.financeEntries, + ID.unique(), + { + tenantId: ctx.tenantId, + createdBy: ctx.user.id, + ...data, + }, + teamRowPermissions(ctx.tenantId), + ); + + await logAudit({ + tenantId: ctx.tenantId, + userId: ctx.user.id, + action: "create", + entityType: "finance_entry", + entityId: row.$id, + changes: data, + }); + } catch (e) { + return { ok: false, error: appwriteError(e) }; + } + + revalidatePath("/finance"); + return { ok: true }; +} + +export async function updateFinanceEntryAction( + _prev: FinanceActionState, + formData: FormData, +): Promise { + const id = String(formData.get("id") ?? ""); + if (!id) return { ok: false, error: "ID eksik." }; + + let ctx; + try { + ctx = await requireTenant(); + } catch { + return { ok: false, error: "Yetkiniz yok." }; + } + + const parsed = financeEntrySchema.safeParse(pickFormFields(formData)); + if (!parsed.success) { + return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) }; + } + + try { + const { tablesDB } = createAdminClient(); + const existing = (await tablesDB.getRow( + DATABASE_ID, + TABLES.financeEntries, + id, + )) as unknown as FinanceEntry; + if (existing.tenantId !== ctx.tenantId) { + return { ok: false, error: "Erişim engellendi." }; + } + + const data = { ...parsed.data, date: toIso(parsed.data.date) }; + await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, id, data); + + await logAudit({ + tenantId: ctx.tenantId, + userId: ctx.user.id, + action: "update", + entityType: "finance_entry", + entityId: id, + changes: data, + }); + } catch (e) { + return { ok: false, error: appwriteError(e) }; + } + + revalidatePath("/finance"); + return { ok: true }; +} + +export async function deleteFinanceEntryAction( + formData: FormData, +): Promise { + const id = String(formData.get("id") ?? ""); + if (!id) return { ok: false, error: "ID eksik." }; + + let ctx; + try { + ctx = await requireTenant(); + } catch { + return { ok: false, error: "Yetkiniz yok." }; + } + + try { + const { tablesDB } = createAdminClient(); + const existing = (await tablesDB.getRow( + DATABASE_ID, + TABLES.financeEntries, + id, + )) as unknown as FinanceEntry; + if (existing.tenantId !== ctx.tenantId) { + return { ok: false, error: "Erişim engellendi." }; + } + + await tablesDB.deleteRow(DATABASE_ID, TABLES.financeEntries, id); + + await logAudit({ + tenantId: ctx.tenantId, + userId: ctx.user.id, + action: "delete", + entityType: "finance_entry", + entityId: id, + changes: { type: existing.type, amount: existing.amount }, + }); + } catch (e) { + return { ok: false, error: appwriteError(e) }; + } + + revalidatePath("/finance"); + return { ok: true }; +} diff --git a/src/lib/appwrite/finance-queries.ts b/src/lib/appwrite/finance-queries.ts new file mode 100644 index 0000000..604bdce --- /dev/null +++ b/src/lib/appwrite/finance-queries.ts @@ -0,0 +1,24 @@ +import "server-only"; + +import { Query } from "node-appwrite"; + +import { createAdminClient } from "./server"; +import { DATABASE_ID, TABLES, type FinanceEntry } from "./schema"; + +export async function listFinanceEntries(tenantId: string): Promise { + try { + const { tablesDB } = createAdminClient(); + const result = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.financeEntries, + queries: [ + Query.equal("tenantId", tenantId), + Query.orderDesc("date"), + Query.limit(1000), + ], + }); + return result.rows as unknown as FinanceEntry[]; + } catch { + return []; + } +} diff --git a/src/lib/appwrite/finance-types.ts b/src/lib/appwrite/finance-types.ts new file mode 100644 index 0000000..94caafc --- /dev/null +++ b/src/lib/appwrite/finance-types.ts @@ -0,0 +1,7 @@ +export type FinanceActionState = { + ok: boolean; + error?: string; + fieldErrors?: Record; +}; + +export const initialFinanceState: FinanceActionState = { ok: false }; diff --git a/src/lib/validation/finance.ts b/src/lib/validation/finance.ts new file mode 100644 index 0000000..1d6aef4 --- /dev/null +++ b/src/lib/validation/finance.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const financeEntrySchema = z.object({ + type: z.enum(["income", "expense", "debt", "receivable"]), + amount: z + .union([z.number(), z.string()]) + .transform((v) => (typeof v === "string" ? Number(v.replace(",", ".")) : v)) + .pipe(z.number().positive("Tutar 0'dan büyük olmalı.")), + date: z.string().min(1, "Tarih zorunlu."), + description: z.string().trim().max(1000).optional().transform((v) => (v ? v : undefined)), + customerId: z.string().optional().transform((v) => (v ? v : undefined)), + invoiceId: z.string().optional().transform((v) => (v ? v : undefined)), + paymentMethod: z + .enum(["cash", "transfer", "card", "check", "other"]) + .optional() + .transform((v) => v || undefined), +}); + +export type FinanceEntryInput = z.infer;
{label}
{formatTRY(amount)}
Kayıt yok.
{state.fieldErrors.amount}
{ctx.settings?.companyName ?? "Çalışma alanı"}
+ Nakit hareketleri, borç ve alacaklarınızı tek yerden takip edin. +