Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
684 lines
21 KiB
TypeScript
684 lines
21 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";
|
|
pricing_type: "hourly" | "flat";
|
|
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", pricing_type: "hourly", 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))}
|
|
{(() => {
|
|
const isHourly = i.category === "time" && i.pricing_type !== "flat";
|
|
const u = isHourly ? "/ hr" : i.category === "time" ? " flat" : unit;
|
|
return u ? (
|
|
<span className="text-muted-foreground text-xs ml-1">{u}</span>
|
|
) : null;
|
|
})()}
|
|
</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,
|
|
pricing_type:
|
|
form.category === "expense" ? "flat" : form.pricing_type ?? "hourly",
|
|
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-3 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",
|
|
pricing_type:
|
|
v === "expense" ? "flat" : form.pricing_type ?? "hourly",
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="time">Time</SelectItem>
|
|
<SelectItem value="expense">Expense</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Pricing</Label>
|
|
<Select
|
|
value={
|
|
form.category === "expense"
|
|
? "flat"
|
|
: form.pricing_type ?? "hourly"
|
|
}
|
|
onValueChange={(v) =>
|
|
setForm({ ...form, pricing_type: v as "hourly" | "flat" })
|
|
}
|
|
disabled={form.category === "expense"}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="hourly">Hourly rate</SelectItem>
|
|
<SelectItem value="flat">Flat fee</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">
|
|
{form.category !== "expense" && form.pricing_type !== "flat"
|
|
? "Rate ($ / hr)"
|
|
: "Amount ($)"}
|
|
</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>
|
|
);
|
|
}
|