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,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<InvoiceItemRow | null>(null);
const [deleting, setDeleting] = useState<InvoiceItemRow | null>(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 (
<>
<Card>
<CardContent className="p-0">
<div className="flex items-center justify-between border-b p-4">
<h2 className="text-sm font-semibold">Kalemler ({items.length})</h2>
<Button
size="sm"
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
>
<Plus className="size-3.5" />
Kalem ekle
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Açıklama</TableHead>
<TableHead className="w-[100px] text-right">Miktar</TableHead>
<TableHead className="w-[140px] text-right">Birim fiyat</TableHead>
<TableHead className="w-[80px] text-right">KDV %</TableHead>
<TableHead className="w-[140px] text-right">Toplam</TableHead>
<TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length ? (
items.map((it) => (
<TableRow
key={it.id}
className="cursor-pointer"
onClick={() => {
setEditing(it);
setFormOpen(true);
}}
>
<TableCell className="font-medium">{it.description}</TableCell>
<TableCell className="text-right tabular-nums">{it.quantity}</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(it.unitPrice)}
</TableCell>
<TableCell className="text-right tabular-nums">{it.vatRate}%</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatTRY(it.lineTotal)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={(e) => {
e.stopPropagation();
setDeleting(it);
}}
>
<Trash2 className="size-3.5" />
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="h-20 text-center">
<p className="text-muted-foreground text-sm">
Henüz kalem eklenmemiş. Yukarıdan ekleyin.
</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<ItemFormSheet
open={formOpen}
onOpenChange={(v) => {
setFormOpen(v);
if (!v) setEditing(null);
}}
invoiceId={invoiceId}
item={editing}
/>
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Kalemi sil</DialogTitle>
<DialogDescription>
<strong>{deleting?.description}</strong> kalemini silmek istediğinize emin misiniz?
</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>
</>
);
}
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Kalemi düzenle" : "Yeni kalem"}</SheetTitle>
</SheetHeader>
<form action={formAction} className="flex flex-1 flex-col">
{isEdit && item && <input type="hidden" name="id" value={item.id} />}
{!isEdit && <input type="hidden" name="invoiceId" value={invoiceId} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-2">
<Label htmlFor="description">Açıklama *</Label>
<Input
id="description"
name="description"
defaultValue={item?.description ?? ""}
placeholder="Hizmet / ürün açıklaması"
required
/>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="quantity">Miktar *</Label>
<Input
id="quantity"
name="quantity"
type="number"
step="0.01"
min="0.01"
defaultValue={item?.quantity ?? "1"}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="unitPrice">Birim () *</Label>
<Input
id="unitPrice"
name="unitPrice"
type="number"
step="0.01"
min="0"
defaultValue={item?.unitPrice ?? ""}
placeholder="0.00"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="vatRate">KDV %</Label>
<Input
id="vatRate"
name="vatRate"
type="number"
step="0.1"
min="0"
max="100"
defaultValue={item?.vatRate ?? "20"}
/>
</div>
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
<div className="flex w-full justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Ekle"}
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}