feat(web): 前移報表 AI 接管總控
All checks were successful
Code Review / ai-code-review (push) Successful in 14s
CD Pipeline / tests (push) Successful in 1m34s
CD Pipeline / build-and-deploy (push) Successful in 4m22s
CD Pipeline / post-deploy-checks (push) Successful in 1m35s

This commit is contained in:
Your Name
2026-06-18 18:31:05 +08:00
parent 38f826a8f5
commit 6d4fa7bffb
3 changed files with 713 additions and 177 deletions

View File

@@ -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<SourceKey, boolean>
const initialSourceState: SourceState = {
incident: false,
resolution: false,
disposition: false,
statusBoard: false,
}
async function fetchJson<T>(path: string): Promise<T | null> {
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<IncidentSummary | null>(null)
const [resolution, setResolution] = useState<ResolutionStats | null>(null)
const [disposition, setDisposition] = useState<DispositionResponse | null>(null)
const [reportBoard, setReportBoard] = useState<ReportStatusBoardSnapshot | null>(null)
const [sourceState, setSourceState] = useState<SourceState>(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<IncidentSummary>('/api/v1/stats/incident-summary'),
fetchJson<ResolutionStats>('/api/v1/stats/resolution-stats'),
fetchJson<DispositionResponse>('/api/v1/stats/disposition'),
fetchJson<ReportStatusBoardSnapshot>('/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 (
<AppLayout locale={params.locale}>
<div className="p-6 space-y-6 min-h-full">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-lg font-semibold tracking-tight">{t('title')}</h1>
<p className="text-xs text-muted-foreground mt-0.5">{t('subtitle')}</p>
<div className="min-h-full space-y-5 p-4 sm:p-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">{t('eyebrow')}</p>
<h1 className="mt-1 text-xl font-semibold tracking-tight text-foreground">{t('title')}</h1>
<p className="mt-1 max-w-3xl text-sm leading-6 text-muted-foreground">{t('subtitle')}</p>
</div>
<button
onClick={fetchAll}
className="p-2 rounded-lg hover:bg-muted transition-colors"
className="inline-flex h-9 w-9 items-center justify-center rounded-[7px] border border-border bg-card text-muted-foreground transition-colors hover:bg-muted"
title={tc('refresh')}
>
<RefreshCw className={cn('w-4 h-4 text-muted-foreground', loading && 'animate-spin')} />
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
</button>
</div>
{loading && (
<div className="text-center py-16 text-muted-foreground text-sm">{tc('loading')}</div>
<div className="rounded-[7px] border border-border bg-card py-16 text-center text-sm text-muted-foreground">
{tc('loading')}
</div>
)}
{!loading && error && (
<div className="text-center py-16 text-red-500 text-sm">{t('fetchError')}</div>
<div className="rounded-[7px] border border-red-200 bg-red-50 py-16 text-center text-sm text-red-600">
{t('fetchError')}
</div>
)}
{!loading && !error && (
<>
{/* ── 頂部 KPI 3 卡 ── */}
<div className="grid grid-cols-3 gap-4">
<KPICard
icon={<BarChart3 className="w-5 h-5 text-blue-500" />}
label={t('totalDispositions')}
value={ds?.total ?? '--'}
color="text-blue-500"
/>
<KPICard
icon={<Bot className="w-5 h-5 text-green-500" />}
label={t('autoRate')}
value={ds ? `${Math.round(ds.auto_rate * 100)}%` : '--'}
color="text-green-500"
/>
<KPICard
icon={<UserCheck className="w-5 h-5 text-orange-500" />}
label={t('humanRate')}
value={ds ? `${Math.round(ds.human_rate * 100)}%` : '--'}
color="text-orange-500"
/>
</div>
<section className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-[7px] bg-blue-50 text-blue-600">
<Gauge className="h-4 w-4" />
</span>
<div className="min-w-0">
<h2 className="text-base font-semibold text-foreground">{t('command.title')}</h2>
<p className="text-xs leading-5 text-muted-foreground">{t('command.subtitle')}</p>
</div>
</div>
{/* ── 四大計數卡片 ── */}
<div className="grid grid-cols-4 gap-3">
<DispositionCard
icon={<Bot className="w-4 h-4" />}
label={t('autoRepair')}
count={ds?.auto_repair ?? 0}
color="text-green-500"
bg="bg-green-500/10"
border="border-green-500/25"
/>
<DispositionCard
icon={<UserCheck className="w-4 h-4" />}
label={t('humanApproved')}
count={ds?.human_approved ?? 0}
color="text-orange-500"
bg="bg-orange-500/10"
border="border-orange-500/25"
/>
<DispositionCard
icon={<Wrench className="w-4 h-4" />}
label={t('manualResolved')}
count={ds?.manual_resolved ?? 0}
color="text-purple-500"
bg="bg-purple-500/10"
border="border-purple-500/25"
/>
<DispositionCard
icon={<Sparkles className="w-4 h-4" />}
label={t('coldStartTrust')}
count={ds?.cold_start_trust ?? 0}
color="text-blue-500"
bg="bg-blue-500/10"
border="border-blue-500/25"
/>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<CommandMetric
icon={<Database className="h-4 w-4" />}
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'}
/>
<CommandMetric
icon={<Activity className="h-4 w-4" />}
label={t('command.metrics.alerts')}
value={summary?.total ?? '--'}
detail={allZeroSignal ? t('command.details.noSignal') : t('command.details.alertSignal')}
tone={allZeroSignal ? 'warn' : 'neutral'}
/>
<CommandMetric
icon={<Bot className="h-4 w-4" />}
label={t('command.metrics.aiHandoff')}
value={`${aiHandoffRate}%`}
detail={t('command.details.aiHandoff', { auto: autoHandled, total: totalDispositions })}
tone={aiHandoffRate > 0 ? 'ok' : 'warn'}
/>
<CommandMetric
icon={<BellRing className="h-4 w-4" />}
label={t('command.metrics.liveDelivery')}
value={liveDelivery}
detail={t('command.details.noSend', { optimization: liveOptimization })}
tone="warn"
/>
</div>
</div>
<div className="min-w-0 rounded-[7px] border border-amber-200 bg-amber-50 p-3">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600" />
<div className="min-w-0">
<p className="text-sm font-semibold text-amber-950">{allZeroSignal ? t('quality.allZeroTitle') : t('quality.title')}</p>
<p className="mt-1 text-xs leading-5 text-amber-900">
{allZeroSignal ? t('quality.allZeroBody') : t('quality.body')}
</p>
</div>
</div>
<div className="mt-3 grid gap-2">
<NextActionLine label={t('quality.actions.stats')} active={allZeroSignal || !sourceState.disposition} />
<NextActionLine label={t('quality.actions.git')} active={allZeroSignal} />
<NextActionLine label={t('quality.actions.cost')} active={allZeroSignal} />
<NextActionLine label={t('quality.actions.noSend')} active />
</div>
</div>
</div>
</section>
<section className="grid gap-4 xl:grid-cols-[0.95fr_1.05fr]">
<div className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
<SectionHeader icon={<GitBranch className="h-4 w-4" />} title={t('sourceMatrix.title')} subtitle={t('sourceMatrix.subtitle')} />
<div className="mt-4 grid gap-2">
{sourceRows.map(row => (
<div key={row.key} className="grid gap-2 rounded-[7px] border border-border bg-background p-3 sm:grid-cols-[180px_90px_1fr] sm:items-center">
<span className="text-sm font-medium text-foreground">{row.label}</span>
<StatusPill ok={row.ok} okLabel={t('sourceMatrix.ok')} failLabel={t('sourceMatrix.fail')} />
<span className="text-xs leading-5 text-muted-foreground">{row.next}</span>
</div>
))}
</div>
</div>
<div className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
<SectionHeader icon={<FileText className="h-4 w-4" />} title={t('cadence.title')} subtitle={t('cadence.subtitle')} />
<div className="mt-4 grid gap-3 md:grid-cols-3">
{(reportBoard?.report_status_cards ?? []).map(card => (
<CadenceCard
key={card.cadence_id}
card={card}
chips={[
t('chip.sections', { count: card.sections_count }),
t('chip.charts', { count: card.chart_count }),
t('chip.work', { count: card.work_units_total }),
t('chip.live', { count: card.live_delivery_count }),
]}
/>
))}
{!reportBoard?.report_status_cards?.length && (
<EmptyPanel label={t('cadence.empty')} />
)}
</div>
</div>
</section>
<section className="grid gap-4 xl:grid-cols-[1.05fr_0.95fr]">
<div className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
<SectionHeader icon={<Layers3 className="h-4 w-4" />} title={t('funnel.title')} subtitle={t('funnel.subtitle')} />
<div className="mt-4 grid gap-3">
<FunnelBar label={t('funnel.alerts')} value={summary?.total ?? 0} max={Math.max(summary?.total ?? 0, totalDispositions, 1)} tone="neutral" />
<FunnelBar label={t('funnel.dispositions')} value={totalDispositions} max={Math.max(summary?.total ?? 0, totalDispositions, 1)} tone="neutral" />
<FunnelBar label={t('funnel.auto')} value={autoHandled} max={Math.max(totalDispositions, 1)} tone="ok" />
<FunnelBar label={t('funnel.human')} value={(ds?.human_approved ?? 0) + (ds?.manual_resolved ?? 0)} max={Math.max(totalDispositions, 1)} tone="warn" />
<FunnelBar label={t('funnel.waiting')} value={waitingApproval} max={Math.max(waitingApproval, reportBoard?.rollups?.workload_unit_total ?? 1)} tone={waitingApproval > 0 ? 'danger' : 'ok'} />
</div>
</div>
<div className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
<SectionHeader icon={<ShieldCheck className="h-4 w-4" />} title={t('assets.title')} subtitle={t('assets.subtitle')} />
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<AssetCard label={t('assets.reports')} value={reportBoard?.rollups?.report_card_count ?? '--'} detail={t('assets.reportsDetail')} />
<AssetCard label={t('assets.agents')} value={reportBoard?.rollups?.agent_status_count ?? '--'} detail={t('assets.agentsDetail')} />
<AssetCard label={t('assets.workload')} value={`${reportBoard?.rollups?.workload_done_total ?? 0}/${reportBoard?.rollups?.workload_unit_total ?? 0}`} detail={t('assets.workloadDetail')} />
<AssetCard label={t('assets.waiting')} value={waitingApproval} detail={t('assets.waitingDetail')} danger={waitingApproval > 0} />
</div>
</div>
</section>
<section className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
<SectionHeader icon={<Bot className="h-4 w-4" />} title={t('agents.title')} subtitle={t('agents.subtitle')} />
<div className="mt-4 grid gap-3 lg:grid-cols-3">
{(reportBoard?.agent_status_reports ?? []).map(agent => (
<AgentCard
key={agent.agent_id}
agent={agent}
chips={[
t('chip.workDone', { done: agent.work_units_done, total: agent.work_units_total }),
t('chip.approval', { count: agent.work_units_waiting_approval }),
t('chip.live', { count: agent.live_runtime_work_units_24h }),
agent.telegram_policy,
agent.learning_state,
]}
/>
))}
{!reportBoard?.agent_status_reports?.length && (
<EmptyPanel label={t('agents.empty')} />
)}
</div>
</section>
<section className="grid gap-4 lg:grid-cols-3">
<KPICard icon={<BarChart3 className="h-5 w-5 text-blue-500" />} label={t('totalDispositions')} value={ds?.total ?? '--'} color="text-blue-500" />
<KPICard icon={<Bot className="h-5 w-5 text-green-500" />} label={t('autoRate')} value={ds ? `${Math.round(ds.auto_rate * 100)}%` : '--'} color="text-green-500" />
<KPICard icon={<UserCheck className="h-5 w-5 text-orange-500" />} label={t('humanRate')} value={ds ? `${Math.round(ds.human_rate * 100)}%` : '--'} color="text-orange-500" />
</section>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<DispositionCard icon={<Bot className="h-4 w-4" />} label={t('autoRepair')} count={ds?.auto_repair ?? 0} tone="green" />
<DispositionCard icon={<UserCheck className="h-4 w-4" />} label={t('humanApproved')} count={ds?.human_approved ?? 0} tone="orange" />
<DispositionCard icon={<Wrench className="h-4 w-4" />} label={t('manualResolved')} count={ds?.manual_resolved ?? 0} tone="purple" />
<DispositionCard icon={<ShieldCheck className="h-4 w-4" />} label={t('coldStartTrust')} count={ds?.cold_start_trust ?? 0} tone="blue" />
</section>
{/* ── 堆疊分佈條 ── */}
{pcts && (
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">{t('dispositionBreakdown')}</p>
<div className="flex h-4 rounded-full overflow-hidden">
{pcts.auto > 0 && <div className="bg-green-500 transition-all" style={{ width: `${pcts.auto}%` }} title={`${t('autoRepair')}: ${pcts.auto}%`} />}
{pcts.cold > 0 && <div className="bg-blue-500 transition-all" style={{ width: `${pcts.cold}%` }} title={`${t('coldStartTrust')}: ${pcts.cold}%`} />}
{pcts.human > 0 && <div className="bg-orange-500 transition-all" style={{ width: `${pcts.human}%` }} title={`${t('humanApproved')}: ${pcts.human}%`} />}
{pcts.manual > 0 && <div className="bg-purple-500 transition-all" style={{ width: `${pcts.manual}%` }} title={`${t('manualResolved')}: ${pcts.manual}%`} />}
<section className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-muted-foreground">{t('dispositionBreakdown')}</p>
<div className="flex h-4 overflow-hidden rounded-[7px] bg-muted">
{pcts.auto > 0 && <div className="bg-green-500" style={{ width: `${pcts.auto}%` }} title={`${t('autoRepair')}: ${pcts.auto}%`} />}
{pcts.cold > 0 && <div className="bg-blue-500" style={{ width: `${pcts.cold}%` }} title={`${t('coldStartTrust')}: ${pcts.cold}%`} />}
{pcts.human > 0 && <div className="bg-orange-500" style={{ width: `${pcts.human}%` }} title={`${t('humanApproved')}: ${pcts.human}%`} />}
{pcts.manual > 0 && <div className="bg-purple-500" style={{ width: `${pcts.manual}%` }} title={`${t('manualResolved')}: ${pcts.manual}%`} />}
</div>
<div className="flex items-center gap-4 mt-2 text-[10px] text-muted-foreground">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-green-500" />{t('autoRepair')} {pcts.auto}%</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-blue-500" />{t('coldStartTrust')} {pcts.cold}%</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-orange-500" />{t('humanApproved')} {pcts.human}%</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-purple-500" />{t('manualResolved')} {pcts.manual}%</span>
<div className="mt-2 flex flex-wrap items-center gap-4 text-[10px] text-muted-foreground">
<Legend color="bg-green-500" label={`${t('autoRepair')} ${pcts.auto}%`} />
<Legend color="bg-blue-500" label={`${t('coldStartTrust')} ${pcts.cold}%`} />
<Legend color="bg-orange-500" label={`${t('humanApproved')} ${pcts.human}%`} />
<Legend color="bg-purple-500" label={`${t('manualResolved')} ${pcts.manual}%`} />
</div>
</div>
</section>
)}
{/* ── 按異常類型明細 ── */}
{disposition?.by_anomaly && disposition.by_anomaly.length > 0 && (
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">{t('byAnomalyType')}</p>
<table className="w-full">
<thead>
<tr className="text-[10px] text-muted-foreground uppercase tracking-wider">
<th className="text-left pb-2 font-medium">{t('anomalyKey')}</th>
<th className="text-right pb-2 font-medium">{t('autoRepair')}</th>
<th className="text-right pb-2 font-medium">{t('humanApproved')}</th>
<th className="text-right pb-2 font-medium">{t('manualResolved')}</th>
<th className="text-right pb-2 font-medium">{t('coldStartTrust')}</th>
<th className="text-right pb-2 font-medium">{t('totalDispositions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{disposition.by_anomaly.map(item => (
<tr key={item.anomaly_key}>
<td className="py-2 text-xs font-mono">{item.alert_name || item.anomaly_key.slice(0, 12)}</td>
<td className="py-2 text-xs text-right text-green-500 font-semibold">{item.disposition.auto_repair}</td>
<td className="py-2 text-xs text-right text-orange-500 font-semibold">{item.disposition.human_approved}</td>
<td className="py-2 text-xs text-right text-purple-500 font-semibold">{item.disposition.manual_resolved}</td>
<td className="py-2 text-xs text-right text-blue-500 font-semibold">{item.disposition.cold_start_trust}</td>
<td className="py-2 text-xs text-right font-bold">{item.disposition.total}</td>
<section className="overflow-hidden rounded-[7px] border border-border bg-card p-4 shadow-sm">
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-muted-foreground">{t('byAnomalyType')}</p>
<div className="overflow-x-auto">
<table className="w-full min-w-[720px]">
<thead>
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
<th className="pb-2 text-left font-medium">{t('anomalyKey')}</th>
<th className="pb-2 text-right font-medium">{t('autoRepair')}</th>
<th className="pb-2 text-right font-medium">{t('humanApproved')}</th>
<th className="pb-2 text-right font-medium">{t('manualResolved')}</th>
<th className="pb-2 text-right font-medium">{t('coldStartTrust')}</th>
<th className="pb-2 text-right font-medium">{t('totalDispositions')}</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody className="divide-y divide-border">
{disposition.by_anomaly.map(item => (
<tr key={item.anomaly_key}>
<td className="py-2 text-xs font-mono">{item.alert_name || item.anomaly_key.slice(0, 12)}</td>
<td className="py-2 text-right text-xs font-semibold text-green-500">{item.disposition.auto_repair}</td>
<td className="py-2 text-right text-xs font-semibold text-orange-500">{item.disposition.human_approved}</td>
<td className="py-2 text-right text-xs font-semibold text-purple-500">{item.disposition.manual_resolved}</td>
<td className="py-2 text-right text-xs font-semibold text-blue-500">{item.disposition.cold_start_trust}</td>
<td className="py-2 text-right text-xs font-bold">{item.disposition.total}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{/* ── 既有: 事件摘要 ── */}
<div className="grid grid-cols-2 gap-4">
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">{t('incidentSummary')}</p>
<section className="grid gap-4 lg:grid-cols-2">
<div className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-muted-foreground">{t('incidentSummary')}</p>
<div className="grid grid-cols-3 gap-3">
<MiniStat label={t('total')} value={summary?.total ?? '--'} />
<MiniStat label={t('resolved')} value={summary?.resolved ?? '--'} />
<MiniStat label={t('unresolved')} value={summary?.unresolved ?? '--'} />
</div>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">{t('resolutionStats')}</p>
<div className="rounded-[7px] border border-border bg-card p-4 shadow-sm">
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-muted-foreground">{t('resolutionStats')}</p>
<div className="grid grid-cols-2 gap-3">
<MiniStat label={t('resolutionRate')} value={resolution?.resolutionRate != null ? `${resolution.resolutionRate}%` : '--'} />
<MiniStat label={t('avgResolutionTime')} value={resolution?.avgResolutionTime ?? '--'} />
</div>
</div>
</div>
</section>
{/* 無資料提示 */}
{!ds || ds.total === 0 ? (
<div className="rounded-xl border border-border bg-card p-8 flex flex-col items-center justify-center text-muted-foreground">
<Inbox className="w-8 h-8 mb-2 opacity-40" />
<p className="text-sm">{t('noData')}</p>
<div className="flex flex-col items-center justify-center rounded-[7px] border border-border bg-card p-8 text-muted-foreground shadow-sm">
<Inbox className="mb-2 h-8 w-8 opacity-40" />
<p className="text-sm">{allZeroSignal ? t('noDataNeedsReview') : t('noData')}</p>
</div>
) : 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 (
<div className="rounded-xl border border-border bg-card p-5 flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center flex-shrink-0">
<div className="flex items-start gap-2">
<span className="inline-flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-[7px] bg-muted text-muted-foreground">
{icon}
</div>
<div>
<p className={cn('text-2xl font-bold tabular-nums', color)}>{value}</p>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground mt-0.5">{label}</p>
</span>
<div className="min-w-0">
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">{subtitle}</p>
</div>
</div>
)
}
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 (
<div className={cn('rounded-xl border p-4 text-center', border, bg)}>
<div className={cn('flex items-center justify-center gap-1.5 mb-2', color)}>
<div className="min-w-0 rounded-[7px] border border-border bg-background p-3">
<div className="flex items-center justify-between gap-2">
<span className={cn('inline-flex h-7 w-7 items-center justify-center rounded-[7px] border', toneClass)}>{icon}</span>
<p className="text-2xl font-semibold tabular-nums text-foreground">{value}</p>
</div>
<p className="mt-2 text-xs font-semibold text-foreground">{label}</p>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">{detail}</p>
</div>
)
}
function NextActionLine({ label, active }: { label: string; active: boolean }) {
return (
<div className="flex items-center gap-2 text-xs text-amber-950">
{active ? <Clock3 className="h-3.5 w-3.5 text-amber-700" /> : <CheckCircle2 className="h-3.5 w-3.5 text-green-600" />}
<span className={cn(!active && 'text-muted-foreground')}>{label}</span>
</div>
)
}
function StatusPill({ ok, okLabel, failLabel }: { ok: boolean; okLabel: string; failLabel: string }) {
return (
<span className={cn(
'inline-flex w-fit items-center gap-1 rounded-[7px] border px-2 py-1 text-[11px] font-semibold',
ok ? 'border-green-200 bg-green-50 text-green-700' : 'border-amber-200 bg-amber-50 text-amber-700'
)}>
{ok ? <CheckCircle2 className="h-3.5 w-3.5" /> : <AlertTriangle className="h-3.5 w-3.5" />}
{ok ? okLabel : failLabel}
</span>
)
}
function CadenceCard({ card, chips }: { card: ReportStatusCard; chips: string[] }) {
const progress = Math.max(0, Math.min(card.completion_percent, 100))
return (
<div className="min-w-0 rounded-[7px] border border-border bg-background p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">{card.display_name}</p>
<p className="text-[11px] text-muted-foreground">{card.owner_agent} · {card.delivery_state}</p>
</div>
<span className="text-lg font-semibold tabular-nums text-foreground">{progress}%</span>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-[7px] bg-muted">
<div className="h-full rounded-[7px] bg-blue-500" style={{ width: `${progress}%` }} />
</div>
<div className="mt-3 flex flex-wrap gap-1.5 text-[10px] text-muted-foreground">
{chips.map(label => <Chip key={label} label={label} />)}
</div>
<p className="mt-3 text-xs leading-5 text-muted-foreground">{card.next_gate}</p>
</div>
)
}
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 (
<div className="grid gap-2 sm:grid-cols-[150px_1fr_70px] sm:items-center">
<span className="text-xs font-medium text-foreground">{label}</span>
<div className="h-2 overflow-hidden rounded-[7px] bg-muted">
<div className={cn('h-full rounded-[7px]', color)} style={{ width: `${width}%` }} />
</div>
<span className="text-right text-sm font-semibold tabular-nums text-foreground">{value}</span>
</div>
)
}
function AssetCard({ label, value, detail, danger = false }: { label: string; value: string | number; detail: string; danger?: boolean }) {
return (
<div className={cn('rounded-[7px] border p-3', danger ? 'border-amber-200 bg-amber-50' : 'border-border bg-background')}>
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-foreground">{value}</p>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">{detail}</p>
</div>
)
}
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 (
<div className="min-w-0 rounded-[7px] border border-border bg-background p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">{agent.display_name}</p>
<p className="text-[11px] text-muted-foreground">{agent.current_state}</p>
</div>
<span className="text-lg font-semibold tabular-nums text-foreground">{progress}%</span>
</div>
<p className="mt-2 text-xs leading-5 text-muted-foreground">{agent.primary_role}</p>
<div className="mt-3 h-2 overflow-hidden rounded-[7px] bg-muted">
<div className={cn('h-full rounded-[7px]', progress >= 80 ? 'bg-green-500' : 'bg-amber-500')} style={{ width: `${progress}%` }} />
</div>
<div className="mt-3 flex flex-wrap gap-1.5 text-[10px] text-muted-foreground">
{chips.map(label => <Chip key={label} label={label} />)}
</div>
</div>
)
}
function KPICard({ icon, label, value, color }: { icon: ReactNode; label: string; value: string | number; color: string }) {
return (
<div className="flex items-center gap-4 rounded-[7px] border border-border bg-card p-5 shadow-sm">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[7px] bg-muted">
{icon}
</div>
<div className="min-w-0">
<p className={cn('text-2xl font-bold tabular-nums', color)}>{value}</p>
<p className="mt-0.5 text-[10px] uppercase tracking-wider text-muted-foreground">{label}</p>
</div>
</div>
)
}
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 (
<div className={cn('rounded-[7px] border p-4 text-center shadow-sm', styles)}>
<div className="mb-2 flex items-center justify-center gap-1.5">
{icon}
<span className="text-[10px] font-bold uppercase tracking-wider">{label}</span>
</div>
<p className={cn('text-3xl font-bold tabular-nums', color)}>{count}</p>
<p className="text-3xl font-bold tabular-nums">{count}</p>
</div>
)
}
@@ -311,7 +644,32 @@ function MiniStat({ label, value }: { label: string; value: string | number }) {
return (
<div className="text-center">
<p className="text-xl font-bold tabular-nums">{value}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">{label}</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{label}</p>
</div>
)
}
function Chip({ label }: { label: string }) {
return (
<span className="rounded-[7px] border border-border bg-card px-2 py-1 text-[10px] leading-none text-muted-foreground">
{label}
</span>
)
}
function Legend({ color, label }: { color: string; label: string }) {
return (
<span className="flex items-center gap-1">
<span className={cn('h-2 w-2 rounded-full', color)} />
{label}
</span>
)
}
function EmptyPanel({ label }: { label: string }) {
return (
<div className="rounded-[7px] border border-dashed border-border bg-background p-6 text-center text-sm text-muted-foreground">
{label}
</div>
)
}