From 52cd231f6756bd69509cedd6c91dc58210a7fb71 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:52:29 +0000 Subject: [PATCH 1/2] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/settings.fees.tsx | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/routes/settings.fees.tsx b/src/routes/settings.fees.tsx index 9d922e3..7b20e80 100644 --- a/src/routes/settings.fees.tsx +++ b/src/routes/settings.fees.tsx @@ -32,7 +32,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { Clock, Loader2, Pencil, Plus, Receipt, Trash2 } from "lucide-react"; +import { Clock, Loader2, Pencil, Plus, Receipt, Trash2, ClipboardPaste } from "lucide-react"; import { toast } from "sonner"; import { formatCurrency } from "@/lib/format"; @@ -52,9 +52,11 @@ interface FeeItem { } function FeeSchedulePage() { + const { user } = useAuth(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [editing, setEditing] = useState | null>(null); + const [importing, setImporting] = useState<"time" | "expense" | null>(null); const load = async () => { setLoading(true); @@ -109,6 +111,7 @@ function FeeSchedulePage() { loading={loading} onEdit={setEditing} onDelete={remove} + onImport={() => setImporting("time")} /> setImporting("expense")} /> setEditing(null)} onSaved={load} /> + + setImporting(null)} + onSaved={load} + /> ); } @@ -138,6 +149,7 @@ function FeeSection({ loading, onEdit, onDelete, + onImport, }: { title: string; icon: React.ReactNode; @@ -146,13 +158,19 @@ function FeeSection({ loading: boolean; onEdit: (i: FeeItem) => void; onDelete: (id: string) => void; + onImport: () => void; }) { return (
-

- {icon} - {title} -

+
+

+ {icon} + {title} +

+ +
{loading ? ( 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 2/2] 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). + + +
+