diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 488676fd5..4338252cd 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2194,8 +2194,9 @@ "fetchError": "無法取得通知頻道" }, "reports": { - "title": "報表", - "subtitle": "事件統計摘要", + "eyebrow": "AI Agent 報表與告警接管", + "title": "報表控制台", + "subtitle": "把日報、週報、月報、告警資料源、AI 接手率與 KM / PlayBook / Verifier 沉澱集中到同一張操作視圖。", "incidentSummary": "事件摘要", "resolutionStats": "解決率統計", "total": "總計", @@ -2214,7 +2215,95 @@ "coldStartTrust": "冷啟動信任", "dispositionBreakdown": "處置方式分佈", "byAnomalyType": "按異常類型明細", - "anomalyKey": "異常類型" + "anomalyKey": "異常類型", + "noDataNeedsReview": "目前統計為 0;請先確認資料源 freshness 與報表可信度,不要直接視為健康。", + "command": { + "title": "報表 / 告警 AI 接管總控", + "subtitle": "先判斷資料是不是可信,再看 AI 是否能接手,最後確認沉澱是否回到 KM、PlayBook、腳本、排程與 Verifier。", + "metrics": { + "confidence": "資料可信度", + "alerts": "告警訊號", + "aiHandoff": "AI 接手率", + "liveDelivery": "live 派送" + }, + "details": { + "allZero": "全 0 需追查,不能當健康。", + "sourceOk": "資料源 {ok}/{total} 可讀。", + "noSignal": "告警與處置統計同時為 0。", + "alertSignal": "已讀取告警統計來源。", + "aiHandoff": "AI / 冷啟動接手 {auto}/{total}。", + "noSend": "Telegram 實發 0;自動優化 {optimization}。" + } + }, + "quality": { + "title": "資料品質可判讀", + "body": "資料源已回應;仍需用趨勢、異常與沉澱資產判斷是否需要 AI 接手。", + "allZeroTitle": "全 0 不是健康", + "allZeroBody": "當告警、提案、執行與成本全為 0,第一件事是查資料鏈路、排程與報表來源,不是發出漂亮摘要。", + "actions": { + "stats": "建立 report-source-gap:stats_api", + "git": "建立 report-source-gap:gitea_activity", + "cost": "建立 report-source-gap:ai_cost_ledger", + "noSend": "維持 no-send preview,不直接實發 Telegram" + } + }, + "sources": { + "incident": "事件統計 API", + "resolution": "解決率 API", + "disposition": "處置統計 API", + "statusBoard": "AI 報表狀態板" + }, + "sourceNext": { + "incident": "缺資料時追查 incidents freshness、Alertmanager 入庫與 recurrence mirror。", + "resolution": "缺資料時追查 resolved_at、duration 與 postmortem 寫回。", + "disposition": "缺資料時追查 approval_records、auto repair execution 與 no-action 分流。", + "statusBoard": "缺資料時追查 committed snapshot、API route 與 no-send preview contract。" + }, + "sourceMatrix": { + "title": "資料源健康矩陣", + "subtitle": "用矩陣取代全 0 報表,先看來源是否真的可讀。", + "ok": "可讀", + "fail": "缺口" + }, + "cadence": { + "title": "日報 / 週報 / 月報", + "subtitle": "每個 cadence 都要有 owner、章節、圖表、工作量、實發狀態與下一關。", + "empty": "尚未讀到報表狀態板" + }, + "funnel": { + "title": "告警到 AI 接手漏斗", + "subtitle": "快速看出訊號是否進來、處置是否形成、AI 能否接手、還有多少卡在人工或審核。", + "alerts": "告警訊號", + "dispositions": "處置紀錄", + "auto": "AI / 冷啟動接手", + "human": "人工處置", + "waiting": "待審核工作" + }, + "assets": { + "title": "自動化資產沉澱", + "subtitle": "報表不能只發摘要,必須看見沉澱是否回到可重用資產。", + "reports": "報表契約", + "reportsDetail": "日報、週報、月報狀態卡。", + "agents": "Agent 狀態", + "agentsDetail": "OpenClaw、Hermes、NemoTron 工作量。", + "workload": "工作完成", + "workloadDetail": "已完成 / 總工作量。", + "waiting": "待審核", + "waitingDetail": "未過 owner gate 的工作不能自動執行。" + }, + "agents": { + "title": "AI Agent 分工與工作量", + "subtitle": "把每個 Agent 的角色、完成量、待審核與 live runtime 計數放在同一列,避免只看到泛泛的 AI 成效百分比。", + "empty": "尚未讀到 Agent 工作狀態" + }, + "chip": { + "sections": "章節 {count}", + "charts": "圖表 {count}", + "work": "工作量 {count}", + "live": "live {count}", + "workDone": "工作 {done}/{total}", + "approval": "待審核 {count}" + } }, "apm": { "title": "APM", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 488676fd5..4338252cd 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -2194,8 +2194,9 @@ "fetchError": "無法取得通知頻道" }, "reports": { - "title": "報表", - "subtitle": "事件統計摘要", + "eyebrow": "AI Agent 報表與告警接管", + "title": "報表控制台", + "subtitle": "把日報、週報、月報、告警資料源、AI 接手率與 KM / PlayBook / Verifier 沉澱集中到同一張操作視圖。", "incidentSummary": "事件摘要", "resolutionStats": "解決率統計", "total": "總計", @@ -2214,7 +2215,95 @@ "coldStartTrust": "冷啟動信任", "dispositionBreakdown": "處置方式分佈", "byAnomalyType": "按異常類型明細", - "anomalyKey": "異常類型" + "anomalyKey": "異常類型", + "noDataNeedsReview": "目前統計為 0;請先確認資料源 freshness 與報表可信度,不要直接視為健康。", + "command": { + "title": "報表 / 告警 AI 接管總控", + "subtitle": "先判斷資料是不是可信,再看 AI 是否能接手,最後確認沉澱是否回到 KM、PlayBook、腳本、排程與 Verifier。", + "metrics": { + "confidence": "資料可信度", + "alerts": "告警訊號", + "aiHandoff": "AI 接手率", + "liveDelivery": "live 派送" + }, + "details": { + "allZero": "全 0 需追查,不能當健康。", + "sourceOk": "資料源 {ok}/{total} 可讀。", + "noSignal": "告警與處置統計同時為 0。", + "alertSignal": "已讀取告警統計來源。", + "aiHandoff": "AI / 冷啟動接手 {auto}/{total}。", + "noSend": "Telegram 實發 0;自動優化 {optimization}。" + } + }, + "quality": { + "title": "資料品質可判讀", + "body": "資料源已回應;仍需用趨勢、異常與沉澱資產判斷是否需要 AI 接手。", + "allZeroTitle": "全 0 不是健康", + "allZeroBody": "當告警、提案、執行與成本全為 0,第一件事是查資料鏈路、排程與報表來源,不是發出漂亮摘要。", + "actions": { + "stats": "建立 report-source-gap:stats_api", + "git": "建立 report-source-gap:gitea_activity", + "cost": "建立 report-source-gap:ai_cost_ledger", + "noSend": "維持 no-send preview,不直接實發 Telegram" + } + }, + "sources": { + "incident": "事件統計 API", + "resolution": "解決率 API", + "disposition": "處置統計 API", + "statusBoard": "AI 報表狀態板" + }, + "sourceNext": { + "incident": "缺資料時追查 incidents freshness、Alertmanager 入庫與 recurrence mirror。", + "resolution": "缺資料時追查 resolved_at、duration 與 postmortem 寫回。", + "disposition": "缺資料時追查 approval_records、auto repair execution 與 no-action 分流。", + "statusBoard": "缺資料時追查 committed snapshot、API route 與 no-send preview contract。" + }, + "sourceMatrix": { + "title": "資料源健康矩陣", + "subtitle": "用矩陣取代全 0 報表,先看來源是否真的可讀。", + "ok": "可讀", + "fail": "缺口" + }, + "cadence": { + "title": "日報 / 週報 / 月報", + "subtitle": "每個 cadence 都要有 owner、章節、圖表、工作量、實發狀態與下一關。", + "empty": "尚未讀到報表狀態板" + }, + "funnel": { + "title": "告警到 AI 接手漏斗", + "subtitle": "快速看出訊號是否進來、處置是否形成、AI 能否接手、還有多少卡在人工或審核。", + "alerts": "告警訊號", + "dispositions": "處置紀錄", + "auto": "AI / 冷啟動接手", + "human": "人工處置", + "waiting": "待審核工作" + }, + "assets": { + "title": "自動化資產沉澱", + "subtitle": "報表不能只發摘要,必須看見沉澱是否回到可重用資產。", + "reports": "報表契約", + "reportsDetail": "日報、週報、月報狀態卡。", + "agents": "Agent 狀態", + "agentsDetail": "OpenClaw、Hermes、NemoTron 工作量。", + "workload": "工作完成", + "workloadDetail": "已完成 / 總工作量。", + "waiting": "待審核", + "waitingDetail": "未過 owner gate 的工作不能自動執行。" + }, + "agents": { + "title": "AI Agent 分工與工作量", + "subtitle": "把每個 Agent 的角色、完成量、待審核與 live runtime 計數放在同一列,避免只看到泛泛的 AI 成效百分比。", + "empty": "尚未讀到 Agent 工作狀態" + }, + "chip": { + "sections": "章節 {count}", + "charts": "圖表 {count}", + "work": "工作量 {count}", + "live": "live {count}", + "workDone": "工作 {done}/{total}", + "approval": "待審核 {count}" + } }, "apm": { "title": "APM", diff --git a/apps/web/src/app/[locale]/reports/page.tsx b/apps/web/src/app/[locale]/reports/page.tsx index 6c7934c08..242789a76 100644 --- a/apps/web/src/app/[locale]/reports/page.tsx +++ b/apps/web/src/app/[locale]/reports/page.tsx @@ -1,34 +1,39 @@ 'use client' /** - * 報表 Page — 完整處置統計儀表板 - * ==================================== - * Sprint 4 E1: 告警處置統計主戰場 - * - * 串接: - * GET /api/v1/stats/incident-summary - * GET /api/v1/stats/resolution-stats - * GET /api/v1/stats/disposition (Sprint 4 新增) - * - * 專案鐵律: 禁止假數據!無資料顯示 '--' - * i18n: 100% next-intl - * - * 2026-04-02 Claude Code — 初版 - * 2026-04-07 Claude Code — Sprint 4 E1 處置統計升級 + * 報表 Page — AI Agent 報表與告警接管總控 + * ======================================== + * 這一頁保留原本處置統計,同時前移日報 / 週報 / 月報資料源健康、 + * 全 0 判讀、AI 接手率、KM / PlayBook / Verifier 沉澱與 no-send 邊界。 */ -import { useEffect, useState, useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' import { cn } from '@/lib/utils' -import { Bot, UserCheck, Wrench, Sparkles, TrendingUp, BarChart3, Inbox, RefreshCw } from 'lucide-react' +import { + Activity, + AlertTriangle, + BarChart3, + BellRing, + Bot, + CheckCircle2, + Clock3, + Database, + FileText, + Gauge, + GitBranch, + Inbox, + Layers3, + RefreshCw, + ShieldCheck, + UserCheck, + Wrench, +} from 'lucide-react' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' -// ============================================================================= -// Types (對齊後端 DispositionResponse) -// ============================================================================= - interface DispositionSummary { total: number auto_repair: number @@ -61,9 +66,81 @@ interface ResolutionStats { avgResolutionTime?: string | number } -// ============================================================================= -// Page -// ============================================================================= +interface ReportStatusCard { + cadence_id: 'daily' | 'weekly' | 'monthly' + display_name: string + owner_agent: string + completion_percent: number + delivery_state: string + sections_count: number + chart_count: number + work_units_total: number + live_delivery_count: number + next_gate: string +} + +interface AgentStatusReport { + agent_id: string + display_name: string + primary_role: string + current_state: string + work_units_total: number + work_units_done: number + work_units_waiting_approval: number + live_runtime_work_units_24h: number + telegram_policy: string + learning_state: string +} + +interface ReportStatusBoardSnapshot { + generated_at: string + program_status: { + overall_completion_percent: number + current_task_id: string + next_task_id: string + status_note: string + } + report_completion_truth: { + live_report_delivery_enabled: boolean + live_telegram_send_count_24h: number + live_auto_optimization_count_24h: number + truth_note: string + } + report_status_cards: ReportStatusCard[] + agent_status_reports: AgentStatusReport[] + rollups: { + report_card_count: number + agent_status_count: number + visible_chart_count: number + workload_unit_total: number + workload_done_total: number + workload_waiting_approval_total: number + live_delivery_count: number + live_telegram_send_count: number + live_runtime_work_units: number + live_auto_optimization_count: number + } +} + +type SourceKey = 'incident' | 'resolution' | 'disposition' | 'statusBoard' +type SourceState = Record + +const initialSourceState: SourceState = { + incident: false, + resolution: false, + disposition: false, + statusBoard: false, +} + +async function fetchJson(path: string): Promise { + try { + const response = await fetch(`${API_BASE}${path}`) + if (!response.ok) return null + return await response.json() + } catch { + return null + } +} export default function ReportsPage({ params }: { params: { locale: string } }) { const t = useTranslations('reports') @@ -71,6 +148,8 @@ export default function ReportsPage({ params }: { params: { locale: string } }) const [summary, setSummary] = useState(null) const [resolution, setResolution] = useState(null) const [disposition, setDisposition] = useState(null) + const [reportBoard, setReportBoard] = useState(null) + const [sourceState, setSourceState] = useState(initialSourceState) const [loading, setLoading] = useState(true) const [error, setError] = useState(false) @@ -78,14 +157,23 @@ export default function ReportsPage({ params }: { params: { locale: string } }) setLoading(true) setError(false) Promise.all([ - fetch(`${API_BASE}/api/v1/stats/incident-summary`).then(r => r.json()).catch(() => null), - fetch(`${API_BASE}/api/v1/stats/resolution-stats`).then(r => r.json()).catch(() => null), - fetch(`${API_BASE}/api/v1/stats/disposition`).then(r => r.json()).catch(() => null), + fetchJson('/api/v1/stats/incident-summary'), + fetchJson('/api/v1/stats/resolution-stats'), + fetchJson('/api/v1/stats/disposition'), + fetchJson('/api/v1/agents/agent-report-status-board'), ]) - .then(([s, r, d]) => { - setSummary(s) - setResolution(r) - setDisposition(d) + .then(([incidentSummary, resolutionStats, dispositionStats, statusBoard]) => { + setSummary(incidentSummary) + setResolution(resolutionStats) + setDisposition(dispositionStats) + setReportBoard(statusBoard) + setSourceState({ + incident: Boolean(incidentSummary), + resolution: Boolean(resolutionStats), + disposition: Boolean(dispositionStats), + statusBoard: Boolean(statusBoard), + }) + setError(!incidentSummary && !resolutionStats && !dispositionStats && !statusBoard) }) .catch(() => setError(true)) .finally(() => setLoading(false)) @@ -94,180 +182,306 @@ export default function ReportsPage({ params }: { params: { locale: string } }) useEffect(() => { fetchAll() }, []) const ds = disposition?.summary + const sourceOkCount = Object.values(sourceState).filter(Boolean).length + const sourceTotal = Object.keys(sourceState).length + const allZeroSignal = Boolean( + sourceOkCount > 0 && + (summary?.total ?? 0) === 0 && + (ds?.total ?? 0) === 0 && + (ds?.auto_repair ?? 0) === 0 && + (ds?.human_approved ?? 0) === 0 + ) + const dataConfidence = sourceTotal > 0 ? Math.round((sourceOkCount / sourceTotal) * 100) : 0 + const autoHandled = (ds?.auto_repair ?? 0) + (ds?.cold_start_trust ?? 0) + const totalDispositions = ds?.total ?? 0 + const aiHandoffRate = totalDispositions > 0 ? Math.round((autoHandled / totalDispositions) * 100) : 0 + const waitingApproval = reportBoard?.rollups?.workload_waiting_approval_total ?? 0 + const liveDelivery = reportBoard?.rollups?.live_delivery_count ?? 0 + const liveOptimization = reportBoard?.rollups?.live_auto_optimization_count ?? 0 + const sourceRows = useMemo( + () => [ + { key: 'incident' as const, label: t('sources.incident'), ok: sourceState.incident, next: t('sourceNext.incident') }, + { key: 'resolution' as const, label: t('sources.resolution'), ok: sourceState.resolution, next: t('sourceNext.resolution') }, + { key: 'disposition' as const, label: t('sources.disposition'), ok: sourceState.disposition, next: t('sourceNext.disposition') }, + { key: 'statusBoard' as const, label: t('sources.statusBoard'), ok: sourceState.statusBoard, next: t('sourceNext.statusBoard') }, + ], + [sourceState, t] + ) - // 處置分佈百分比 (用於堆疊條) const pcts = useMemo(() => { if (!ds || ds.total === 0) return null - const t = ds.total + const total = ds.total return { - auto: Math.round((ds.auto_repair / t) * 100), - human: Math.round((ds.human_approved / t) * 100), - manual: Math.round((ds.manual_resolved / t) * 100), - cold: Math.round((ds.cold_start_trust / t) * 100), + auto: Math.round((ds.auto_repair / total) * 100), + human: Math.round((ds.human_approved / total) * 100), + manual: Math.round((ds.manual_resolved / total) * 100), + cold: Math.round((ds.cold_start_trust / total) * 100), } }, [ds]) return ( -
- - {/* Header */} -
-
-

{t('title')}

-

{t('subtitle')}

+
+
+
+

{t('eyebrow')}

+

{t('title')}

+

{t('subtitle')}

{loading && ( -
{tc('loading')}
+
+ {tc('loading')} +
)} {!loading && error && ( -
{t('fetchError')}
+
+ {t('fetchError')} +
)} {!loading && !error && ( <> - {/* ── 頂部 KPI 3 卡 ── */} -
- } - label={t('totalDispositions')} - value={ds?.total ?? '--'} - color="text-blue-500" - /> - } - label={t('autoRate')} - value={ds ? `${Math.round(ds.auto_rate * 100)}%` : '--'} - color="text-green-500" - /> - } - label={t('humanRate')} - value={ds ? `${Math.round(ds.human_rate * 100)}%` : '--'} - color="text-orange-500" - /> -
+
+
+
+
+ + + +
+

{t('command.title')}

+

{t('command.subtitle')}

+
+
- {/* ── 四大計數卡片 ── */} -
- } - label={t('autoRepair')} - count={ds?.auto_repair ?? 0} - color="text-green-500" - bg="bg-green-500/10" - border="border-green-500/25" - /> - } - label={t('humanApproved')} - count={ds?.human_approved ?? 0} - color="text-orange-500" - bg="bg-orange-500/10" - border="border-orange-500/25" - /> - } - label={t('manualResolved')} - count={ds?.manual_resolved ?? 0} - color="text-purple-500" - bg="bg-purple-500/10" - border="border-purple-500/25" - /> - } - label={t('coldStartTrust')} - count={ds?.cold_start_trust ?? 0} - color="text-blue-500" - bg="bg-blue-500/10" - border="border-blue-500/25" - /> -
+
+ } + label={t('command.metrics.confidence')} + value={`${dataConfidence}%`} + detail={allZeroSignal ? t('command.details.allZero') : t('command.details.sourceOk', { ok: sourceOkCount, total: sourceTotal })} + tone={allZeroSignal || dataConfidence < 100 ? 'warn' : 'ok'} + /> + } + label={t('command.metrics.alerts')} + value={summary?.total ?? '--'} + detail={allZeroSignal ? t('command.details.noSignal') : t('command.details.alertSignal')} + tone={allZeroSignal ? 'warn' : 'neutral'} + /> + } + label={t('command.metrics.aiHandoff')} + value={`${aiHandoffRate}%`} + detail={t('command.details.aiHandoff', { auto: autoHandled, total: totalDispositions })} + tone={aiHandoffRate > 0 ? 'ok' : 'warn'} + /> + } + label={t('command.metrics.liveDelivery')} + value={liveDelivery} + detail={t('command.details.noSend', { optimization: liveOptimization })} + tone="warn" + /> +
+
+ +
+
+ +
+

