ShopFloorPage.tsx
4.48 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
// Shop-floor dashboard.
//
// Polls /api/v1/production/work-orders/shop-floor every 5s and
// renders one card per IN_PROGRESS work order with its current
// operation, planned vs actual minutes, and operations completed.
// Designed to be projected on a wall-mounted screen — the cards
// are large, the typography is high-contrast, and the only state
// is "what's running right now".
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { production } from '@/api/client'
import type { ShopFloorEntry } from '@/types/api'
import { PageHeader } from '@/components/PageHeader'
import { Loading } from '@/components/Loading'
import { ErrorBox } from '@/components/ErrorBox'
import { StatusBadge } from '@/components/StatusBadge'
const POLL_MS = 5000
export function ShopFloorPage() {
const [rows, setRows] = useState<ShopFloorEntry[]>([])
const [error, setError] = useState<Error | null>(null)
const [loading, setLoading] = useState(true)
const [updatedAt, setUpdatedAt] = useState<Date | null>(null)
useEffect(() => {
let active = true
let timer: number | null = null
const tick = async () => {
try {
const data = await production.shopFloor()
if (!active) return
setRows(data)
setUpdatedAt(new Date())
setError(null)
} catch (e: unknown) {
if (active) setError(e instanceof Error ? e : new Error(String(e)))
} finally {
if (active) setLoading(false)
}
if (active) timer = window.setTimeout(tick, POLL_MS)
}
tick()
return () => {
active = false
if (timer !== null) window.clearTimeout(timer)
}
}, [])
return (
<div>
<PageHeader
title="Shop Floor"
subtitle={`Live view of every work order in progress · refreshes every ${POLL_MS / 1000}s${
updatedAt ? ' · last update ' + updatedAt.toLocaleTimeString() : ''
}`}
/>
{loading && <Loading />}
{error && <ErrorBox error={error} />}
{!loading && rows.length === 0 && (
<div className="card p-8 text-center text-sm text-slate-400">
No work orders are in progress right now.
</div>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{rows.map((r) => {
const std = Number(r.totalStandardMinutes)
const act = Number(r.totalActualMinutes)
const pct = std > 0 ? Math.min(100, Math.round((act / std) * 100)) : 0
return (
<Link
key={r.workOrderId}
to={`/work-orders/${r.workOrderId}`}
className="card p-5 transition hover:shadow-md"
>
<div className="mb-2 flex items-center justify-between">
<span className="font-mono text-lg font-semibold text-brand-600">
{r.workOrderCode}
</span>
<span className="text-xs text-slate-500">
{r.operationsCompleted} / {r.operationsTotal} ops
</span>
</div>
<div className="mb-3 text-xs text-slate-500">
Output: <span className="font-mono">{r.outputItemCode}</span> ×{' '}
{String(r.outputQuantity)}
</div>
<div className="mb-3">
<div className="text-xs text-slate-400">Current operation</div>
{r.currentOperationCode ? (
<div className="mt-1 flex items-center gap-2">
<span className="font-mono font-medium">{r.currentOperationCode}</span>
<span className="text-xs text-slate-500">@ {r.currentWorkCenter}</span>
{r.currentOperationStatus && <StatusBadge status={r.currentOperationStatus} />}
</div>
) : (
<div className="mt-1 text-sm text-slate-400">No routing</div>
)}
</div>
<div className="mt-2">
<div className="mb-1 flex items-center justify-between text-xs text-slate-500">
<span>{act.toFixed(0)} actual min</span>
<span>{std.toFixed(0)} std min</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
<div
className="h-full bg-brand-500 transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
</Link>
)
})}
</div>
</div>
)
}