Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
574 lines
25 KiB
TypeScript
574 lines
25 KiB
TypeScript
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
|
import { useEffect, useState, useMemo } from "react";
|
|
import { ProtectedLayout } from "@/components/protected-layout";
|
|
import { PageContainer } from "@/components/app-shell";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
|
import { supabase } from "@/integrations/supabase/client";
|
|
import { useAuth } from "@/lib/auth";
|
|
import { ArrowLeft, FileDown, Plus, Trash2, Loader2, Send, Ban, CheckCircle2, DollarSign } from "lucide-react";
|
|
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
|
|
import { downloadInvoicePdf, type InvoicePdfInput } from "@/lib/invoice-pdf";
|
|
import { recalcInvoiceTotals } from "@/lib/invoice-generation";
|
|
import { toast } from "sonner";
|
|
|
|
export const Route = createFileRoute("/invoices/$invoiceId")({
|
|
component: () => (
|
|
<ProtectedLayout>
|
|
<InvoiceDetail />
|
|
</ProtectedLayout>
|
|
),
|
|
});
|
|
|
|
function InvoiceDetail() {
|
|
const { invoiceId } = Route.useParams();
|
|
const navigate = useNavigate();
|
|
const { user, isAdmin } = useAuth();
|
|
const [invoice, setInvoice] = useState<any>(null);
|
|
const [items, setItems] = useState<any[]>([]);
|
|
const [payments, setPayments] = useState<any[]>([]);
|
|
const [firm, setFirm] = useState<any>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [savingItem, setSavingItem] = useState(false);
|
|
const [payOpen, setPayOpen] = useState(false);
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
const [invRes, itemRes, payRes, firmRes] = await Promise.all([
|
|
supabase
|
|
.from("invoices")
|
|
.select("*, client:clients(id, name, primary_contact_name, address_line1, address_line2, city, state, postal_code)")
|
|
.eq("id", invoiceId)
|
|
.maybeSingle(),
|
|
supabase
|
|
.from("invoice_line_items")
|
|
.select("*, case:cases(id, case_number, title, practice_area), user:profiles(id, full_name, email)")
|
|
.eq("invoice_id", invoiceId)
|
|
.order("sort_order", { ascending: true }),
|
|
supabase.from("invoice_payments").select("*").eq("invoice_id", invoiceId).order("paid_on", { ascending: false }),
|
|
supabase.from("firm_settings").select("*").maybeSingle(),
|
|
]);
|
|
setInvoice(invRes.data);
|
|
setItems(itemRes.data ?? []);
|
|
setPayments(payRes.data ?? []);
|
|
setFirm(firmRes.data);
|
|
setLoading(false);
|
|
};
|
|
|
|
useEffect(() => { load(); }, [invoiceId]);
|
|
|
|
const groups = useMemo(() => {
|
|
const map = new Map<string, { caseRow: any; items: any[]; subtotal: number }>();
|
|
for (const it of items) {
|
|
const key = it.case?.id ?? "_none";
|
|
if (!map.has(key)) map.set(key, { caseRow: it.case, items: [], subtotal: 0 });
|
|
const g = map.get(key)!;
|
|
g.items.push(it);
|
|
// Only billable rows contribute to the case subtotal. A line is
|
|
// non-billable when it references a source time/expense entry but its
|
|
// amount is zero (the generator stores it that way for transparency).
|
|
const isNonBillable =
|
|
Number(it.amount) === 0 && (it.time_entry_id || it.expense_id);
|
|
if (!isNonBillable) g.subtotal += Number(it.amount);
|
|
}
|
|
return Array.from(map.values());
|
|
}, [items]);
|
|
|
|
if (loading) return <PageContainer><p className="text-muted-foreground">Loading…</p></PageContainer>;
|
|
if (!invoice) {
|
|
return (
|
|
<PageContainer>
|
|
<p className="text-muted-foreground">Invoice not found.</p>
|
|
<Button variant="outline" className="mt-3" asChild>
|
|
<Link to="/invoices"><ArrowLeft className="h-4 w-4 mr-2" /> All invoices</Link>
|
|
</Button>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
const canEdit = isAdmin || invoice.created_by === user?.id;
|
|
const isDraft = invoice.status === "draft";
|
|
const balance = Number(invoice.total) - Number(invoice.amount_paid);
|
|
|
|
const setStatus = async (status: string) => {
|
|
const { error } = await supabase.from("invoices").update({ status: status as any }).eq("id", invoiceId);
|
|
if (error) toast.error(error.message);
|
|
else { toast.success(`Marked ${status}`); load(); }
|
|
};
|
|
|
|
const removeItem = async (id: string) => {
|
|
if (!confirm("Remove this line item?")) return;
|
|
const { error } = await supabase.from("invoice_line_items").delete().eq("id", id);
|
|
if (error) return toast.error(error.message);
|
|
await recalcInvoiceTotals(invoiceId);
|
|
load();
|
|
};
|
|
|
|
const updateItem = async (id: string, patch: any) => {
|
|
const next: any = { ...patch };
|
|
if (patch.quantity != null || patch.rate != null) {
|
|
const cur = items.find((i) => i.id === id);
|
|
const q = Number(patch.quantity ?? cur.quantity);
|
|
const r = Number(patch.rate ?? cur.rate);
|
|
next.amount = +(q * r).toFixed(2);
|
|
}
|
|
const { error } = await supabase.from("invoice_line_items").update(next).eq("id", id);
|
|
if (error) return toast.error(error.message);
|
|
await recalcInvoiceTotals(invoiceId);
|
|
load();
|
|
};
|
|
|
|
const addManualItem = async (caseId: string | null) => {
|
|
setSavingItem(true);
|
|
const sort_order = items.length ? Math.max(...items.map((i) => i.sort_order)) + 1 : 0;
|
|
const { error } = await supabase.from("invoice_line_items").insert({
|
|
invoice_id: invoiceId,
|
|
case_id: caseId,
|
|
kind: "manual",
|
|
description: "New line",
|
|
quantity: 1,
|
|
rate: 0,
|
|
amount: 0,
|
|
sort_order,
|
|
});
|
|
setSavingItem(false);
|
|
if (error) return toast.error(error.message);
|
|
await recalcInvoiceTotals(invoiceId);
|
|
load();
|
|
};
|
|
|
|
const updateNotes = async (notes: string) => {
|
|
await supabase.from("invoices").update({ notes }).eq("id", invoiceId);
|
|
};
|
|
|
|
const updateTaxRate = async (ratePct: string) => {
|
|
const subtotal = Number(invoice.subtotal);
|
|
const taxRate = Math.max(0, Number(ratePct) || 0) / 100;
|
|
const tax = +(subtotal * taxRate).toFixed(2);
|
|
const total = +(subtotal + tax).toFixed(2);
|
|
await supabase.from("invoices").update({ tax, total }).eq("id", invoiceId);
|
|
load();
|
|
};
|
|
|
|
const downloadPdf = async () => {
|
|
let logoDataUrl: string | null = null;
|
|
if (firm?.logo_storage_path) {
|
|
const { data: signed } = await supabase.storage
|
|
.from("firm-logos")
|
|
.createSignedUrl(firm.logo_storage_path, 60);
|
|
if (signed?.signedUrl) {
|
|
try {
|
|
const res = await fetch(signed.signedUrl);
|
|
const blob = await res.blob();
|
|
logoDataUrl = await new Promise<string>((resolve) => {
|
|
const r = new FileReader();
|
|
r.onload = () => resolve(r.result as string);
|
|
r.readAsDataURL(blob);
|
|
});
|
|
} catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
const input: InvoicePdfInput = {
|
|
firm: {
|
|
name: firm?.company_name,
|
|
address1: firm?.address_line1,
|
|
address2: firm?.address_line2,
|
|
city: firm?.city,
|
|
state: firm?.state,
|
|
postal: firm?.postal_code,
|
|
email: firm?.contact_email,
|
|
phone: firm?.contact_phone,
|
|
website: firm?.website,
|
|
footerNote: firm?.footer_note,
|
|
logoDataUrl,
|
|
},
|
|
client: {
|
|
name: invoice.client?.name ?? "Client",
|
|
contact: invoice.client?.primary_contact_name,
|
|
address1: invoice.client?.address_line1,
|
|
address2: invoice.client?.address_line2,
|
|
city: invoice.client?.city,
|
|
state: invoice.client?.state,
|
|
postal: invoice.client?.postal_code,
|
|
},
|
|
invoice: {
|
|
number: invoice.invoice_number,
|
|
issueDate: invoice.issue_date,
|
|
dueDate: invoice.due_date,
|
|
status: invoice.status,
|
|
notes: invoice.notes,
|
|
},
|
|
groups: groups.map((g) => ({
|
|
caseNumber: g.caseRow?.case_number ?? "—",
|
|
caseTitle: g.caseRow?.title ?? "(unassigned)",
|
|
practiceArea: g.caseRow?.practice_area,
|
|
subtotal: g.subtotal,
|
|
items: g.items.map((it) => {
|
|
const name: string =
|
|
it.user?.full_name || it.user?.email || "";
|
|
const initials = name
|
|
.trim()
|
|
.split(/\s+/)
|
|
.map((p: string) => p[0])
|
|
.filter(Boolean)
|
|
.slice(0, 3)
|
|
.join("")
|
|
.toUpperCase();
|
|
const isNonBillable =
|
|
Number(it.amount) === 0 && (it.time_entry_id || it.expense_id);
|
|
return {
|
|
kind: it.kind,
|
|
description: it.description,
|
|
work_date: it.work_date,
|
|
quantity: Number(it.quantity),
|
|
rate: Number(it.rate),
|
|
amount: Number(it.amount),
|
|
user_name: name || null,
|
|
user_initials: initials || null,
|
|
billable: !isNonBillable,
|
|
};
|
|
}),
|
|
})),
|
|
totals: {
|
|
subtotal: Number(invoice.subtotal),
|
|
tax: Number(invoice.tax),
|
|
total: Number(invoice.total),
|
|
paid: Number(invoice.amount_paid),
|
|
balance,
|
|
},
|
|
};
|
|
await downloadInvoicePdf(input, `${invoice.invoice_number}.pdf`);
|
|
};
|
|
|
|
const deleteInvoice = async () => {
|
|
if (!confirm("Delete this draft invoice and unbill its time/expenses?")) return;
|
|
// Unlink time/expenses
|
|
await supabase.from("time_entries").update({ invoice_id: null }).eq("invoice_id", invoiceId);
|
|
await supabase.from("expenses").update({ invoice_id: null }).eq("invoice_id", invoiceId);
|
|
const { error } = await supabase.from("invoices").delete().eq("id", invoiceId);
|
|
if (error) return toast.error(error.message);
|
|
toast.success("Invoice deleted");
|
|
navigate({ to: "/invoices" });
|
|
};
|
|
|
|
return (
|
|
<PageContainer>
|
|
<Button variant="ghost" size="sm" asChild className="mb-3 -ml-2">
|
|
<Link to="/invoices"><ArrowLeft className="h-4 w-4 mr-1" /> All invoices</Link>
|
|
</Button>
|
|
|
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-4 mb-6">
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<span className="text-xs uppercase tracking-widest text-muted-foreground">Invoice</span>
|
|
<Badge variant="outline" className={statusBadgeClass(invoice.status)}>{invoice.status}</Badge>
|
|
</div>
|
|
<h1 className="font-serif text-3xl">{invoice.invoice_number}</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
<Link to="/clients/$clientId" params={{ clientId: invoice.client?.id }} className="hover:text-primary">
|
|
{invoice.client?.name}
|
|
</Link>
|
|
{" · "}Issued {formatDate(invoice.issue_date)}
|
|
{invoice.due_date && <> · Due {formatDate(invoice.due_date)}</>}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button variant="outline" onClick={downloadPdf}><FileDown className="h-4 w-4 mr-2" /> Download PDF</Button>
|
|
{canEdit && isDraft && (
|
|
<Button onClick={() => setStatus("sent")}><Send className="h-4 w-4 mr-2" /> Mark sent</Button>
|
|
)}
|
|
{canEdit && (invoice.status === "sent" || invoice.status === "overdue") && (
|
|
<Button variant="outline" onClick={() => setStatus("paid")}><CheckCircle2 className="h-4 w-4 mr-2" /> Mark paid</Button>
|
|
)}
|
|
{canEdit && invoice.status !== "void" && invoice.status !== "paid" && (
|
|
<Button variant="outline" onClick={() => setStatus("void")}><Ban className="h-4 w-4 mr-2" /> Void</Button>
|
|
)}
|
|
{canEdit && (
|
|
<Button onClick={() => setPayOpen(true)}><DollarSign className="h-4 w-4 mr-2" /> Record payment</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid lg:grid-cols-3 gap-5">
|
|
<div className="lg:col-span-2 space-y-5">
|
|
{groups.length === 0 && (
|
|
<Card className="border-border/60">
|
|
<CardContent className="p-6 text-center text-muted-foreground">No line items.</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{groups.map((g, gi) => (
|
|
<Card key={gi} className="border-border/60 overflow-hidden">
|
|
<CardContent className="p-0">
|
|
<div className="px-4 py-3 bg-muted/40 border-b flex flex-wrap items-center justify-between gap-2">
|
|
<div>
|
|
<div className="font-medium">{g.caseRow?.title ?? "Unassigned"}</div>
|
|
<div className="text-xs text-muted-foreground">{g.caseRow?.case_number}{g.caseRow?.practice_area ? ` · ${g.caseRow.practice_area}` : ""}</div>
|
|
</div>
|
|
<div className="text-sm tabular-nums font-medium">{formatCurrency(g.subtotal)}</div>
|
|
</div>
|
|
<table className="w-full text-sm">
|
|
<thead className="text-xs uppercase tracking-wider text-muted-foreground">
|
|
<tr>
|
|
<th className="text-left px-3 py-2 font-medium w-[100px]">Date</th>
|
|
<th className="text-left px-3 py-2 font-medium">Description</th>
|
|
<th className="text-right px-3 py-2 font-medium w-[80px]">Qty</th>
|
|
<th className="text-right px-3 py-2 font-medium w-[100px]">Rate</th>
|
|
<th className="text-right px-3 py-2 font-medium w-[110px]">Amount</th>
|
|
{canEdit && isDraft && <th className="w-[40px]" />}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{g.items.map((it) => (
|
|
<tr key={it.id} className="border-t">
|
|
<td className="px-3 py-2 text-muted-foreground text-xs">{it.work_date ? formatDate(it.work_date) : "—"}</td>
|
|
<td className="px-3 py-2">
|
|
{canEdit && isDraft ? (
|
|
<Input className="h-8" defaultValue={it.description}
|
|
onBlur={(e) => e.target.value !== it.description && updateItem(it.id, { description: e.target.value })} />
|
|
) : (
|
|
<div>
|
|
<div>{it.description}</div>
|
|
{it.user?.full_name && <div className="text-[11px] text-muted-foreground italic">{it.user.full_name}</div>}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2 text-right tabular-nums">
|
|
{canEdit && isDraft ? (
|
|
<Input className="h-8 text-right tabular-nums" defaultValue={Number(it.quantity)}
|
|
onBlur={(e) => Number(e.target.value) !== Number(it.quantity) && updateItem(it.id, { quantity: Number(e.target.value) })} />
|
|
) : Number(it.quantity).toFixed(2)}
|
|
</td>
|
|
<td className="px-3 py-2 text-right tabular-nums">
|
|
{canEdit && isDraft ? (
|
|
<Input className="h-8 text-right tabular-nums" defaultValue={Number(it.rate)}
|
|
onBlur={(e) => Number(e.target.value) !== Number(it.rate) && updateItem(it.id, { rate: Number(e.target.value) })} />
|
|
) : formatCurrency(it.rate)}
|
|
</td>
|
|
<td className="px-3 py-2 text-right tabular-nums font-medium">{formatCurrency(it.amount)}</td>
|
|
{canEdit && isDraft && (
|
|
<td className="px-2 py-2 text-right">
|
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive" onClick={() => removeItem(it.id)}>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{canEdit && isDraft && (
|
|
<div className="border-t px-3 py-2 bg-muted/20">
|
|
<Button variant="ghost" size="sm" onClick={() => addManualItem(g.caseRow?.id ?? null)} disabled={savingItem}>
|
|
<Plus className="h-3.5 w-3.5 mr-1.5" /> Add line to {g.caseRow?.case_number ?? "this group"}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
|
|
{canEdit && isDraft && groups.length === 0 && (
|
|
<Button variant="outline" onClick={() => addManualItem(null)}>
|
|
<Plus className="h-4 w-4 mr-2" /> Add first line item
|
|
</Button>
|
|
)}
|
|
|
|
<Card className="border-border/60">
|
|
<CardContent className="p-4 space-y-2">
|
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label>
|
|
{canEdit && isDraft ? (
|
|
<Textarea defaultValue={invoice.notes ?? ""} rows={3} placeholder="Payment terms, thank-you message…"
|
|
onBlur={(e) => updateNotes(e.target.value)} />
|
|
) : (
|
|
<p className="text-sm whitespace-pre-wrap">{invoice.notes || <span className="text-muted-foreground italic">—</span>}</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<Card className="border-border/60">
|
|
<CardContent className="p-5 space-y-2 text-sm">
|
|
<Row label="Subtotal" value={formatCurrency(invoice.subtotal)} />
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-muted-foreground">Tax</span>
|
|
{canEdit && isDraft ? (
|
|
<div className="flex items-center gap-1">
|
|
<Input className="h-7 w-16 text-right tabular-nums" type="number" step="0.01"
|
|
defaultValue={invoice.subtotal > 0 ? ((Number(invoice.tax) / Number(invoice.subtotal)) * 100).toFixed(2) : "0"}
|
|
onBlur={(e) => updateTaxRate(e.target.value)} />
|
|
<span className="text-xs text-muted-foreground">%</span>
|
|
</div>
|
|
) : <span className="tabular-nums">{formatCurrency(invoice.tax)}</span>}
|
|
</div>
|
|
<div className="flex justify-between pt-2 border-t font-medium">
|
|
<span>Total</span><span className="tabular-nums font-serif text-lg">{formatCurrency(invoice.total)}</span>
|
|
</div>
|
|
{Number(invoice.amount_paid) > 0 && (
|
|
<>
|
|
<Row label="Paid" value={`- ${formatCurrency(invoice.amount_paid)}`} muted />
|
|
<div className="flex justify-between pt-2 border-t font-medium">
|
|
<span>Balance due</span><span className="tabular-nums font-serif text-lg text-primary">{formatCurrency(balance)}</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-border/60">
|
|
<CardContent className="p-5">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h3 className="font-serif text-sm">Payments</h3>
|
|
{canEdit && (
|
|
<Button variant="outline" size="sm" onClick={() => setPayOpen(true)}>
|
|
<Plus className="h-3.5 w-3.5 mr-1" /> Add
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{payments.length === 0 && <p className="text-sm text-muted-foreground">No payments recorded.</p>}
|
|
<div className="space-y-1">
|
|
{payments.map((p) => (
|
|
<div key={p.id} className="flex justify-between items-baseline text-sm border-b py-1.5 last:border-0">
|
|
<div>
|
|
<div>{formatDate(p.paid_on)}</div>
|
|
<div className="text-xs text-muted-foreground">{p.method || "—"}{p.reference ? ` · ${p.reference}` : ""}</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="tabular-nums font-medium">{formatCurrency(p.amount)}</span>
|
|
{canEdit && (
|
|
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
|
onClick={async () => {
|
|
if (!confirm("Delete this payment?")) return;
|
|
await supabase.from("invoice_payments").delete().eq("id", p.id);
|
|
load();
|
|
}}>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{canEdit && isDraft && (
|
|
<Button variant="outline" className="w-full text-destructive hover:text-destructive" onClick={deleteInvoice}>
|
|
<Trash2 className="h-4 w-4 mr-2" /> Delete draft
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<RecordPaymentDialog
|
|
open={payOpen}
|
|
onOpenChange={setPayOpen}
|
|
invoiceId={invoiceId}
|
|
balance={balance}
|
|
userId={user?.id ?? ""}
|
|
onSaved={load}
|
|
/>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
function Row({ label, value, muted }: { label: string; value: string; muted?: boolean }) {
|
|
return (
|
|
<div className="flex justify-between">
|
|
<span className={muted ? "text-muted-foreground" : "text-muted-foreground"}>{label}</span>
|
|
<span className={`tabular-nums ${muted ? "text-muted-foreground" : ""}`}>{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RecordPaymentDialog({ open, onOpenChange, invoiceId, balance, userId, onSaved }: {
|
|
open: boolean; onOpenChange: (b: boolean) => void; invoiceId: string; balance: number; userId: string; onSaved: () => void;
|
|
}) {
|
|
const [amount, setAmount] = useState(balance > 0 ? balance.toFixed(2) : "");
|
|
const [paidOn, setPaidOn] = useState(new Date().toISOString().slice(0, 10));
|
|
const [method, setMethod] = useState("check");
|
|
const [reference, setReference] = useState("");
|
|
const [notes, setNotes] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => { if (open) setAmount(balance > 0 ? balance.toFixed(2) : ""); }, [open, balance]);
|
|
|
|
const save = async () => {
|
|
const amt = Number(amount);
|
|
if (!amt || amt <= 0) return toast.error("Enter a valid amount");
|
|
setSaving(true);
|
|
const { error } = await supabase.from("invoice_payments").insert({
|
|
invoice_id: invoiceId,
|
|
amount: amt,
|
|
paid_on: paidOn,
|
|
method,
|
|
reference: reference || null,
|
|
notes: notes || null,
|
|
created_by: userId || null,
|
|
});
|
|
setSaving(false);
|
|
if (error) return toast.error(error.message);
|
|
toast.success("Payment recorded");
|
|
onOpenChange(false);
|
|
onSaved();
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent>
|
|
<DialogHeader><DialogTitle>Record payment</DialogTitle></DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label>Amount</Label>
|
|
<Input type="number" step="0.01" value={amount} onChange={(e) => setAmount(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<Label>Paid on</Label>
|
|
<Input type="date" value={paidOn} onChange={(e) => setPaidOn(e.target.value)} />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label>Method</Label>
|
|
<Select value={method} onValueChange={setMethod}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="check">Check</SelectItem>
|
|
<SelectItem value="ach">ACH / Wire</SelectItem>
|
|
<SelectItem value="card">Credit Card</SelectItem>
|
|
<SelectItem value="cash">Cash</SelectItem>
|
|
<SelectItem value="trust">Trust transfer</SelectItem>
|
|
<SelectItem value="other">Other</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label>Reference</Label>
|
|
<Input value={reference} onChange={(e) => setReference(e.target.value)} placeholder="Check #, txn id…" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<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={save} disabled={saving}>
|
|
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}Save payment
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|