feat(invoices): full invoice + line items module

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.
This commit is contained in:
kovakmedya
2026-04-30 06:09:24 +03:00
parent 98ab73235f
commit d99daca3ca
11 changed files with 1777 additions and 0 deletions
@@ -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 (
<>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => window.print()}>
<Printer className="size-3.5" />
Yazdır
</Button>
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="size-3.5" />
Düzenle
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setDeleting(true)}
>
<Trash2 className="size-3.5" />
Sil
</Button>
</div>
<InvoiceFormSheet
open={editOpen}
onOpenChange={setEditOpen}
invoice={invoice}
customers={customers}
/>
<Dialog open={deleting} onOpenChange={setDeleting}>
<DialogContent>
<DialogHeader>
<DialogTitle>Faturayı sil</DialogTitle>
<DialogDescription>
<strong>{invoice.number}</strong> ve tüm kalemleri silinecek. Bu işlem geri alınamaz.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleting(false)} 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>
</>
);
}