From d9eaed41e0a3130bd27ccd518a62869609bd2a83 Mon Sep 17 00:00:00 2001 From: Oleg Date: Fri, 21 Aug 2026 06:01:52 +0000 Subject: [PATCH] Dashboard: show ticket assignee on cards, widen layout to use the screen better MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TicketDTO now carries assigneeName alongside the existing assigneeId — the board's TicketCard shows it as a small avatar+name line ("Не назначено" when nobody's assigned), so it's actually visible which admin/agent owns a ticket without opening it. listTickets/listTicketsForCustomer resolve it via a LEFT JOIN on users (avoids N+1 across a whole board); the handful of single-ticket call sites (setTicketStatus, assignTicket, setTicketTags, appendMessage, getTicketWithMessages) use a small getAssigneeName() lookup. Also widened the admin layout's max-width from 7xl (1280px) to 1800px — the 4-column kanban board was leaving a lot of unused space on wide screens, cards were narrower than they needed to be. Verified visually via Playwright screenshots at both 1920px and 1440px. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u --- src/app/(admin)/dashboard/ticket-board.tsx | 11 +++++++ src/app/(admin)/layout.tsx | 4 +-- src/lib/tickets/service.ts | 37 +++++++++++++++++----- src/lib/tickets/types.ts | 1 + 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/app/(admin)/dashboard/ticket-board.tsx b/src/app/(admin)/dashboard/ticket-board.tsx index 006e5c1..34709ea 100644 --- a/src/app/(admin)/dashboard/ticket-board.tsx +++ b/src/app/(admin)/dashboard/ticket-board.tsx @@ -243,6 +243,17 @@ function TicketCard({ ticket }: { ticket: TicketDTO }) { {formatRelativeTime(ticket.lastMessageAt)} + +
+ {ticket.assigneeName ? ( + <> + + {ticket.assigneeName} + + ) : ( + Не назначено + )} +
); diff --git a/src/app/(admin)/layout.tsx b/src/app/(admin)/layout.tsx index e06ad7a..50f1fcc 100644 --- a/src/app/(admin)/layout.tsx +++ b/src/app/(admin)/layout.tsx @@ -22,7 +22,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN return (
-
+
@@ -41,7 +41,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
-
{children}
+
{children}
diff --git a/src/lib/tickets/service.ts b/src/lib/tickets/service.ts index 2b343cf..2e60e5c 100644 --- a/src/lib/tickets/service.ts +++ b/src/lib/tickets/service.ts @@ -32,7 +32,12 @@ type AttachmentInput = { isInline?: boolean; }; -function toTicketDTO(ticket: typeof tickets.$inferSelect, customerName: string, tags: TagDTO[]): TicketDTO { +function toTicketDTO( + ticket: typeof tickets.$inferSelect, + customerName: string, + tags: TagDTO[], + assigneeName: string | null = null, +): TicketDTO { return { id: ticket.id, subject: ticket.subject, @@ -42,6 +47,7 @@ function toTicketDTO(ticket: typeof tickets.$inferSelect, customerName: string, customerId: ticket.customerId, customerName, assigneeId: ticket.assigneeId, + assigneeName, tags, createdAt: ticket.createdAt.getTime(), updatedAt: ticket.updatedAt.getTime(), @@ -49,6 +55,13 @@ function toTicketDTO(ticket: typeof tickets.$inferSelect, customerName: string, }; } +/** Single-ticket lookups only — listTickets/listTicketsForCustomer LEFT JOIN users directly instead, to avoid N+1 queries across a whole board. */ +async function getAssigneeName(assigneeId: string | null): Promise { + if (!assigneeId) return null; + const user = await db.query.users.findFirst({ where: eq(users.id, assigneeId) }); + return user?.name ?? null; +} + function toMessageDTO(message: typeof messages.$inferSelect, attachments: AttachmentDTO[] = []): MessageDTO { return { id: message.id, @@ -170,7 +183,7 @@ async function appendMessage(params: { const ticket = await touchTicket(params.ticket.id, { lastMessageAt: new Date() }); const name = await customerName(ticket.customerId); const ticketTagsList = await getTagsForTicket(ticket.id); - const dto = toTicketDTO(ticket, name, ticketTagsList); + const dto = toTicketDTO(ticket, name, ticketTagsList, await getAssigneeName(ticket.assigneeId)); const messageDto = toMessageDTO(message, attachmentDTOs); publishTicketEvent({ @@ -437,14 +450,14 @@ export async function setTicketStatus( status: (typeof tickets.$inferSelect)["status"], ) { const ticket = await touchTicket(ticketId, { status }); - const dto = toTicketDTO(ticket, await customerName(ticket.customerId), await getTagsForTicket(ticket.id)); + const dto = toTicketDTO(ticket, await customerName(ticket.customerId), await getTagsForTicket(ticket.id), await getAssigneeName(ticket.assigneeId)); publishTicketEvent({ type: "ticket.updated", ticket: dto }); return dto; } 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)); + const dto = toTicketDTO(ticket, await customerName(ticket.customerId), await getTagsForTicket(ticket.id), await getAssigneeName(ticket.assigneeId)); publishTicketEvent({ type: "ticket.updated", ticket: dto }); return dto; } @@ -485,9 +498,10 @@ export async function listTickets( } const rows = await db - .select({ ticket: tickets, customerName: customers.displayName }) + .select({ ticket: tickets, customerName: customers.displayName, assigneeName: users.name }) .from(tickets) .innerJoin(customers, eq(tickets.customerId, customers.id)) + .leftJoin(users, eq(tickets.assigneeId, users.id)) .orderBy(desc(tickets.lastMessageAt)); const idsToKeep = matchingTicketIds; @@ -498,7 +512,9 @@ export async function listTickets( } const tagsMap = await getTagsForTickets(filteredRows.map((r) => r.ticket.id)); - return filteredRows.map((row) => toTicketDTO(row.ticket, row.customerName, tagsMap.get(row.ticket.id) ?? [])); + return filteredRows.map((row) => + toTicketDTO(row.ticket, row.customerName, tagsMap.get(row.ticket.id) ?? [], row.assigneeName ?? null), + ); } export async function getTicketWithMessages(ticketId: string) { @@ -519,7 +535,7 @@ export async function getTicketWithMessages(ticketId: string) { const ticketTagsList = await getTagsForTicket(ticketId); return { - ticket: toTicketDTO(row[0].ticket, row[0].customerName, ticketTagsList), + ticket: toTicketDTO(row[0].ticket, row[0].customerName, ticketTagsList, await getAssigneeName(row[0].ticket.assigneeId)), messages: ticketMessages.map((m) => toMessageDTO(m, attachmentsMap.get(m.id) ?? [])), }; } @@ -729,7 +745,12 @@ export async function setTicketTags(ticketId: string, tagIds: string[]): Promise const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, ticketId) }); if (ticket) { - const dto = toTicketDTO(ticket, await customerName(ticket.customerId), await getTagsForTicket(ticketId)); + const dto = toTicketDTO( + ticket, + await customerName(ticket.customerId), + await getTagsForTicket(ticketId), + await getAssigneeName(ticket.assigneeId), + ); publishTicketEvent({ type: "ticket.updated", ticket: dto }); } return getTagsForTicket(ticketId); diff --git a/src/lib/tickets/types.ts b/src/lib/tickets/types.ts index caee864..55530e5 100644 --- a/src/lib/tickets/types.ts +++ b/src/lib/tickets/types.ts @@ -22,6 +22,7 @@ export interface TicketDTO { customerId: string; customerName: string; assigneeId: string | null; + assigneeName: string | null; tags: TagDTO[]; createdAt: number; updatedAt: number;