Header redesign, sound/toast notifications, and agent scoping
Four requested improvements: 1. Logo + wordmark now link to /dashboard. 2. Header reordered: nav links (Заявки/Статистика) centered, right side now reads notifications -> theme -> settings -> account name -> logout, matching the requested order. 3. New TicketToasts component: in-page toast + a short Web Audio chime for new tickets and customer messages, shown regardless of tab focus. Complements the existing DesktopNotifications, which only fires the native OS notification while the tab is backgrounded. Also added a 30s polling refresh on the dashboard as a plain safety net under the existing SSE push, in case an event is dropped by whatever's in front of this app on the network. 4. Agents now see only tickets assigned to them plus unassigned ones (not the whole board) — listTickets() takes an optional visibility filter, applied in both the dashboard page and the /api/tickets search route, and mirrored client-side so a live SSE update can't leak a just-reassigned-away ticket onto their board. Settings gear and the Статистика nav link are now hidden entirely for agents rather than just showing fewer items inside. Also closed a real gap this surfaced: /stats had no server-side role check at all (only the nav link was hidden), so any agent could load it directly — added the same session.user.role !== "admin" guard the other admin-only settings pages already have. Verified end-to-end with a temporary Playwright pass (installed and fully removed afterward): logo click navigation, header icon order, agent view missing the gear/Статистика, direct-URL access to /stats and /settings/accounts still redirecting non-admins, and ticket visibility correctly disappearing from an agent's board the moment it's reassigned to someone else. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
This commit is contained in:
1 parent
c32888981c
commit
69467239ed
11 files changed
+201
-21
No files matched your search
@@ -1,7 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getCurrentSession } from "@/lib/auth/session";
|
||||
import { listTickets } from "@/lib/tickets/service";
|
||||
import { TicketBoard } from "./ticket-board";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const tickets = await listTickets();
|
||||
return <TicketBoard initialTickets={tickets} />;
|
||||
const session = await getCurrentSession();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
const currentUser = { id: session.user.id, role: session.user.role };
|
||||
const tickets = await listTickets(undefined, currentUser);
|
||||
return <TicketBoard initialTickets={tickets} currentUser={currentUser} />;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Send, Globe, UserRound, Mail, MessageSquare, Plus, Inbox, Search, X } f
|
||||
import { useTicketEvents } from "@/lib/events/use-ticket-events";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import type { TicketDTO, TicketStatus } from "@/lib/tickets/types";
|
||||
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
|
||||
import { NewTicketModal } from "./new-ticket-modal";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import { TAG_COLOR_CLASSES } from "@/lib/tags/colors";
|
||||
@@ -42,7 +43,13 @@ const PRIORITY_COLOR: Record<TicketDTO["priority"], string> = {
|
||||
urgent: "var(--danger)",
|
||||
};
|
||||
|
||||
export function TicketBoard({ initialTickets }: { initialTickets: TicketDTO[] }) {
|
||||
export function TicketBoard({
|
||||
initialTickets,
|
||||
currentUser,
|
||||
}: {
|
||||
initialTickets: TicketDTO[];
|
||||
currentUser: { id: string; role: "admin" | "agent" };
|
||||
}) {
|
||||
const [tickets, setTickets] = useState<TicketDTO[]>(initialTickets);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [activeTagId, setActiveTagId] = useState<string | null>(null);
|
||||
@@ -52,13 +59,30 @@ export function TicketBoard({ initialTickets }: { initialTickets: TicketDTO[] })
|
||||
|
||||
useTicketEvents("/api/events", (event) => {
|
||||
if (event.type === "ticket.created" || event.type === "ticket.updated") {
|
||||
const visible = isTicketVisibleTo(event.ticket, currentUser);
|
||||
setTickets((prev) => {
|
||||
const withoutIt = prev.filter((t) => t.id !== event.ticket.id);
|
||||
return [event.ticket, ...withoutIt];
|
||||
// Drop it if a live update (e.g. reassignment) just made it invisible
|
||||
// to this agent — otherwise it'd linger on their board until reload.
|
||||
return visible ? [event.ticket, ...withoutIt] : withoutIt;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Server push (SSE) already keeps this current, but that depends on the
|
||||
// event stream actually reaching the browser through whatever reverse
|
||||
// proxy sits in front — a periodic refetch is a plain safety net so the
|
||||
// board is never more than 30s stale even if that path is broken.
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
fetch("/api/tickets")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setTickets(data.tickets ?? []))
|
||||
.catch(() => {});
|
||||
}, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Search hits the server (message bodies aren't loaded client-side),
|
||||
// debounced. An empty query needs no fetch — baseTickets below just falls
|
||||
// back to the live SSE-backed list, so there's nothing to reset here.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTicketEvents } from "@/lib/events/use-ticket-events";
|
||||
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
|
||||
|
||||
/**
|
||||
* No visible UI — just a permanently-mounted subscriber (in the admin
|
||||
@@ -10,7 +11,7 @@ import { useTicketEvents } from "@/lib/events/use-ticket-events";
|
||||
* response," while the tab is in the background. Agent-authored messages
|
||||
* are deliberately skipped — that's the agent's own action, not a signal.
|
||||
*/
|
||||
export function DesktopNotifications() {
|
||||
export function DesktopNotifications({ currentUser }: { currentUser: { id: string; role: "admin" | "agent" } }) {
|
||||
const router = useRouter();
|
||||
|
||||
useTicketEvents("/api/events", (event) => {
|
||||
@@ -18,12 +19,14 @@ export function DesktopNotifications() {
|
||||
if (Notification.permission !== "granted" || !document.hidden) return;
|
||||
|
||||
if (event.type === "ticket.created") {
|
||||
if (!isTicketVisibleTo(event.ticket, currentUser)) return;
|
||||
const notification = new Notification("Новая заявка", { body: event.ticket.subject });
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
router.push(`/tickets/${event.ticket.id}`);
|
||||
};
|
||||
} else if (event.type === "message.created" && event.message.authorType === "customer") {
|
||||
if (!isTicketVisibleTo({ assigneeId: event.assigneeId }, currentUser)) return;
|
||||
const notification = new Notification(`Сообщение от ${event.message.authorName}`, {
|
||||
body: event.message.body.slice(0, 120),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Ticket } from "lucide-react";
|
||||
import { getCurrentSession } from "@/lib/auth/session";
|
||||
import { LogoutButton } from "./logout-button";
|
||||
@@ -7,6 +8,7 @@ import { ThemeToggle } from "./theme-toggle";
|
||||
import { SettingsMenu } from "./settings-menu";
|
||||
import { NotificationToggle } from "./notification-toggle";
|
||||
import { DesktopNotifications } from "./desktop-notifications";
|
||||
import { TicketToasts } from "./ticket-toasts";
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await getCurrentSession();
|
||||
@@ -14,30 +16,34 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const isAdmin = session.user.role === "admin";
|
||||
const currentUser = { id: session.user.id, role: session.user.role };
|
||||
|
||||
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="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">
|
||||
<Ticket size={15} strokeWidth={2.5} />
|
||||
</div>
|
||||
top-tickets
|
||||
</Link>
|
||||
<div className="order-last w-full overflow-x-auto sm:order-none sm:w-auto sm:flex-1 sm:overflow-visible sm:flex sm:justify-center">
|
||||
<NavLinks isAdmin={isAdmin} />
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 sm:gap-3">
|
||||
<span className="hidden text-sm text-text-muted sm:inline">{session.user.name}</span>
|
||||
<div className="ml-auto flex items-center gap-2 sm:ml-0 sm:gap-3">
|
||||
<NotificationToggle />
|
||||
<SettingsMenu isAdmin={session.user.role === "admin"} />
|
||||
<ThemeToggle />
|
||||
{isAdmin && <SettingsMenu isAdmin={isAdmin} />}
|
||||
<span className="hidden text-sm text-text-muted sm:inline">{session.user.name}</span>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
<div className="order-last w-full overflow-x-auto sm:order-none sm:w-auto sm:overflow-visible">
|
||||
<NavLinks />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-6 sm:px-6">{children}</main>
|
||||
<DesktopNotifications />
|
||||
<DesktopNotifications currentUser={currentUser} />
|
||||
<TicketToasts currentUser={currentUser} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,16 +5,17 @@ import { usePathname } from "next/navigation";
|
||||
import { LayoutDashboard, BarChart3 } from "lucide-react";
|
||||
|
||||
const links = [
|
||||
{ href: "/dashboard", label: "Заявки", icon: LayoutDashboard },
|
||||
{ href: "/stats", label: "Статистика", icon: BarChart3 },
|
||||
{ href: "/dashboard", label: "Заявки", icon: LayoutDashboard, adminOnly: false },
|
||||
{ href: "/stats", label: "Статистика", icon: BarChart3, adminOnly: true },
|
||||
];
|
||||
|
||||
export function NavLinks() {
|
||||
export function NavLinks({ isAdmin }: { isAdmin: boolean }) {
|
||||
const pathname = usePathname();
|
||||
const visibleLinks = links.filter((link) => !link.adminOnly || isAdmin);
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1">
|
||||
{links.map(({ href, label, icon: Icon }) => {
|
||||
{visibleLinks.map(({ href, label, icon: Icon }) => {
|
||||
const active = pathname.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Inbox, CircleDot, Clock, CheckCircle2 } from "lucide-react";
|
||||
import { getCurrentSession } from "@/lib/auth/session";
|
||||
import { getStatsOverview } from "@/lib/tickets/service";
|
||||
import { formatDurationMinutes } from "@/lib/format";
|
||||
import { StatTile, HorizontalBar, DailyBarChart } from "./charts";
|
||||
@@ -19,6 +21,11 @@ const CHANNEL_META: Record<string, { label: string; colorClass: string }> = {
|
||||
};
|
||||
|
||||
export default async function StatsPage() {
|
||||
const session = await getCurrentSession();
|
||||
if (!session || session.user.role !== "admin") {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
const stats = await getStatsOverview();
|
||||
const maxChannelCount = Math.max(...Object.values(stats.channelCounts), 1);
|
||||
const maxAgentWorkload = Math.max(...stats.agentWorkload.map((a) => a.openCount), 1);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Bell, X } from "lucide-react";
|
||||
import { useTicketEvents } from "@/lib/events/use-ticket-events";
|
||||
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
/** A short two-tone chime via the Web Audio API — no audio asset needed. */
|
||||
function playChime() {
|
||||
try {
|
||||
const AudioCtor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioCtor) return;
|
||||
const ctx = new AudioCtor();
|
||||
const now = ctx.currentTime;
|
||||
for (const [freq, start] of [[880, 0], [1175, 0.12]] as const) {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = "sine";
|
||||
osc.frequency.value = freq;
|
||||
gain.gain.setValueAtTime(0.0001, now + start);
|
||||
gain.gain.exponentialRampToValueAtTime(0.25, now + start + 0.01);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + start + 0.25);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start(now + start);
|
||||
osc.stop(now + start + 0.25);
|
||||
}
|
||||
setTimeout(() => ctx.close(), 500);
|
||||
} catch {
|
||||
// audio isn't critical to the notification — a silent toast still works
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-page toast + sound for new tickets and customer messages, shown
|
||||
* regardless of tab focus. Complements DesktopNotifications, which only
|
||||
* fires the native OS notification while the tab is in the background —
|
||||
* this is the "still on the page" case.
|
||||
*/
|
||||
export function TicketToasts({ currentUser }: { currentUser: { id: string; role: "admin" | "agent" } }) {
|
||||
const router = useRouter();
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const idCounter = useRef(0);
|
||||
|
||||
const pushToast = useCallback((title: string, body: string, href: string) => {
|
||||
const id = `${Date.now()}-${idCounter.current++}`;
|
||||
setToasts((prev) => [...prev, { id, title, body, href }]);
|
||||
playChime();
|
||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 6_000);
|
||||
}, []);
|
||||
|
||||
useTicketEvents("/api/events", (event) => {
|
||||
if (event.type === "ticket.created") {
|
||||
if (!isTicketVisibleTo(event.ticket, currentUser)) return;
|
||||
pushToast("Новая заявка", event.ticket.subject, `/tickets/${event.ticket.id}`);
|
||||
} else if (event.type === "message.created" && event.message.authorType === "customer") {
|
||||
if (!isTicketVisibleTo({ assigneeId: event.assigneeId }, currentUser)) return;
|
||||
pushToast(`Сообщение от ${event.message.authorName}`, event.message.body.slice(0, 120), `/tickets/${event.ticketId}`);
|
||||
}
|
||||
});
|
||||
|
||||
function dismiss(id: string) {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2">
|
||||
<AnimatePresence initial={false}>
|
||||
{toasts.map((toast) => (
|
||||
<motion.div
|
||||
key={toast.id}
|
||||
initial={{ opacity: 0, y: 12, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="card pointer-events-auto flex items-start gap-2 p-3 shadow-md"
|
||||
>
|
||||
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-soft text-accent-soft-text">
|
||||
<Bell size={13} />
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
dismiss(toast.id);
|
||||
router.push(toast.href);
|
||||
}}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<p className="truncate text-sm font-medium">{toast.title}</p>
|
||||
<p className="line-clamp-2 text-xs text-text-muted">{toast.body}</p>
|
||||
</button>
|
||||
<button onClick={() => dismiss(toast.id)} className="text-text-faint hover:text-text" aria-label="Закрыть">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export async function GET(request: Request) {
|
||||
if (!session) return response;
|
||||
|
||||
const query = new URL(request.url).searchParams.get("q") ?? undefined;
|
||||
const tickets = await listTickets(query);
|
||||
const tickets = await listTickets(query, { id: session.user.id, role: session.user.role });
|
||||
return NextResponse.json({ tickets });
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { TicketDTO, MessageDTO } from "@/lib/tickets/types";
|
||||
export type TicketEvent =
|
||||
| { type: "ticket.created"; ticket: TicketDTO }
|
||||
| { type: "ticket.updated"; ticket: TicketDTO }
|
||||
| { type: "message.created"; ticketId: string; customerId: string; message: MessageDTO };
|
||||
| { type: "message.created"; ticketId: string; customerId: string; assigneeId: string | null; message: MessageDTO };
|
||||
|
||||
// Next.js dev mode reloads this module on every edit — stash the emitter on
|
||||
// globalThis so hot reload doesn't spawn a second, disconnected bus.
|
||||
|
||||
@@ -161,6 +161,7 @@ async function appendMessage(params: {
|
||||
type: "message.created",
|
||||
ticketId: ticket.id,
|
||||
customerId: ticket.customerId,
|
||||
assigneeId: dto.assigneeId,
|
||||
message: messageDto,
|
||||
});
|
||||
|
||||
@@ -415,8 +416,16 @@ export async function assignTicket(ticketId: string, assigneeId: string | null)
|
||||
* Optional `query` filters by ticket subject, customer name, or any message
|
||||
* body in the thread — a plain LIKE search, not FTS5 (simpler and
|
||||
* sufficient at this ticket volume; revisit if it gets slow).
|
||||
*
|
||||
* `visibility` restricts the result to what an agent should see: tickets
|
||||
* assigned to them, plus unassigned ones (so new/unclaimed tickets are
|
||||
* still discoverable to pick up) — not the whole board. Admins see
|
||||
* everything, so pass `undefined`/role "admin" for that case.
|
||||
*/
|
||||
export async function listTickets(query?: string): Promise<TicketDTO[]> {
|
||||
export async function listTickets(
|
||||
query?: string,
|
||||
visibility?: { id: string; role: "admin" | "agent" },
|
||||
): Promise<TicketDTO[]> {
|
||||
let matchingTicketIds: Set<string> | null = null;
|
||||
|
||||
const trimmed = query?.trim();
|
||||
@@ -446,7 +455,13 @@ export async function listTickets(query?: string): Promise<TicketDTO[]> {
|
||||
.orderBy(desc(tickets.lastMessageAt));
|
||||
|
||||
const idsToKeep = matchingTicketIds;
|
||||
const filteredRows = idsToKeep ? rows.filter((r) => idsToKeep.has(r.ticket.id)) : rows;
|
||||
let filteredRows = idsToKeep ? rows.filter((r) => idsToKeep.has(r.ticket.id)) : rows;
|
||||
|
||||
if (visibility?.role === "agent") {
|
||||
filteredRows = filteredRows.filter(
|
||||
(r) => r.ticket.assigneeId === null || r.ticket.assigneeId === visibility.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) ?? []));
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { TicketDTO } from "./types";
|
||||
|
||||
/** Agents see tickets assigned to them plus unassigned ones (so new tickets are still discoverable to pick up); admins see everything. */
|
||||
export function isTicketVisibleTo(
|
||||
ticket: Pick<TicketDTO, "assigneeId">,
|
||||
user: { id: string; role: "admin" | "agent" },
|
||||
): boolean {
|
||||
if (user.role === "admin") return true;
|
||||
return ticket.assigneeId === null || ticket.assigneeId === user.id;
|
||||
}
|
||||
Reference in new issue
Block a user