import { createFileRoute, Link } from "@tanstack/react-router"; import { useEffect, useMemo, useState } from "react"; import { ProtectedLayout } from "@/components/protected-layout"; import { PageContainer, PageHeader } from "@/components/app-shell"; 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 { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ArrowLeft, Briefcase, Clock, DollarSign, Loader2, Receipt, Save, Wand2, } from "lucide-react"; import { toast } from "sonner"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/lib/auth"; import { formatCurrency } from "@/lib/format"; export const Route = createFileRoute("/clients/$clientId/fees")({ component: () => ( ), }); // route: client adjust fees page type BillingMethod = "hourly" | "flat" | "contingency" | "pro_bono"; const BILLING_OPTIONS: { value: BillingMethod; label: string }[] = [ { value: "hourly", label: "Hourly" }, { value: "flat", label: "Flat fee" }, { value: "contingency", label: "Contingency" }, { value: "pro_bono", label: "Pro bono" }, ]; interface CaseFeeRow { id: string; case_number: string; title: string; status: string; billing_method: BillingMethod | null; default_hourly_rate: number | null; flat_fee_amount: number | null; dirty?: boolean; } interface FeeItem { id: string; name: string; description: string | null; category: "time" | "expense"; pricing_type: "hourly" | "flat"; amount: number; billable: boolean; active: boolean; } interface OverrideRow { fee_item_id: string; enabled: boolean; amount: number; dirty?: boolean; hasRecord?: boolean; } function toNumOrNull(v: string): number | null { if (v === "" || v == null) return null; const n = Number(v); return Number.isFinite(n) ? n : null; } function asInputStr(n: number | null | undefined): string { if (n == null) return ""; return String(n); } function ClientFeesPage() { const { clientId } = Route.useParams(); const { user, isAdmin } = useAuth(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [client, setClient] = useState(null); const [defaultsHourlyStr, setDefaultsHourlyStr] = useState(""); const [defaultsFlatStr, setDefaultsFlatStr] = useState(""); const [defaultsDirty, setDefaultsDirty] = useState(false); const [rows, setRows] = useState([]); const [selected, setSelected] = useState>(new Set()); const [feeItems, setFeeItems] = useState([]); const [overrides, setOverrides] = useState>({}); const canEdit = isAdmin || client?.created_by === user?.id; const load = async () => { setLoading(true); const [ { data: c, error: ce }, { data: cs, error: cse }, { data: fi }, { data: ov }, ] = await Promise.all([ supabase.from("clients").select("*").eq("id", clientId).maybeSingle(), supabase .from("cases") .select( "id, case_number, title, status, billing_method, default_hourly_rate, flat_fee_amount", ) .eq("client_id", clientId) .order("title", { ascending: true }), supabase .from("fee_schedule_items") .select("*") .eq("active", true) .order("category") .order("sort_order") .order("name"), supabase .from("client_fee_overrides") .select("fee_item_id, enabled, amount") .eq("client_id", clientId), ]); if (ce) toast.error("Failed to load client", { description: ce.message }); if (cse) toast.error("Failed to load cases", { description: cse.message }); setClient(c); setDefaultsHourlyStr(asInputStr(c?.default_hourly_rate ?? null)); setDefaultsFlatStr(asInputStr(c?.default_flat_fee_amount ?? null)); setDefaultsDirty(false); setRows( ((cs ?? []) as any[]).map((x) => ({ id: x.id, case_number: x.case_number, title: x.title, status: x.status, billing_method: (x.billing_method as BillingMethod | null) ?? null, default_hourly_rate: x.default_hourly_rate ?? null, flat_fee_amount: x.flat_fee_amount ?? null, })), ); setSelected(new Set()); const items = (fi ?? []) as FeeItem[]; setFeeItems(items); const ovMap: Record = {}; ((ov ?? []) as any[]).forEach((o) => { ovMap[o.fee_item_id] = { fee_item_id: o.fee_item_id, enabled: !!o.enabled, amount: Number(o.amount) || 0, hasRecord: true, }; }); items.forEach((it) => { if (!ovMap[it.id]) { ovMap[it.id] = { fee_item_id: it.id, enabled: false, amount: Number(it.amount) || 0, hasRecord: false, }; } }); setOverrides(ovMap); setLoading(false); }; useEffect(() => { load(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [clientId]); const dirtyRows = useMemo(() => rows.filter((r) => r.dirty), [rows]); const dirtyOverrides = useMemo( () => Object.values(overrides).filter((o) => o.dirty), [overrides], ); const updateRow = (id: string, patch: Partial) => { setRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch, dirty: true } : r)), ); }; const toggleAll = () => { if (selected.size === rows.length) setSelected(new Set()); else setSelected(new Set(rows.map((r) => r.id))); }; const toggleOne = (id: string) => { setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const applyDefaultsToSelected = (field: "hourly" | "flat") => { if (selected.size === 0) { toast.error("Pick at least one case to apply to"); return; } const value = field === "hourly" ? toNumOrNull(defaultsHourlyStr) : toNumOrNull(defaultsFlatStr); if (value == null) { toast.error("Enter a default value first"); return; } setRows((prev) => prev.map((r) => selected.has(r.id) ? { ...r, ...(field === "hourly" ? { default_hourly_rate: value } : { flat_fee_amount: value, billing_method: r.billing_method ?? "flat", }), dirty: true, } : r, ), ); toast.success( `Applied ${formatCurrency(value)} to ${selected.size} case${selected.size === 1 ? "" : "s"} — remember to save`, ); }; const saveDefaults = async () => { setSaving(true); const payload = { default_hourly_rate: toNumOrNull(defaultsHourlyStr), default_flat_fee_amount: toNumOrNull(defaultsFlatStr), }; const { error } = await supabase .from("clients") .update(payload) .eq("id", clientId); setSaving(false); if (error) { toast.error("Could not save defaults", { description: error.message }); return; } setDefaultsDirty(false); toast.success("Client default fees saved"); }; const saveCases = async () => { if (dirtyRows.length === 0) { toast.error("Nothing to save"); return; } setSaving(true); let failed = 0; for (const r of dirtyRows) { const { error } = await supabase .from("cases") .update({ billing_method: r.billing_method, default_hourly_rate: r.default_hourly_rate, flat_fee_amount: r.flat_fee_amount, }) .eq("id", r.id); if (error) failed++; } setSaving(false); if (failed > 0) { toast.error(`${failed} case${failed === 1 ? "" : "s"} failed to save`); } else { toast.success(`Saved ${dirtyRows.length} case${dirtyRows.length === 1 ? "" : "s"}`); } load(); }; const updateOverride = (feeItemId: string, patch: Partial) => { setOverrides((prev) => ({ ...prev, [feeItemId]: { ...prev[feeItemId], ...patch, dirty: true }, })); }; const saveOverrides = async () => { if (dirtyOverrides.length === 0) { toast.error("Nothing to save"); return; } setSaving(true); let failed = 0; for (const o of dirtyOverrides) { if (!o.enabled && o.hasRecord) { const { error } = await supabase .from("client_fee_overrides") .delete() .eq("client_id", clientId) .eq("fee_item_id", o.fee_item_id); if (error) failed++; } else if (o.enabled) { const { error } = await supabase .from("client_fee_overrides") .upsert( { client_id: clientId, fee_item_id: o.fee_item_id, enabled: true, amount: o.amount, created_by: user?.id, }, { onConflict: "client_id,fee_item_id" }, ); if (error) failed++; } } setSaving(false); if (failed > 0) { toast.error(`${failed} override${failed === 1 ? "" : "s"} failed to save`); } else { toast.success( `Saved ${dirtyOverrides.length} override${dirtyOverrides.length === 1 ? "" : "s"}`, ); } load(); }; const enabledCount = useMemo( () => Object.values(overrides).filter((o) => o.enabled).length, [overrides], ); if (loading) { return (

