Added billing edit tab

X-Lovable-Edit-ID: edt-daa10324-afa8-4db1-ac72-a37c0a0ed312
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:44:34 +00:00
co-authored by renee-png
4 changed files with 468 additions and 1 deletions
+452
View File
@@ -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<ClientDefaults>({
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<CaseFeeRow[]>([]);
const [selected, setSelected] = useState<Set<string>>(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<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;
}
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 <p className="text-sm text-muted-foreground">Loading fees…</p>;
}
return (
<div className="space-y-5">
<Card className="border-border/60">
<CardContent className="p-4 space-y-3">
<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>
<div className="flex gap-2">
<Input
type="number"
step="0.01"
min="0"
placeholder="$ / hr"
value={defaultsHourlyStr}
disabled={!canEdit}
onChange={(e) => {
setDefaultsHourlyStr(e.target.value);
setDefaultsDirty(true);
}}
/>
<Button
variant="outline"
size="sm"
disabled={!canEdit || selected.size === 0}
onClick={() => applyDefaultsToSelected("hourly")}
title="Apply to selected cases"
>
<Wand2 className="h-3.5 w-3.5 mr-1.5" /> Apply
</Button>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Default flat fee</Label>
<div className="flex gap-2">
<Input
type="number"
step="0.01"
min="0"
placeholder="$ flat"
value={defaultsFlatStr}
disabled={!canEdit}
onChange={(e) => {
setDefaultsFlatStr(e.target.value);
setDefaultsDirty(true);
}}
/>
<Button
variant="outline"
size="sm"
disabled={!canEdit || selected.size === 0}
onClick={() => applyDefaultsToSelected("flat")}
title="Apply to selected cases"
>
<Wand2 className="h-3.5 w-3.5 mr-1.5" /> Apply
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
<div className="flex items-center justify-between gap-2">
<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>
<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>
<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-[260px]">
{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>
</div>
);
}
+6
View File
@@ -539,6 +539,8 @@ export type Database = {
client_type: Database["public"]["Enums"]["client_type"]
created_at: string
created_by: string | null
default_flat_fee_amount: number | null
default_hourly_rate: number | null
external_id: string | null
id: string
management_company: string | null
@@ -562,6 +564,8 @@ export type Database = {
client_type?: Database["public"]["Enums"]["client_type"]
created_at?: string
created_by?: string | null
default_flat_fee_amount?: number | null
default_hourly_rate?: number | null
external_id?: string | null
id?: string
management_company?: string | null
@@ -585,6 +589,8 @@ export type Database = {
client_type?: Database["public"]["Enums"]["client_type"]
created_at?: string
created_by?: string | null
default_flat_fee_amount?: number | null
default_hourly_rate?: number | null
external_id?: string | null
id?: string
management_company?: string | null
+7 -1
View File
@@ -9,9 +9,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { supabase } from "@/integrations/supabase/client";
import { ClientFormDialog } from "@/components/clients/client-form-dialog";
import { useAuth } from "@/lib/auth";
import { ArrowLeft, Edit, Plus, Building2, User, MapPin, Mail, Phone, Users, Activity, Briefcase, FileDown, Contact, Receipt, Archive, ArchiveRestore, ListPlus, Save } from "lucide-react";
import { ArrowLeft, Edit, Plus, Building2, User, MapPin, Mail, Phone, Users, Activity, Briefcase, FileDown, Contact, Receipt, Archive, ArchiveRestore, ListPlus, Save, DollarSign } from "lucide-react";
import { ContactsLinkTab } from "@/components/contacts/contacts-link-tab";
import { ClientCustomFieldsTab } from "@/components/clients/client-custom-fields-tab";
import { AdjustFeesTab } from "@/components/clients/adjust-fees-tab";
import { formatCurrency, formatDate, formatDateTime, statusBadgeClass } from "@/lib/format";
import { toast } from "sonner";
import { downloadStatusReport, saveStatusReportToDb } from "@/lib/status-pdf";
@@ -235,6 +236,7 @@ function ClientDetail() {
<TabsTrigger value="cases" className="flex-1"><Briefcase className="h-3.5 w-3.5 mr-1.5" />Cases</TabsTrigger>
<TabsTrigger value="status" className="flex-1"><Activity className="h-3.5 w-3.5 mr-1.5" />Status</TabsTrigger>
<TabsTrigger value="contacts" className="flex-1"><Contact className="h-3.5 w-3.5 mr-1.5" />Contacts</TabsTrigger>
<TabsTrigger value="fees" className="flex-1"><DollarSign className="h-3.5 w-3.5 mr-1.5" />Adjust Fees</TabsTrigger>
<TabsTrigger value="custom" className="flex-1"><ListPlus className="h-3.5 w-3.5 mr-1.5" />Fields</TabsTrigger>
</TabsList>
@@ -342,6 +344,10 @@ function ClientDetail() {
<ContactsLinkTab parentId={clientId} parentTable="client_contacts" />
</TabsContent>
<TabsContent value="fees">
<AdjustFeesTab clientId={clientId} canEdit={canEdit} />
</TabsContent>
<TabsContent value="custom">
<ClientCustomFieldsTab clientId={clientId} />
</TabsContent>
@@ -0,0 +1,3 @@
ALTER TABLE public.clients
ADD COLUMN IF NOT EXISTS default_hourly_rate numeric,
ADD COLUMN IF NOT EXISTS default_flat_fee_amount numeric;