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