Files
acmcc/src/pages/accounting/AccountingDocumentForm.tsx
T
2026-06-01 20:19:26 -04:00

158 lines
7.4 KiB
TypeScript

import { useState } from "react";
import { accounting } from "@/lib/accountingClient";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Loader2, Plus, Trash2 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
type Item = { description: string; quantity: number; rate: number };
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
companyId: string;
/** "invoices" or "bills" */
table: "invoices" | "bills";
/** "invoice" or "bill" */
itemTable: "invoice_items" | "bill_items";
/** Counterparty options: customers for invoices, vendors for bills */
parties: Array<{ id: string; name: string }>;
partyField: "customer_id" | "vendor_id";
partyLabel: string;
onSaved: () => void;
}
export default function AccountingDocumentForm({
open, onOpenChange, companyId, table, itemTable, parties, partyField, partyLabel, onSaved,
}: Props) {
const { toast } = useToast();
const [saving, setSaving] = useState(false);
const [partyId, setPartyId] = useState<string>("");
const [number, setNumber] = useState("");
const [issueDate, setIssueDate] = useState(new Date().toISOString().slice(0, 10));
const [dueDate, setDueDate] = useState("");
const [notes, setNotes] = useState("");
const [items, setItems] = useState<Item[]>([{ description: "", quantity: 1, rate: 0 }]);
const subtotal = items.reduce((s, i) => s + i.quantity * i.rate, 0);
const reset = () => {
setPartyId(""); setNumber(""); setIssueDate(new Date().toISOString().slice(0, 10));
setDueDate(""); setNotes(""); setItems([{ description: "", quantity: 1, rate: 0 }]);
};
const handleSave = async () => {
if (!number.trim() || items.length === 0) return;
setSaving(true);
const { data: doc, error } = await accounting.from(table).insert({
company_id: companyId,
[partyField]: partyId || null,
number,
issue_date: issueDate,
due_date: dueDate || null,
subtotal,
tax: 0,
total: subtotal,
notes: notes || null,
}).select().single();
if (error || !doc) {
toast({ title: "Failed to save", description: error?.message, variant: "destructive" });
setSaving(false);
return;
}
const lines = items
.filter((i) => i.description.trim())
.map((i) => ({
[`${table === "invoices" ? "invoice" : "bill"}_id`]: (doc as any).id,
description: i.description,
quantity: i.quantity,
rate: i.rate,
amount: i.quantity * i.rate,
}));
if (lines.length) {
const { error: e2 } = await accounting.from(itemTable).insert(lines);
if (e2) toast({ title: "Saved, but line items failed", description: e2.message, variant: "destructive" });
}
toast({ title: `${table === "invoices" ? "Invoice" : "Bill"} created` });
reset();
onOpenChange(false);
onSaved();
setSaving(false);
};
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) reset(); onOpenChange(v); }}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>New {table === "invoices" ? "Invoice" : "Bill"}</DialogTitle>
<DialogDescription>Create a {table === "invoices" ? "customer invoice" : "vendor bill"} with line items.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label>{partyLabel}</Label>
<Select value={partyId} onValueChange={setPartyId}>
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>
{parties.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1"><Label>Number</Label><Input value={number} onChange={(e) => setNumber(e.target.value)} placeholder="0001" /></div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1"><Label>Date</Label><Input type="date" value={issueDate} onChange={(e) => setIssueDate(e.target.value)} /></div>
<div className="space-y-1"><Label>Due</Label><Input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} /></div>
</div>
</div>
<div className="border rounded-md">
<div className="grid grid-cols-12 gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b">
<div className="col-span-6">Description</div>
<div className="col-span-2 text-right">Qty</div>
<div className="col-span-2 text-right">Rate</div>
<div className="col-span-1 text-right">Amount</div>
<div className="col-span-1"></div>
</div>
{items.map((it, idx) => (
<div key={idx} className="grid grid-cols-12 gap-2 px-3 py-2 border-b last:border-b-0">
<Input className="col-span-6" value={it.description} onChange={(e) => {
const next = [...items]; next[idx] = { ...it, description: e.target.value }; setItems(next);
}} />
<Input className="col-span-2 text-right" type="number" step="0.01" value={it.quantity} onChange={(e) => {
const next = [...items]; next[idx] = { ...it, quantity: Number(e.target.value) }; setItems(next);
}} />
<Input className="col-span-2 text-right" type="number" step="0.01" value={it.rate} onChange={(e) => {
const next = [...items]; next[idx] = { ...it, rate: Number(e.target.value) }; setItems(next);
}} />
<div className="col-span-1 text-right text-sm tabular-nums self-center">{(it.quantity * it.rate).toFixed(2)}</div>
<Button type="button" variant="ghost" size="icon" className="col-span-1 h-8 w-8 justify-self-end"
onClick={() => setItems(items.filter((_, i) => i !== idx))} disabled={items.length === 1}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<div className="flex items-center justify-between px-3 py-2 bg-muted/30">
<Button type="button" size="sm" variant="outline" className="gap-2"
onClick={() => setItems([...items, { description: "", quantity: 1, rate: 0 }])}>
<Plus className="h-3 w-3" /> Line
</Button>
<div className="text-sm">Total: <span className="font-semibold tabular-nums">${subtotal.toFixed(2)}</span></div>
</div>
</div>
<div className="space-y-1"><Label>Notes</Label><Textarea rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={saving || !number.trim()}>{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : "Create"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}