diff --git a/e2e/tests/web.spec.ts b/e2e/tests/web.spec.ts
index 0567caf..587533a 100644
--- a/e2e/tests/web.spec.ts
+++ b/e2e/tests/web.spec.ts
@@ -345,6 +345,93 @@ test.describe('Web SPA', () => {
).toContainText('host-ckl-1')
})
+ test('asset review workspace renders 3-pane layout and respects URL state (M23)', async ({
+ page,
+ request,
+ }) => {
+ // Make sure the SPA has rehydrated storageState so we can read
+ // the OIDC token for the API hops below.
+ await page.goto(urls.web)
+ const token = await readAccessToken(page)
+
+ // Import the sample XCCDF so TEST_OS_STIG/V2R3 exists in the
+ // library. The newly-created asset will be mapped to it via the
+ // create-asset form.
+ const xccdfPath = join(
+ process.cwd(),
+ '..',
+ 'api',
+ 'internal',
+ 'xccdf',
+ 'testdata',
+ 'sample.xccdf.xml',
+ )
+ const xccdf = readFileSync(xccdfPath)
+ const imp = await request.post(`${urls.api}/api/stigs?clobber=true`, {
+ headers: { Authorization: `Bearer ${token}` },
+ multipart: {
+ importFile: {
+ name: 'TEST_OS_STIG.xml',
+ mimeType: 'application/xml',
+ buffer: xccdf,
+ },
+ },
+ })
+ expect(imp.status(), await imp.text()).toBe(200)
+
+ // Create a fresh collection.
+ await page.goto(`${urls.web}/collections`)
+ const collectionName = `e2e-workspace-coll-${Date.now()}`
+ await page.getByTestId('new-collection-button').click()
+ const cdialog = page.getByTestId('new-collection-dialog')
+ await cdialog.getByTestId('new-collection-name-input').fill(collectionName)
+ await cdialog.getByTestId('new-collection-submit').click()
+ await expect(page).toHaveURL(/\/collections\/\d+$/, { timeout: 10_000 })
+ const collectionId = page.url().match(/\/collections\/(\d+)/)?.[1]
+ expect(collectionId).toBeTruthy()
+
+ // Create an asset. The dialog's STIG checkbox is sourced from
+ // `useSTIGs()` (library-wide) so TEST_OS_STIG will be selectable
+ // once the import above completes.
+ await page.getByTestId('collection-tab-assets').click()
+ await page.getByTestId('new-asset-button').click()
+ const adialog = page.getByTestId('new-asset-dialog')
+ await adialog.getByTestId('asset-name-input').fill('e2e-workspace-host')
+ // Pick the STIG from the multi-select if available; otherwise the
+ // form will create the asset with no STIGs and the workspace will
+ // show the empty-STIGs state, which is still a valid smoke test.
+ const stigCheckbox = adialog.getByTestId('asset-stig-TEST_OS_STIG')
+ if (await stigCheckbox.isVisible().catch(() => false)) {
+ await stigCheckbox.check()
+ }
+ await adialog.getByTestId('new-asset-submit').click()
+ await expect(page).toHaveURL(/\/collections\/\d+\/assets\/\d+$/, {
+ timeout: 10_000,
+ })
+ const assetId = page.url().match(/\/assets\/(\d+)/)?.[1]
+ expect(assetId).toBeTruthy()
+
+ // Click the workspace CTA on the asset detail page.
+ const openWorkspace = page.getByTestId('asset-open-workspace')
+ await expect(openWorkspace).toBeVisible()
+ await openWorkspace.click()
+ await expect(page).toHaveURL(
+ new RegExp(`/collections/${collectionId}/assets/${assetId}/workspace`),
+ )
+
+ // Three panes are visible.
+ await expect(page.getByTestId('review-workspace')).toBeVisible()
+ await expect(page.getByTestId('workspace-rule-list')).toBeVisible()
+ await expect(page.getByTestId('workspace-rule-detail')).toBeVisible()
+ await expect(page.getByTestId('workspace-review-pane')).toBeVisible()
+ // STIG picker is visible regardless of whether anything was
+ // mapped.
+ await expect(page.getByTestId('workspace-stig-select')).toBeVisible()
+ // Search input gets focus when '/' is pressed.
+ await page.keyboard.press('/')
+ await expect(page.getByTestId('workspace-search')).toBeFocused()
+ })
+
test('admin layout renders with the four tabs (M18f)', async ({ page }) => {
await page.goto(`${urls.web}/admin/users`)
await expect(page.getByTestId('admin-layout')).toBeVisible()
diff --git a/web/src/app/pages/assets/detail.tsx b/web/src/app/pages/assets/detail.tsx
index 6221856..7936b49 100644
--- a/web/src/app/pages/assets/detail.tsx
+++ b/web/src/app/pages/assets/detail.tsx
@@ -5,7 +5,7 @@
// to show its rules + each rule's current review result. Clicking a
// rule navigates to the single-rule review editor.
-import { ArrowLeft, Loader2, Pencil, Trash2 } from 'lucide-react'
+import { ArrowLeft, ClipboardCheck, Loader2, Pencil, Trash2 } from 'lucide-react'
import * as React from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
@@ -122,26 +122,39 @@ export function AssetDetailPage() {
)}
- {canEdit && (
-
-
-
+ {canEdit && (
+ <>
+
setEditOpen(true)}
+ data-testid="asset-edit-button"
+ >
+ Edit
+
+
setConfirmDelete(true)}
+ data-testid="asset-delete-button"
+ >
+ Delete
+
+ >
+ )}
+
diff --git a/web/src/app/pages/assets/workspace.tsx b/web/src/app/pages/assets/workspace.tsx
new file mode 100644
index 0000000..7b949f7
--- /dev/null
+++ b/web/src/app/pages/assets/workspace.tsx
@@ -0,0 +1,1042 @@
+// /collections/:collectionId/assets/:assetId/workspace
+//
+// Three-pane review workspace for an Asset.
+//
+// Left ~30% Rule list for the selected STIG (filter + sort + row select)
+// Middle ~40% Selected rule's RuleDetail (vuln discussion, check, fix, ccis)
+// Right ~30% Review Resources tabs (History / Other Assets / Status text)
+// stacked above the Evaluation form
+//
+// State that survives reload is encoded in the URL as
+// ?stig=
&rule=. Selecting a row pushes the
+// `rule` param; selecting a STIG pushes both.
+//
+// Keyboard shortcuts (only when the rule list has focus or no input is
+// focused — text inputs/textareas swallow the keys naturally):
+//
+// j / ArrowDown next rule
+// k / ArrowUp prev rule
+// f result -> fail
+// p result -> pass
+// n result -> notapplicable
+// u result -> notchecked
+// Ctrl+Enter save the current evaluation
+// Ctrl+Shift+Enter save + advance to next unreviewed rule
+// / focus the search box
+
+import {
+ ArrowLeft,
+ ChevronLeft,
+ ChevronRight,
+ Loader2,
+} from 'lucide-react'
+import * as React from 'react'
+import { Link, useParams, useSearchParams } from 'react-router-dom'
+
+import { Button } from '@/components/ui/button'
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import {
+ useAsset,
+ useAssetStigs,
+ useCurrentUser,
+ usePutReview,
+ useReview,
+ useReviewHistory,
+ useReviewsByAsset,
+ useRuleByRuleId,
+ useRulesByRevision,
+ type Review,
+ type ReviewResult,
+ type ReviewStatusLabel,
+ type Rule,
+} from '@/lib/api/hooks'
+import { atLeast, roleForCollection } from '@/lib/auth/roles'
+
+const RESULT_OPTIONS: ReviewResult[] = [
+ 'fail',
+ 'pass',
+ 'notapplicable',
+ 'notchecked',
+ 'unknown',
+ 'error',
+ 'notselected',
+ 'informational',
+ 'fixed',
+]
+
+const STATUS_OPTIONS: { value: ReviewStatusLabel | ''; label: string }[] = [
+ { value: '', label: 'Keep current (saved)' },
+ { value: 'saved', label: 'Saved' },
+ { value: 'submitted', label: 'Submitted' },
+]
+
+type StatusFilter = '' | 'unreviewed' | ReviewStatusLabel
+
+export function AssetReviewWorkspacePage() {
+ const { collectionId, assetId } = useParams<{
+ collectionId: string
+ assetId: string
+ }>()
+ const [params, setParams] = useSearchParams()
+ const me = useCurrentUser()
+ const role = roleForCollection(me.data, collectionId ?? '')
+ const canEdit = atLeast(role, 2)
+
+ const asset = useAsset(assetId)
+ const stigs = useAssetStigs(assetId)
+ const reviews = useReviewsByAsset(collectionId, assetId)
+
+ // STIG selection. Default to the first assigned STIG if the URL
+ // has none.
+ const stigParam = params.get('stig') ?? ''
+ const selectedStig =
+ stigs.data?.find((s) => s.benchmarkId === stigParam) ??
+ stigs.data?.[0]
+
+ React.useEffect(() => {
+ if (selectedStig && !stigParam) {
+ setParams(
+ (prev) => {
+ const next = new URLSearchParams(prev)
+ next.set('stig', selectedStig.benchmarkId)
+ return next
+ },
+ { replace: true },
+ )
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedStig?.benchmarkId, stigParam])
+
+ // Rules for the selected STIG (basic projection — full detail is
+ // loaded per-rule on selection).
+ const rules = useRulesByRevision(
+ selectedStig?.benchmarkId,
+ selectedStig?.revisionStr ?? (selectedStig ? 'latest' : undefined),
+ )
+
+ const reviewsByRule = React.useMemo(() => {
+ const m = new Map()
+ for (const r of reviews.data ?? []) {
+ const id = r.ruleId ?? r.ruleIds?.[0]
+ if (id) m.set(id, r)
+ }
+ return m
+ }, [reviews.data])
+
+ // ---- Rule list filters ------------------------------------------
+ const [search, setSearch] = React.useState('')
+ const [severity, setSeverity] = React.useState<'' | 'high' | 'medium' | 'low'>('')
+ const [statusFilter, setStatusFilter] = React.useState('')
+ const [resultFilter, setResultFilter] = React.useState<'' | ReviewResult>('')
+ const searchRef = React.useRef(null)
+
+ const filteredRules = React.useMemo(() => {
+ if (!rules.data) return [] as Rule[]
+ const needle = search.trim().toLowerCase()
+ return rules.data.filter((r) => {
+ if (severity && r.severity !== severity) return false
+ const rev = reviewsByRule.get(r.ruleId)
+ if (statusFilter === 'unreviewed' && rev) return false
+ if (
+ statusFilter !== '' &&
+ statusFilter !== 'unreviewed' &&
+ rev?.status?.label !== statusFilter
+ )
+ return false
+ if (resultFilter && rev?.result !== resultFilter) return false
+ if (!needle) return true
+ return (
+ r.ruleId.toLowerCase().includes(needle) ||
+ r.title.toLowerCase().includes(needle) ||
+ (r.groupId?.toLowerCase().includes(needle) ?? false)
+ )
+ })
+ }, [rules.data, search, severity, statusFilter, resultFilter, reviewsByRule])
+
+ // ---- Selected rule ----------------------------------------------
+ const ruleParam = params.get('rule') ?? ''
+ const selectedRuleId = React.useMemo(() => {
+ if (ruleParam && filteredRules.some((r) => r.ruleId === ruleParam)) {
+ return ruleParam
+ }
+ return filteredRules[0]?.ruleId ?? ''
+ }, [ruleParam, filteredRules])
+
+ // Whenever the URL `rule` param drifts out of the filtered set,
+ // fall back to the first row but don't loop on it.
+ React.useEffect(() => {
+ if (!selectedRuleId) return
+ if (selectedRuleId !== ruleParam) {
+ setParams(
+ (prev) => {
+ const next = new URLSearchParams(prev)
+ next.set('rule', selectedRuleId)
+ return next
+ },
+ { replace: true },
+ )
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedRuleId])
+
+ function selectRule(ruleId: string) {
+ setParams(
+ (prev) => {
+ const next = new URLSearchParams(prev)
+ next.set('rule', ruleId)
+ return next
+ },
+ { replace: false },
+ )
+ }
+
+ // Auto-scroll the selected row into view.
+ React.useEffect(() => {
+ const el = document.querySelector(
+ `[data-testid="ws-rule-row-${selectedRuleId}"]`,
+ )
+ if (el) (el as HTMLElement).scrollIntoView({ block: 'nearest' })
+ }, [selectedRuleId])
+
+ const ruleDetail = useRuleByRuleId(selectedRuleId || undefined)
+ const review = useReview(collectionId, assetId, selectedRuleId || undefined)
+ const history = useReviewHistory(collectionId, {
+ assetId,
+ ruleId: selectedRuleId || undefined,
+ })
+
+ // ---- Evaluation form state --------------------------------------
+ const [result, setResult] = React.useState('notchecked')
+ const [detail, setDetail] = React.useState('')
+ const [comment, setComment] = React.useState('')
+ const [status, setStatus] = React.useState('')
+ const [savedAt, setSavedAt] = React.useState(null)
+ const [error, setError] = React.useState(null)
+ const put = usePutReview()
+
+ React.useEffect(() => {
+ if (review.isPending) return
+ if (review.data) {
+ setResult(review.data.result)
+ setDetail(review.data.detail ?? '')
+ setComment(review.data.comment ?? '')
+ setStatus('')
+ } else {
+ setResult('notchecked')
+ setDetail('')
+ setComment('')
+ setStatus('')
+ }
+ setSavedAt(null)
+ setError(null)
+ }, [review.isPending, review.data, selectedRuleId])
+
+ async function save(advance: boolean) {
+ if (!collectionId || !assetId || !selectedRuleId) return
+ setError(null)
+ setSavedAt(null)
+ try {
+ await put.mutateAsync({
+ collectionId,
+ assetId,
+ ruleId: selectedRuleId,
+ body: {
+ result,
+ detail,
+ comment,
+ ...(status ? { status } : {}),
+ },
+ })
+ setSavedAt(new Date().toLocaleTimeString())
+ if (advance) {
+ const idx = filteredRules.findIndex(
+ (r) => r.ruleId === selectedRuleId,
+ )
+ const nextRule =
+ filteredRules.slice(idx + 1).find((r) => !reviewsByRule.has(r.ruleId)) ??
+ filteredRules[idx + 1]
+ if (nextRule) selectRule(nextRule.ruleId)
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save review.')
+ }
+ }
+
+ // ---- Keyboard shortcuts -----------------------------------------
+ React.useEffect(() => {
+ if (!canEdit) return
+ function onKey(e: KeyboardEvent) {
+ const tgt = e.target as HTMLElement | null
+ const tag = tgt?.tagName ?? ''
+ const isText =
+ tag === 'INPUT' ||
+ tag === 'TEXTAREA' ||
+ tag === 'SELECT' ||
+ (tgt?.isContentEditable ?? false)
+
+ if (e.ctrlKey && e.key === 'Enter') {
+ e.preventDefault()
+ void save(e.shiftKey)
+ return
+ }
+ if (isText) return
+
+ if (e.key === '/') {
+ e.preventDefault()
+ searchRef.current?.focus()
+ return
+ }
+ const arrow = e.key === 'ArrowDown' || e.key === 'ArrowUp'
+ if (e.key === 'j' || e.key === 'k' || arrow) {
+ e.preventDefault()
+ const idx = filteredRules.findIndex((r) => r.ruleId === selectedRuleId)
+ const dir = e.key === 'j' || e.key === 'ArrowDown' ? 1 : -1
+ const nxt = filteredRules[Math.min(
+ Math.max(idx + dir, 0),
+ filteredRules.length - 1,
+ )]
+ if (nxt) selectRule(nxt.ruleId)
+ return
+ }
+ if (e.key === 'f') setResult('fail')
+ else if (e.key === 'p') setResult('pass')
+ else if (e.key === 'n') setResult('notapplicable')
+ else if (e.key === 'u') setResult('notchecked')
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [filteredRules, selectedRuleId, canEdit, result, detail, comment, status])
+
+ // ---- Render -----------------------------------------------------
+ if (!collectionId || !assetId) return null
+
+ if (asset.isLoading) {
+ return (
+
+ Loading workspace…
+
+ )
+ }
+
+ if (asset.isError || !asset.data) {
+ return (
+
+
+
Back to Collection
+
+
Asset not found.
+
+ )
+ }
+
+ const counts = countByResult(filteredRules, reviewsByRule)
+
+ return (
+
+ {/* Breadcrumb + STIG picker -------------------------------- */}
+
+
+ {asset.data.name}
+
+ /
+
+ {selectedStig && (
+
+ {filteredRules.length} of {rules.data?.length ?? 0} rules ·{' '}
+ {counts.fail} fail ·{' '}
+ {counts.pass} pass ·{' '}
+ {counts.na} N/A ·{' '}
+ {counts.unreviewed} unreviewed
+
+ )}
+
+
+ {/* Three-pane body ----------------------------------------- */}
+
+
+
+
+
+ save(false)}
+ onSaveAdvance={() => save(true)}
+ onPrev={() => {
+ const i = filteredRules.findIndex((r) => r.ruleId === selectedRuleId)
+ if (i > 0) selectRule(filteredRules[i - 1].ruleId)
+ }}
+ onNext={() => {
+ const i = filteredRules.findIndex((r) => r.ruleId === selectedRuleId)
+ if (i >= 0 && i < filteredRules.length - 1)
+ selectRule(filteredRules[i + 1].ruleId)
+ }}
+ />
+
+
+ )
+}
+
+// ---- Rule list pane --------------------------------------------------
+
+function RuleListPane({
+ rules,
+ all,
+ loading,
+ selectedRuleId,
+ reviewsByRule,
+ onSelect,
+ search,
+ setSearch,
+ searchRef,
+ severity,
+ setSeverity,
+ statusFilter,
+ setStatusFilter,
+ resultFilter,
+ setResultFilter,
+}: {
+ rules: Rule[]
+ all: Rule[] | null
+ loading: boolean
+ selectedRuleId: string
+ reviewsByRule: Map
+ onSelect: (ruleId: string) => void
+ search: string
+ setSearch: (s: string) => void
+ searchRef: React.RefObject
+ severity: '' | 'high' | 'medium' | 'low'
+ setSeverity: (s: '' | 'high' | 'medium' | 'low') => void
+ statusFilter: StatusFilter
+ setStatusFilter: (s: StatusFilter) => void
+ resultFilter: '' | ReviewResult
+ setResultFilter: (s: '' | ReviewResult) => void
+}) {
+ return (
+
+
+
+ setSearch(e.target.value)}
+ data-testid="workspace-search"
+ />
+
+
+ setSeverity('')}>All sev
+ setSeverity('high')}>CAT I
+ setSeverity('medium')}>CAT II
+ setSeverity('low')}>CAT III
+
+
+ setStatusFilter('')}>Any status
+ setStatusFilter('unreviewed')}>Unreviewed
+ setStatusFilter('saved')}>Saved
+ setStatusFilter('submitted')}>Submitted
+
+
+ setResultFilter('')}>Any result
+ setResultFilter('fail')}>Fail
+ setResultFilter('pass')}>Pass
+ setResultFilter('notapplicable')}>N/A
+
+
+
+ {loading && (
+
+ Loading rules…
+
+ )}
+ {!loading && all && all.length === 0 && (
+
+ This STIG has no rules.
+
+ )}
+ {!loading && all && all.length > 0 && rules.length === 0 && (
+
+ No rules match the current filter.
+
+ )}
+ {!loading && rules.length > 0 && (
+
+ )}
+
+
+ )
+}
+
+// ---- Rule detail pane ------------------------------------------------
+
+function RuleDetailPane({
+ ruleId,
+ loading,
+ detail,
+}: {
+ ruleId: string
+ loading: boolean
+ detail: import('@/lib/api').RuleDetail | null
+}) {
+ return (
+
+
+
+ {ruleId || 'No rule selected'}
+
+ {detail?.title && (
+
+ {detail.title}
+
+ )}
+
+
+ {loading && (
+
+ Loading rule detail…
+
+ )}
+ {!loading && detail && (
+ <>
+ {detail.detail?.vulnDiscussion && (
+
+ {detail.detail.vulnDiscussion}
+
+ )}
+ {detail.check?.content && (
+
+
+ {detail.check.content}
+
+
+ )}
+ {detail.fix?.text && (
+
+
+ {detail.fix.text}
+
+
+ )}
+ {detail.ccis && detail.ccis.length > 0 && (
+
+
+ {detail.ccis.map((c) => (
+ -
+ {c.cci}
+
+ ))}
+
+
+ )}
+ >
+ )}
+ {!loading && !detail && ruleId && (
+
+ No detail available for this rule.
+
+ )}
+
+
+ )
+}
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ )
+}
+
+// ---- Review pane (history + evaluation form) ------------------------
+
+type ReviewPaneTab = 'history' | 'other' | 'status'
+
+function ReviewPane({
+ ruleId,
+ canEdit,
+ review,
+ history,
+ historyLoading,
+ result,
+ setResult,
+ detail,
+ setDetail,
+ comment,
+ setComment,
+ status,
+ setStatus,
+ savedAt,
+ error,
+ pending,
+ onSave,
+ onSaveAdvance,
+ onPrev,
+ onNext,
+}: {
+ ruleId: string
+ canEdit: boolean
+ review: Review | null
+ history: import('@/lib/api').ReviewHistoryAsset[]
+ historyLoading: boolean
+ result: ReviewResult
+ setResult: (r: ReviewResult) => void
+ detail: string
+ setDetail: (s: string) => void
+ comment: string
+ setComment: (s: string) => void
+ status: ReviewStatusLabel | ''
+ setStatus: (s: ReviewStatusLabel | '') => void
+ savedAt: string | null
+ error: string | null
+ pending: boolean
+ onSave: () => void
+ onSaveAdvance: () => void
+ onPrev: () => void
+ onNext: () => void
+}) {
+ const [tab, setTab] = React.useState('history')
+ // Reset to history when rule changes; keeps the panel showing the
+ // most useful info first.
+ React.useEffect(() => {
+ setTab('history')
+ }, [ruleId])
+
+ return (
+
+
+
+
+ setTab('history')}>History
+ setTab('other')}>Other Assets
+ setTab('status')}>Status Text
+
+
+
+ {tab === 'history' && (
+
+ )}
+ {tab === 'other' && (
+
+ Cross-asset view for this rule is coming soon (M23b). Use the
+ Reviews tab on the Collection for now.
+
+ )}
+ {tab === 'status' && (
+
+
+ Status reviewer note (if any) on the current review.
+
+
+ {review?.status?.text || '— none —'}
+
+
+ )}
+
+
+
+
+
+ Evaluation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {savedAt && (
+
+ Saved at {savedAt}.
+
+ )}
+
+
+
+ {pending ? : null}
+ Save
+
+
+ Save + next
+
+
+ Ctrl+Enter to save · Ctrl+Shift+Enter to advance
+
+
+
+ {review && (
+
+ Last touched by{' '}
+ {review.username ?? '—'} ·{' '}
+ {review.ts ? new Date(review.ts).toLocaleString() : '—'} ·{' '}
+ status {review.status?.label ?? '—'}
+
+ )}
+
+
+
+ )
+}
+
+function HistoryList({
+ loading,
+ history,
+}: {
+ loading: boolean
+ history: import('@/lib/api').ReviewHistoryAsset[]
+}) {
+ const entries = React.useMemo(() => {
+ const rows: import('@/lib/api').ReviewHistoryEntry[] = []
+ for (const a of history) {
+ for (const r of a.reviewHistories) {
+ for (const e of r.history) rows.push(e)
+ }
+ }
+ rows.sort((a, b) => (a.ts < b.ts ? 1 : -1))
+ return rows.slice(0, 20)
+ }, [history])
+ if (loading) {
+ return (
+
+ Loading history…
+
+ )
+ }
+ if (entries.length === 0) {
+ return (
+
+ No prior reviews for this rule on this asset.
+
+ )
+ }
+ return (
+
+ )
+}
+
+// ---- Bits ------------------------------------------------------------
+
+function FilterPill({
+ active,
+ onClick,
+ children,
+}: {
+ active: boolean
+ onClick: () => void
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+function SeverityPill({ severity }: { severity: string }) {
+ const cls =
+ severity === 'high'
+ ? 'border-rose-500 bg-rose-500/10 text-rose-500'
+ : severity === 'medium'
+ ? 'border-amber-500 bg-amber-500/10 text-amber-500'
+ : 'border-sky-500 bg-sky-500/10 text-sky-500'
+ const label =
+ severity === 'high' ? 'I' : severity === 'medium' ? 'II' : 'III'
+ return (
+
+ CAT {label}
+
+ )
+}
+
+function ResultPill({
+ rev,
+}: {
+ rev: { result?: string; status?: { label?: string } } | undefined
+}) {
+ if (!rev?.result) {
+ return (
+
+ unreviewed
+
+ )
+ }
+ const cls =
+ rev.result === 'fail'
+ ? 'border-rose-500 bg-rose-500/10 text-rose-500'
+ : rev.result === 'pass'
+ ? 'border-emerald-500 bg-emerald-500/10 text-emerald-500'
+ : 'border-amber-500 bg-amber-500/10 text-amber-500'
+ return (
+
+ {rev.result}
+ {rev.status?.label ? ` · ${rev.status.label}` : ''}
+
+ )
+}
+
+function countByResult(
+ rules: Rule[],
+ reviewsByRule: Map,
+): { fail: number; pass: number; na: number; unreviewed: number } {
+ let fail = 0,
+ pass = 0,
+ na = 0,
+ unreviewed = 0
+ for (const r of rules) {
+ const rev = reviewsByRule.get(r.ruleId)
+ if (!rev) {
+ unreviewed++
+ continue
+ }
+ if (rev.result === 'fail') fail++
+ else if (rev.result === 'pass') pass++
+ else if (rev.result === 'notapplicable') na++
+ }
+ return { fail, pass, na, unreviewed }
+}
diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx
index e15060a..a65b21d 100644
--- a/web/src/app/router.tsx
+++ b/web/src/app/router.tsx
@@ -11,6 +11,7 @@ import { UserGroupsPage } from './pages/admin/user-groups-page'
import { UsersPage } from './pages/admin/users-page'
import { AssetDetailPage } from './pages/assets/detail'
import { ReviewEditorPage } from './pages/assets/review-editor'
+import { AssetReviewWorkspacePage } from './pages/assets/workspace'
import { CollectionDetailPage } from './pages/collections/detail'
import { CollectionsListPage } from './pages/collections/list'
import { DashboardPage } from './pages/dashboard'
@@ -57,6 +58,14 @@ export const router = createBrowserRouter([
),
},
+ {
+ path: 'collections/:collectionId/assets/:assetId/workspace',
+ element: (
+
+
+
+ ),
+ },
{
path: 'collections/:collectionId/assets/:assetId/rules/:ruleId',
element: (