Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-26 08:36:13 +00:00
co-authored by renee-png
parent fc8bb95010
commit 5548328513
@@ -0,0 +1,416 @@
import { useEffect, useMemo, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Loader2, Save, Trash2, Wand2, Copy } from "lucide-react";
import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { BUCKETS, num } from "@/lib/ledger";
import { formatCurrency, formatDate } from "@/lib/format";
type Mode = "save" | "apply" | "copy";
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
collectionId: string;
caseId: string;
/** Current ledger entries — used for "save as template" mode. */
currentEntries: any[];
onApplied: () => void;
}
/** Template entry shape stored in jsonb. Dates are stored as day offsets
* from "today" so the template stays portable across cases. */
interface TemplateEntry {
day_offset: number;
description: string | null;
account: string | null;
transaction_type: string;
assess: number;
late: number;
admin: number;
legal: number;
viol: number;
interest: number;
bank: number;
payment: number;
}
export function LedgerTemplateDialog({
open,
onOpenChange,
collectionId,
caseId,
currentEntries,
onApplied,
}: Props) {
const { user } = useAuth();
const [mode, setMode] = useState<Mode>("apply");
const [busy, setBusy] = useState(false);
// ── Save tab state
const [tplName, setTplName] = useState("");
const [tplDesc, setTplDesc] = useState("");
// ── Apply tab state
const [templates, setTemplates] = useState<any[]>([]);
const [selectedTpl, setSelectedTpl] = useState<string>("");
const [replaceOnApply, setReplaceOnApply] = useState(false);
// ── Copy tab state
const [otherCols, setOtherCols] = useState<any[]>([]);
const [selectedCol, setSelectedCol] = useState<string>("");
const [replaceOnCopy, setReplaceOnCopy] = useState(false);
useEffect(() => {
if (!open) return;
// load templates
(async () => {
const { data } = await supabase
.from("ledger_templates" as any)
.select("*")
.order("name", { ascending: true });
setTemplates((data as any[]) ?? []);
})();
// load other collections (across all cases) for "copy from"
(async () => {
const { data } = await supabase
.from("collections")
.select("id, name, opened_at, homeowner:homeowners(first_name,last_name,unit_number), case:cases(case_number,title)")
.neq("id", collectionId)
.order("created_at", { ascending: false })
.limit(500);
setOtherCols((data as any[]) ?? []);
})();
setTplName("");
setTplDesc("");
setSelectedTpl("");
setSelectedCol("");
setReplaceOnApply(false);
setReplaceOnCopy(false);
}, [open, collectionId]);
const collectionOptions = useMemo(
() =>
otherCols.map((c) => {
const ho = c.homeowner;
const hoLabel = ho ? `${ho.last_name ?? ""}, ${ho.first_name ?? ""}${ho.unit_number ? ` · Unit ${ho.unit_number}` : ""}` : "—";
const caseLabel = c.case ? `${c.case.case_number} · ${c.case.title}` : "";
return {
value: c.id,
label: `${hoLabel}${c.name ? ` — ${c.name}` : ""}`,
description: caseLabel,
};
}),
[otherCols],
);
// ─── Build TemplateEntry[] from raw ledger rows
const buildTemplateEntries = (rows: any[], baseDateISO?: string): TemplateEntry[] => {
const baseDate = baseDateISO ? new Date(baseDateISO) : new Date(rows[0]?.entry_date ?? new Date());
return rows.map((e) => {
const d = new Date(e.entry_date);
const offset = Math.round((d.getTime() - baseDate.getTime()) / (1000 * 60 * 60 * 24));
return {
day_offset: offset,
description: e.description ?? null,
account: e.account ?? null,
transaction_type: e.transaction_type ?? "adjustment",
assess: num(e.assess),
late: num(e.late),
admin: num(e.admin),
legal: num(e.legal),
viol: num(e.viol),
interest: num(e.interest),
bank: num(e.bank),
payment: num(e.payment),
};
});
};
// ─── Save current ledger as template
const handleSave = async () => {
if (!tplName.trim()) { toast.error("Name is required"); return; }
if (!currentEntries.length) { toast.error("Nothing to save — ledger is empty"); return; }
setBusy(true);
try {
const earliest = currentEntries
.map((e) => e.entry_date)
.sort()[0];
const entries = buildTemplateEntries(currentEntries, earliest);
const { error } = await (supabase.from("ledger_templates" as any) as any).insert({
name: tplName.trim(),
description: tplDesc.trim() || null,
entries,
created_by: user?.id,
});
if (error) { toast.error(error.message); return; }
toast.success(`Saved template "${tplName}" with ${entries.length} entries`);
onOpenChange(false);
} finally { setBusy(false); }
};
// ─── Insert rows into THIS collection
const insertRows = async (
source: TemplateEntry[] | any[],
isTemplate: boolean,
replaceExisting: boolean,
) => {
if (!source.length) { toast.error("Nothing to apply"); return; }
setBusy(true);
try {
if (replaceExisting) {
const { error: delErr } = await supabase
.from("collection_ledger_entries")
.delete()
.eq("collection_id", collectionId);
if (delErr) { toast.error(`Delete failed: ${delErr.message}`); return; }
}
// Build new rows for THIS collection
const today = new Date();
const todayISO = today.toISOString().slice(0, 10);
let sortBase = 0;
// get current max sort_order if appending
if (!replaceExisting) {
const { data } = await supabase
.from("collection_ledger_entries")
.select("sort_order")
.eq("collection_id", collectionId)
.order("sort_order", { ascending: false })
.limit(1);
sortBase = Number((data as any[])?.[0]?.sort_order ?? 0);
}
let earliestSourceDate: Date | null = null;
if (!isTemplate) {
const dates = (source as any[])
.map((e) => new Date(e.entry_date))
.filter((d) => !isNaN(d.getTime()))
.sort((a, b) => a.getTime() - b.getTime());
earliestSourceDate = dates[0] ?? null;
}
const rows = source.map((e: any, i: number) => {
let entryDate: string;
if (isTemplate) {
const d = new Date(today);
d.setDate(d.getDate() + (Number(e.day_offset) || 0));
entryDate = d.toISOString().slice(0, 10);
} else if (earliestSourceDate) {
const src = new Date(e.entry_date);
const offset = Math.round((src.getTime() - earliestSourceDate.getTime()) / (1000 * 60 * 60 * 24));
const d = new Date(today);
d.setDate(d.getDate() + offset);
entryDate = d.toISOString().slice(0, 10);
} else {
entryDate = todayISO;
}
return {
collection_id: collectionId,
entry_date: entryDate,
description: e.description ?? null,
account: e.account ?? null,
transaction_type: e.transaction_type ?? "adjustment",
assess: num(e.assess),
late: num(e.late),
admin: num(e.admin),
legal: num(e.legal),
viol: num(e.viol),
interest: num(e.interest),
bank: num(e.bank),
payment: num(e.payment),
sort_order: sortBase + (i + 1) * 10,
created_by: user?.id,
};
});
const { error } = await supabase
.from("collection_ledger_entries")
.insert(rows);
if (error) { toast.error(error.message); return; }
toast.success(`Added ${rows.length} entries`);
onApplied();
onOpenChange(false);
} finally { setBusy(false); }
};
const handleApply = async () => {
const tpl = templates.find((t) => t.id === selectedTpl);
if (!tpl) { toast.error("Pick a template"); return; }
await insertRows(tpl.entries ?? [], true, replaceOnApply);
};
const handleCopy = async () => {
if (!selectedCol) { toast.error("Pick a ledger to copy from"); return; }
const { data, error } = await supabase
.from("collection_ledger_entries")
.select("*")
.eq("collection_id", selectedCol)
.order("sort_order", { ascending: true })
.order("entry_date", { ascending: true });
if (error) { toast.error(error.message); return; }
await insertRows((data as any[]) ?? [], false, replaceOnCopy);
};
const handleDeleteTemplate = async (id: string) => {
if (!confirm("Delete this template?")) return;
const { error } = await supabase.from("ledger_templates" as any).delete().eq("id", id);
if (error) { toast.error(error.message); return; }
setTemplates((prev) => prev.filter((t) => t.id !== id));
if (selectedTpl === id) setSelectedTpl("");
toast.success("Template deleted");
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Ledger templates</DialogTitle>
<DialogDescription>
Save the current ledger as a reusable template, apply a saved template, or copy entries from another homeowner ledger.
</DialogDescription>
</DialogHeader>
<Tabs value={mode} onValueChange={(v) => setMode(v as Mode)}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="apply"><Wand2 className="h-3.5 w-3.5 mr-1.5" /> Apply template</TabsTrigger>
<TabsTrigger value="copy"><Copy className="h-3.5 w-3.5 mr-1.5" /> Copy from ledger</TabsTrigger>
<TabsTrigger value="save"><Save className="h-3.5 w-3.5 mr-1.5" /> Save as template</TabsTrigger>
</TabsList>
{/* Apply */}
<TabsContent value="apply" className="space-y-3 pt-3">
{templates.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
No templates yet. Save one from an existing ledger first.
</p>
) : (
<>
<div className="border rounded-md max-h-72 overflow-auto divide-y">
{templates.map((t) => (
<label
key={t.id}
className={`flex items-start gap-3 p-3 cursor-pointer hover:bg-muted/30 ${selectedTpl === t.id ? "bg-muted/40" : ""}`}
>
<input
type="radio"
name="tpl"
className="mt-1"
checked={selectedTpl === t.id}
onChange={() => setSelectedTpl(t.id)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{t.name}</span>
<Badge variant="outline" className="text-[10px]">
{(t.entries as any[])?.length ?? 0} entries
</Badge>
</div>
{t.description && (
<p className="text-xs text-muted-foreground mt-0.5">{t.description}</p>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleDeleteTemplate(t.id); }}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</label>
))}
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={replaceOnApply} onCheckedChange={(v) => setReplaceOnApply(!!v)} />
Replace all existing entries (otherwise append)
</label>
<p className="text-[11px] text-muted-foreground">
Dates are anchored to today using the original day offsets.
</p>
</>
)}
</TabsContent>
{/* Copy */}
<TabsContent value="copy" className="space-y-3 pt-3">
<div>
<Label className="text-xs">Source ledger</Label>
<SearchableSelect
value={selectedCol}
onValueChange={setSelectedCol}
options={collectionOptions}
placeholder="Search homeowner or case…"
/>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={replaceOnCopy} onCheckedChange={(v) => setReplaceOnCopy(!!v)} />
Replace all existing entries (otherwise append)
</label>
<p className="text-[11px] text-muted-foreground">
The earliest source entry will be dated today; subsequent entries keep their relative spacing.
</p>
</TabsContent>
{/* Save */}
<TabsContent value="save" className="space-y-3 pt-3">
<div>
<Label className="text-xs">Template name</Label>
<Input value={tplName} onChange={(e) => setTplName(e.target.value)} placeholder="e.g. Standard delinquency cycle" />
</div>
<div>
<Label className="text-xs">Description (optional)</Label>
<Textarea value={tplDesc} onChange={(e) => setTplDesc(e.target.value)} rows={2} />
</div>
<p className="text-[11px] text-muted-foreground">
Saves {currentEntries.length} entries. Dates are stored as day offsets from the earliest entry, so the template can be applied to any future ledger.
</p>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>Cancel</Button>
{mode === "save" && (
<Button onClick={handleSave} disabled={busy || !tplName.trim()}>
{busy ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <Save className="h-4 w-4 mr-1.5" />}
Save template
</Button>
)}
{mode === "apply" && (
<Button onClick={handleApply} disabled={busy || !selectedTpl}>
{busy ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <Wand2 className="h-4 w-4 mr-1.5" />}
Apply template
</Button>
)}
{mode === "copy" && (
<Button onClick={handleCopy} disabled={busy || !selectedCol}>
{busy ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <Copy className="h-4 w-4 mr-1.5" />}
Copy entries
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}