feat: batch ticket operations — multi-select, bulk status, bulk assign

- Checkbox column on every ticket row
- Select multiple tickets via checkboxes
- Floating sticky action bar at bottom when tickets selected:
  - Shows count: "3 selected" with Clear button
  - Quick status change buttons
  - Assign to me button
- Checkbox click stops propagation (doesn't select ticket for triage panel)
- Batch operations run sequentially via API

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-09 21:58:09 +02:00
parent 38a82ad0d8
commit e486558309

View File

@@ -185,6 +185,8 @@ function TicketWorkbenchContent() {
const [error, setError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [selectedTxs, setSelectedTxs] = useState<Transaction[]>([]);
const [batchIds, setBatchIds] = useState<Set<number>>(new Set());
const [batchSaving, setBatchSaving] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [filters, setFilters] = useState<Filter[]>([]);
@@ -543,6 +545,36 @@ function TicketWorkbenchContent() {
setTickets((prev) => prev.map((t) => (t.id === ticketId ? data.ticket : t)));
}
};
const handleBatchStatus = async (newStatus: string) => {
setBatchSaving(true);
for (const id of batchIds) {
await updateTicket(id, { status: newStatus });
}
setBatchSaving(false);
setBatchIds(new Set());
await fetchData();
};
const handleBatchAssign = async () => {
const me = users[0]?.id;
if (!me) return;
setBatchSaving(true);
for (const id of batchIds) {
await updateTicket(id, { owner_id: me });
}
setBatchSaving(false);
setBatchIds(new Set());
await fetchData();
};
const toggleBatchId = (id: number) => {
setBatchIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const handleColumnResize = (colKey: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -931,8 +963,17 @@ function TicketWorkbenchContent() {
? "bg-accent/80 shadow-[inset_3px_0_0_var(--primary)]"
: "hover:bg-accent/45"
)}
style={{ width: availableColumns.filter((c) => c.visible).reduce((sum, c) => sum + c.width, 96), minWidth: "100%" }}
style={{ width: availableColumns.filter((c) => c.visible).reduce((sum, c) => sum + c.width, 96) + 36, minWidth: "100%" }}
>
{/* Checkbox */}
<span className="flex w-9 shrink-0 items-center justify-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={batchIds.has(ticket.id)}
onChange={() => toggleBatchId(ticket.id)}
className="h-3.5 w-3.5 rounded border-border accent-primary"
/>
</span>
{availableColumns.filter((c) => c.visible).map((col) => {
if (col.key.startsWith("cf.")) {
const cfKey = col.key.slice(3);
@@ -1010,6 +1051,41 @@ function TicketWorkbenchContent() {
</div>
</section>
{/* Floating batch action bar */}
{batchIds.size > 0 && (
<div className="sticky bottom-0 z-20 flex items-center gap-3 border-t border-border bg-card/95 px-5 py-3 shadow-lg backdrop-blur">
<span className="text-sm font-semibold tabular-nums text-foreground">
{batchIds.size} selected
</span>
<button type="button" onClick={() => setBatchIds(new Set())} className="text-xs text-muted-foreground hover:text-foreground">
Clear
</button>
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-muted-foreground">Status:</span>
{statusOptions.filter((s) => s.key !== "all").slice(0, 5).map((s) => (
<button
key={s.key}
type="button"
disabled={batchSaving}
onClick={() => handleBatchStatus(s.key)}
className="rounded bg-muted/60 px-2.5 py-1 text-xs font-semibold text-foreground hover:bg-accent disabled:opacity-50"
>
{s.label}
</button>
))}
<div className="mx-2 h-5 w-px bg-border" />
<button
type="button"
disabled={batchSaving}
onClick={handleBatchAssign}
className="rounded bg-primary px-3 py-1 text-xs font-semibold text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{batchSaving ? "Saving..." : "Assign to me"}
</button>
</div>
</div>
)}
<aside className="hidden min-h-0 border-l border-border bg-card/76 backdrop-blur xl:flex xl:flex-col">
{selectedTicket ? (
<>