mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Accounting: recurring bills & journal entries
Add accounting.recurring_templates (bill|journal) with a schedule
(frequency/interval/day-of-month/start/end). New generate_due_recurring()
materialises real bills (-> A/P) and journal entries on cadence — catching
up any missed periods — posting through existing triggers. Runs nightly via
pg_cron ('accounting-recurring-daily') and on demand from a new Recurring
page ('Generate due now'). Page lists templates with pause/resume/edit/
delete; create dialog handles both kinds (vendor + line items for bills,
signed balanced lines for journals). Wired into routes + accounting nav.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
AccountingDashboardPage,
|
||||
AccountingChartOfAccountsPage,
|
||||
AccountingJournalEntriesPage,
|
||||
AccountingRecurringPage,
|
||||
AccountingGeneralLedgerPage,
|
||||
AccountingInvoicesPage,
|
||||
AccountingBillsPage,
|
||||
@@ -381,6 +382,7 @@ const App = () => (
|
||||
<Route index element={<AccountingDashboardPage />} />
|
||||
<Route path="chart-of-accounts" element={<AccountingChartOfAccountsPage />} />
|
||||
<Route path="journal-entries" element={<AccountingJournalEntriesPage />} />
|
||||
<Route path="recurring" element={<AccountingRecurringPage />} />
|
||||
<Route path="general-ledger" element={<AccountingGeneralLedgerPage />} />
|
||||
<Route path="invoices" element={<AccountingInvoicesPage />} />
|
||||
<Route path="bills" element={<AccountingBillsPage />} />
|
||||
|
||||
@@ -2,6 +2,7 @@ export { default as AccountingLayout } from "./AccountingLayout";
|
||||
export { default as AccountingDashboardPage } from "./AccountingDashboardPage";
|
||||
export { default as AccountingChartOfAccountsPage } from "./AccountingChartOfAccountsPage";
|
||||
export { default as AccountingJournalEntriesPage } from "./AccountingJournalEntriesPage";
|
||||
export { default as AccountingRecurringPage } from "./AccountingRecurringPage";
|
||||
export { default as AccountingGeneralLedgerPage } from "./AccountingGeneralLedgerPage";
|
||||
export { default as AccountingInvoicesPage } from "./AccountingInvoicesPage";
|
||||
export { default as AccountingBillsPage } from "./AccountingBillsPage";
|
||||
|
||||
@@ -45,6 +45,7 @@ const NAV: NavSection[] = [
|
||||
{ to: "chart-of-accounts", label: "Chart of Accounts" },
|
||||
{ to: "budgets", label: "Budgeting" },
|
||||
{ to: "journal-entries", label: "Journal Entries" },
|
||||
{ to: "recurring", label: "Recurring" },
|
||||
{ to: "general-ledger", label: "General Ledger" },
|
||||
{ to: "banking", label: "Banking" },
|
||||
{ to: "reconciliation", label: "Reconciliation" },
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { accounting } from "@/lib/accountingClient";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useCompanyId } from "./lib/useCompanyId";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||
import { Plus, Trash2, Pencil, Loader2, Play, Pause, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { money, fmtDate } from "./lib/format";
|
||||
|
||||
type Kind = "bill" | "journal";
|
||||
type Freq = "weekly" | "monthly" | "quarterly" | "yearly";
|
||||
type Item = { description: string; quantity: number; rate: number; account_id: string | null };
|
||||
type Line = { account_id: string; amount: string; description: string };
|
||||
|
||||
const FREQS: { value: Freq; label: string }[] = [
|
||||
{ value: "weekly", label: "Weekly" },
|
||||
{ value: "monthly", label: "Monthly" },
|
||||
{ value: "quarterly", label: "Quarterly" },
|
||||
{ value: "yearly", label: "Yearly" },
|
||||
];
|
||||
const todayISO = () => new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" });
|
||||
const newItem = (): Item => ({ description: "", quantity: 1, rate: 0, account_id: null });
|
||||
const newLine = (): Line => ({ account_id: "", amount: "", description: "" });
|
||||
|
||||
function scheduleLabel(t: any): string {
|
||||
const every = t.interval_count > 1 ? `every ${t.interval_count} ` : "";
|
||||
const unit = { weekly: "week", monthly: "month", quarterly: "quarter", yearly: "year" }[t.frequency as Freq];
|
||||
const plural = t.interval_count > 1 ? "s" : "";
|
||||
const dom = t.day_of_month && t.frequency !== "weekly" ? ` on day ${t.day_of_month}` : "";
|
||||
return `${every}${unit}${plural}${dom}`.replace(/^every 1 /, "");
|
||||
}
|
||||
function templateAmount(t: any): number {
|
||||
const p = t.payload ?? {};
|
||||
if (t.kind === "bill") {
|
||||
const sub = (p.items ?? []).reduce((s: number, i: any) => s + Number(i.quantity || 0) * Number(i.rate || 0), 0);
|
||||
return sub * (1 + Number(p.tax_pct || 0) / 100);
|
||||
}
|
||||
return (p.lines ?? []).reduce((s: number, l: any) => s + Math.max(0, Number(l.amount || 0)), 0);
|
||||
}
|
||||
|
||||
export default function AccountingRecurringPage() {
|
||||
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
|
||||
const cid = companyId ?? "";
|
||||
const cur = "USD";
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
// shared schedule fields
|
||||
const [kind, setKind] = useState<Kind>("bill");
|
||||
const [name, setName] = useState("");
|
||||
const [frequency, setFrequency] = useState<Freq>("monthly");
|
||||
const [intervalCount, setIntervalCount] = useState("1");
|
||||
const [dayOfMonth, setDayOfMonth] = useState("");
|
||||
const [startDate, setStartDate] = useState(todayISO());
|
||||
const [endDate, setEndDate] = useState("");
|
||||
// bill fields
|
||||
const [vendorId, setVendorId] = useState("");
|
||||
const [dueDays, setDueDays] = useState("30");
|
||||
const [memo, setMemo] = useState("");
|
||||
const [items, setItems] = useState<Item[]>([newItem()]);
|
||||
// journal fields
|
||||
const [jDescription, setJDescription] = useState("");
|
||||
const [jReference, setJReference] = useState("");
|
||||
const [lines, setLines] = useState<Line[]>([newLine(), newLine()]);
|
||||
|
||||
const { data: templates = [], isLoading } = useQuery({
|
||||
queryKey: ["recurring-templates", cid],
|
||||
enabled: !!cid,
|
||||
queryFn: async () =>
|
||||
(await accounting.from("recurring_templates").select("*").eq("company_id", cid).order("next_run_date")).data ?? [],
|
||||
});
|
||||
|
||||
const { data: vendors = [] } = useQuery({
|
||||
queryKey: ["vendors-lookup", associationId],
|
||||
enabled: !!associationId,
|
||||
queryFn: async () => (await supabase.from("vendors").select("id,name").eq("is_active", true)
|
||||
.or(`association_id.eq.${associationId},association_ids.cs.{${associationId}}`).order("name")).data ?? [],
|
||||
});
|
||||
|
||||
const { data: accounts = [] } = useQuery({
|
||||
queryKey: ["recurring-accounts", cid],
|
||||
enabled: !!cid,
|
||||
queryFn: async () => (await accounting.from("accounts").select("id,name,code,type,is_bank")
|
||||
.eq("company_id", cid).eq("is_archived", false).order("code")).data ?? [],
|
||||
});
|
||||
const billAccounts = useMemo(() => (accounts as any[]).filter((a) => !a.is_bank && ["expense", "asset", "equity", "liability"].includes(a.type)), [accounts]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingId(null); setKind("bill"); setName(""); setFrequency("monthly"); setIntervalCount("1");
|
||||
setDayOfMonth(""); setStartDate(todayISO()); setEndDate("");
|
||||
setVendorId(""); setDueDays("30"); setMemo(""); setItems([newItem()]);
|
||||
setJDescription(""); setJReference(""); setLines([newLine(), newLine()]);
|
||||
};
|
||||
const openCreate = () => { resetForm(); setOpen(true); };
|
||||
const openEdit = (t: any) => {
|
||||
const p = t.payload ?? {};
|
||||
setEditingId(t.id); setKind(t.kind); setName(t.name); setFrequency(t.frequency);
|
||||
setIntervalCount(String(t.interval_count)); setDayOfMonth(t.day_of_month ? String(t.day_of_month) : "");
|
||||
setStartDate(t.next_run_date ?? t.start_date ?? todayISO()); setEndDate(t.end_date ?? "");
|
||||
if (t.kind === "bill") {
|
||||
setVendorId(p.vendor_public_id ?? ""); setDueDays(String(p.due_days ?? 30));
|
||||
setMemo(p.memo ?? "");
|
||||
setItems((p.items ?? []).length ? p.items.map((i: any) => ({ description: i.description ?? "", quantity: Number(i.quantity ?? 1), rate: Number(i.rate ?? 0), account_id: i.account_id ?? null })) : [newItem()]);
|
||||
} else {
|
||||
setJDescription(p.description ?? ""); setJReference(p.reference ?? "");
|
||||
setLines((p.lines ?? []).length ? p.lines.map((l: any) => ({ account_id: l.account_id ?? "", amount: String(l.amount ?? ""), description: l.description ?? "" })) : [newLine(), newLine()]);
|
||||
}
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const billTotal = useMemo(() => items.reduce((s, i) => s + Number(i.quantity || 0) * Number(i.rate || 0), 0), [items]);
|
||||
const jSigned = useMemo(() => lines.reduce((s, l) => s + (Number(l.amount) || 0), 0), [lines]);
|
||||
const jBalanced = Math.abs(jSigned) < 0.005;
|
||||
|
||||
const save = async () => {
|
||||
if (!name.trim()) return toast.error("Give the template a name");
|
||||
const n = Math.max(1, parseInt(intervalCount) || 1);
|
||||
const dom = dayOfMonth ? Math.min(31, Math.max(1, parseInt(dayOfMonth))) : null;
|
||||
let payload: any;
|
||||
if (kind === "bill") {
|
||||
if (!vendorId) return toast.error("Select a vendor");
|
||||
const liveItems = items.filter((i) => i.account_id && (Number(i.quantity) * Number(i.rate)) !== 0);
|
||||
if (!liveItems.length) return toast.error("Add at least one line with an account and amount");
|
||||
// resolve the public vendor to its accounting.vendors row (find-or-create)
|
||||
const { data: acctVendor, error: vErr } = await supabase.rpc("ensure_accounting_vendor", { _association_id: associationId, _public_vendor_id: vendorId });
|
||||
if (vErr) return toast.error(vErr.message);
|
||||
payload = {
|
||||
vendor_id: acctVendor, vendor_public_id: vendorId,
|
||||
due_days: parseInt(dueDays) || 0, tax_pct: 0, memo: memo || null,
|
||||
items: liveItems.map((i) => ({ account_id: i.account_id, description: i.description, quantity: Number(i.quantity), rate: Number(i.rate) })),
|
||||
};
|
||||
} else {
|
||||
const liveLines = lines.filter((l) => l.account_id && Number(l.amount));
|
||||
if (liveLines.length < 2) return toast.error("Add at least two lines with an account and amount");
|
||||
if (!jBalanced) return toast.error(`Out of balance by ${money(Math.abs(jSigned), cur)} — debits (+) must equal credits (−)`);
|
||||
payload = {
|
||||
description: jDescription || name, reference: jReference || null,
|
||||
lines: liveLines.map((l) => ({ account_id: l.account_id, amount: Number(l.amount), description: l.description || null })),
|
||||
};
|
||||
}
|
||||
setSaving(true);
|
||||
const row: any = {
|
||||
company_id: cid, kind, name: name.trim(), frequency, interval_count: n, day_of_month: dom,
|
||||
start_date: startDate, next_run_date: startDate, end_date: endDate || null, payload,
|
||||
};
|
||||
let error;
|
||||
if (editingId) {
|
||||
// keep start_date stable on edit; update everything else incl. next_run_date
|
||||
delete row.start_date;
|
||||
({ error } = await accounting.from("recurring_templates").update(row).eq("id", editingId));
|
||||
} else {
|
||||
({ error } = await accounting.from("recurring_templates").insert(row));
|
||||
}
|
||||
setSaving(false);
|
||||
if (error) return toast.error(error.message);
|
||||
toast.success(editingId ? "Recurring template updated" : "Recurring template created");
|
||||
setOpen(false); resetForm();
|
||||
qc.invalidateQueries({ queryKey: ["recurring-templates", cid] });
|
||||
};
|
||||
|
||||
const togglePause = async (t: any) => {
|
||||
const { error } = await accounting.from("recurring_templates").update({ active: !t.active }).eq("id", t.id);
|
||||
if (error) return toast.error(error.message);
|
||||
qc.invalidateQueries({ queryKey: ["recurring-templates", cid] });
|
||||
};
|
||||
const remove = async (t: any) => {
|
||||
if (!confirm(`Delete recurring ${t.kind} "${t.name}"? Already-generated bills/journals are kept.`)) return;
|
||||
const { error } = await accounting.from("recurring_templates").delete().eq("id", t.id);
|
||||
if (error) return toast.error(error.message);
|
||||
toast.success("Template deleted");
|
||||
qc.invalidateQueries({ queryKey: ["recurring-templates", cid] });
|
||||
};
|
||||
const generateDue = async () => {
|
||||
setGenerating(true);
|
||||
const { data, error } = await accounting.rpc("generate_due_recurring", { p_as_of: todayISO(), p_company_id: cid });
|
||||
setGenerating(false);
|
||||
if (error) return toast.error(error.message);
|
||||
const n = Number(data) || 0;
|
||||
toast.success(n ? `Generated ${n} item${n === 1 ? "" : "s"}` : "Nothing due right now");
|
||||
qc.invalidateQueries({ queryKey: ["recurring-templates", cid] });
|
||||
qc.invalidateQueries({ queryKey: ["bills", cid] });
|
||||
qc.invalidateQueries({ queryKey: ["journal-entries", cid] });
|
||||
qc.invalidateQueries({ queryKey: ["reports-data", cid] });
|
||||
};
|
||||
|
||||
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
||||
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
|
||||
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
|
||||
|
||||
const dueCount = (templates as any[]).filter((t) => t.active && t.next_run_date <= todayISO()).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight">Recurring Bills & Journals</h1>
|
||||
<p className="text-sm text-muted-foreground">Templates auto-generate nightly; {dueCount > 0 ? `${dueCount} due now.` : "nothing due right now."}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={generateDue} disabled={generating}>
|
||||
{generating ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-1 h-4 w-4" />} Generate due now
|
||||
</Button>
|
||||
<Button onClick={openCreate}><Plus className="mr-1 h-4 w-4" /> New recurring</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center"><Loader2 className="h-5 w-5 animate-spin mx-auto text-muted-foreground" /></div>
|
||||
) : (templates as any[]).length === 0 ? (
|
||||
<div className="p-10 text-center text-sm text-muted-foreground">No recurring templates yet. Click <strong>New recurring</strong> to set up a recurring bill or journal entry.</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead><TableHead>Type</TableHead><TableHead>Schedule</TableHead>
|
||||
<TableHead>Next run</TableHead><TableHead className="text-right">Amount</TableHead>
|
||||
<TableHead>Status</TableHead><TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(templates as any[]).map((t) => {
|
||||
const due = t.active && t.next_run_date <= todayISO();
|
||||
return (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="font-medium">{t.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="capitalize">{t.kind}</Badge></TableCell>
|
||||
<TableCell className="capitalize text-muted-foreground">{scheduleLabel(t)}</TableCell>
|
||||
<TableCell className={due ? "text-amber-600 font-medium" : ""}>{fmtDate(t.next_run_date)}{due ? " · due" : ""}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{money(templateAmount(t), cur)}</TableCell>
|
||||
<TableCell>{t.active ? <Badge>Active</Badge> : <Badge variant="secondary">Paused</Badge>}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">
|
||||
<Button variant="ghost" size="icon" title={t.active ? "Pause" : "Resume"} onClick={() => togglePause(t)}>
|
||||
{t.active ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" title="Edit" onClick={() => openEdit(t)}><Pencil className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon" title="Delete" onClick={() => remove(t)}><Trash2 className="h-4 w-4" /></Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) resetForm(); }}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader><DialogTitle>{editingId ? "Edit" : "New"} recurring {kind}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Type</Label>
|
||||
<Select value={kind} onValueChange={(v) => setKind(v as Kind)} disabled={!!editingId}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="bill">Bill (A/P)</SelectItem><SelectItem value="journal">Journal entry</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>Name</Label><Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Monthly management fee" /></div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<Label>Frequency</Label>
|
||||
<Select value={frequency} onValueChange={(v) => setFrequency(v as Freq)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{FREQS.map((f) => <SelectItem key={f.value} value={f.value}>{f.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>Every</Label><Input type="number" min="1" value={intervalCount} onChange={(e) => setIntervalCount(e.target.value)} /></div>
|
||||
<div><Label>{editingId ? "Next run" : "Start date"}</Label><Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} /></div>
|
||||
<div><Label>End date <span className="text-muted-foreground">(optional)</span></Label><Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} /></div>
|
||||
</div>
|
||||
{frequency !== "weekly" && (
|
||||
<div className="w-40"><Label>Day of month <span className="text-muted-foreground">(opt)</span></Label><Input type="number" min="1" max="31" value={dayOfMonth} onChange={(e) => setDayOfMonth(e.target.value)} placeholder="from start" /></div>
|
||||
)}
|
||||
|
||||
{kind === "bill" ? (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<Label>Vendor (Pay to)</Label>
|
||||
<Select value={vendorId} onValueChange={setVendorId}>
|
||||
<SelectTrigger><SelectValue placeholder="Select vendor" /></SelectTrigger>
|
||||
<SelectContent>{(vendors as any[]).map((v) => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>Due in (days)</Label><Input type="number" min="0" value={dueDays} onChange={(e) => setDueDays(e.target.value)} /></div>
|
||||
</div>
|
||||
<div><Label>Memo</Label><Input value={memo} onChange={(e) => setMemo(e.target.value)} placeholder="optional" /></div>
|
||||
<div className="space-y-2">
|
||||
<Label>Line items</Label>
|
||||
{items.map((it, idx) => (
|
||||
<div key={idx} className="grid grid-cols-12 gap-2 items-center">
|
||||
<Select value={it.account_id ?? ""} onValueChange={(v) => setItems(items.map((x, i) => i === idx ? { ...x, account_id: v } : x))}>
|
||||
<SelectTrigger className="col-span-4"><SelectValue placeholder="Account" /></SelectTrigger>
|
||||
<SelectContent>{billAccounts.map((a) => <SelectItem key={a.id} value={a.id}>{a.code} · {a.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Input className="col-span-4" placeholder="Description" value={it.description} onChange={(e) => setItems(items.map((x, i) => i === idx ? { ...x, description: e.target.value } : x))} />
|
||||
<Input className="col-span-1" type="number" title="Qty" value={it.quantity} onChange={(e) => setItems(items.map((x, i) => i === idx ? { ...x, quantity: Number(e.target.value) } : x))} />
|
||||
<Input className="col-span-2" type="number" title="Rate" value={it.rate} onChange={(e) => setItems(items.map((x, i) => i === idx ? { ...x, rate: Number(e.target.value) } : x))} />
|
||||
<Button variant="ghost" size="icon" className="col-span-1" onClick={() => setItems(items.length > 1 ? items.filter((_, i) => i !== idx) : items)}><Trash2 className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" size="sm" onClick={() => setItems([...items, newItem()])}><Plus className="mr-1 h-3 w-3" /> Add line</Button>
|
||||
</div>
|
||||
<div className="text-right text-sm font-medium">Total per bill: {money(billTotal, cur)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>Description</Label><Input value={jDescription} onChange={(e) => setJDescription(e.target.value)} placeholder={name || "Journal description"} /></div>
|
||||
<div><Label>Reference <span className="text-muted-foreground">(opt)</span></Label><Input value={jReference} onChange={(e) => setJReference(e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Lines <span className="text-muted-foreground">(+ debit, − credit)</span></Label>
|
||||
{lines.map((ln, idx) => (
|
||||
<div key={idx} className="grid grid-cols-12 gap-2 items-center">
|
||||
<Select value={ln.account_id} onValueChange={(v) => setLines(lines.map((x, i) => i === idx ? { ...x, account_id: v } : x))}>
|
||||
<SelectTrigger className="col-span-5"><SelectValue placeholder="Account" /></SelectTrigger>
|
||||
<SelectContent>{(accounts as any[]).map((a) => <SelectItem key={a.id} value={a.id}>{a.code} · {a.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Input className="col-span-4" placeholder="Description" value={ln.description} onChange={(e) => setLines(lines.map((x, i) => i === idx ? { ...x, description: e.target.value } : x))} />
|
||||
<Input className="col-span-2" type="number" placeholder="+/-" value={ln.amount} onChange={(e) => setLines(lines.map((x, i) => i === idx ? { ...x, amount: e.target.value } : x))} />
|
||||
<Button variant="ghost" size="icon" className="col-span-1" onClick={() => setLines(lines.length > 2 ? lines.filter((_, i) => i !== idx) : lines)}><Trash2 className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" size="sm" onClick={() => setLines([...lines, newLine()])}><Plus className="mr-1 h-3 w-3" /> Add line</Button>
|
||||
<div className={`text-right text-sm font-medium ${jBalanced ? "text-muted-foreground" : "text-destructive"}`}>
|
||||
{jBalanced ? "Balanced" : `Out of balance by ${money(Math.abs(jSigned), cur)}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setOpen(false); resetForm(); }}>Cancel</Button>
|
||||
<Button onClick={save} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} {editingId ? "Save" : "Create"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user