mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
158 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
} |