Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-22 17:52:09 +00:00
co-authored by renee-png
parent 5d44dd66b5
commit 411e252819
+784
View File
@@ -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/$clientId/fees")({
component: () => (
<ProtectedLayout>
<ClientFeesPage />
</ProtectedLayout>
),
});
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<any>(null);
const [defaultsHourlyStr, setDefaultsHourlyStr] = useState("");
const [defaultsFlatStr, setDefaultsFlatStr] = useState("");
const [defaultsDirty, setDefaultsDirty] = useState(false);
const [rows, setRows] = useState<CaseFeeRow[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [feeItems, setFeeItems] = useState<FeeItem[]>([]);
const [overrides, setOverrides] = useState<Record<string, OverrideRow>>({});
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<string, OverrideRow> = {};
((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<CaseFeeRow>) => {
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<OverrideRow>) => {
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 (
<PageContainer>
<p className="text-muted-foreground">Loading…</p>
</PageContainer>
);
}
if (!client) {
return (
<PageContainer>
<p className="text-muted-foreground">Client not found.</p>
</PageContainer>
);
}
const timeItems = feeItems.filter((i) => i.category === "time");
const expenseItems = feeItems.filter((i) => i.category === "expense");
return (
<PageContainer>
<Button variant="ghost" size="sm" asChild className="mb-3 -ml-2">
<Link to="/clients/$clientId" params={{ clientId }}>
<ArrowLeft className="h-4 w-4 mr-1" /> Back to client
</Link>
</Button>
<PageHeader
title="Adjust Fees"
description={`${client.name} · manage default rates, per-case billing, and per-client expense overrides`}
/>
<Tabs defaultValue="defaults" className="mt-2">
<TabsList>
<TabsTrigger value="defaults">
<DollarSign className="h-3.5 w-3.5 mr-1.5" /> Defaults
</TabsTrigger>
<TabsTrigger value="cases">
<Briefcase className="h-3.5 w-3.5 mr-1.5" /> Cases ({rows.length})
</TabsTrigger>
<TabsTrigger value="expenses">
<Receipt className="h-3.5 w-3.5 mr-1.5" /> Expense Overrides ({enabledCount})
</TabsTrigger>
<TabsTrigger value="time">
<Clock className="h-3.5 w-3.5 mr-1.5" /> Time Overrides
</TabsTrigger>
</TabsList>
<TabsContent value="defaults" className="mt-4">
<Card className="border-border/60 max-w-3xl">
<CardContent className="p-5 space-y-4">
<div className="flex items-center justify-between gap-2">
<div>
<h4 className="font-serif text-base">Client default fees</h4>
<p className="text-xs text-muted-foreground">
Reusable defaults you can apply to any case below.
</p>
</div>
{defaultsDirty && (
<Button size="sm" onClick={saveDefaults} disabled={!canEdit || saving}>
{saving ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5 mr-1.5" />
)}
Save defaults
</Button>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Default hourly rate</Label>
<Input
type="number"
step="0.01"
min="0"
placeholder="$ / hr"
value={defaultsHourlyStr}
disabled={!canEdit}
onChange={(e) => {
setDefaultsHourlyStr(e.target.value);
setDefaultsDirty(true);
}}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Default flat fee</Label>
<Input
type="number"
step="0.01"
min="0"
placeholder="$ flat"
value={defaultsFlatStr}
disabled={!canEdit}
onChange={(e) => {
setDefaultsFlatStr(e.target.value);
setDefaultsDirty(true);
}}
/>
</div>
</div>
<p className="text-xs text-muted-foreground pt-2 border-t">
Switch to the <strong>Cases</strong> tab to bulk-apply these defaults to selected cases.
</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="cases" className="mt-4 space-y-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div>
<h4 className="font-serif text-base">Case-level fees</h4>
<p className="text-xs text-muted-foreground">
Edit billing method and amounts per case. Tick rows to apply client defaults in bulk.
</p>
</div>
<div className="flex gap-2 items-center">
<Button
variant="outline"
size="sm"
disabled={!canEdit || selected.size === 0 || !defaultsHourlyStr}
onClick={() => applyDefaultsToSelected("hourly")}
>
<Wand2 className="h-3.5 w-3.5 mr-1.5" /> Apply hourly default
</Button>
<Button
variant="outline"
size="sm"
disabled={!canEdit || selected.size === 0 || !defaultsFlatStr}
onClick={() => applyDefaultsToSelected("flat")}
>
<Wand2 className="h-3.5 w-3.5 mr-1.5" /> Apply flat default
</Button>
<Button
size="sm"
onClick={saveCases}
disabled={!canEdit || saving || dirtyRows.length === 0}
>
{saving ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5 mr-1.5" />
)}
Save {dirtyRows.length > 0 ? `(${dirtyRows.length})` : ""}
</Button>
</div>
</div>
<Card className="border-border/60">
<CardContent className="p-0">
{rows.length === 0 ? (
<div className="p-8 text-center text-sm text-muted-foreground">
No cases for this client yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8">
<Checkbox
checked={
selected.size === rows.length
? true
: selected.size > 0
? "indeterminate"
: false
}
onCheckedChange={toggleAll}
disabled={!canEdit}
aria-label="Select all"
/>
</TableHead>
<TableHead>Case</TableHead>
<TableHead className="w-[160px]">Billing method</TableHead>
<TableHead className="w-[140px] text-right">Hourly rate</TableHead>
<TableHead className="w-[140px] text-right">Flat fee</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow
key={r.id}
data-state={selected.has(r.id) ? "selected" : undefined}
>
<TableCell>
<Checkbox
checked={selected.has(r.id)}
onCheckedChange={() => toggleOne(r.id)}
disabled={!canEdit}
aria-label={`Select ${r.title}`}
/>
</TableCell>
<TableCell>
<div className="text-sm font-medium truncate max-w-[360px]">
{r.title}
{r.dirty && (
<span className="ml-2 text-[10px] uppercase tracking-wider text-amber-600 dark:text-amber-400">
unsaved
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{r.case_number} · {r.status.replace("_", " ")}
</div>
</TableCell>
<TableCell>
<Select
value={r.billing_method ?? "__none"}
onValueChange={(v) =>
updateRow(r.id, {
billing_method:
v === "__none" ? null : (v as BillingMethod),
})
}
disabled={!canEdit}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none">— Not set —</SelectItem>
{BILLING_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input
type="number"
step="0.01"
min="0"
className="h-8 text-right"
placeholder="$ / hr"
value={asInputStr(r.default_hourly_rate)}
disabled={!canEdit}
onChange={(e) =>
updateRow(r.id, {
default_hourly_rate: toNumOrNull(e.target.value),
})
}
/>
</TableCell>
<TableCell>
<Input
type="number"
step="0.01"
min="0"
className="h-8 text-right"
placeholder="$ flat"
value={asInputStr(r.flat_fee_amount)}
disabled={!canEdit}
onChange={(e) =>
updateRow(r.id, {
flat_fee_amount: toNumOrNull(e.target.value),
})
}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="expenses" className="mt-4">
<FeeOverrideSection
title="Expense fee overrides"
description="Pick which expense items have a custom amount for this client. Unchecked items use the firm default."
items={expenseItems}
overrides={overrides}
updateOverride={updateOverride}
saveOverrides={saveOverrides}
saving={saving}
canEdit={canEdit}
dirtyCount={dirtyOverrides.length}
/>
</TabsContent>
<TabsContent value="time" className="mt-4">
<FeeOverrideSection
title="Time fee overrides"
description="Override per-client rates for time fee items (e.g. specific attorney rates for this client)."
items={timeItems}
overrides={overrides}
updateOverride={updateOverride}
saveOverrides={saveOverrides}
saving={saving}
canEdit={canEdit}
dirtyCount={dirtyOverrides.length}
/>
</TabsContent>
</Tabs>
</PageContainer>
);
}
function FeeOverrideSection({
title,
description,
items,
overrides,
updateOverride,
saveOverrides,
saving,
canEdit,
dirtyCount,
}: {
title: string;
description: string;
items: FeeItem[];
overrides: Record<string, OverrideRow>;
updateOverride: (id: string, patch: Partial<OverrideRow>) => void;
saveOverrides: () => void;
saving: boolean;
canEdit: boolean;
dirtyCount: number;
}) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div>
<h4 className="font-serif text-base">{title}</h4>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<Button
size="sm"
onClick={saveOverrides}
disabled={!canEdit || saving || dirtyCount === 0}
>
{saving ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5 mr-1.5" />
)}
Save {dirtyCount > 0 ? `(${dirtyCount})` : ""}
</Button>
</div>
<Card className="border-border/60">
<CardContent className="p-0">
{items.length === 0 ? (
<div className="p-8 text-center text-sm text-muted-foreground">
No active fee items in this category. Add some in Settings → Fees.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8"></TableHead>
<TableHead>Fee item</TableHead>
<TableHead className="w-[140px] text-right">Firm default</TableHead>
<TableHead className="w-[160px] text-right">Client amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((it) => {
const ov = overrides[it.id];
if (!ov) return null;
return (
<TableRow
key={it.id}
data-state={ov.enabled ? "selected" : undefined}
>
<TableCell>
<Checkbox
checked={ov.enabled}
disabled={!canEdit}
onCheckedChange={(v) =>
updateOverride(it.id, { enabled: !!v })
}
aria-label={`Override ${it.name}`}
/>
</TableCell>
<TableCell>
<div className="text-sm font-medium">
{it.name}
{ov.dirty && (
<span className="ml-2 text-[10px] uppercase tracking-wider text-amber-600 dark:text-amber-400">
unsaved
</span>
)}
</div>
{it.description && (
<div className="text-xs text-muted-foreground line-clamp-1 max-w-md">
{it.description}
</div>
)}
</TableCell>
<TableCell className="text-right font-mono text-xs text-muted-foreground">
{formatCurrency(Number(it.amount))}
{it.category === "time" && (
<span className="ml-1">
{it.pricing_type === "flat" ? "flat" : "/hr"}
</span>
)}
</TableCell>
<TableCell>
<Input
type="number"
step="0.01"
min="0"
className="h-8 text-right"
placeholder={`$ ${formatCurrency(Number(it.amount))}`}
value={ov.enabled ? asInputStr(ov.amount) : ""}
disabled={!canEdit || !ov.enabled}
onChange={(e) =>
updateOverride(it.id, {
amount: Number(e.target.value) || 0,
})
}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}