Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user