feat: initialize manufacturing progress dashboard
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user