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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user