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