From 1acc780359730aa657a364b6a2b5674f41d90c84 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 28 May 2026 14:28:27 -0400 Subject: [PATCH] feat(l1): drafts + tickets pages + coverage banner + seat counter widget L1DraftsPage is a Phase 1 placeholder (AI drafts arrive in Phase 2). L1TicketsPage replaces the stub with a status-filterable internal-tickets queue. L1CoverageBanner renders inside L1RouteGuard so every /l1/* page shows it for engineer-coverers (hidden for native L1). SeatCounterWidget + /api/seats.ts surface engineer + L1 seat usage from the /accounts/me/ seats endpoint (T9). Co-Authored-By: Claude Opus 4.7 --- frontend/src/api/seats.ts | 17 ++++++ .../components/admin/SeatCounterWidget.tsx | 33 +++++++++++ .../src/components/l1/L1CoverageBanner.tsx | 23 ++++++++ .../src/components/layout/L1RouteGuard.tsx | 10 +++- frontend/src/pages/AccountSettingsPage.tsx | 3 + frontend/src/pages/l1/L1DraftsPage.tsx | 6 +- frontend/src/pages/l1/L1TicketsPage.tsx | 55 +++++++++++++++++-- 7 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 frontend/src/api/seats.ts create mode 100644 frontend/src/components/admin/SeatCounterWidget.tsx create mode 100644 frontend/src/components/l1/L1CoverageBanner.tsx diff --git a/frontend/src/api/seats.ts b/frontend/src/api/seats.ts new file mode 100644 index 00000000..7637bcd8 --- /dev/null +++ b/frontend/src/api/seats.ts @@ -0,0 +1,17 @@ +import { apiClient } from './client' + +export interface SeatCheck { + available: boolean + current: number + limit: number | null + role: 'engineer' | 'l1_tech' +} + +export interface SeatUsage { + engineer: SeatCheck + l1_tech: SeatCheck +} + +export const seatsApi = { + getUsage: () => apiClient.get('/accounts/me/seats').then((r) => r.data), +} diff --git a/frontend/src/components/admin/SeatCounterWidget.tsx b/frontend/src/components/admin/SeatCounterWidget.tsx new file mode 100644 index 00000000..c6756a3a --- /dev/null +++ b/frontend/src/components/admin/SeatCounterWidget.tsx @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react' +import { seatsApi, type SeatUsage } from '@/api/seats' + +interface RowProps { label: string; check: SeatUsage['engineer'] } + +function SeatRow({ label, check }: RowProps) { + const overLimit = check.limit !== null && check.current > check.limit + const limitText = check.limit === null ? '∞' : check.limit + return ( +
+

{label}

+

{check.current} / {limitText}

+ {overLimit &&

Over limit (grandfathered)

} +
+ ) +} + +export function SeatCounterWidget() { + const [usage, setUsage] = useState(null) + + useEffect(() => { + seatsApi.getUsage().then(setUsage).catch(() => setUsage(null)) + }, []) + + if (!usage) return null + + return ( +
+ + +
+ ) +} diff --git a/frontend/src/components/l1/L1CoverageBanner.tsx b/frontend/src/components/l1/L1CoverageBanner.tsx new file mode 100644 index 00000000..4fb5fd56 --- /dev/null +++ b/frontend/src/components/l1/L1CoverageBanner.tsx @@ -0,0 +1,23 @@ +import { useNavigate } from 'react-router-dom' +import { usePermissions } from '@/hooks/usePermissions' + +export function L1CoverageBanner() { + const perms = usePermissions() + const navigate = useNavigate() + + // Show only for engineer-coverers / owners-stepping-in. Native L1 doesn't see it. + if (perms.isL1Tech) return null + if (!perms.canCoverL1) return null + + return ( +
+ You're covering L1. Actions logged as coverage. + +
+ ) +} diff --git a/frontend/src/components/layout/L1RouteGuard.tsx b/frontend/src/components/layout/L1RouteGuard.tsx index b613dc26..2c4adeaf 100644 --- a/frontend/src/components/layout/L1RouteGuard.tsx +++ b/frontend/src/components/layout/L1RouteGuard.tsx @@ -1,10 +1,18 @@ import { Navigate } from 'react-router-dom' import { usePermissions } from '@/hooks/usePermissions' +import { L1CoverageBanner } from '@/components/l1/L1CoverageBanner' export function L1RouteGuard({ children }: { children: React.ReactNode }) { const { canUseL1Surface } = usePermissions() if (!canUseL1Surface) { return } - return <>{children} + return ( +
+ +
+ {children} +
+
+ ) } diff --git a/frontend/src/pages/AccountSettingsPage.tsx b/frontend/src/pages/AccountSettingsPage.tsx index 8b888f36..0cf99650 100644 --- a/frontend/src/pages/AccountSettingsPage.tsx +++ b/frontend/src/pages/AccountSettingsPage.tsx @@ -33,6 +33,7 @@ import { Spinner } from '@/components/common/Spinner' import { cn } from '@/lib/utils' import { usePermissions } from '@/hooks/usePermissions' import { useSubscription } from '@/hooks/useSubscription' +import { SeatCounterWidget } from '@/components/admin/SeatCounterWidget' import { useAuthStore } from '@/store/authStore' import { CheckoutButton } from '@/components/subscription/CheckoutButton' import { toast } from '@/lib/toast' @@ -432,6 +433,8 @@ export function AccountSettingsPage() {
People + +
-

My Drafts

-

Loading…

+

My AI drafts

+

+ AI-built drafts you've created will show here once AI build is enabled (Phase 2). +

) diff --git a/frontend/src/pages/l1/L1TicketsPage.tsx b/frontend/src/pages/l1/L1TicketsPage.tsx index b4c1b01f..b39b05a4 100644 --- a/frontend/src/pages/l1/L1TicketsPage.tsx +++ b/frontend/src/pages/l1/L1TicketsPage.tsx @@ -1,12 +1,59 @@ +import { useEffect, useState } from 'react' import { PageMeta } from '@/components/common/PageMeta' +import { l1Api } from '@/api/l1' +import type { QueueRow } from '@/types/l1' export default function L1TicketsPage() { + const [rows, setRows] = useState([]) + const [statusFilter, setStatusFilter] = useState('') + + useEffect(() => { + l1Api.queue(statusFilter || undefined).then(setRows).catch(() => setRows([])) + }, [statusFilter]) + return (
- -
-

L1 Tickets

-

Loading…

+ +
+
+

Tickets

+ +
+
+ {rows.map((r) => ( +
+
+
+ + #{r.ticket_id.slice(0, 8)} + + {r.problem_statement} +
+
+ + {r.status} + + + {r.ticket_kind === 'psa' ? 'PSA' : 'Internal'} + +
+
+
+ ))} + {rows.length === 0 && ( +

No tickets.

+ )} +
)