d99daca3ca
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.
304 lines
9.6 KiB
TypeScript
304 lines
9.6 KiB
TypeScript
"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>
|
||
);
|
||
}
|