{allZeroSignal ? t('quality.allZeroTitle') : t('quality.title')}

+

+ {allZeroSignal ? t('quality.allZeroBody') : t('quality.body')} +

+
+
+
+ + + + +
+
+
+
+ +
+
+ } title={t('sourceMatrix.title')} subtitle={t('sourceMatrix.subtitle')} /> +
+ {sourceRows.map(row => ( +
+ {row.label} + + {row.next} +
+ ))} +
+
+ +
+ } title={t('cadence.title')} subtitle={t('cadence.subtitle')} /> +
+ {(reportBoard?.report_status_cards ?? []).map(card => ( + + ))} + {!reportBoard?.report_status_cards?.length && ( + + )} +
+
+
+ +
+
+ } title={t('funnel.title')} subtitle={t('funnel.subtitle')} /> +
+ + + + + 0 ? 'danger' : 'ok'} /> +
+
+ +
+ } title={t('assets.title')} subtitle={t('assets.subtitle')} /> +
+ + + + 0} /> +
+
+
+ +
+ } title={t('agents.title')} subtitle={t('agents.subtitle')} /> +
+ {(reportBoard?.agent_status_reports ?? []).map(agent => ( + + ))} + {!reportBoard?.agent_status_reports?.length && ( + + )} +
+
+ +
+ } label={t('totalDispositions')} value={ds?.total ?? '--'} color="text-blue-500" /> + } label={t('autoRate')} value={ds ? `${Math.round(ds.auto_rate * 100)}%` : '--'} color="text-green-500" /> + } label={t('humanRate')} value={ds ? `${Math.round(ds.human_rate * 100)}%` : '--'} color="text-orange-500" /> +
+ +
+ } label={t('autoRepair')} count={ds?.auto_repair ?? 0} tone="green" /> + } label={t('humanApproved')} count={ds?.human_approved ?? 0} tone="orange" /> + } label={t('manualResolved')} count={ds?.manual_resolved ?? 0} tone="purple" /> + } label={t('coldStartTrust')} count={ds?.cold_start_trust ?? 0} tone="blue" /> +
- {/* ── 堆疊分佈條 ── */} {pcts && ( -
-

{t('dispositionBreakdown')}

-
- {pcts.auto > 0 &&
} - {pcts.cold > 0 &&
} - {pcts.human > 0 &&
} - {pcts.manual > 0 &&
} +
+

{t('dispositionBreakdown')}

+
+ {pcts.auto > 0 &&
} + {pcts.cold > 0 &&
} + {pcts.human > 0 &&
} + {pcts.manual > 0 &&
}
-
- {t('autoRepair')} {pcts.auto}% - {t('coldStartTrust')} {pcts.cold}% - {t('humanApproved')} {pcts.human}% - {t('manualResolved')} {pcts.manual}% +
+ + + +
-
+
)} - {/* ── 按異常類型明細 ── */} {disposition?.by_anomaly && disposition.by_anomaly.length > 0 && ( -
-

{t('byAnomalyType')}

- - - - - - - - - - - - - {disposition.by_anomaly.map(item => ( - - - - - - - +
+

{t('byAnomalyType')}

+
+
{t('anomalyKey')}{t('autoRepair')}{t('humanApproved')}{t('manualResolved')}{t('coldStartTrust')}{t('totalDispositions')}
{item.alert_name || item.anomaly_key.slice(0, 12)}{item.disposition.auto_repair}{item.disposition.human_approved}{item.disposition.manual_resolved}{item.disposition.cold_start_trust}{item.disposition.total}
+ + + + + + + + - ))} - -
{t('anomalyKey')}{t('autoRepair')}{t('humanApproved')}{t('manualResolved')}{t('coldStartTrust')}{t('totalDispositions')}
-
+ + + {disposition.by_anomaly.map(item => ( + + {item.alert_name || item.anomaly_key.slice(0, 12)} + {item.disposition.auto_repair} + {item.disposition.human_approved} + {item.disposition.manual_resolved} + {item.disposition.cold_start_trust} + {item.disposition.total} + + ))} + + +
+ )} - {/* ── 既有: 事件摘要 ── */} -
-
-

