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:57:57 +00:00
co-authored by renee-png
parent 3a6f70a733
commit 8f8f4598f4
+156
View File
@@ -0,0 +1,156 @@
import { useEffect, useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { BUCKETS, num } from "@/lib/ledger";
import { formatCurrency } from "@/lib/format";
export interface EditableEntry {
id: string;
entry_date: string;
description: string | null;
account: string | null;
assess: number;
late: number;
admin: number;
legal: number;
viol: number;
interest: number;
bank: number;
payment: number;
runningBalance?: number;
}
interface Props {
entry: EditableEntry;
onPatch: (id: string, patch: Partial<EditableEntry>) => void | Promise<void>;
onRemove: (id: string) => void;
}
const NUM_COLS: { key: keyof EditableEntry; danger?: boolean; positive?: boolean }[] = [
{ key: "assess" },
{ key: "late" },
{ key: "admin" },
{ key: "legal" },
{ key: "viol" },
{ key: "interest" },
{ key: "bank", danger: true },
{ key: "payment", positive: true },
];
export function SortableLedgerRow({ entry, onPatch, onRemove }: Props) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: entry.id,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
backgroundColor: isDragging ? "hsl(var(--muted))" : undefined,
};
// Local field state (for typing without re-render thrashing) — committed on blur
const [draft, setDraft] = useState<EditableEntry>(entry);
useEffect(() => {
setDraft(entry);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entry.id, entry.entry_date, entry.description, entry.account,
entry.assess, entry.late, entry.admin, entry.legal,
entry.viol, entry.interest, entry.bank, entry.payment]);
const commit = (key: keyof EditableEntry, raw: string | number) => {
let val: any = raw;
if (NUM_COLS.some((c) => c.key === key)) val = raw === "" ? 0 : Number(raw) || 0;
if (val === (entry as any)[key]) return;
setDraft((d) => ({ ...d, [key]: val }));
onPatch(entry.id, { [key]: val } as any);
};
return (
<tr ref={setNodeRef} style={style} className="border-t hover:bg-muted/20 group">
<td className="px-1 py-1 align-middle">
<button
type="button"
className="cursor-grab active:cursor-grabbing text-muted-foreground/40 hover:text-foreground p-1"
{...attributes}
{...listeners}
aria-label="Drag to reorder"
>
<GripVertical className="h-4 w-4" />
</button>
</td>
<td className="px-1 py-1">
<input
type="date"
value={draft.entry_date}
onChange={(e) => setDraft((d) => ({ ...d, entry_date: e.target.value }))}
onBlur={(e) => commit("entry_date", e.target.value)}
className="w-full bg-transparent border border-transparent hover:border-border focus:border-ring rounded px-1.5 py-1 text-xs outline-none"
/>
</td>
<td className="px-1 py-1">
<input
type="text"
value={draft.description ?? ""}
onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
onBlur={(e) => commit("description", e.target.value)}
placeholder="—"
className="w-full bg-transparent border border-transparent hover:border-border focus:border-ring rounded px-1.5 py-1 text-sm outline-none min-w-[180px]"
/>
</td>
<td className="px-1 py-1">
<input
type="text"
value={draft.account ?? ""}
onChange={(e) => setDraft((d) => ({ ...d, account: e.target.value }))}
onBlur={(e) => commit("account", e.target.value)}
placeholder="—"
className="w-full bg-transparent border border-transparent hover:border-border focus:border-ring rounded px-1.5 py-1 text-xs text-muted-foreground capitalize outline-none min-w-[80px]"
/>
</td>
{NUM_COLS.map(({ key, danger, positive }) => {
const v = num((draft as any)[key]);
return (
<td key={key} className="px-0.5 py-1">
<input
type="number"
step="0.01"
value={v === 0 ? "" : v}
onChange={(e) => setDraft((d) => ({ ...d, [key]: e.target.value === "" ? 0 : Number(e.target.value) || 0 }))}
onBlur={(e) => commit(key, e.target.value)}
placeholder="0.00"
className={`w-20 text-right font-mono text-sm bg-transparent border border-transparent hover:border-border focus:border-ring rounded px-1.5 py-1 outline-none ${
v === 0
? "text-muted-foreground/40 placeholder:text-muted-foreground/40"
: danger
? "text-destructive"
: positive
? "text-emerald-600"
: ""
}`}
/>
</td>
);
})}
<td className={`px-3 py-2 text-right font-mono font-semibold whitespace-nowrap ${
(entry.runningBalance ?? 0) > 0 ? "" : (entry.runningBalance ?? 0) < 0 ? "text-emerald-600" : "text-muted-foreground"
}`}>
{formatCurrency(entry.runningBalance ?? 0)}
</td>
<td className="px-2 py-2 text-right">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-40 group-hover:opacity-100"
onClick={() => onRemove(entry.id)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</td>
</tr>
);
}
export const LEDGER_NUM_COLS = NUM_COLS;
export { BUCKETS };