Loading…

); } if (!client) { return (

Client not found.

); } const timeItems = feeItems.filter((i) => i.category === "time"); const expenseItems = feeItems.filter((i) => i.category === "expense"); return ( Defaults Cases ({rows.length}) Expense Overrides ({enabledCount}) Time Overrides

Client default fees

Reusable defaults you can apply to any case below.

{defaultsDirty && ( )}
{ setDefaultsHourlyStr(e.target.value); setDefaultsDirty(true); }} />
{ setDefaultsFlatStr(e.target.value); setDefaultsDirty(true); }} />

Switch to the Cases tab to bulk-apply these defaults to selected cases.

Case-level fees

Edit billing method and amounts per case. Tick rows to apply client defaults in bulk.

{rows.length === 0 ? (
No cases for this client yet.
) : ( 0 ? "indeterminate" : false } onCheckedChange={toggleAll} disabled={!canEdit} aria-label="Select all" /> Case Billing method Hourly rate Flat fee {rows.map((r) => ( toggleOne(r.id)} disabled={!canEdit} aria-label={`Select ${r.title}`} />
{r.title} {r.dirty && ( unsaved )}
{r.case_number} · {r.status.replace("_", " ")}
updateRow(r.id, { default_hourly_rate: toNumOrNull(e.target.value), }) } /> updateRow(r.id, { flat_fee_amount: toNumOrNull(e.target.value), }) } />
))}
)}
); } function FeeOverrideSection({ title, description, items, overrides, updateOverride, saveOverrides, saving, canEdit, dirtyCount, }: { title: string; description: string; items: FeeItem[]; overrides: Record; updateOverride: (id: string, patch: Partial) => void; saveOverrides: () => void; saving: boolean; canEdit: boolean; dirtyCount: number; }) { return (

{title}

{description}

{items.length === 0 ? (
No active fee items in this category. Add some in Settings → Fees.
) : ( Fee item Firm default Client amount {items.map((it) => { const ov = overrides[it.id]; if (!ov) return null; return ( updateOverride(it.id, { enabled: !!v }) } aria-label={`Override ${it.name}`} />
{it.name} {ov.dirty && ( unsaved )}
{it.description && (
{it.description}
)}
{formatCurrency(Number(it.amount))} {it.category === "time" && ( {it.pricing_type === "flat" ? "flat" : "/hr"} )} updateOverride(it.id, { amount: Number(e.target.value) || 0, }) } />
); })}
)}
); }