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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user