Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
5d44dd66b5
commit
411e252819
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user