Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
5786976405
commit
1a520ee9bc
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user