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