fix(admin): Support entry in sidebar + awaiting-admin badge
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
The /admin/support page existed but was invisible from the panel — sidebar NAV array didn't list it. Adds Support as the 2nd nav item (right after Overview, since unanswered tickets are the most-time-sensitive thing an admin checks). Sidebar polls /v1/admin/support/counts every 30s and renders an amber count badge next to the entry when tickets are awaiting_admin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ef30baf52a
commit
20910f5466
@ -3,6 +3,7 @@ import {
|
|||||||
createDb,
|
createDb,
|
||||||
desc,
|
desc,
|
||||||
eq,
|
eq,
|
||||||
|
sql,
|
||||||
supportMessages,
|
supportMessages,
|
||||||
supportTickets,
|
supportTickets,
|
||||||
users,
|
users,
|
||||||
@ -182,6 +183,18 @@ export async function supportRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── Admin-side ────────────────────────────────────────────────────────
|
// ─── Admin-side ────────────────────────────────────────────────────────
|
||||||
|
app.get(
|
||||||
|
'/v1/admin/support/counts',
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (_req, reply) => {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(supportTickets)
|
||||||
|
.where(eq(supportTickets.status, 'awaiting_admin'));
|
||||||
|
return reply.send({ awaitingAdmin: row?.count ?? 0 });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
'/v1/admin/support/tickets',
|
'/v1/admin/support/tickets',
|
||||||
{ preHandler: requireAdmin },
|
{ preHandler: requireAdmin },
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { usePathname, useRouter } from 'next/navigation';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
LifeBuoy,
|
||||||
Users,
|
Users,
|
||||||
Building2,
|
Building2,
|
||||||
Server,
|
Server,
|
||||||
@ -29,6 +30,7 @@ interface MeUser {
|
|||||||
|
|
||||||
const NAV: { href: string; label: string; icon: React.ComponentType<{ size?: number }> }[] = [
|
const NAV: { href: string; label: string; icon: React.ComponentType<{ size?: number }> }[] = [
|
||||||
{ href: '/admin', label: 'Overview', icon: LayoutGrid },
|
{ href: '/admin', label: 'Overview', icon: LayoutGrid },
|
||||||
|
{ href: '/admin/support', label: 'Support', icon: LifeBuoy },
|
||||||
{ href: '/admin/users', label: 'Users', icon: Users },
|
{ href: '/admin/users', label: 'Users', icon: Users },
|
||||||
{ href: '/admin/orgs', label: 'Organizations', icon: Building2 },
|
{ href: '/admin/orgs', label: 'Organizations', icon: Building2 },
|
||||||
{ href: '/admin/servers', label: 'MCP servers', icon: Server },
|
{ href: '/admin/servers', label: 'MCP servers', icon: Server },
|
||||||
@ -45,6 +47,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [user, setUser] = useState<MeUser | null>(null);
|
const [user, setUser] = useState<MeUser | null>(null);
|
||||||
const [authState, setAuthState] = useState<'checking' | 'ok' | 'forbidden'>('checking');
|
const [authState, setAuthState] = useState<'checking' | 'ok' | 'forbidden'>('checking');
|
||||||
|
const [supportPending, setSupportPending] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pathname === '/admin/login') {
|
if (pathname === '/admin/login') {
|
||||||
@ -63,6 +66,26 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
.catch(() => setAuthState('forbidden'));
|
.catch(() => setAuthState('forbidden'));
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Poll support count every 30s so the sidebar badge stays fresh while admin
|
||||||
|
// is doing other work in the panel.
|
||||||
|
useEffect(() => {
|
||||||
|
if (authState !== 'ok' || pathname === '/admin/login') return;
|
||||||
|
let cancelled = false;
|
||||||
|
const load = () => {
|
||||||
|
apiFetch<{ awaitingAdmin: number }>('/v1/admin/support/counts')
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) setSupportPending(r.awaitingAdmin);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const t = setInterval(load, 30_000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(t);
|
||||||
|
};
|
||||||
|
}, [authState, pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authState === 'forbidden' && pathname !== '/admin/login') {
|
if (authState === 'forbidden' && pathname !== '/admin/login') {
|
||||||
router.replace('/admin/login');
|
router.replace('/admin/login');
|
||||||
@ -126,7 +149,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon size={13} />
|
<Icon size={13} />
|
||||||
{item.label}
|
<span className="flex-1">{item.label}</span>
|
||||||
|
{item.href === '/admin/support' && supportPending > 0 && (
|
||||||
|
<span className="mono inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-amber-500/30 px-1 text-[10px] font-semibold text-amber-300">
|
||||||
|
{supportPending}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user