Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-19 00:53:03 +00:00
co-authored by renee-png
parent 52cd231f67
commit 7bdddb3512
+231
View File
@@ -416,3 +416,234 @@ function FeeEditDialog({
</Dialog>
);
}
// Parse a tab- or comma-separated row, respecting basic quoted values.
function parseRow(line: string): string[] {
const out: string[] = [];
let cur = "";
let inQuotes = false;
const sep = line.includes("\t") ? "\t" : ",";
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuotes && line[i + 1] === '"') {
cur += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (ch === sep && !inQuotes) {
out.push(cur);
cur = "";
} else {
cur += ch;
}
}
out.push(cur);
return out.map((s) => s.trim());
}
const HEADER_ALIASES: Record<string, string> = {
name: "name",
item: "name",
title: "name",
description: "description",
desc: "description",
notes: "description",
amount: "amount",
rate: "amount",
price: "amount",
cost: "amount",
hourly_rate: "amount",
billable: "billable",
active: "active",
enabled: "active",
sort: "sort_order",
sort_order: "sort_order",
order: "sort_order",
};
function parseBool(v: string, fallback: boolean): boolean {
if (!v) return fallback;
const s = v.trim().toLowerCase();
if (["true", "yes", "y", "1", "x"].includes(s)) return true;
if (["false", "no", "n", "0", ""].includes(s)) return false;
return fallback;
}
function FeeImportDialog({
category,
userId,
onClose,
onSaved,
}: {
category: "time" | "expense" | null;
userId: string | undefined;
onClose: () => void;
onSaved: () => void;
}) {
const [text, setText] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (category) setText("");
}, [category]);
const preview = (() => {
if (!text.trim()) return { rows: [], errors: [] as string[] };
const lines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
if (lines.length === 0) return { rows: [], errors: [] };
// Detect header row
const firstCols = parseRow(lines[0]).map((c) => c.toLowerCase());
const hasHeader = firstCols.some((c) => c in HEADER_ALIASES);
const headers = hasHeader
? firstCols.map((c) => HEADER_ALIASES[c] ?? c)
: ["name", "amount", "description"];
const dataLines = hasHeader ? lines.slice(1) : lines;
const rows: Array<{
name: string;
description: string | null;
amount: number;
billable: boolean;
active: boolean;
sort_order: number;
}> = [];
const errors: string[] = [];
dataLines.forEach((line, idx) => {
const cols = parseRow(line);
const rec: Record<string, string> = {};
headers.forEach((h, i) => {
rec[h] = cols[i] ?? "";
});
const name = (rec.name ?? "").trim();
if (!name) {
errors.push(`Row ${idx + 1}: missing name`);
return;
}
const amountRaw = (rec.amount ?? "").replace(/[$,\s]/g, "");
const amount = amountRaw === "" ? 0 : Number(amountRaw);
if (Number.isNaN(amount)) {
errors.push(`Row ${idx + 1}: invalid amount "${rec.amount}"`);
return;
}
rows.push({
name,
description: rec.description?.trim() || null,
amount,
billable: parseBool(rec.billable ?? "", true),
active: parseBool(rec.active ?? "", true),
sort_order: parseInt(rec.sort_order ?? "0") || 0,
});
});
return { rows, errors };
})();
const submit = async () => {
if (!category) return;
if (preview.rows.length === 0) {
toast.error("Nothing to import");
return;
}
setSaving(true);
const payload = preview.rows.map((r) => ({
...r,
category,
created_by: userId,
}));
const { error } = await supabase
.from("fee_schedule_items")
.insert(payload);
setSaving(false);
if (error) {
toast.error("Import failed", { description: error.message });
return;
}
toast.success(`Imported ${payload.length} ${category} fee item${payload.length === 1 ? "" : "s"}`);
onClose();
onSaved();
};
return (
<Dialog open={!!category} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="font-serif">
Paste {category === "expense" ? "expense" : "time"} fee items
</DialogTitle>
<DialogDescription>
Paste rows from a spreadsheet. Tab- or comma-separated. First column
should be the item name; second column the {category === "expense" ? "amount" : "hourly rate"};
optional third column the description. A header row is auto-detected
(supported headers: name, amount/rate, description, billable, active,
sort_order).
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Textarea
rows={10}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={
category === "expense"
? "Filing fee\t450\nService of process\t75\nCertified mail\t12.50"
: "Senior partner\t450\nAssociate\t275\nParalegal\t125"
}
className="font-mono text-xs"
/>
{text.trim() && (
<div className="text-xs space-y-1">
<div className="text-muted-foreground">
{preview.rows.length} row{preview.rows.length === 1 ? "" : "s"} ready to import
{preview.errors.length > 0 && (
<span className="text-destructive">
{" "}· {preview.errors.length} skipped
</span>
)}
</div>
{preview.rows.length > 0 && (
<div className="border rounded p-2 max-h-40 overflow-auto">
{preview.rows.slice(0, 8).map((r, i) => (
<div key={i} className="flex justify-between gap-3 py-0.5">
<span className="truncate">{r.name}</span>
<span className="font-mono text-muted-foreground">
{formatCurrency(r.amount)}
</span>
</div>
))}
{preview.rows.length > 8 && (
<div className="text-muted-foreground text-[10px] pt-1">
… and {preview.rows.length - 8} more
</div>
)}
</div>
)}
{preview.errors.length > 0 && (
<div className="text-destructive text-[11px] space-y-0.5">
{preview.errors.slice(0, 5).map((e, i) => (
<div key={i}>{e}</div>
))}
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={submit} disabled={saving || preview.rows.length === 0}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Import {preview.rows.length || ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}