Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-19 16:26:51 +00:00
co-authored by renee-png
parent 5786976405
commit 1a520ee9bc
+236 -5
View File
@@ -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<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">
@@ -200,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>
@@ -210,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 || "—"}
@@ -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<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)
.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,