Add bulk status change for tickets on the board

Select multiple tickets (any column) and open/pend/close them in one
action via a floating selection toolbar, backed by a new
PATCH /api/tickets/bulk-status endpoint. Scoped by the same
per-ticket visibility rule agents already get everywhere else.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-09-07 05:42:10 +00:00
1 parent 99927aea0b
commit 4d393aec91
3 files changed
+169 -4

No files matched your search

+115 -4
View File
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { Send, Globe, UserRound, Mail, MessageSquare, Plus, Inbox, Search, X } from "lucide-react"; import { Send, Globe, UserRound, Mail, MessageSquare, Plus, Inbox, Search, X, Check } from "lucide-react";
import { useTicketEvents } from "@/lib/events/use-ticket-events"; import { useTicketEvents } from "@/lib/events/use-ticket-events";
import { formatRelativeTime } from "@/lib/format"; import { formatRelativeTime } from "@/lib/format";
import type { TicketDTO, TicketStatus, TagDTO } from "@/lib/tickets/types"; import type { TicketDTO, TicketStatus, TagDTO } from "@/lib/tickets/types";
@@ -58,6 +58,41 @@ export function TicketBoard({
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [searchResults, setSearchResults] = useState<TicketDTO[] | null>(null); const [searchResults, setSearchResults] = useState<TicketDTO[] | null>(null);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkUpdating, setBulkUpdating] = useState(false);
const [bulkError, setBulkError] = useState<string | null>(null);
const toggleSelected = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const clearSelection = () => setSelected(new Set());
const applyBulkStatus = async (status: TicketStatus) => {
setBulkUpdating(true);
setBulkError(null);
try {
const res = await fetch("/api/tickets/bulk-status", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticketIds: Array.from(selected), status }),
});
if (!res.ok) throw new Error();
// The board's own SSE subscription (useTicketEvents above) already
// applies each updated ticket as its own ticket.updated event — no
// need to patch local state here too.
clearSelection();
} catch {
setBulkError("Не удалось изменить статус — попробуйте ещё раз");
} finally {
setBulkUpdating(false);
}
};
useTicketEvents("/api/events", (event) => { useTicketEvents("/api/events", (event) => {
if (event.type === "ticket.created" || event.type === "ticket.updated") { if (event.type === "ticket.created" || event.type === "ticket.updated") {
@@ -186,7 +221,12 @@ export function TicketBoard({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{byStatus[col.status].map((ticket) => ( {byStatus[col.status].map((ticket) => (
<TicketCard key={ticket.id} ticket={ticket} /> <TicketCard
key={ticket.id}
ticket={ticket}
selected={selected.has(ticket.id)}
onToggleSelect={toggleSelected}
/>
))} ))}
</AnimatePresence> </AnimatePresence>
{byStatus[col.status].length === 0 && ( {byStatus[col.status].length === 0 && (
@@ -201,11 +241,64 @@ export function TicketBoard({
</div> </div>
<NewTicketModal open={modalOpen} onClose={() => setModalOpen(false)} /> <NewTicketModal open={modalOpen} onClose={() => setModalOpen(false)} />
<AnimatePresence>
{selected.size > 0 && (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 16 }}
transition={{ duration: 0.15 }}
className="fixed inset-x-0 bottom-4 z-30 flex justify-center px-4"
>
<div className="card flex flex-wrap items-center gap-2 px-3 py-2 shadow-lg">
<span className="mr-1 text-sm font-medium">Выбрано: {selected.size}</span>
<button
className="rounded-md bg-success-soft px-2.5 py-1.5 text-xs font-medium text-success-soft-text transition-opacity hover:opacity-80 disabled:opacity-50"
disabled={bulkUpdating}
onClick={() => applyBulkStatus("open")}
>
Открыть
</button>
<button
className="rounded-md bg-warning-soft px-2.5 py-1.5 text-xs font-medium text-warning-soft-text transition-opacity hover:opacity-80 disabled:opacity-50"
disabled={bulkUpdating}
onClick={() => applyBulkStatus("pending")}
>
В ожидание
</button>
<button
className="rounded-md bg-danger-soft px-2.5 py-1.5 text-xs font-medium text-danger-soft-text transition-opacity hover:opacity-80 disabled:opacity-50"
disabled={bulkUpdating}
onClick={() => applyBulkStatus("closed")}
>
Закрыть
</button>
<button
className="rounded-md px-2.5 py-1.5 text-xs font-medium text-text-faint hover:text-text disabled:opacity-50"
disabled={bulkUpdating}
onClick={clearSelection}
>
Снять выделение
</button>
{bulkError && <span className="w-full text-xs text-danger-soft-text">{bulkError}</span>}
</div>
</motion.div>
)}
</AnimatePresence>
</div> </div>
); );
} }
function TicketCard({ ticket }: { ticket: TicketDTO }) { function TicketCard({
ticket,
selected,
onToggleSelect,
}: {
ticket: TicketDTO;
selected: boolean;
onToggleSelect: (id: string) => void;
}) {
const Icon = CHANNEL_ICON[ticket.channel]; const Icon = CHANNEL_ICON[ticket.channel];
return ( return (
@@ -218,9 +311,27 @@ function TicketCard({ ticket }: { ticket: TicketDTO }) {
> >
<Link <Link
href={`/tickets/${ticket.id}`} href={`/tickets/${ticket.id}`}
className="card block p-3 shadow-sm transition-shadow hover:shadow-md" className={`card relative block p-3 pr-9 shadow-sm transition-shadow hover:shadow-md ${
selected ? "ring-2 ring-accent" : ""
}`}
style={{ borderLeft: `3px solid ${PRIORITY_COLOR[ticket.priority]}` }} style={{ borderLeft: `3px solid ${PRIORITY_COLOR[ticket.priority]}` }}
> >
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleSelect(ticket.id);
}}
aria-label={selected ? "Снять выделение с заявки" : "Выбрать заявку"}
className={`absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full border transition-colors ${
selected
? "border-accent bg-accent text-white"
: "border-border bg-surface text-transparent hover:border-accent"
}`}
>
<Check size={12} />
</button>
<p className="mb-1 line-clamp-2 text-sm font-medium">{ticket.subject}</p> <p className="mb-1 line-clamp-2 text-sm font-medium">{ticket.subject}</p>
{ticket.tags.length > 0 && ( {ticket.tags.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1"> <div className="mb-2 flex flex-wrap gap-1">
+29
View File
@@ -0,0 +1,29 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { bulkSetTicketStatus } from "@/lib/tickets/service";
const bulkSchema = z.object({
ticketIds: z.array(z.string()).min(1).max(200),
status: z.enum(["new", "open", "pending", "closed"]),
});
export async function PATCH(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = bulkSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const updatedIds = await bulkSetTicketStatus(parsed.data.ticketIds, parsed.data.status, {
id: session.user.id,
role: session.user.role,
});
return NextResponse.json({ updatedIds });
}
+25
View File
@@ -13,6 +13,7 @@ import {
} from "@/lib/db/schema"; } from "@/lib/db/schema";
import { publishTicketEvent } from "@/lib/events/bus"; import { publishTicketEvent } from "@/lib/events/bus";
import { notifyPushForCustomerMessage } from "@/lib/push/service"; import { notifyPushForCustomerMessage } from "@/lib/push/service";
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
import type { import type {
TicketDTO, TicketDTO,
MessageDTO, MessageDTO,
@@ -457,6 +458,30 @@ export async function setTicketStatus(
return dto; return dto;
} }
/**
* Changes status on many tickets at once, silently skipping any id that
* doesn't exist or isn't visible to the acting user (an agent selecting
* tickets only ever sees their own, so this is mostly a safety net against
* a stale client-side selection). Returns the ids actually updated.
*/
export async function bulkSetTicketStatus(
ticketIds: string[],
status: (typeof tickets.$inferSelect)["status"],
currentUser: { id: string; role: "admin" | "agent" },
): Promise<string[]> {
if (ticketIds.length === 0) return [];
const rows = await db.query.tickets.findMany({ where: inArray(tickets.id, ticketIds) });
const visible = rows.filter((row) => isTicketVisibleTo(row, currentUser));
const updated: string[] = [];
for (const row of visible) {
await setTicketStatus(row.id, status);
updated.push(row.id);
}
return updated;
}
export async function assignTicket(ticketId: string, assigneeId: string | null) { export async function assignTicket(ticketId: string, assigneeId: string | null) {
const ticket = await touchTicket(ticketId, { assigneeId }); const ticket = await touchTicket(ticketId, { assigneeId });
const dto = toTicketDTO(ticket, await customerName(ticket.customerId), await getTagsForTicket(ticket.id), await getAssigneeName(ticket.assigneeId)); const dto = toTicketDTO(ticket, await customerName(ticket.customerId), await getTagsForTicket(ticket.id), await getAssigneeName(ticket.assigneeId));