Files
mylegal-stage-law/src/routes/settings.fees.tsx
T
2026-04-19 00:53:03 +00:00

650 lines
20 KiB
TypeScript

import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Clock, Loader2, Pencil, Plus, Receipt, Trash2, ClipboardPaste } from "lucide-react";
import { toast } from "sonner";
import { formatCurrency } from "@/lib/format";
export const Route = createFileRoute("/settings/fees")({
component: FeeSchedulePage,
});
interface FeeItem {
id: string;
name: string;
description: string | null;
category: "time" | "expense";
amount: number;
billable: boolean;
active: boolean;
sort_order: number;
}
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);
const { data } = await supabase
.from("fee_schedule_items")
.select("*")
.order("category")
.order("sort_order")
.order("name");
setItems((data ?? []) as FeeItem[]);
setLoading(false);
};
useEffect(() => {
load();
}, []);
const remove = async (id: string) => {
if (!confirm("Delete this fee item?")) return;
const { error } = await supabase
.from("fee_schedule_items")
.delete()
.eq("id", id);
if (error) toast.error("Could not delete", { description: error.message });
else {
toast.success("Fee item deleted");
load();
}
};
const timeItems = items.filter((i) => i.category === "time");
const expenseItems = items.filter((i) => i.category === "expense");
return (
<div className="space-y-6 max-w-5xl">
<div className="flex justify-between items-start gap-4 flex-wrap">
<p className="text-sm text-muted-foreground max-w-2xl">
Define reusable billable items. Time fees set the hourly rate when
logging time; expense fees auto-fill the amount when adding an
expense. The billable flag becomes the default for new entries.
</p>
<Button onClick={() => setEditing({ category: "time", billable: true, active: true, amount: 0 })}>
<Plus className="h-4 w-4 mr-2" /> Add fee item
</Button>
</div>
<FeeSection
title="Time fees"
icon={<Clock className="h-4 w-4 text-muted-foreground" />}
unit="/ hr"
items={timeItems}
loading={loading}
onEdit={setEditing}
onDelete={remove}
onImport={() => setImporting("time")}
/>
<FeeSection
title="Expense fees"
icon={<Receipt className="h-4 w-4 text-muted-foreground" />}
unit=""
items={expenseItems}
loading={loading}
onEdit={setEditing}
onDelete={remove}
onImport={() => setImporting("expense")}
/>
<FeeEditDialog
item={editing}
onClose={() => setEditing(null)}
onSaved={load}
/>
<FeeImportDialog
category={importing}
userId={user?.id}
onClose={() => setImporting(null)}
onSaved={load}
/>
</div>
);
}
function FeeSection({
title,
icon,
unit,
items,
loading,
onEdit,
onDelete,
onImport,
}: {
title: string;
icon: React.ReactNode;
unit: string;
items: FeeItem[];
loading: boolean;
onEdit: (i: FeeItem) => void;
onDelete: (id: string) => void;
onImport: () => void;
}) {
return (
<div>
<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 ? (
<div className="p-8 text-center text-muted-foreground text-sm">
Loading…
</div>
) : items.length === 0 ? (
<div className="p-8 text-center text-muted-foreground text-sm">
No {title.toLowerCase()} configured yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead>Billable</TableHead>
<TableHead>Active</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((i) => (
<TableRow key={i.id}>
<TableCell className="font-medium">{i.name}</TableCell>
<TableCell className="text-muted-foreground max-w-md">
{i.description || "—"}
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(Number(i.amount))}
{unit && (
<span className="text-muted-foreground text-xs ml-1">
{unit}
</span>
)}
</TableCell>
<TableCell>
{i.billable ? (
<Badge variant="outline" className="text-[10px]">
Billable
</Badge>
) : (
<span className="text-xs text-muted-foreground">No</span>
)}
</TableCell>
<TableCell>
{i.active ? (
<Badge variant="outline" className="text-[10px]">
Active
</Badge>
) : (
<span className="text-xs text-muted-foreground">
Inactive
</span>
)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(i)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(i.id)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
function FeeEditDialog({
item,
onClose,
onSaved,
}: {
item: Partial<FeeItem> | null;
onClose: () => void;
onSaved: () => void;
}) {
const { user } = useAuth();
const [form, setForm] = useState<Partial<FeeItem>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (item) setForm(item);
}, [item]);
const submit = async () => {
if (!form.name?.trim()) return toast.error("Name required");
if (!form.category) return toast.error("Category required");
const payload: any = {
name: form.name.trim(),
description: form.description?.trim() || null,
category: form.category,
amount: Number(form.amount) || 0,
billable: form.billable ?? true,
active: form.active ?? true,
sort_order: form.sort_order ?? 0,
};
setSaving(true);
const { error } = form.id
? await supabase
.from("fee_schedule_items")
.update(payload)
.eq("id", form.id)
: await supabase
.from("fee_schedule_items")
.insert({ ...payload, created_by: user?.id });
setSaving(false);
if (error) {
toast.error("Could not save", { description: error.message });
return;
}
toast.success(form.id ? "Fee updated" : "Fee added");
onClose();
onSaved();
};
return (
<Dialog open={!!item} onOpenChange={(v) => !v && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="font-serif">
{form.id ? "Edit fee item" : "Add fee item"}
</DialogTitle>
<DialogDescription>
Time fees feed the hourly rate dropdown; expense fees feed the
expense amount picker.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Category</Label>
<Select
value={form.category ?? "time"}
onValueChange={(v) =>
setForm({ ...form, category: v as "time" | "expense" })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="time">Time (hourly rate)</SelectItem>
<SelectItem value="expense">Expense (flat amount)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">
{form.category === "expense" ? "Amount ($)" : "Rate ($ / hr)"}
</Label>
<Input
type="number"
step="0.01"
min="0"
value={form.amount ?? ""}
onChange={(e) =>
setForm({ ...form, amount: parseFloat(e.target.value) || 0 })
}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Input
value={form.name ?? ""}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder={
form.category === "expense"
? "e.g. Filing fee"
: "e.g. Senior partner rate"
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Description (optional)</Label>
<Textarea
rows={2}
value={form.description ?? ""}
onChange={(e) =>
setForm({ ...form, description: e.target.value })
}
/>
</div>
<div className="grid grid-cols-3 gap-3">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={form.billable ?? true}
onCheckedChange={(c) =>
setForm({ ...form, billable: !!c })
}
/>
Billable by default
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={form.active ?? true}
onCheckedChange={(c) => setForm({ ...form, active: !!c })}
/>
Active
</label>
<div className="space-y-1.5">
<Label className="text-xs">Sort order</Label>
<Input
type="number"
value={form.sort_order ?? 0}
onChange={(e) =>
setForm({
...form,
sort_order: parseInt(e.target.value) || 0,
})
}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={submit} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</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>
);
}