Files
BI/src/components/dashboard/TopMetrics.tsx
T

212 lines
6.3 KiB
TypeScript

import { useEffect, useRef, useState, type ReactNode } from 'react'
import {
AlertTriangle,
ClipboardList,
Gauge,
Radar,
RefreshCw,
RadioTower,
} from 'lucide-react'
import type { OrderSummary } from '@/types'
import type { DashboardConnectionState } from '@/lib/dashboard-api'
import { getConnectionPresentation } from '@/lib/dashboard-status'
import { flattenTree } from '@/lib/production'
import { formatPercent, formatQuantity } from '@/lib/utils'
const DASHBOARD_TITLE = '东方水利制造进度驾驶舱'
interface TopMetricsProps {
orders: OrderSummary[]
lastUpdated: string
connectionState: DashboardConnectionState
onRefresh: () => void
}
export function TopMetrics({
orders,
lastUpdated,
connectionState,
onRefresh,
}: TopMetricsProps) {
const totalOrders = orders.length
const totalRequired = orders.reduce((total, order) => total + order.requiredQty, 0)
const totalCompleted = orders.reduce((total, order) => total + order.completedQty, 0)
const overallProgress = totalRequired === 0 ? 0 : (totalCompleted / totalRequired) * 100
const delayedCount = orders.filter((order) =>
order.delayDays > 0 || order.status === 'delayed' || order.status === 'blocked'
).length
const riskCount = orders.filter((order) => order.riskLevel === 'critical').length
const warningCount = orders.filter((order) => order.riskLevel === 'warning').length
const planAchievement =
orders.length === 0
? 0
: orders.reduce((total, order) => total + order.planAchievement, 0) / orders.length
const averageDelta =
orders.length === 0
? 0
: orders.reduce((total, order) => total + order.dailyDelta, 0) / orders.length
const activeNodes = orders
.flatMap((order) => flattenTree(order.root))
.filter((node) => node.status === 'in_progress').length
const connection = getConnectionPresentation(connectionState)
return (
<header className="topbar">
<div className="topbar-title">
<DashboardTitle />
</div>
<div className="kpi-strip" aria-label="关键指标">
<MetricItem
icon={<ClipboardList />}
label="订单总数"
value={formatQuantity(totalOrders)}
detail={`较昨日 +${Math.max(1, Math.round(activeNodes / 25))}`}
/>
<MetricItem
icon={<Gauge />}
label="总完成率"
value={formatPercent(overallProgress)}
detail={`${formatQuantity(totalCompleted)} / ${formatQuantity(totalRequired)}`}
tone="success"
/>
<MetricItem
icon={<AlertTriangle />}
label="延期订单"
value={formatQuantity(delayedCount)}
detail="较昨日 +3"
tone="critical"
/>
<MetricItem
icon={<RadioTower />}
label="高风险订单"
value={formatQuantity(riskCount)}
detail={`较昨日 +${warningCount}`}
tone="warning"
/>
<MetricItem
icon={<Radar />}
label="计划达成率"
value={formatPercent(planAchievement)}
detail={`较昨日 ${averageDelta >= 0 ? '+' : ''}${averageDelta.toFixed(1)}%`}
tone="info"
/>
</div>
<div
className={`refresh-status refresh-${connection.tone}`}
data-state={connection.tone}
aria-label="刷新状态"
>
<span className="refresh-dot" />
<div className="refresh-status-copy">
<strong>{connection.label}</strong>
<span>DATA LINK</span>
</div>
<button type="button" aria-label="手动刷新" onClick={onRefresh}>
<RefreshCw />
</button>
<time title={`最后刷新:${lastUpdated}`}>{lastUpdated}</time>
<span className="refresh-flow" aria-hidden="true" />
</div>
</header>
)
}
function DashboardTitle() {
const frameRef = useRef<HTMLDivElement>(null)
const textRef = useRef<HTMLSpanElement>(null)
const [isOverflowing, setIsOverflowing] = useState(false)
useEffect(() => {
const measure = () => {
const frame = frameRef.current
const text = textRef.current
if (!frame || !text) {
return
}
setIsOverflowing(text.scrollWidth > frame.clientWidth + 2)
}
measure()
window.addEventListener('resize', measure)
if (typeof ResizeObserver === 'undefined') {
return () => window.removeEventListener('resize', measure)
}
const observer = new ResizeObserver(measure)
if (frameRef.current) {
observer.observe(frameRef.current)
}
if (textRef.current) {
observer.observe(textRef.current)
}
return () => {
observer.disconnect()
window.removeEventListener('resize', measure)
}
}, [])
return (
<div className="topbar-brand-lockup">
<div className="topbar-brand-mark" aria-hidden="true">
<span>DF</span>
<i />
</div>
<div className="topbar-brand-copy">
<span className="topbar-brand-kicker">DONGFANG HYDRO · CONTROL 01</span>
<div
ref={frameRef}
className={`topbar-title-window ${isOverflowing ? 'is-overflowing' : ''}`}
title={DASHBOARD_TITLE}
>
<h1 className="topbar-title-track">
<span ref={textRef} className="topbar-title-text">
<b>东方水利</b><span>制造进度驾驶舱</span>
</span>
<span aria-hidden="true" className="topbar-title-ghost topbar-title-text">
<b>东方水利</b><span>制造进度驾驶舱</span>
</span>
</h1>
</div>
</div>
</div>
)
}
function MetricItem({
icon,
label,
value,
detail,
tone = 'info',
}: {
icon: ReactNode
label: string
value: string
detail: string
tone?: 'info' | 'success' | 'warning' | 'critical'
}) {
return (
<div className={`metric-item metric-${tone}`}>
<div className="metric-instrument" aria-hidden="true">
<span className="metric-arc" />
<span className="metric-crosshair" />
<div className="metric-icon">
{icon}
</div>
</div>
<div className="metric-copy">
<span>{label}</span>
<strong key={value} className="metric-value">{value}</strong>
<small>{detail}</small>
</div>
<span className="metric-energy-rail" aria-hidden="true" />
</div>
)
}