Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
787 lines
26 KiB
TypeScript
787 lines
26 KiB
TypeScript
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>
|
|
),
|
|
});
|
|
|
|
// 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<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>
|
|
);
|
|
}
|