From d99daca3ca53cc045ae2f7e71cfea6bac17a60a5 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 30 Apr 2026 06:09:24 +0300 Subject: [PATCH] feat(invoices): full invoice + line items module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The most complex module. Two-table model: invoices (header) + invoice_items (lines). Auto-numbering via tenant_settings.invoiceCounter, auto-totals on item changes. Schema/validation: - lib/validation/invoices.ts: invoiceSchema (header) + invoiceItemSchema (line). Both coerce comma decimals. - lib/appwrite/invoice-actions.ts: * createInvoiceAction — fetches tenant_settings, increments invoiceCounter, formats number as '{prefix}-{year}-{0000}', persists totals as 0/0/0 (recomputed when items added). * updateInvoiceAction / deleteInvoiceAction — header CRUD; delete cascades to remove all items first then header. * addInvoiceItemAction / updateInvoiceItemAction / deleteInvoiceItemAction — line CRUD. Each computes lineTotal (qty*unit + vat) and triggers recomputeTotals(invoiceId) which re-sums all items and updates the header subtotal/vatTotal/total. All audit-logged. Queries: - listInvoices, getInvoice (with tenant cross-check), listInvoiceItems. UI: - /invoices index: 4 stat cards (Toplam / Tahsil edildi / Bekleyen / Gecikmiş), table with overdue-aware due date coloring, status badges, number is a Link to detail. - InvoiceFormSheet: customer + dates (default issue=today, due=+30d) + status + notes. After create, redirects to /invoices/[id] for adding items. - /invoices/[id] detail: header strip with status, dates, customer name; print/edit/delete actions; items editor card; subtotal/VAT/total card; notes card. - InvoiceItemsEditor: rows are clickable to edit, X button to delete. ItemFormSheet for add/edit (description + qty + unitPrice + VAT %). Print is just window.print() for now — relies on browser dialog. Detail page deliberately uses tabular-nums for amounts. --- .../[id]/components/header-actions.tsx | 95 ++++ .../invoices/[id]/components/items-editor.tsx | 303 +++++++++++ src/app/(dashboard)/invoices/[id]/page.tsx | 134 +++++ .../components/invoice-form-sheet.tsx | 190 +++++++ .../invoices/components/invoices-client.tsx | 372 ++++++++++++++ .../(dashboard)/invoices/components/types.ts | 33 ++ src/app/(dashboard)/invoices/page.tsx | 56 ++ src/lib/appwrite/invoice-actions.ts | 486 ++++++++++++++++++ src/lib/appwrite/invoice-queries.ts | 64 +++ src/lib/appwrite/invoice-types.ts | 8 + src/lib/validation/invoices.ts | 36 ++ 11 files changed, 1777 insertions(+) create mode 100644 src/app/(dashboard)/invoices/[id]/components/header-actions.tsx create mode 100644 src/app/(dashboard)/invoices/[id]/components/items-editor.tsx create mode 100644 src/app/(dashboard)/invoices/[id]/page.tsx create mode 100644 src/app/(dashboard)/invoices/components/invoice-form-sheet.tsx create mode 100644 src/app/(dashboard)/invoices/components/invoices-client.tsx create mode 100644 src/app/(dashboard)/invoices/components/types.ts create mode 100644 src/app/(dashboard)/invoices/page.tsx create mode 100644 src/lib/appwrite/invoice-actions.ts create mode 100644 src/lib/appwrite/invoice-queries.ts create mode 100644 src/lib/appwrite/invoice-types.ts create mode 100644 src/lib/validation/invoices.ts diff --git a/src/app/(dashboard)/invoices/[id]/components/header-actions.tsx b/src/app/(dashboard)/invoices/[id]/components/header-actions.tsx new file mode 100644 index 0000000..1693043 --- /dev/null +++ b/src/app/(dashboard)/invoices/[id]/components/header-actions.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, Pencil, Printer, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { deleteInvoiceAction } from "@/lib/appwrite/invoice-actions"; + +import { InvoiceFormSheet } from "../../components/invoice-form-sheet"; +import type { Customer, InvoiceRow } from "../../components/types"; + +type Props = { invoice: InvoiceRow; customers: Customer[] }; + +export function InvoiceHeaderActions({ invoice, customers }: Props) { + const router = useRouter(); + const [editOpen, setEditOpen] = useState(false); + const [deleting, setDeleting] = useState(false); + const [busy, startTransition] = useTransition(); + + const handleDelete = () => { + startTransition(async () => { + const fd = new FormData(); + fd.set("id", invoice.id); + const result = await deleteInvoiceAction(fd); + if (result.ok) { + toast.success("Fatura silindi."); + router.push("/invoices"); + } else { + toast.error(result.error ?? "Silme başarısız."); + setDeleting(false); + } + }); + }; + + return ( + <> +
+ + + +
+ + + + + + + Faturayı sil + + {invoice.number} ve tüm kalemleri silinecek. Bu işlem geri alınamaz. + + + + + + + + + + ); +} diff --git a/src/app/(dashboard)/invoices/[id]/components/items-editor.tsx b/src/app/(dashboard)/invoices/[id]/components/items-editor.tsx new file mode 100644 index 0000000..52b3157 --- /dev/null +++ b/src/app/(dashboard)/invoices/[id]/components/items-editor.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { useActionState, useEffect, useState, useTransition } from "react"; +import { Loader2, Plus, Save, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +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 { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + addInvoiceItemAction, + deleteInvoiceItemAction, + updateInvoiceItemAction, +} from "@/lib/appwrite/invoice-actions"; +import { initialInvoiceState } from "@/lib/appwrite/invoice-types"; +import { formatTRY } from "@/lib/format"; + +export type InvoiceItemRow = { + id: string; + description: string; + quantity: number; + unitPrice: number; + vatRate: number; + lineTotal: number; +}; + +type Props = { invoiceId: string; items: InvoiceItemRow[] }; + +export function InvoiceItemsEditor({ invoiceId, items }: Props) { + const [formOpen, setFormOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + const [busy, startTransition] = useTransition(); + + const handleDelete = () => { + if (!deleting) return; + startTransition(async () => { + const fd = new FormData(); + fd.set("id", deleting.id); + const result = await deleteInvoiceItemAction(fd); + if (result.ok) { + toast.success("Kalem silindi."); + setDeleting(null); + } else { + toast.error(result.error ?? "Silme başarısız."); + } + }); + }; + + return ( + <> + + +
+

Kalemler ({items.length})

+ +
+ + + + + Açıklama + Miktar + Birim fiyat + KDV % + Toplam + + + + + {items.length ? ( + items.map((it) => ( + { + setEditing(it); + setFormOpen(true); + }} + > + {it.description} + {it.quantity} + + {formatTRY(it.unitPrice)} + + {it.vatRate}% + + {formatTRY(it.lineTotal)} + + + + + + )) + ) : ( + + +

+ Henüz kalem eklenmemiş. Yukarıdan ekleyin. +

+
+
+ )} +
+
+
+
+ + { + setFormOpen(v); + if (!v) setEditing(null); + }} + invoiceId={invoiceId} + item={editing} + /> + + !v && setDeleting(null)}> + + + Kalemi sil + + {deleting?.description} kalemini silmek istediğinize emin misiniz? + + + + + + + + + + ); +} + +function ItemFormSheet({ + open, + onOpenChange, + invoiceId, + item, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + invoiceId: string; + item?: InvoiceItemRow | null; +}) { + const isEdit = Boolean(item); + const action = isEdit ? updateInvoiceItemAction : addInvoiceItemAction; + const [state, formAction, isPending] = useActionState(action, initialInvoiceState); + + useEffect(() => { + if (state.ok) { + toast.success(isEdit ? "Kalem güncellendi." : "Kalem eklendi."); + onOpenChange(false); + } else if (state.error) { + toast.error(state.error); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state]); + + return ( + + + + {isEdit ? "Kalemi düzenle" : "Yeni kalem"} + + +
+ {isEdit && item && } + {!isEdit && } + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/invoices/[id]/page.tsx b/src/app/(dashboard)/invoices/[id]/page.tsx new file mode 100644 index 0000000..9563e0c --- /dev/null +++ b/src/app/(dashboard)/invoices/[id]/page.tsx @@ -0,0 +1,134 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { listCustomers } from "@/lib/appwrite/customer-queries"; +import { getInvoice, listInvoiceItems } from "@/lib/appwrite/invoice-queries"; +import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import { formatDate, formatTRY } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +import { STATUS_COLOR, STATUS_LABEL } from "../components/types"; +import { InvoiceItemsEditor } from "./components/items-editor"; +import { InvoiceHeaderActions } from "./components/header-actions"; + +export const metadata: Metadata = { + title: "İşletmem — Fatura", +}; + +export default async function InvoiceDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + let ctx; + try { + ctx = await requireTenant(); + } catch { + redirect("/onboarding"); + } + + const invoice = await getInvoice(ctx.tenantId, id); + if (!invoice) notFound(); + + const [items, customers] = await Promise.all([ + listInvoiceItems(ctx.tenantId, id), + listCustomers(ctx.tenantId), + ]); + + const customerName = customers.find((c) => c.$id === invoice.customerId)?.name ?? "—"; + + return ( +
+
+ +
+ +
+
+

{customerName}

+

{invoice.number}

+
+ + {STATUS_LABEL[invoice.status ?? "draft"]} + + + Düzenleme: {formatDate(invoice.issueDate)} + + Vade: {formatDate(invoice.dueDate)} +
+
+ ({ id: c.$id, name: c.name }))} + /> +
+ + ({ + id: it.$id, + description: it.description, + quantity: it.quantity, + unitPrice: it.unitPrice, + vatRate: it.vatRate ?? 0, + lineTotal: it.lineTotal, + }))} + /> + + + +
+ Ara toplam + {formatTRY(invoice.subtotal ?? 0)} +
+
+ KDV + {formatTRY(invoice.vatTotal ?? 0)} +
+
+
+ Genel toplam + {formatTRY(invoice.total ?? 0)} +
+
+
+ + {invoice.notes && ( + + +

Notlar

+

{invoice.notes}

+
+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/invoices/components/invoice-form-sheet.tsx b/src/app/(dashboard)/invoices/components/invoice-form-sheet.tsx new file mode 100644 index 0000000..e09fed6 --- /dev/null +++ b/src/app/(dashboard)/invoices/components/invoice-form-sheet.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useActionState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +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 { + createInvoiceAction, + updateInvoiceAction, +} from "@/lib/appwrite/invoice-actions"; +import { initialInvoiceState } from "@/lib/appwrite/invoice-types"; + +import type { Customer, InvoiceRow } from "./types"; + +type Props = { + open: boolean; + onOpenChange: (v: boolean) => void; + invoice?: InvoiceRow | null; + customers: Customer[]; +}; + +function isoToDate(iso: string): string { + if (!iso) return ""; + return iso.slice(0, 10); +} + +export function InvoiceFormSheet({ open, onOpenChange, invoice, customers }: Props) { + const isEdit = Boolean(invoice); + const action = isEdit ? updateInvoiceAction : createInvoiceAction; + const [state, formAction, isPending] = useActionState(action, initialInvoiceState); + const router = useRouter(); + + useEffect(() => { + if (state.ok) { + toast.success(isEdit ? "Fatura güncellendi." : "Fatura oluşturuldu."); + onOpenChange(false); + if (!isEdit && state.invoiceId) { + router.push(`/invoices/${state.invoiceId}`); + } + } 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); + const inThirty = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + + return ( + + + + {isEdit ? "Faturayı düzenle" : "Yeni fatura"} + + {isEdit + ? "Fatura bilgilerini güncelleyin. Kalem eklemek için fatura detayına gidin." + : "Faturayı oluşturun, ardından detay sayfasında kalemleri ekleyin. Numara otomatik üretilir."} + + + +
+ {isEdit && invoice && } + +
+
+ + + {state.fieldErrors?.customerId && ( +

{state.fieldErrors.customerId}

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