Dashboard: show ticket assignee on cards, widen layout to use the screen better

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-21 06:01:52 +00:00
1 parent 1a31f72dd8
commit d9eaed41e0
4 files changed
+43 -10

No files matched your search

@@ -243,6 +243,17 @@ function TicketCard({ ticket }: { ticket: TicketDTO }) {
<span className="text-xs text-text-faint">{formatRelativeTime(ticket.lastMessageAt)}</span> <span className="text-xs text-text-faint">{formatRelativeTime(ticket.lastMessageAt)}</span>
</span> </span>
</div> </div>
<div className="mt-1.5 flex items-center gap-1.5 border-t border-border pt-1.5">
{ticket.assigneeName ? (
<>
<Avatar name={ticket.assigneeName} size={16} />
<span className="truncate text-xs text-text-muted">{ticket.assigneeName}</span>
</>
) : (
<span className="text-xs text-text-faint">Не назначено</span>
)}
</div>
</Link> </Link>
</motion.div> </motion.div>
); );
+2 -2
View File
@@ -22,7 +22,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
return ( return (
<div className="flex min-h-screen flex-col bg-bg"> <div className="flex min-h-screen flex-col bg-bg">
<header className="border-b border-border bg-surface"> <header className="border-b border-border bg-surface">
<div className="mx-auto flex max-w-7xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3 sm:px-6"> <div className="mx-auto flex max-w-[1800px] flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3 sm:px-6">
<Link href="/dashboard" className="flex items-center gap-2 font-display text-[15px] font-bold tracking-tight"> <Link href="/dashboard" className="flex items-center gap-2 font-display text-[15px] font-bold tracking-tight">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-accent text-white"> <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-accent text-white">
<Ticket size={15} strokeWidth={2.5} /> <Ticket size={15} strokeWidth={2.5} />
@@ -41,7 +41,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
</div> </div>
</div> </div>
</header> </header>
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-6 sm:px-6">{children}</main> <main className="mx-auto w-full max-w-[1800px] flex-1 px-4 py-6 sm:px-6">{children}</main>
<DesktopNotifications currentUser={currentUser} /> <DesktopNotifications currentUser={currentUser} />
<TicketToasts currentUser={currentUser} /> <TicketToasts currentUser={currentUser} />
</div> </div>
+29 -8
View File
@@ -32,7 +32,12 @@ type AttachmentInput = {
isInline?: boolean; 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 { return {
id: ticket.id, id: ticket.id,
subject: ticket.subject, subject: ticket.subject,
@@ -42,6 +47,7 @@ function toTicketDTO(ticket: typeof tickets.$inferSelect, customerName: string,
customerId: ticket.customerId, customerId: ticket.customerId,
customerName, customerName,
assigneeId: ticket.assigneeId, assigneeId: ticket.assigneeId,
assigneeName,
tags, tags,
createdAt: ticket.createdAt.getTime(), createdAt: ticket.createdAt.getTime(),
updatedAt: ticket.updatedAt.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<string | null> {
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 { function toMessageDTO(message: typeof messages.$inferSelect, attachments: AttachmentDTO[] = []): MessageDTO {
return { return {
id: message.id, id: message.id,
@@ -170,7 +183,7 @@ async function appendMessage(params: {
const ticket = await touchTicket(params.ticket.id, { lastMessageAt: new Date() }); const ticket = await touchTicket(params.ticket.id, { lastMessageAt: new Date() });
const name = await customerName(ticket.customerId); const name = await customerName(ticket.customerId);
const ticketTagsList = await getTagsForTicket(ticket.id); 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); const messageDto = toMessageDTO(message, attachmentDTOs);
publishTicketEvent({ publishTicketEvent({
@@ -437,14 +450,14 @@ export async function setTicketStatus(
status: (typeof tickets.$inferSelect)["status"], status: (typeof tickets.$inferSelect)["status"],
) { ) {
const ticket = await touchTicket(ticketId, { 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 }); publishTicketEvent({ type: "ticket.updated", ticket: dto });
return dto; return dto;
} }
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)); const dto = toTicketDTO(ticket, await customerName(ticket.customerId), await getTagsForTicket(ticket.id), await getAssigneeName(ticket.assigneeId));
publishTicketEvent({ type: "ticket.updated", ticket: dto }); publishTicketEvent({ type: "ticket.updated", ticket: dto });
return dto; return dto;
} }
@@ -485,9 +498,10 @@ export async function listTickets(
} }
const rows = await db const rows = await db
.select({ ticket: tickets, customerName: customers.displayName }) .select({ ticket: tickets, customerName: customers.displayName, assigneeName: users.name })
.from(tickets) .from(tickets)
.innerJoin(customers, eq(tickets.customerId, customers.id)) .innerJoin(customers, eq(tickets.customerId, customers.id))
.leftJoin(users, eq(tickets.assigneeId, users.id))
.orderBy(desc(tickets.lastMessageAt)); .orderBy(desc(tickets.lastMessageAt));
const idsToKeep = matchingTicketIds; const idsToKeep = matchingTicketIds;
@@ -498,7 +512,9 @@ export async function listTickets(
} }
const tagsMap = await getTagsForTickets(filteredRows.map((r) => r.ticket.id)); 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) { export async function getTicketWithMessages(ticketId: string) {
@@ -519,7 +535,7 @@ export async function getTicketWithMessages(ticketId: string) {
const ticketTagsList = await getTagsForTicket(ticketId); const ticketTagsList = await getTagsForTicket(ticketId);
return { 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) ?? [])), 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) }); const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, ticketId) });
if (ticket) { 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 }); publishTicketEvent({ type: "ticket.updated", ticket: dto });
} }
return getTagsForTicket(ticketId); return getTagsForTicket(ticketId);
+1
View File
@@ -22,6 +22,7 @@ export interface TicketDTO {
customerId: string; customerId: string;
customerName: string; customerName: string;
assigneeId: string | null; assigneeId: string | null;
assigneeName: string | null;
tags: TagDTO[]; tags: TagDTO[];
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;