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>
</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>
</motion.div>
);
+2 -2
View File
@@ -22,7 +22,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
return (
<div className="flex min-h-screen flex-col bg-bg">
<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">
<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} />
@@ -41,7 +41,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
</div>
</div>
</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} />
<TicketToasts currentUser={currentUser} />
</div>
+29 -8
View File
@@ -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<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 {
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);
+1
View File
@@ -22,6 +22,7 @@ export interface TicketDTO {
customerId: string;
customerName: string;
assigneeId: string | null;
assigneeName: string | null;
tags: TagDTO[];
createdAt: number;
updatedAt: number;