From 7b8f01692548353fe669e9c9d0804bfb69e1dc1e 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:43:41 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/clients/adjust-fees-tab.tsx | 452 +++++++++++++++++++++ 1 file changed, 452 insertions(+) create mode 100644 src/components/clients/adjust-fees-tab.tsx diff --git a/src/components/clients/adjust-fees-tab.tsx b/src/components/clients/adjust-fees-tab.tsx new file mode 100644 index 0000000..3b8b179 --- /dev/null +++ b/src/components/clients/adjust-fees-tab.tsx @@ -0,0 +1,452 @@ +import { useEffect, useMemo, useState } from "react"; +import { supabase } from "@/integrations/supabase/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent } from "@/components/ui/card"; +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 { Loader2, Save, Wand2 } from "lucide-react"; +import { toast } from "sonner"; +import { formatCurrency } from "@/lib/format"; + +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 ClientDefaults { + default_hourly_rate: number | null; + default_flat_fee_amount: number | null; +} + +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); +} + +export function AdjustFeesTab({ + clientId, + canEdit, +}: { + clientId: string; + canEdit: boolean; +}) { + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [defaults, setDefaults] = useState({ + default_hourly_rate: null, + default_flat_fee_amount: 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 load = async () => { + setLoading(true); + const [{ data: c, error: ce }, { data: cs, error: cse }] = await Promise.all([ + supabase + .from("clients") + .select("default_hourly_rate, default_flat_fee_amount") + .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("opened_at", { ascending: false }), + ]); + if (ce) toast.error("Failed to load client defaults", { description: ce.message }); + if (cse) toast.error("Failed to load cases", { description: cse.message }); + const def: ClientDefaults = { + default_hourly_rate: c?.default_hourly_rate ?? null, + default_flat_fee_amount: c?.default_flat_fee_amount ?? null, + }; + setDefaults(def); + setDefaultsHourlyStr(asInputStr(def.default_hourly_rate)); + setDefaultsFlatStr(asInputStr(def.default_flat_fee_amount)); + setDefaultsDirty(false); + setRows( + ((cs ?? []) as any[]).map((c) => ({ + id: c.id, + case_number: c.case_number, + title: c.title, + status: c.status, + billing_method: (c.billing_method as BillingMethod | null) ?? null, + default_hourly_rate: c.default_hourly_rate ?? null, + flat_fee_amount: c.flat_fee_amount ?? null, + })), + ); + setSelected(new Set()); + setLoading(false); + }; + + useEffect(() => { + load(); + }, [clientId]); + + const dirtyRows = useMemo(() => rows.filter((r) => r.dirty), [rows]); + + 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; + } + setDefaults(payload); + 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(); + }; + + if (loading) { + return

Loading fees…

; + } + + return ( +
+ + +
+
+

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); + }} + /> + +
+
+
+
+
+ +
+
+

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), + }) + } + /> + +
+ ))} +
+
+ )} +
+
+
+ ); +} \ No newline at end of file