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([]); const [loading, setLoading] = useState(true); const [editing, setEditing] = useState | null>(null); const [importing, setImporting] = useState<"time" | "expense" | null>(null); const [bulk, setBulk] = useState<{ category: "time" | "expense"; ids: string[] } | 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 (

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. If a time item's rate is left at $0, the user's profile hourly rate is used when logging time.

} unit="/ hr" items={timeItems} loading={loading} onEdit={setEditing} onDelete={remove} onImport={() => setImporting("time")} onBulkEdit={(ids) => setBulk({ category: "time", ids })} /> } unit="" items={expenseItems} loading={loading} onEdit={setEditing} onDelete={remove} onImport={() => setImporting("expense")} onBulkEdit={(ids) => setBulk({ category: "expense", ids })} /> setEditing(null)} onSaved={load} /> setImporting(null)} onSaved={load} /> setBulk(null)} onSaved={() => { setBulk(null); load(); }} />
); } function FeeSection({ title, icon, unit, items, loading, onEdit, onDelete, onImport, onBulkEdit, }: { title: string; icon: React.ReactNode; unit: string; items: FeeItem[]; loading: boolean; onEdit: (i: FeeItem) => void; onDelete: (id: string) => void; onImport: () => void; onBulkEdit: (ids: string[]) => void; }) { const [selected, setSelected] = useState>(new Set()); // Drop selections that no longer exist (after a reload). useEffect(() => { setSelected((prev) => { const ids = new Set(items.map((i) => i.id)); const next = new Set(); prev.forEach((id) => { if (ids.has(id)) next.add(id); }); return next.size === prev.size ? prev : next; }); }, [items]); const toggle = (id: string) => { setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const allSelected = items.length > 0 && selected.size === items.length; const someSelected = selected.size > 0 && !allSelected; const toggleAll = () => { if (allSelected) setSelected(new Set()); else setSelected(new Set(items.map((i) => i.id))); }; return (

{icon} {title}

{selected.size > 0 && ( <> {selected.size} selected )}
{loading ? (
Loading…
) : items.length === 0 ? (
No {title.toLowerCase()} configured yet.
) : ( Name Description Amount Billable Active {items.map((i) => ( toggle(i.id)} aria-label={`Select ${i.name}`} /> {i.name} {i.description || "—"} {formatCurrency(Number(i.amount))} {(() => { const isHourly = i.category === "time" && i.pricing_type !== "flat"; const u = isHourly ? "/ hr" : i.category === "time" ? " flat" : unit; return u ? ( {u} ) : null; })()} {i.billable ? ( Billable ) : ( No )} {i.active ? ( Active ) : ( Inactive )} ))}
)}
); } function FeeBulkEditDialog({ bulk, onClose, onSaved, }: { bulk: { category: "time" | "expense"; ids: string[] } | null; onClose: () => void; onSaved: () => void; }) { const [billableMode, setBillableMode] = useState<"keep" | "true" | "false">("keep"); const [activeMode, setActiveMode] = useState<"keep" | "true" | "false">("keep"); const [pricingMode, setPricingMode] = useState<"keep" | "hourly" | "flat">("keep"); const [amountMode, setAmountMode] = useState<"keep" | "set" | "clear">("keep"); const [amount, setAmount] = useState(""); const [saving, setSaving] = useState(false); useEffect(() => { if (bulk) { setBillableMode("keep"); setActiveMode("keep"); setPricingMode("keep"); setAmountMode("keep"); setAmount(""); } }, [bulk]); if (!bulk) return null; const isTime = bulk.category === "time"; const count = bulk.ids.length; const submit = async () => { const payload: Record = {}; if (billableMode !== "keep") payload.billable = billableMode === "true"; if (activeMode !== "keep") payload.active = activeMode === "true"; if (isTime && pricingMode !== "keep") payload.pricing_type = pricingMode; if (amountMode === "clear") payload.amount = 0; if (amountMode === "set") { const n = parseFloat(amount); if (Number.isNaN(n) || n < 0) { toast.error("Enter a valid amount"); return; } payload.amount = n; } if (Object.keys(payload).length === 0) { toast.error("Pick at least one field to change"); return; } setSaving(true); const { error } = await supabase .from("fee_schedule_items") .update(payload as never) .in("id", bulk.ids); setSaving(false); if (error) { toast.error("Bulk update failed", { description: error.message }); return; } toast.success(`Updated ${count} item${count === 1 ? "" : "s"}`); onSaved(); }; return ( !v && onClose()}> Bulk edit {count} {isTime ? "time" : "expense"} item{count === 1 ? "" : "s"} Only fields set to a new value are applied — others are left unchanged. {isTime && "Setting an amount of $0 makes time entries fall back to the user's profile hourly rate."}
{isTime && (
)}
setAmount(e.target.value)} className="col-span-2" />
); } function FeeEditDialog({ item, onClose, onSaved, }: { item: Partial | null; onClose: () => void; onSaved: () => void; }) { const { user } = useAuth(); const [form, setForm] = useState>({}); 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 ( !v && onClose()}> {form.id ? "Edit fee item" : "Add fee item"} Time fees feed the hourly rate dropdown; expense fees feed the expense amount picker.
setForm({ ...form, amount: parseFloat(e.target.value) || 0 }) } />
setForm({ ...form, name: e.target.value })} placeholder={ form.category === "expense" ? "e.g. Filing fee" : "e.g. Senior partner rate" } />