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:
ogrechkoandClaude Sonnet 5 committed 2026-07-26 14:38:52 +00:00
1 parent 4efce5f307
commit 9f1413f0b9
23 files changed
+1315 -64

No files matched your search

+142 -3
View File
@@ -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,
};
}