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