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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user