Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 01:51:29 +00:00
co-authored by renee-png
parent b0e58bb513
commit 13b6765214
+170 -210
View File
@@ -1045,266 +1045,226 @@ function NewHomeownerDialog({
);
}
// ─── Post ledger entry dialog ───────────────────────────────────────
function PostEntryDialog({
open,
onOpenChange,
collectionId,
userId,
onSaved,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
collectionId: string;
userId?: string;
onSaved: () => void;
}) {
const [form, setForm] = useState({
entry_date: new Date().toISOString().slice(0, 10),
transaction_type: "assessment",
description: "",
amount: "",
});
// ─── Add Charge dialog (multi-bucket) ───────────────────────────────
const CHARGE_BUCKETS: { key: typeof BUCKETS[number]; label: string }[] = [
{ key: "assess", label: "Assessment" },
{ key: "late", label: "Late Fee" },
{ key: "admin", label: "Admin Fee" },
{ key: "legal", label: "Legal Fee" },
{ key: "viol", label: "Violation Fine" },
{ key: "interest", label: "Interest" },
];
function AddChargeDialog({
open, onOpenChange, collectionId, userId, onSaved,
}: { open: boolean; onOpenChange: (v: boolean) => void; collectionId: string; userId?: string; onSaved: () => void }) {
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
const [description, setDescription] = useState("");
const [account, setAccount] = useState<typeof BUCKETS[number]>("assess");
const [amount, setAmount] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) {
setForm({
entry_date: new Date().toISOString().slice(0, 10),
transaction_type: "assessment",
description: "",
amount: "",
});
}
if (open) { setDate(new Date().toISOString().slice(0, 10)); setDescription(""); setAccount("assess"); setAmount(""); }
}, [open]);
const isPayment = PAYMENT_TYPES.has(form.transaction_type);
const submit = async () => {
const amount = parseFloat(form.amount);
if (isNaN(amount) || amount <= 0) {
toast.error("Enter a valid amount");
return;
}
const amt = parseFloat(amount);
if (isNaN(amt) || amt <= 0) { toast.error("Enter an amount"); return; }
setSubmitting(true);
const { error } = await supabase
.from("collection_ledger_entries")
.insert({
collection_id: collectionId,
entry_date: form.entry_date,
transaction_type: form.transaction_type,
description: form.description || null,
debit: isPayment ? 0 : amount,
credit: isPayment ? amount : 0,
created_by: userId,
});
const row: any = {
collection_id: collectionId, entry_date: date,
transaction_type: account, account, description: description || null,
created_by: userId,
};
row[account] = amt;
const { error } = await supabase.from("collection_ledger_entries").insert(row);
setSubmitting(false);
if (error) {
toast.error("Could not post", { description: error.message });
return;
}
toast.success("Entry posted");
onOpenChange(false);
onSaved();
if (error) { toast.error(error.message); return; }
toast.success("Charge posted"); onOpenChange(false); onSaved();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="font-serif">Post ledger entry</DialogTitle>
</DialogHeader>
<DialogHeader><DialogTitle className="font-serif">Add Charge (Debit)</DialogTitle></DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div><Label>Date</Label><Input type="date" value={date} onChange={(e) => setDate(e.target.value)} /></div>
<div>
<Label>Date</Label>
<Input
type="date"
value={form.entry_date}
onChange={(e) =>
setForm({ ...form, entry_date: e.target.value })
}
/>
</div>
<div>
<Label>Type</Label>
<Select
value={form.transaction_type}
onValueChange={(v) =>
setForm({ ...form, transaction_type: v })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<Label>Account</Label>
<Select value={account} onValueChange={(v) => setAccount(v as any)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{TXN_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
{CHARGE_BUCKETS.map((b) => (<SelectItem key={b.key} value={b.key}>{b.label}</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>{isPayment ? "Payment amount" : "Charge amount"}</Label>
<Input
type="number"
step="0.01"
placeholder="0.00"
value={form.amount}
onChange={(e) => setForm({ ...form, amount: e.target.value })}
/>
</div>
<div>
<Label>Description</Label>
<Textarea
rows={2}
value={form.description}
onChange={(e) =>
setForm({ ...form, description: e.target.value })
}
/>
</div>
<div><Label>Amount</Label><Input type="number" step="0.01" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="0.00" /></div>
<div><Label>Description</Label><Textarea rows={2} value={description} onChange={(e) => setDescription(e.target.value)} /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={submit} disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Post
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={submit} disabled={submitting}>{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}Post</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── Post interest dialog (auto-calculated, monthly, non-compounded) ─
function PostInterestDialog({
open,
onOpenChange,
collectionId,
userId,
annualRate,
unpaidAssessmentBalance,
onSaved,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
collectionId: string;
userId?: string;
annualRate: number;
unpaidAssessmentBalance: number;
onSaved: () => void;
}) {
// monthly rate = annual / 12, applied to unpaid assessment balance (non-compounded)
const monthlyRate = annualRate / 100 / 12;
const computed = Math.round(unpaidAssessmentBalance * monthlyRate * 100) / 100;
const [form, setForm] = useState({
entry_date: new Date().toISOString().slice(0, 10),
amount: computed.toFixed(2),
description: `Interest @ ${annualRate}% per annum (monthly, non-compounded) on $${unpaidAssessmentBalance.toFixed(2)} unpaid assessment balance`,
});
function AddPaymentDialog({
open, onOpenChange, collectionId, userId, onSaved,
}: { open: boolean; onOpenChange: (v: boolean) => void; collectionId: string; userId?: string; onSaved: () => void }) {
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
const [description, setDescription] = useState("Payment");
const [amount, setAmount] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) {
setForm({
entry_date: new Date().toISOString().slice(0, 10),
amount: computed.toFixed(2),
description: `Interest @ ${annualRate}% per annum (monthly, non-compounded) on $${unpaidAssessmentBalance.toFixed(2)} unpaid assessment balance`,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, annualRate, unpaidAssessmentBalance]);
if (open) { setDate(new Date().toISOString().slice(0, 10)); setDescription("Payment"); setAmount(""); }
}, [open]);
const submit = async () => {
const amount = parseFloat(form.amount);
if (isNaN(amount) || amount <= 0) {
toast.error("Nothing to post (interest is $0)");
return;
}
const amt = parseFloat(amount);
if (isNaN(amt) || amt <= 0) { toast.error("Enter an amount"); return; }
setSubmitting(true);
const { error } = await supabase
.from("collection_ledger_entries")
.insert({
collection_id: collectionId,
entry_date: form.entry_date,
transaction_type: "interest",
description: form.description || null,
debit: amount,
credit: 0,
created_by: userId,
});
const { error } = await supabase.from("collection_ledger_entries").insert({
collection_id: collectionId, entry_date: date,
transaction_type: "payment", account: "payment",
description: description || null, payment: amt, created_by: userId,
});
setSubmitting(false);
if (error) {
toast.error("Could not post", { description: error.message });
return;
}
toast.success("Interest posted");
onOpenChange(false);
onSaved();
if (error) { toast.error(error.message); return; }
toast.success(`Payment recorded — applied by priority`);
onOpenChange(false); onSaved();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="font-serif">Post monthly interest</DialogTitle>
<DialogDescription>
Auto-calculated at <strong>{annualRate}% per annum</strong> ÷ 12 ={" "}
{(monthlyRate * 100).toFixed(4)}% monthly, applied to the current{" "}
<strong>${unpaidAssessmentBalance.toFixed(2)}</strong> unpaid
assessment balance. Override the amount if needed.
</DialogDescription>
<DialogTitle className="font-serif">Add Payment (AR Credit)</DialogTitle>
<DialogDescription>Payments are auto-applied to outstanding categories in priority order: Bank → Interest → Late → Legal → Admin → Violations → Assessments.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Date</Label>
<Input
type="date"
value={form.entry_date}
onChange={(e) =>
setForm({ ...form, entry_date: e.target.value })
}
/>
</div>
<div>
<Label>Amount</Label>
<Input
type="number"
step="0.01"
value={form.amount}
onChange={(e) => setForm({ ...form, amount: e.target.value })}
/>
</div>
</div>
<div>
<Label>Description</Label>
<Textarea
rows={2}
value={form.description}
onChange={(e) =>
setForm({ ...form, description: e.target.value })
}
/>
<div><Label>Date</Label><Input type="date" value={date} onChange={(e) => setDate(e.target.value)} /></div>
<div><Label>Amount</Label><Input type="number" step="0.01" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="0.00" /></div>
</div>
<div><Label>Description</Label><Textarea rows={2} value={description} onChange={(e) => setDescription(e.target.value)} /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={submit} disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Post interest
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={submit} disabled={submitting}>{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}Post payment</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function AddBankFeeDialog({
open, onOpenChange, collectionId, userId, onSaved,
}: { open: boolean; onOpenChange: (v: boolean) => void; collectionId: string; userId?: string; onSaved: () => void }) {
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
const [description, setDescription] = useState("Returned check / NSF fee");
const [amount, setAmount] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) { setDate(new Date().toISOString().slice(0, 10)); setDescription("Returned check / NSF fee"); setAmount(""); }
}, [open]);
const submit = async () => {
const amt = parseFloat(amount);
if (isNaN(amt) || amt <= 0) { toast.error("Enter an amount"); return; }
setSubmitting(true);
const { error } = await supabase.from("collection_ledger_entries").insert({
collection_id: collectionId, entry_date: date,
transaction_type: "bank_fee", account: "bank",
description: description || null, bank: amt, created_by: userId,
});
setSubmitting(false);
if (error) { toast.error(error.message); return; }
toast.success("Bank fee posted (top priority)");
onOpenChange(false); onSaved();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="font-serif">Add Bank Fee</DialogTitle>
<DialogDescription>Bank fees sit at the top of the priority order — payments cover these first.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div><Label>Date</Label><Input type="date" value={date} onChange={(e) => setDate(e.target.value)} /></div>
<div><Label>Amount</Label><Input type="number" step="0.01" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="0.00" /></div>
</div>
<div><Label>Description</Label><Textarea rows={2} value={description} onChange={(e) => setDescription(e.target.value)} /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={submit} disabled={submitting}>{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}Post bank fee</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ImportCsvDialog({
open, onOpenChange, collectionId, userId, onSaved,
}: { open: boolean; onOpenChange: (v: boolean) => void; collectionId: string; userId?: string; onSaved: () => void }) {
const [text, setText] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => { if (open) setText(""); }, [open]);
const submit = async () => {
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
if (lines.length < 2) { toast.error("Need at least a header row + 1 data row"); return; }
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
const idx = (n: string) => headers.indexOf(n);
const dateI = idx("date"), descI = idx("description");
const map: Record<string, number> = {
assess: idx("assess"), late: idx("late"), admin: idx("admin"),
legal: idx("legal"), viol: idx("viol"), interest: idx("int") >= 0 ? idx("int") : idx("interest"),
bank: idx("bank"), payment: idx("payment") >= 0 ? idx("payment") : idx("pay"),
};
if (dateI < 0) { toast.error("CSV must have a 'date' column"); return; }
const rows: any[] = [];
for (let i = 1; i < lines.length; i++) {
const cells = lines[i].split(",");
const r: any = { collection_id: collectionId, entry_date: cells[dateI], created_by: userId, transaction_type: "import", account: "import" };
r.description = descI >= 0 ? cells[descI]?.replace(/^"|"$/g, "") : null;
Object.entries(map).forEach(([k, j]) => { if (j >= 0) r[k] = parseFloat(cells[j]) || 0; });
rows.push(r);
}
setSubmitting(true);
const { error } = await supabase.from("collection_ledger_entries").insert(rows);
setSubmitting(false);
if (error) { toast.error(error.message); return; }
toast.success(`Imported ${rows.length} rows`);
onOpenChange(false); onSaved();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="font-serif">Import CSV</DialogTitle>
<DialogDescription>
Paste CSV with columns: <code>date, description, assess, late, admin, legal, viol, int, bank, payment</code>. Date in YYYY-MM-DD.
</DialogDescription>
</DialogHeader>
<Textarea rows={10} value={text} onChange={(e) => setText(e.target.value)} className="font-mono text-xs" placeholder="date,description,assess,late,admin,legal,viol,int,bank,payment&#10;2026-01-01,January assessment,250,0,0,0,0,0,0,0" />
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={submit} disabled={submitting}>{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}Import</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}