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 { 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">
|
||||||
|
|||||||
@@ -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";
|
} 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));
|
||||||
|
|||||||
Reference in new issue
Block a user