From 7bdddb3512dd79259fc720ece650d50b4deb431d Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 00:53:03 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/settings.fees.tsx | 231 +++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) diff --git a/src/routes/settings.fees.tsx b/src/routes/settings.fees.tsx index 7b20e80..abb5560 100644 --- a/src/routes/settings.fees.tsx +++ b/src/routes/settings.fees.tsx @@ -416,3 +416,234 @@ function FeeEditDialog({ ); } + +// 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 = { + 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 = {}; + 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 ( + !v && onClose()}> + + + + Paste {category === "expense" ? "expense" : "time"} fee items + + + 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). + + +
+