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:
@@ -185,6 +185,8 @@ function TicketWorkbenchContent() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
const [selectedTxs, setSelectedTxs] = useState<Transaction[]>([]);
|
const [selectedTxs, setSelectedTxs] = useState<Transaction[]>([]);
|
||||||
|
const [batchIds, setBatchIds] = useState<Set<number>>(new Set());
|
||||||
|
const [batchSaving, setBatchSaving] = useState(false);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [filters, setFilters] = useState<Filter[]>([]);
|
const [filters, setFilters] = useState<Filter[]>([]);
|
||||||
@@ -543,6 +545,36 @@ function TicketWorkbenchContent() {
|
|||||||
setTickets((prev) => prev.map((t) => (t.id === ticketId ? data.ticket : t)));
|
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) => {
|
const handleColumnResize = (colKey: string, e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -931,8 +963,17 @@ function TicketWorkbenchContent() {
|
|||||||
? "bg-accent/80 shadow-[inset_3px_0_0_var(--primary)]"
|
? "bg-accent/80 shadow-[inset_3px_0_0_var(--primary)]"
|
||||||
: "hover:bg-accent/45"
|
: "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) => {
|
{availableColumns.filter((c) => c.visible).map((col) => {
|
||||||
if (col.key.startsWith("cf.")) {
|
if (col.key.startsWith("cf.")) {
|
||||||
const cfKey = col.key.slice(3);
|
const cfKey = col.key.slice(3);
|
||||||
@@ -1010,6 +1051,41 @@ function TicketWorkbenchContent() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<aside className="hidden min-h-0 border-l border-border bg-card/76 backdrop-blur xl:flex xl:flex-col">
|
||||||
{selectedTicket ? (
|
{selectedTicket ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user