Design overhaul, stats page, internal notes
Design: Onest+Unbounded font pairing (Cyrillic-verified, replacing plain
Golos Text), manual light/dark toggle wired to the data-theme cascade
already built in MVP-1, refreshed palette + new chart-mark tokens
(CVD-validated via the dataviz skill's validator), colored avatar
initials, per-channel colored badges, favicon, nicer empty states.
Stats: /stats page — status/channel breakdown, 14-day ticket volume,
avg first-response time, per-agent open-ticket workload. Colors and
chart forms follow the dataviz skill's procedure (categorical order
re-stepped to clear the CVD adjacency check in both themes).
Internal notes: messages.visibility ("public"/"internal") column.
Agent-only notes never reach customer-facing reads (getTicketForCustomer)
or the customer SSE stream (/api/portal/events, reused by the widget) —
fixed at both the initial-fetch and live-delivery layers.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
1 parent
4efce5f307
commit
9f1413f0b9
23 files changed
+1315
-64
No files matched your search
+142
-3
@@ -2,7 +2,7 @@ import { eq, and, ne, desc } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { customers, tickets, messages, users, widgetSites } from "@/lib/db/schema";
|
||||
import { publishTicketEvent } from "@/lib/events/bus";
|
||||
import type { TicketDTO, MessageDTO, TicketChannel } from "@/lib/tickets/types";
|
||||
import type { TicketDTO, MessageDTO, TicketChannel, TicketStatus } from "@/lib/tickets/types";
|
||||
|
||||
function toTicketDTO(ticket: typeof tickets.$inferSelect, customerName: string): TicketDTO {
|
||||
return {
|
||||
@@ -28,6 +28,7 @@ function toMessageDTO(message: typeof messages.$inferSelect): MessageDTO {
|
||||
authorName: message.authorName,
|
||||
body: message.body,
|
||||
direction: message.direction,
|
||||
visibility: message.visibility,
|
||||
createdAt: message.createdAt.getTime(),
|
||||
};
|
||||
}
|
||||
@@ -55,6 +56,7 @@ async function appendMessage(params: {
|
||||
authorName: string;
|
||||
body: string;
|
||||
direction: "in" | "out";
|
||||
visibility?: "public" | "internal";
|
||||
emailMessageId?: string;
|
||||
}) {
|
||||
const [message] = await db
|
||||
@@ -66,6 +68,7 @@ async function appendMessage(params: {
|
||||
authorName: params.authorName,
|
||||
body: params.body,
|
||||
direction: params.direction,
|
||||
visibility: params.visibility ?? "public",
|
||||
emailMessageId: params.emailMessageId,
|
||||
})
|
||||
.returning();
|
||||
@@ -278,6 +281,33 @@ export async function recordAgentReply(params: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal, agent-only note — never delivered over any channel, never
|
||||
* visible to the customer (filtered out of getTicketForCustomer). Unlike
|
||||
* recordAgentReply, doesn't reopen the ticket — it's team communication,
|
||||
* not customer-facing activity.
|
||||
*/
|
||||
export async function recordInternalNote(params: {
|
||||
ticketId: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
body: string;
|
||||
}) {
|
||||
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
|
||||
if (!ticket) throw new Error("Ticket not found");
|
||||
|
||||
return appendMessage({
|
||||
ticket,
|
||||
ticketIsNew: false,
|
||||
authorType: "agent",
|
||||
authorId: params.agentId,
|
||||
authorName: params.agentName,
|
||||
body: params.body,
|
||||
direction: "out",
|
||||
visibility: "internal",
|
||||
});
|
||||
}
|
||||
|
||||
export async function setTicketStatus(
|
||||
ticketId: string,
|
||||
status: (typeof tickets.$inferSelect)["status"],
|
||||
@@ -366,11 +396,19 @@ export async function listTicketsForCustomer(customerId: string): Promise<Ticket
|
||||
return rows.map((row) => toTicketDTO(row.ticket, row.customerName));
|
||||
}
|
||||
|
||||
/** Returns the ticket+messages only if it actually belongs to this customer. */
|
||||
/**
|
||||
* Returns the ticket+messages only if it actually belongs to this customer,
|
||||
* with internal-only notes stripped out — this is the customer-facing read
|
||||
* path (portal, widget), unlike getTicketWithMessages which the admin
|
||||
* thread view uses and which returns everything.
|
||||
*/
|
||||
export async function getTicketForCustomer(customerId: string, ticketId: string) {
|
||||
const result = await getTicketWithMessages(ticketId);
|
||||
if (!result || result.ticket.customerId !== customerId) return null;
|
||||
return result;
|
||||
return {
|
||||
ticket: result.ticket,
|
||||
messages: result.messages.filter((m) => m.visibility === "public"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createWidgetSite(name: string, allowedOrigin?: string) {
|
||||
@@ -446,3 +484,104 @@ export async function createManualTicket(params: {
|
||||
direction: "in",
|
||||
});
|
||||
}
|
||||
|
||||
export interface StatsOverview {
|
||||
totalTickets: number;
|
||||
openTickets: number;
|
||||
newToday: number;
|
||||
avgFirstResponseMinutes: number | null;
|
||||
statusCounts: Record<TicketStatus, number>;
|
||||
channelCounts: Record<TicketChannel, number>;
|
||||
ticketsPerDay: { label: string; count: number }[];
|
||||
agentWorkload: { name: string; openCount: number }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* All stats computed in JS over the full table contents rather than SQL
|
||||
* aggregation — simplest correct option at this ticket volume. Move to
|
||||
* GROUP BY / date() SQL if the table grows into the tens of thousands.
|
||||
*/
|
||||
export async function getStatsOverview(): Promise<StatsOverview> {
|
||||
const allTickets = await db.query.tickets.findMany();
|
||||
|
||||
const statusCounts: Record<TicketStatus, number> = { new: 0, open: 0, pending: 0, closed: 0 };
|
||||
const channelCounts: Record<TicketChannel, number> = {
|
||||
telegram: 0,
|
||||
email: 0,
|
||||
portal: 0,
|
||||
widget: 0,
|
||||
manual: 0,
|
||||
};
|
||||
for (const t of allTickets) {
|
||||
statusCounts[t.status] += 1;
|
||||
channelCounts[t.channel] += 1;
|
||||
}
|
||||
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const newToday = allTickets.filter((t) => t.createdAt.getTime() >= todayStart.getTime()).length;
|
||||
|
||||
const ticketsPerDay: { label: string; count: number }[] = [];
|
||||
for (let i = 13; i >= 0; i--) {
|
||||
const dayStart = todayStart.getTime() - i * dayMs;
|
||||
const dayEnd = dayStart + dayMs;
|
||||
const count = allTickets.filter((t) => {
|
||||
const ts = t.createdAt.getTime();
|
||||
return ts >= dayStart && ts < dayEnd;
|
||||
}).length;
|
||||
ticketsPerDay.push({
|
||||
label: new Date(dayStart).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }),
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
const agentMessages = await db.query.messages.findMany({
|
||||
where: and(eq(messages.authorType, "agent"), eq(messages.visibility, "public")),
|
||||
orderBy: messages.createdAt,
|
||||
});
|
||||
const firstAgentReplyAt = new Map<string, number>();
|
||||
for (const m of agentMessages) {
|
||||
if (!firstAgentReplyAt.has(m.ticketId)) {
|
||||
firstAgentReplyAt.set(m.ticketId, m.createdAt.getTime());
|
||||
}
|
||||
}
|
||||
const responseTimesMinutes: number[] = [];
|
||||
for (const t of allTickets) {
|
||||
const firstReply = firstAgentReplyAt.get(t.id);
|
||||
if (firstReply) {
|
||||
responseTimesMinutes.push((firstReply - t.createdAt.getTime()) / 60_000);
|
||||
}
|
||||
}
|
||||
const avgFirstResponseMinutes =
|
||||
responseTimesMinutes.length > 0
|
||||
? responseTimesMinutes.reduce((a, b) => a + b, 0) / responseTimesMinutes.length
|
||||
: null;
|
||||
|
||||
const agents = await listAgents();
|
||||
const openCountByAgent = new Map<string, number>();
|
||||
let unassignedOpen = 0;
|
||||
for (const t of allTickets) {
|
||||
if (t.status === "closed") continue;
|
||||
if (t.assigneeId) {
|
||||
openCountByAgent.set(t.assigneeId, (openCountByAgent.get(t.assigneeId) ?? 0) + 1);
|
||||
} else {
|
||||
unassignedOpen += 1;
|
||||
}
|
||||
}
|
||||
const agentWorkload = agents.map((a) => ({ name: a.name, openCount: openCountByAgent.get(a.id) ?? 0 }));
|
||||
if (unassignedOpen > 0) {
|
||||
agentWorkload.push({ name: "Не назначено", openCount: unassignedOpen });
|
||||
}
|
||||
|
||||
return {
|
||||
totalTickets: allTickets.length,
|
||||
openTickets: allTickets.filter((t) => t.status !== "closed").length,
|
||||
newToday,
|
||||
avgFirstResponseMinutes,
|
||||
statusCounts,
|
||||
channelCounts,
|
||||
ticketsPerDay,
|
||||
agentWorkload,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user