Added CSV paste import

X-Lovable-Edit-ID: edt-e4cfd959-57e5-48a6-b70a-6cdbae6e29f3
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:12 +00:00
co-authored by renee-png
+254 -5
View File
@@ -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<FeeItem[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<Partial<FeeItem> | 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")}
/>
<FeeSection
@@ -119,6 +122,7 @@ function FeeSchedulePage() {
loading={loading}
onEdit={setEditing}
onDelete={remove}
onImport={() => setImporting("expense")}
/>
<FeeEditDialog
@@ -126,6 +130,13 @@ function FeeSchedulePage() {
onClose={() => setEditing(null)}
onSaved={load}
/>
<FeeImportDialog
category={importing}
userId={user?.id}
onClose={() => setImporting(null)}
onSaved={load}
/>
</div>
);
}
@@ -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 (
<div>
<h3 className="font-serif text-lg flex items-center gap-2 mb-2">
{icon}
{title}
</h3>
<div className="flex items-center justify-between mb-2">
<h3 className="font-serif text-lg flex items-center gap-2">
{icon}
{title}
</h3>
<Button variant="outline" size="sm" onClick={onImport}>
<ClipboardPaste className="h-3.5 w-3.5 mr-2" /> Paste import
</Button>
</div>
<Card className="border-border/60">
<CardContent className="p-0">
{loading ? (
@@ -398,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>
);
}