Added bulk edit to fee schedule
X-Lovable-Edit-ID: edt-622a6f2c-8958-4192-9777-b608d6b2ed8d Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -186,9 +186,14 @@ export function CaseTimeTab({ caseRecord, onInvoice }: CaseTimeTabProps) {
|
||||
setSelectedFeeId(v);
|
||||
const fee = fees.find((f) => f.id === v);
|
||||
if (fee) {
|
||||
// If the fee item has no rate set (0), fall back to the
|
||||
// case's default rate or the user's profile hourly rate.
|
||||
const fallback =
|
||||
caseRecord.default_hourly_rate ?? profileRate ?? 0;
|
||||
const rate = Number(fee.amount) > 0 ? fee.amount : fallback;
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
hourly_rate: String(fee.amount),
|
||||
hourly_rate: rate ? String(rate) : f.hourly_rate,
|
||||
billable: fee.billable,
|
||||
description: fee.description || fee.name,
|
||||
}));
|
||||
@@ -201,7 +206,10 @@ export function CaseTimeTab({ caseRecord, onInvoice }: CaseTimeTabProps) {
|
||||
<SelectContent>
|
||||
{fees.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.name} — {formatCurrency(Number(f.amount))}/hr
|
||||
{f.name}
|
||||
{Number(f.amount) > 0
|
||||
? ` — ${formatCurrency(Number(f.amount))}/hr`
|
||||
: " — uses your default rate"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -220,9 +220,11 @@ export function QuickAddTime() {
|
||||
setFeeId(v);
|
||||
const fee = fees.find((f) => f.id === v);
|
||||
if (fee) {
|
||||
const fallback = activeCase?.default_hourly_rate ?? profileRate ?? 0;
|
||||
const rate = Number(fee.amount) > 0 ? fee.amount : fallback;
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
rate: String(fee.amount),
|
||||
rate: rate ? String(rate) : f.rate,
|
||||
billable: fee.billable,
|
||||
description: f.description || fee.name,
|
||||
}));
|
||||
@@ -233,7 +235,10 @@ export function QuickAddTime() {
|
||||
<SelectContent>
|
||||
{fees.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.name} — ${Number(f.amount).toFixed(2)}/hr
|
||||
{f.name}
|
||||
{Number(f.amount) > 0
|
||||
? ` — $${Number(f.amount).toFixed(2)}/hr`
|
||||
: " — uses your default rate"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -101,8 +101,11 @@ export function HeaderTimer() {
|
||||
|
||||
const isFlatFee = selectedFee?.pricing_type === "flat";
|
||||
|
||||
// If a fee item has no rate (0), fall back to the case rate then the user's
|
||||
// profile rate. Flat-fee items always use their own amount.
|
||||
const feeRate = selectedFee && Number(selectedFee.amount) > 0 ? selectedFee.amount : null;
|
||||
const effectiveRate =
|
||||
selectedFee?.amount ?? activeCase?.default_hourly_rate ?? profileRate ?? 0;
|
||||
feeRate ?? activeCase?.default_hourly_rate ?? profileRate ?? 0;
|
||||
|
||||
const handleSelectFee = (id: string) => {
|
||||
setFeeItemId(id);
|
||||
|
||||
@@ -58,6 +58,7 @@ function FeeSchedulePage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<Partial<FeeItem> | null>(null);
|
||||
const [importing, setImporting] = useState<"time" | "expense" | null>(null);
|
||||
const [bulk, setBulk] = useState<{ category: "time" | "expense"; ids: string[] } | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -97,7 +98,9 @@ function FeeSchedulePage() {
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
Define reusable billable items. Time fees set the hourly rate when
|
||||
logging time; expense fees auto-fill the amount when adding an
|
||||
expense. The billable flag becomes the default for new entries.
|
||||
expense. The billable flag becomes the default for new entries. If a
|
||||
time item's rate is left at $0, the user's profile hourly rate is used
|
||||
when logging time.
|
||||
</p>
|
||||
<Button onClick={() => setEditing({ category: "time", pricing_type: "hourly", billable: true, active: true, amount: 0 })}>
|
||||
<Plus className="h-4 w-4 mr-2" /> Add fee item
|
||||
@@ -113,6 +116,7 @@ function FeeSchedulePage() {
|
||||
onEdit={setEditing}
|
||||
onDelete={remove}
|
||||
onImport={() => setImporting("time")}
|
||||
onBulkEdit={(ids) => setBulk({ category: "time", ids })}
|
||||
/>
|
||||
|
||||
<FeeSection
|
||||
@@ -124,6 +128,7 @@ function FeeSchedulePage() {
|
||||
onEdit={setEditing}
|
||||
onDelete={remove}
|
||||
onImport={() => setImporting("expense")}
|
||||
onBulkEdit={(ids) => setBulk({ category: "expense", ids })}
|
||||
/>
|
||||
|
||||
<FeeEditDialog
|
||||
@@ -138,6 +143,15 @@ function FeeSchedulePage() {
|
||||
onClose={() => setImporting(null)}
|
||||
onSaved={load}
|
||||
/>
|
||||
|
||||
<FeeBulkEditDialog
|
||||
bulk={bulk}
|
||||
onClose={() => setBulk(null)}
|
||||
onSaved={() => {
|
||||
setBulk(null);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -151,6 +165,7 @@ function FeeSection({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onImport,
|
||||
onBulkEdit,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -160,17 +175,64 @@ function FeeSection({
|
||||
onEdit: (i: FeeItem) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onImport: () => void;
|
||||
onBulkEdit: (ids: string[]) => void;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
// Drop selections that no longer exist (after a reload).
|
||||
useEffect(() => {
|
||||
setSelected((prev) => {
|
||||
const ids = new Set(items.map((i) => i.id));
|
||||
const next = new Set<string>();
|
||||
prev.forEach((id) => {
|
||||
if (ids.has(id)) next.add(id);
|
||||
});
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const allSelected = items.length > 0 && selected.size === items.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
const toggleAll = () => {
|
||||
if (allSelected) setSelected(new Set());
|
||||
else setSelected(new Set(items.map((i) => i.id)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center justify-between mb-2 gap-2 flex-wrap">
|
||||
<h3 className="font-serif text-lg flex items-center gap-2">
|
||||
{icon}
|
||||
{title}
|
||||
</h3>
|
||||
<Button variant="outline" size="sm" onClick={onImport}>
|
||||
<ClipboardPaste className="h-3.5 w-3.5 mr-2" /> Paste import
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{selected.size > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selected.size} selected
|
||||
</span>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onBulkEdit(Array.from(selected))}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 mr-2" /> Bulk edit
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onImport}>
|
||||
<ClipboardPaste className="h-3.5 w-3.5 mr-2" /> Paste import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Card className="border-border/60">
|
||||
<CardContent className="p-0">
|
||||
@@ -186,6 +248,13 @@ function FeeSection({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8">
|
||||
<Checkbox
|
||||
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
@@ -196,7 +265,14 @@ function FeeSection({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((i) => (
|
||||
<TableRow key={i.id}>
|
||||
<TableRow key={i.id} data-state={selected.has(i.id) ? "selected" : undefined}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selected.has(i.id)}
|
||||
onCheckedChange={() => toggle(i.id)}
|
||||
aria-label={`Select ${i.name}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{i.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground max-w-md">
|
||||
{i.description || "—"}
|
||||
@@ -258,6 +334,175 @@ function FeeSection({
|
||||
);
|
||||
}
|
||||
|
||||
function FeeBulkEditDialog({
|
||||
bulk,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
bulk: { category: "time" | "expense"; ids: string[] } | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [billableMode, setBillableMode] = useState<"keep" | "true" | "false">("keep");
|
||||
const [activeMode, setActiveMode] = useState<"keep" | "true" | "false">("keep");
|
||||
const [pricingMode, setPricingMode] = useState<"keep" | "hourly" | "flat">("keep");
|
||||
const [amountMode, setAmountMode] = useState<"keep" | "set" | "clear">("keep");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (bulk) {
|
||||
setBillableMode("keep");
|
||||
setActiveMode("keep");
|
||||
setPricingMode("keep");
|
||||
setAmountMode("keep");
|
||||
setAmount("");
|
||||
}
|
||||
}, [bulk]);
|
||||
|
||||
if (!bulk) return null;
|
||||
const isTime = bulk.category === "time";
|
||||
const count = bulk.ids.length;
|
||||
|
||||
const submit = async () => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (billableMode !== "keep") payload.billable = billableMode === "true";
|
||||
if (activeMode !== "keep") payload.active = activeMode === "true";
|
||||
if (isTime && pricingMode !== "keep") payload.pricing_type = pricingMode;
|
||||
if (amountMode === "clear") payload.amount = 0;
|
||||
if (amountMode === "set") {
|
||||
const n = parseFloat(amount);
|
||||
if (Number.isNaN(n) || n < 0) {
|
||||
toast.error("Enter a valid amount");
|
||||
return;
|
||||
}
|
||||
payload.amount = n;
|
||||
}
|
||||
if (Object.keys(payload).length === 0) {
|
||||
toast.error("Pick at least one field to change");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const { error } = await supabase
|
||||
.from("fee_schedule_items")
|
||||
.update(payload as never)
|
||||
.in("id", bulk.ids);
|
||||
setSaving(false);
|
||||
if (error) {
|
||||
toast.error("Bulk update failed", { description: error.message });
|
||||
return;
|
||||
}
|
||||
toast.success(`Updated ${count} item${count === 1 ? "" : "s"}`);
|
||||
onSaved();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!bulk} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-serif">
|
||||
Bulk edit {count} {isTime ? "time" : "expense"} item{count === 1 ? "" : "s"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Only fields set to a new value are applied — others are left
|
||||
unchanged. {isTime && "Setting an amount of $0 makes time entries fall back to the user's profile hourly rate."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Billable</Label>
|
||||
<Select value={billableMode} onValueChange={(v) => setBillableMode(v as "keep" | "true" | "false")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="keep">Keep current</SelectItem>
|
||||
<SelectItem value="true">Mark billable</SelectItem>
|
||||
<SelectItem value="false">Mark non-billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Active</Label>
|
||||
<Select value={activeMode} onValueChange={(v) => setActiveMode(v as "keep" | "true" | "false")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="keep">Keep current</SelectItem>
|
||||
<SelectItem value="true">Mark active</SelectItem>
|
||||
<SelectItem value="false">Mark inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isTime && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Pricing type</Label>
|
||||
<Select value={pricingMode} onValueChange={(v) => setPricingMode(v as "keep" | "hourly" | "flat")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="keep">Keep current</SelectItem>
|
||||
<SelectItem value="hourly">Hourly rate</SelectItem>
|
||||
<SelectItem value="flat">Flat fee</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Amount</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Select value={amountMode} onValueChange={(v) => setAmountMode(v as "keep" | "set" | "clear")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="keep">Keep current</SelectItem>
|
||||
<SelectItem value="set">Set to…</SelectItem>
|
||||
{isTime && (
|
||||
<SelectItem value="clear">
|
||||
Clear (use user's rate)
|
||||
</SelectItem>
|
||||
)}
|
||||
{!isTime && (
|
||||
<SelectItem value="clear">Set to $0</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder={isTime ? "$ / hr or flat" : "$"}
|
||||
value={amount}
|
||||
disabled={amountMode !== "set"}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className="col-span-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Apply to {count}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function FeeEditDialog({
|
||||
item,
|
||||
onClose,
|
||||
|
||||
Reference in New Issue
Block a user