Files
isletmem-kovakcrm/src/app/(dashboard)/invoices/[id]/components/items-editor.tsx
T
kovakmedya d99daca3ca 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.
2026-04-30 06:09:24 +03:00

304 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}