WorkOrderDetailPage.tsx 7.56 KB
// Work-order detail screen — read-only header + start/complete
// action verbs that drive the v2 state machine. The shop-floor
// dashboard at /shop-floor handles the per-operation walk for v3
// routing-equipped orders. v1 SPA keeps this screen simple: start a
// DRAFT, complete an IN_PROGRESS into a warehouse.

import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { inventory, production } from '@/api/client'
import type { Location, WorkOrder } from '@/types/api'
import { PageHeader } from '@/components/PageHeader'
import { Loading } from '@/components/Loading'
import { ErrorBox } from '@/components/ErrorBox'
import { StatusBadge } from '@/components/StatusBadge'

export function WorkOrderDetailPage() {
  const { id = '' } = useParams<{ id: string }>()
  const navigate = useNavigate()
  const [order, setOrder] = useState<WorkOrder | null>(null)
  const [locations, setLocations] = useState<Location[]>([])
  const [outputLocation, setOutputLocation] = useState<string>('')
  const [loading, setLoading] = useState(true)
  const [acting, setActing] = useState(false)
  const [error, setError] = useState<Error | null>(null)
  const [actionMessage, setActionMessage] = useState<string | null>(null)

  const refresh = useCallback(async () => {
    const o = await production.getWorkOrder(id)
    setOrder(o)
  }, [id])

  useEffect(() => {
    let active = true
    setLoading(true)
    Promise.all([production.getWorkOrder(id), inventory.listLocations()])
      .then(([o, locs]: [WorkOrder, Location[]]) => {
        if (!active) return
        setOrder(o)
        setLocations(locs.filter((l) => l.active))
        const firstWarehouse = locs.find((l) => l.active && l.type === 'WAREHOUSE')
        setOutputLocation(firstWarehouse?.code ?? locs[0]?.code ?? '')
      })
      .catch((e: unknown) => {
        if (active) setError(e instanceof Error ? e : new Error(String(e)))
      })
      .finally(() => active && setLoading(false))
    return () => {
      active = false
    }
  }, [id])

  if (loading) return <Loading />
  if (error) return <ErrorBox error={error} />
  if (!order) return <ErrorBox error="Work order not found" />

  const onStart = async () => {
    setActing(true)
    setError(null)
    setActionMessage(null)
    try {
      await production.startWorkOrder(order.id)
      await refresh()
      setActionMessage('Started. Operations can now be walked from the Shop Floor screen.')
    } catch (e: unknown) {
      setError(e instanceof Error ? e : new Error(String(e)))
    } finally {
      setActing(false)
    }
  }

  const onComplete = async () => {
    if (!outputLocation) {
      setError(new Error('Pick an output location first.'))
      return
    }
    setActing(true)
    setError(null)
    setActionMessage(null)
    try {
      await production.completeWorkOrder(order.id, outputLocation)
      await refresh()
      setActionMessage(
        `Completed. Materials issued, finished goods credited to ${outputLocation}.`,
      )
    } catch (e: unknown) {
      setError(e instanceof Error ? e : new Error(String(e)))
    } finally {
      setActing(false)
    }
  }

  const canStart = order.status === 'DRAFT'
  const canComplete = order.status === 'IN_PROGRESS'

  return (
    <div>
      <PageHeader
        title={`Work Order ${order.code}`}
        subtitle={`Output: ${order.outputItemCode} × ${order.outputQuantity}${
          order.sourceSalesOrderCode ? ' · from SO ' + order.sourceSalesOrderCode : ''
        }`}
        actions={
          <button className="btn-secondary" onClick={() => navigate('/work-orders')}>
            ← Back
          </button>
        }
      />

      <div className="card mb-6 p-5">
        <div className="flex items-center gap-3">
          <span className="text-sm text-slate-500">Status:</span>
          <StatusBadge status={order.status} />
        </div>
        {actionMessage && (
          <div className="mt-4 rounded-md border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm text-emerald-800">
            {actionMessage}
          </div>
        )}
      </div>

      <div className="card mb-6 p-5">
        <h2 className="mb-3 text-base font-semibold text-slate-800">Actions</h2>
        <div className="flex flex-wrap items-center gap-3">
          <button className="btn-primary" disabled={!canStart || acting} onClick={onStart}>
            Start
          </button>
          <div className="flex items-center gap-2">
            <select
              className="rounded-md border border-slate-300 px-2 py-1 text-sm shadow-sm focus:border-brand-500 focus:ring-brand-500"
              value={outputLocation}
              onChange={(e) => setOutputLocation(e.target.value)}
              disabled={!canComplete || acting}
            >
              {locations.map((l) => (
                <option key={l.id} value={l.code}>
                  {l.code} — {l.name}
                </option>
              ))}
            </select>
            <button className="btn-primary" disabled={!canComplete || acting} onClick={onComplete}>
              Complete
            </button>
          </div>
        </div>
      </div>

      <div className="grid gap-6 md:grid-cols-2">
        <div className="card">
          <div className="border-b border-slate-200 px-5 py-3">
            <h2 className="text-base font-semibold text-slate-800">BOM inputs</h2>
          </div>
          {order.inputs.length === 0 ? (
            <div className="p-5 text-sm text-slate-400">No BOM lines.</div>
          ) : (
            <table className="table-base">
              <thead className="bg-slate-50">
                <tr>
                  <th>#</th>
                  <th>Item</th>
                  <th>Qty / unit</th>
                  <th>Source loc</th>
                </tr>
              </thead>
              <tbody className="divide-y divide-slate-100">
                {order.inputs.map((i) => (
                  <tr key={i.id}>
                    <td>{i.lineNo}</td>
                    <td className="font-mono">{i.itemCode}</td>
                    <td className="font-mono tabular-nums">{String(i.quantityPerUnit)}</td>
                    <td className="font-mono">{i.sourceLocationCode}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          )}
        </div>
        <div className="card">
          <div className="border-b border-slate-200 px-5 py-3">
            <h2 className="text-base font-semibold text-slate-800">Routing operations</h2>
          </div>
          {order.operations.length === 0 ? (
            <div className="p-5 text-sm text-slate-400">No routing.</div>
          ) : (
            <table className="table-base">
              <thead className="bg-slate-50">
                <tr>
                  <th>#</th>
                  <th>Operation</th>
                  <th>Work center</th>
                  <th>Std min</th>
                  <th>Status</th>
                </tr>
              </thead>
              <tbody className="divide-y divide-slate-100">
                {order.operations.map((o) => (
                  <tr key={o.id}>
                    <td>{o.lineNo}</td>
                    <td className="font-mono">{o.operationCode}</td>
                    <td className="font-mono">{o.workCenter}</td>
                    <td className="font-mono tabular-nums">{String(o.standardMinutes)}</td>
                    <td>
                      <StatusBadge status={o.status} />
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          )}
        </div>
      </div>
    </div>
  )
}