{t('incidentSummary')}

+
+
+

{t('incidentSummary')}

-
-

{t('resolutionStats')}

+
+

{t('resolutionStats')}

-
+
- {/* 無資料提示 */} {!ds || ds.total === 0 ? ( -
- -

{t('noData')}

+
+ +

{allZeroSignal ? t('noDataNeedsReview') : t('noData')}

) : null} @@ -277,32 +491,151 @@ export default function ReportsPage({ params }: { params: { locale: string } }) ) } -// ============================================================================= -// Sub-components -// ============================================================================= - -function KPICard({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: string | number; color: string }) { +function SectionHeader({ icon, title, subtitle }: { icon: ReactNode; title: string; subtitle: string }) { return ( -
-
+
+ {icon} -
-
-

{value}

-

{label}

+ +
+

{title}

+

{subtitle}

) } -function DispositionCard({ icon, label, count, color, bg, border }: { icon: React.ReactNode; label: string; count: number; color: string; bg: string; border: string }) { +function CommandMetric({ icon, label, value, detail, tone }: { icon: ReactNode; label: string; value: string | number; detail: string; tone: 'ok' | 'warn' | 'neutral' }) { + const toneClass = tone === 'ok' ? 'text-green-600 bg-green-50 border-green-200' : tone === 'warn' ? 'text-amber-700 bg-amber-50 border-amber-200' : 'text-blue-600 bg-blue-50 border-blue-200' return ( -
-
+
+
+ {icon} +

{value}

+
+

{label}

+

{detail}

+
+ ) +} + +function NextActionLine({ label, active }: { label: string; active: boolean }) { + return ( +
+ {active ? : } + {label} +
+ ) +} + +function StatusPill({ ok, okLabel, failLabel }: { ok: boolean; okLabel: string; failLabel: string }) { + return ( + + {ok ? : } + {ok ? okLabel : failLabel} + + ) +} + +function CadenceCard({ card, chips }: { card: ReportStatusCard; chips: string[] }) { + const progress = Math.max(0, Math.min(card.completion_percent, 100)) + return ( +
+
+
+

{card.display_name}

+

{card.owner_agent} · {card.delivery_state}

+
+ {progress}% +
+
+
+
+
+ {chips.map(label => )} +
+

{card.next_gate}

+
+ ) +} + +function FunnelBar({ label, value, max, tone }: { label: string; value: number; max: number; tone: 'ok' | 'warn' | 'danger' | 'neutral' }) { + const width = Math.max(4, Math.min(100, Math.round((value / Math.max(max, 1)) * 100))) + const color = tone === 'ok' ? 'bg-green-500' : tone === 'warn' ? 'bg-amber-500' : tone === 'danger' ? 'bg-red-500' : 'bg-blue-500' + return ( +
+ {label} +
+
+
+ {value} +
+ ) +} + +function AssetCard({ label, value, detail, danger = false }: { label: string; value: string | number; detail: string; danger?: boolean }) { + return ( +
+

{label}

+

{value}

+

{detail}

+
+ ) +} + +function AgentCard({ agent, chips }: { agent: AgentStatusReport; chips: string[] }) { + const progress = agent.work_units_total > 0 ? Math.round((agent.work_units_done / agent.work_units_total) * 100) : 0 + return ( +
+
+
+

{agent.display_name}

+

{agent.current_state}

+
+ {progress}% +
+

{agent.primary_role}

+
+
= 80 ? 'bg-green-500' : 'bg-amber-500')} style={{ width: `${progress}%` }} /> +
+
+ {chips.map(label => )} +
+
+ ) +} + +function KPICard({ icon, label, value, color }: { icon: ReactNode; label: string; value: string | number; color: string }) { + return ( +
+
+ {icon} +
+
+

{value}

+

{label}

+
+
+ ) +} + +function DispositionCard({ icon, label, count, tone }: { icon: ReactNode; label: string; count: number; tone: 'green' | 'orange' | 'purple' | 'blue' }) { + const styles = { + green: 'border-green-500/25 bg-green-500/10 text-green-600', + orange: 'border-orange-500/25 bg-orange-500/10 text-orange-600', + purple: 'border-purple-500/25 bg-purple-500/10 text-purple-600', + blue: 'border-blue-500/25 bg-blue-500/10 text-blue-600', + }[tone] + return ( +
+
{icon} {label}
-

{count}

+

{count}

) } @@ -311,7 +644,32 @@ function MiniStat({ label, value }: { label: string; value: string | number }) { return (

{value}

-

{label}

+

{label}

+
+ ) +} + +function Chip({ label }: { label: string }) { + return ( + + {label} + + ) +} + +function Legend({ color, label }: { color: string; label: string }) { + return ( + + + {label} + + ) +} + +function EmptyPanel({ label }: { label: string }) { + return ( +
+ {label}
) }