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:
1 parent
99927aea0b
commit
4d393aec91
3 files changed
+169
-4
No files matched your search
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
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 { formatRelativeTime } from "@/lib/format";
|
||||
import type { TicketDTO, TicketStatus, TagDTO } from "@/lib/tickets/types";
|
||||
@@ -58,6 +58,41 @@ export function TicketBoard({
|
||||
const [query, setQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<TicketDTO[] | null>(null);
|
||||
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) => {
|
||||
if (event.type === "ticket.created" || event.type === "ticket.updated") {
|
||||
@@ -186,7 +221,12 @@ export function TicketBoard({
|
||||
<div className="flex flex-col gap-2">
|
||||
<AnimatePresence initial={false}>
|
||||
{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>
|
||||
{byStatus[col.status].length === 0 && (
|
||||
@@ -201,11 +241,64 @@ export function TicketBoard({
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function TicketCard({ ticket }: { ticket: TicketDTO }) {
|
||||
function TicketCard({
|
||||
ticket,
|
||||
selected,
|
||||
onToggleSelect,
|
||||
}: {
|
||||
ticket: TicketDTO;
|
||||
selected: boolean;
|
||||
onToggleSelect: (id: string) => void;
|
||||
}) {
|
||||
const Icon = CHANNEL_ICON[ticket.channel];
|
||||
|
||||
return (
|
||||
@@ -218,9 +311,27 @@ function TicketCard({ ticket }: { ticket: TicketDTO }) {
|
||||
>
|
||||
<Link
|
||||
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]}` }}
|
||||
>
|
||||
<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>
|
||||
{ticket.tags.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/lib/db/schema";
|
||||
import { publishTicketEvent } from "@/lib/events/bus";
|
||||
import { notifyPushForCustomerMessage } from "@/lib/push/service";
|
||||
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
|
||||
import type {
|
||||
TicketDTO,
|
||||
MessageDTO,
|
||||
@@ -457,6 +458,30 @@ export async function setTicketStatus(
|
||||
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) {
|
||||
const ticket = await touchTicket(ticketId, { assigneeId });
|
||||
const dto = toTicketDTO(ticket, await customerName(ticket.customerId), await getTagsForTicket(ticket.id), await getAssigneeName(ticket.assigneeId));
|
||||
|
||||
Reference in new issue
Block a user