ShopFloorPage.tsx 4.48 KB
// 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>
  )
}