From 914e6b8581aa445fc5f124ab6ce098fc049a993e Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:52:00 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/clients..fees.tsx | 784 +++++++++++++++++++++++++++++++++++ 1 file changed, 784 insertions(+) create mode 100644 src/routes/clients..fees.tsx diff --git a/src/routes/clients..fees.tsx b/src/routes/clients..fees.tsx new file mode 100644 index 0000000..51e3e2b --- /dev/null +++ b/src/routes/clients..fees.tsx @@ -0,0 +1,784 @@ +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/fees")({ + component: () => ( + + + + ), +}); + +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, + }) + } + /> + +
+ ); + })} +
+
+ )} +
+
+
+ ); +}