diff --git a/src/routes/settings.fees.tsx b/src/routes/settings.fees.tsx index 5900f38..d37d454 100644 --- a/src/routes/settings.fees.tsx +++ b/src/routes/settings.fees.tsx @@ -165,6 +165,7 @@ function FeeSection({ onEdit, onDelete, onImport, + onBulkEdit, }: { title: string; icon: React.ReactNode; @@ -174,17 +175,64 @@ function FeeSection({ onEdit: (i: FeeItem) => void; onDelete: (id: string) => void; onImport: () => void; + onBulkEdit: (ids: string[]) => void; }) { + const [selected, setSelected] = useState>(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(); + 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 (
-
+

{icon} {title}

- +
+ {selected.size > 0 && ( + <> + + {selected.size} selected + + + + )} + +
@@ -200,6 +248,13 @@ function FeeSection({ + + + Name Description Amount @@ -210,7 +265,14 @@ function FeeSection({ {items.map((i) => ( - + + + toggle(i.id)} + aria-label={`Select ${i.name}`} + /> + {i.name} {i.description || "—"} @@ -272,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 = {}; + 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) + .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 ( + !v && onClose()}> + + + + Bulk edit {count} {isTime ? "time" : "expense"} item{count === 1 ? "" : "s"} + + + 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."} + + + +
+
+
+ + +
+
+ + +
+
+ + {isTime && ( +
+ + +
+ )} + +
+ +
+ + setAmount(e.target.value)} + className="col-span-2" + /> +
+
+
+ + + + + +
+
+ ); +} + function FeeEditDialog({ item, onClose,