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