diff --git a/src/app/(admin)/dashboard/ticket-board.tsx b/src/app/(admin)/dashboard/ticket-board.tsx index 34709ea..4b00baf 100644 --- a/src/app/(admin)/dashboard/ticket-board.tsx +++ b/src/app/(admin)/dashboard/ticket-board.tsx @@ -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(null); const [searching, setSearching] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [bulkUpdating, setBulkUpdating] = useState(false); + const [bulkError, setBulkError] = useState(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({
{byStatus[col.status].map((ticket) => ( - + ))} {byStatus[col.status].length === 0 && ( @@ -201,11 +241,64 @@ export function TicketBoard({
setModalOpen(false)} /> + + + {selected.size > 0 && ( + +
+ Выбрано: {selected.size} + + + + + {bulkError && {bulkError}} +
+
+ )} +
); } -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 }) { > +

{ticket.subject}

{ticket.tags.length > 0 && (
diff --git a/src/app/api/tickets/bulk-status/route.ts b/src/app/api/tickets/bulk-status/route.ts new file mode 100644 index 0000000..0c1b886 --- /dev/null +++ b/src/app/api/tickets/bulk-status/route.ts @@ -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 }); +} diff --git a/src/lib/tickets/service.ts b/src/lib/tickets/service.ts index 778a29c..b813856 100644 --- a/src/lib/tickets/service.ts +++ b/src/lib/tickets/service.ts @@ -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 { + 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));