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 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 14:28:27 -04:00
parent d3fd9143d7
commit 1acc780359
7 changed files with 140 additions and 7 deletions

17
frontend/src/api/seats.ts Normal file
View File

@@ -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<SeatUsage>('/accounts/me/seats').then((r) => r.data),
}

View File

@@ -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 (
<div className={overLimit ? 'text-warning' : ''}>
<p className="text-xs uppercase tracking-wider text-muted-foreground mb-1">{label}</p>
<p className="text-lg font-mono">{check.current} / {limitText}</p>
{overLimit && <p className="text-xs">Over limit (grandfathered)</p>}
</div>
)
}
export function SeatCounterWidget() {
const [usage, setUsage] = useState<SeatUsage | null>(null)
useEffect(() => {
seatsApi.getUsage().then(setUsage).catch(() => setUsage(null))
}, [])
if (!usage) return null
return (
<div className="rounded-lg border border-default bg-card p-4 grid grid-cols-2 gap-4">
<SeatRow label="Engineer seats" check={usage.engineer} />
<SeatRow label="L1 seats" check={usage.l1_tech} />
</div>
)
}

View File

@@ -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 (
<div className="bg-info/10 text-info text-sm px-4 py-1.5 flex items-center justify-between border-b border-info/20">
<span>You're covering L1. Actions logged as coverage.</span>
<button
onClick={() => navigate('/')}
className="text-info hover:underline underline-offset-2"
>
Switch back
</button>
</div>
)
}

View File

@@ -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 <Navigate to="/" replace />
}
return <>{children}</>
return (
<div className="flex flex-col h-full">
<L1CoverageBanner />
<div className="flex-1 min-h-0 flex flex-col">
{children}
</div>
</div>
)
}

View File

@@ -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() {
<section className="space-y-5 border-t border-border pt-8">
<SectionLabel>People</SectionLabel>
<SeatCounterWidget />
<form onSubmit={handleInvite} className="flex flex-wrap items-center gap-2">
<input
type="email"

View File

@@ -5,8 +5,10 @@ export default function L1DraftsPage() {
<div className="overflow-y-auto h-full">
<PageMeta title="My Drafts" />
<div className="max-w-4xl mx-auto px-6 pt-12 pb-12">
<h1 className="font-heading text-2xl font-bold">My Drafts</h1>
<p className="text-muted-foreground mt-2">Loading</p>
<h1 className="font-heading text-2xl font-bold mb-2">My AI drafts</h1>
<p className="text-muted-foreground">
AI-built drafts you've created will show here once AI build is enabled (Phase 2).
</p>
</div>
</div>
)

View File

@@ -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<QueueRow[]>([])
const [statusFilter, setStatusFilter] = useState<string>('')
useEffect(() => {
l1Api.queue(statusFilter || undefined).then(setRows).catch(() => setRows([]))
}, [statusFilter])
return (
<div className="overflow-y-auto h-full">
<PageMeta title="L1 Tickets" />
<div className="max-w-4xl mx-auto px-6 pt-12 pb-12">
<h1 className="font-heading text-2xl font-bold">L1 Tickets</h1>
<p className="text-muted-foreground mt-2">Loading</p>
<PageMeta title="Tickets" />
<div className="max-w-5xl mx-auto px-6 pt-12 pb-12">
<div className="flex items-center justify-between mb-6">
<h1 className="font-heading text-2xl font-bold">Tickets</h1>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="bg-card border border-default rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40"
>
<option value="">All</option>
<option value="open">Open</option>
<option value="walking">Walking</option>
<option value="resolved">Resolved</option>
<option value="escalated">Escalated</option>
</select>
</div>
<div className="rounded-lg border border-default bg-card overflow-hidden">
{rows.map((r) => (
<div key={r.ticket_id} className="px-4 py-3 border-b border-default last:border-b-0">
<div className="flex items-center justify-between">
<div>
<span className="font-mono text-xs text-muted-foreground mr-2">
#{r.ticket_id.slice(0, 8)}
</span>
<span className="text-sm">{r.problem_statement}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs px-2 py-0.5 rounded bg-elevated text-muted-foreground">
{r.status}
</span>
<span className="text-xs text-muted-foreground">
{r.ticket_kind === 'psa' ? 'PSA' : 'Internal'}
</span>
</div>
</div>
</div>
))}
{rows.length === 0 && (
<p className="px-4 py-8 text-sm text-muted-foreground text-center">No tickets.</p>
)}
</div>
</div>
</div>
)