Commit 25353240e8ce29cee6982f17981576c0d4d38281

Authored by zichun
1 parent 7b0d2f8c

feat(web): CRUD create forms for items, partners, purchase orders, work orders

Extends the R1 SPA with create forms for the four entities operators
interact with most. Each page follows the same pattern proven by
CreateSalesOrderPage: a card-scoped form with dropdowns populated
from the API, inline validation, and a redirect to the detail or
list page on success.

New pages:
  - CreateItemPage — code, name, type (GOOD/SERVICE/DIGITAL),
    UoM dropdown populated from /api/v1/catalog/uoms
  - CreatePartnerPage — code, name, type (CUSTOMER/SUPPLIER/BOTH),
    optional email + phone
  - CreatePurchaseOrderPage — symmetric to CreateSalesOrderPage;
    supplier dropdown filtered to SUPPLIER/BOTH partners,
    optional expected date, dynamic line items
  - CreateWorkOrderPage — output item + quantity + optional due
    date, dynamic BOM inputs (item + qty/unit + source location
    dropdown), dynamic routing operations (op code + work center
    + std minutes). The most complex form in the SPA — matches
    the EBC-PP-001 work order creation flow

API client additions: catalog.createItem, partners.create,
purchaseOrders.create, production.createWorkOrder — each a
typed wrapper around POST to the corresponding endpoint.

List pages updated: Items, Partners, Purchase Orders, Work Orders
all now show a "+ New" button in the PageHeader that links to
the create form.

Routes wired: /items/new, /partners/new, /purchase-orders/new,
/work-orders/new — all covered by the existing SpaController
wildcard patterns and SecurityConfiguration permitAll rules.
web/src/App.tsx
... ... @@ -14,8 +14,10 @@ import { ProtectedRoute } from '@/components/ProtectedRoute'
14 14 import { LoginPage } from '@/pages/LoginPage'
15 15 import { DashboardPage } from '@/pages/DashboardPage'
16 16 import { ItemsPage } from '@/pages/ItemsPage'
  17 +import { CreateItemPage } from '@/pages/CreateItemPage'
17 18 import { UomsPage } from '@/pages/UomsPage'
18 19 import { PartnersPage } from '@/pages/PartnersPage'
  20 +import { CreatePartnerPage } from '@/pages/CreatePartnerPage'
19 21 import { LocationsPage } from '@/pages/LocationsPage'
20 22 import { BalancesPage } from '@/pages/BalancesPage'
21 23 import { MovementsPage } from '@/pages/MovementsPage'
... ... @@ -23,8 +25,10 @@ import { SalesOrdersPage } from '@/pages/SalesOrdersPage'
23 25 import { CreateSalesOrderPage } from '@/pages/CreateSalesOrderPage'
24 26 import { SalesOrderDetailPage } from '@/pages/SalesOrderDetailPage'
25 27 import { PurchaseOrdersPage } from '@/pages/PurchaseOrdersPage'
  28 +import { CreatePurchaseOrderPage } from '@/pages/CreatePurchaseOrderPage'
26 29 import { PurchaseOrderDetailPage } from '@/pages/PurchaseOrderDetailPage'
27 30 import { WorkOrdersPage } from '@/pages/WorkOrdersPage'
  31 +import { CreateWorkOrderPage } from '@/pages/CreateWorkOrderPage'
28 32 import { WorkOrderDetailPage } from '@/pages/WorkOrderDetailPage'
29 33 import { ShopFloorPage } from '@/pages/ShopFloorPage'
30 34 import { JournalEntriesPage } from '@/pages/JournalEntriesPage'
... ... @@ -43,8 +47,10 @@ export default function App() {
43 47 >
44 48 <Route index element={<DashboardPage />} />
45 49 <Route path="items" element={<ItemsPage />} />
  50 + <Route path="items/new" element={<CreateItemPage />} />
46 51 <Route path="uoms" element={<UomsPage />} />
47 52 <Route path="partners" element={<PartnersPage />} />
  53 + <Route path="partners/new" element={<CreatePartnerPage />} />
48 54 <Route path="locations" element={<LocationsPage />} />
49 55 <Route path="balances" element={<BalancesPage />} />
50 56 <Route path="movements" element={<MovementsPage />} />
... ... @@ -52,8 +58,10 @@ export default function App() {
52 58 <Route path="sales-orders/new" element={<CreateSalesOrderPage />} />
53 59 <Route path="sales-orders/:id" element={<SalesOrderDetailPage />} />
54 60 <Route path="purchase-orders" element={<PurchaseOrdersPage />} />
  61 + <Route path="purchase-orders/new" element={<CreatePurchaseOrderPage />} />
55 62 <Route path="purchase-orders/:id" element={<PurchaseOrderDetailPage />} />
56 63 <Route path="work-orders" element={<WorkOrdersPage />} />
  64 + <Route path="work-orders/new" element={<CreateWorkOrderPage />} />
57 65 <Route path="work-orders/:id" element={<WorkOrderDetailPage />} />
58 66 <Route path="shop-floor" element={<ShopFloorPage />} />
59 67 <Route path="journal-entries" element={<JournalEntriesPage />} />
... ...
web/src/api/client.ts
... ... @@ -136,6 +136,10 @@ export const auth = {
136 136 export const catalog = {
137 137 listItems: () => apiFetch<Item[]>('/api/v1/catalog/items'),
138 138 getItem: (id: string) => apiFetch<Item>(`/api/v1/catalog/items/${id}`),
  139 + createItem: (body: {
  140 + code: string; name: string; description?: string | null;
  141 + itemType: string; baseUomCode: string; active?: boolean
  142 + }) => apiFetch<Item>('/api/v1/catalog/items', { method: 'POST', body: JSON.stringify(body) }),
139 143 listUoms: () => apiFetch<Uom[]>('/api/v1/catalog/uoms'),
140 144 }
141 145  
... ... @@ -144,6 +148,11 @@ export const catalog = {
144 148 export const partners = {
145 149 list: () => apiFetch<Partner[]>('/api/v1/partners/partners'),
146 150 get: (id: string) => apiFetch<Partner>(`/api/v1/partners/partners/${id}`),
  151 + create: (body: {
  152 + code: string; name: string; type: string;
  153 + email?: string | null; phone?: string | null;
  154 + taxId?: string | null; website?: string | null
  155 + }) => apiFetch<Partner>('/api/v1/partners/partners', { method: 'POST', body: JSON.stringify(body) }),
147 156 }
148 157  
149 158 // ─── Inventory ───────────────────────────────────────────────────────
... ... @@ -190,6 +199,11 @@ export const salesOrders = {
190 199 export const purchaseOrders = {
191 200 list: () => apiFetch<PurchaseOrder[]>('/api/v1/orders/purchase-orders'),
192 201 get: (id: string) => apiFetch<PurchaseOrder>(`/api/v1/orders/purchase-orders/${id}`),
  202 + create: (body: {
  203 + code: string; partnerCode: string; orderDate: string;
  204 + expectedDate?: string | null; currencyCode: string;
  205 + lines: { lineNo: number; itemCode: string; quantity: number; unitPrice: number; currencyCode: string }[]
  206 + }) => apiFetch<PurchaseOrder>('/api/v1/orders/purchase-orders', { method: 'POST', body: JSON.stringify(body) }),
193 207 confirm: (id: string) =>
194 208 apiFetch<PurchaseOrder>(`/api/v1/orders/purchase-orders/${id}/confirm`, {
195 209 method: 'POST',
... ... @@ -211,6 +225,12 @@ export const production = {
211 225 listWorkOrders: () => apiFetch<WorkOrder[]>('/api/v1/production/work-orders'),
212 226 getWorkOrder: (id: string) =>
213 227 apiFetch<WorkOrder>(`/api/v1/production/work-orders/${id}`),
  228 + createWorkOrder: (body: {
  229 + code: string; outputItemCode: string; outputQuantity: number;
  230 + dueDate?: string | null; sourceSalesOrderCode?: string | null;
  231 + inputs?: { lineNo: number; itemCode: string; quantityPerUnit: number; sourceLocationCode: string }[];
  232 + operations?: { lineNo: number; operationCode: string; workCenter: string; standardMinutes: number }[];
  233 + }) => apiFetch<WorkOrder>('/api/v1/production/work-orders', { method: 'POST', body: JSON.stringify(body) }),
214 234 startWorkOrder: (id: string) =>
215 235 apiFetch<WorkOrder>(`/api/v1/production/work-orders/${id}/start`, {
216 236 method: 'POST',
... ...
web/src/pages/CreateItemPage.tsx 0 → 100644
  1 +import { useEffect, useState, type FormEvent } from 'react'
  2 +import { useNavigate } from 'react-router-dom'
  3 +import { catalog } from '@/api/client'
  4 +import type { Uom } from '@/types/api'
  5 +import { PageHeader } from '@/components/PageHeader'
  6 +import { ErrorBox } from '@/components/ErrorBox'
  7 +
  8 +const ITEM_TYPES = ['GOOD', 'SERVICE', 'DIGITAL'] as const
  9 +
  10 +export function CreateItemPage() {
  11 + const navigate = useNavigate()
  12 + const [code, setCode] = useState('')
  13 + const [name, setName] = useState('')
  14 + const [description, setDescription] = useState('')
  15 + const [itemType, setItemType] = useState<string>('GOOD')
  16 + const [baseUomCode, setBaseUomCode] = useState('')
  17 + const [uoms, setUoms] = useState<Uom[]>([])
  18 + const [submitting, setSubmitting] = useState(false)
  19 + const [error, setError] = useState<Error | null>(null)
  20 +
  21 + useEffect(() => {
  22 + catalog.listUoms().then((u) => {
  23 + setUoms(u)
  24 + if (u.length > 0 && !baseUomCode) setBaseUomCode(u[0].code)
  25 + })
  26 + }, []) // eslint-disable-line react-hooks/exhaustive-deps
  27 +
  28 + const onSubmit = async (e: FormEvent) => {
  29 + e.preventDefault()
  30 + setError(null)
  31 + setSubmitting(true)
  32 + try {
  33 + await catalog.createItem({
  34 + code, name, itemType, baseUomCode,
  35 + description: description || null,
  36 + })
  37 + navigate('/items')
  38 + } catch (err: unknown) {
  39 + setError(err instanceof Error ? err : new Error(String(err)))
  40 + } finally {
  41 + setSubmitting(false)
  42 + }
  43 + }
  44 +
  45 + return (
  46 + <div>
  47 + <PageHeader
  48 + title="New Item"
  49 + subtitle="Add a raw material, finished good, or service to the catalog."
  50 + actions={<button className="btn-secondary" onClick={() => navigate('/items')}>Cancel</button>}
  51 + />
  52 + <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl">
  53 + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
  54 + <div>
  55 + <label className="block text-sm font-medium text-slate-700">Item code</label>
  56 + <input type="text" required value={code} onChange={(e) => setCode(e.target.value)}
  57 + placeholder="PAPER-120G-A3" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
  58 + </div>
  59 + <div>
  60 + <label className="block text-sm font-medium text-slate-700">Name</label>
  61 + <input type="text" required value={name} onChange={(e) => setName(e.target.value)}
  62 + placeholder="120g A3 coated paper" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
  63 + </div>
  64 + <div>
  65 + <label className="block text-sm font-medium text-slate-700">Type</label>
  66 + <select value={itemType} onChange={(e) => setItemType(e.target.value)}
  67 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm">
  68 + {ITEM_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
  69 + </select>
  70 + </div>
  71 + <div>
  72 + <label className="block text-sm font-medium text-slate-700">Base UoM</label>
  73 + <select value={baseUomCode} onChange={(e) => setBaseUomCode(e.target.value)}
  74 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm">
  75 + {uoms.map((u) => <option key={u.id} value={u.code}>{u.code} — {u.name}</option>)}
  76 + </select>
  77 + </div>
  78 + </div>
  79 + <div>
  80 + <label className="block text-sm font-medium text-slate-700">Description (optional)</label>
  81 + <input type="text" value={description} onChange={(e) => setDescription(e.target.value)}
  82 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
  83 + </div>
  84 + {error && <ErrorBox error={error} />}
  85 + <button type="submit" className="btn-primary" disabled={submitting}>
  86 + {submitting ? 'Creating...' : 'Create Item'}
  87 + </button>
  88 + </form>
  89 + </div>
  90 + )
  91 +}
... ...
web/src/pages/CreatePartnerPage.tsx 0 → 100644
  1 +import { useState, type FormEvent } from 'react'
  2 +import { useNavigate } from 'react-router-dom'
  3 +import { partners } from '@/api/client'
  4 +import { PageHeader } from '@/components/PageHeader'
  5 +import { ErrorBox } from '@/components/ErrorBox'
  6 +
  7 +const PARTNER_TYPES = ['CUSTOMER', 'SUPPLIER', 'BOTH'] as const
  8 +
  9 +export function CreatePartnerPage() {
  10 + const navigate = useNavigate()
  11 + const [code, setCode] = useState('')
  12 + const [name, setName] = useState('')
  13 + const [type, setType] = useState<string>('CUSTOMER')
  14 + const [email, setEmail] = useState('')
  15 + const [phone, setPhone] = useState('')
  16 + const [submitting, setSubmitting] = useState(false)
  17 + const [error, setError] = useState<Error | null>(null)
  18 +
  19 + const onSubmit = async (e: FormEvent) => {
  20 + e.preventDefault()
  21 + setError(null)
  22 + setSubmitting(true)
  23 + try {
  24 + await partners.create({
  25 + code, name, type,
  26 + email: email || null,
  27 + phone: phone || null,
  28 + })
  29 + navigate('/partners')
  30 + } catch (err: unknown) {
  31 + setError(err instanceof Error ? err : new Error(String(err)))
  32 + } finally {
  33 + setSubmitting(false)
  34 + }
  35 + }
  36 +
  37 + return (
  38 + <div>
  39 + <PageHeader
  40 + title="New Partner"
  41 + subtitle="Add a customer, supplier, or dual-role partner."
  42 + actions={<button className="btn-secondary" onClick={() => navigate('/partners')}>Cancel</button>}
  43 + />
  44 + <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl">
  45 + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
  46 + <div>
  47 + <label className="block text-sm font-medium text-slate-700">Partner code</label>
  48 + <input type="text" required value={code} onChange={(e) => setCode(e.target.value)}
  49 + placeholder="CUST-NEWCO" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
  50 + </div>
  51 + <div>
  52 + <label className="block text-sm font-medium text-slate-700">Name</label>
  53 + <input type="text" required value={name} onChange={(e) => setName(e.target.value)}
  54 + placeholder="New Company Ltd." className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
  55 + </div>
  56 + <div>
  57 + <label className="block text-sm font-medium text-slate-700">Type</label>
  58 + <select value={type} onChange={(e) => setType(e.target.value)}
  59 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm">
  60 + {PARTNER_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
  61 + </select>
  62 + </div>
  63 + <div>
  64 + <label className="block text-sm font-medium text-slate-700">Email (optional)</label>
  65 + <input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
  66 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
  67 + </div>
  68 + <div>
  69 + <label className="block text-sm font-medium text-slate-700">Phone (optional)</label>
  70 + <input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)}
  71 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
  72 + </div>
  73 + </div>
  74 + {error && <ErrorBox error={error} />}
  75 + <button type="submit" className="btn-primary" disabled={submitting}>
  76 + {submitting ? 'Creating...' : 'Create Partner'}
  77 + </button>
  78 + </form>
  79 + </div>
  80 + )
  81 +}
... ...
web/src/pages/CreatePurchaseOrderPage.tsx 0 → 100644
  1 +import { useEffect, useState, type FormEvent } from 'react'
  2 +import { useNavigate } from 'react-router-dom'
  3 +import { catalog, partners, purchaseOrders } from '@/api/client'
  4 +import type { Item, Partner } from '@/types/api'
  5 +import { PageHeader } from '@/components/PageHeader'
  6 +import { ErrorBox } from '@/components/ErrorBox'
  7 +
  8 +interface LineInput {
  9 + itemCode: string
  10 + quantity: string
  11 + unitPrice: string
  12 +}
  13 +
  14 +export function CreatePurchaseOrderPage() {
  15 + const navigate = useNavigate()
  16 + const [code, setCode] = useState('')
  17 + const [partnerCode, setPartnerCode] = useState('')
  18 + const [expectedDate, setExpectedDate] = useState('')
  19 + const [currencyCode] = useState('USD')
  20 + const [lines, setLines] = useState<LineInput[]>([{ itemCode: '', quantity: '', unitPrice: '' }])
  21 + const [items, setItems] = useState<Item[]>([])
  22 + const [supplierList, setSupplierList] = useState<Partner[]>([])
  23 + const [submitting, setSubmitting] = useState(false)
  24 + const [error, setError] = useState<Error | null>(null)
  25 +
  26 + useEffect(() => {
  27 + Promise.all([catalog.listItems(), partners.list()]).then(([i, p]) => {
  28 + setItems(i)
  29 + const suppliers = p.filter((x) => x.type === 'SUPPLIER' || x.type === 'BOTH')
  30 + setSupplierList(suppliers)
  31 + if (suppliers.length > 0 && !partnerCode) setPartnerCode(suppliers[0].code)
  32 + })
  33 + }, []) // eslint-disable-line react-hooks/exhaustive-deps
  34 +
  35 + const addLine = () =>
  36 + setLines([...lines, { itemCode: items[0]?.code ?? '', quantity: '1', unitPrice: '1.00' }])
  37 +
  38 + const removeLine = (idx: number) => {
  39 + if (lines.length <= 1) return
  40 + setLines(lines.filter((_, i) => i !== idx))
  41 + }
  42 +
  43 + const updateLine = (idx: number, field: keyof LineInput, value: string) => {
  44 + const next = [...lines]
  45 + next[idx] = { ...next[idx], [field]: value }
  46 + setLines(next)
  47 + }
  48 +
  49 + const onSubmit = async (e: FormEvent) => {
  50 + e.preventDefault()
  51 + setError(null)
  52 + setSubmitting(true)
  53 + try {
  54 + const created = await purchaseOrders.create({
  55 + code, partnerCode, currencyCode,
  56 + orderDate: new Date().toISOString().slice(0, 10),
  57 + expectedDate: expectedDate || null,
  58 + lines: lines.map((l, i) => ({
  59 + lineNo: i + 1,
  60 + itemCode: l.itemCode,
  61 + quantity: Number(l.quantity),
  62 + unitPrice: Number(l.unitPrice),
  63 + currencyCode,
  64 + })),
  65 + })
  66 + navigate(`/purchase-orders/${created.id}`)
  67 + } catch (err: unknown) {
  68 + setError(err instanceof Error ? err : new Error(String(err)))
  69 + } finally {
  70 + setSubmitting(false)
  71 + }
  72 + }
  73 +
  74 + return (
  75 + <div>
  76 + <PageHeader
  77 + title="New Purchase Order"
  78 + subtitle="Order materials from a supplier. Confirm and receive to credit inventory."
  79 + actions={<button className="btn-secondary" onClick={() => navigate('/purchase-orders')}>Cancel</button>}
  80 + />
  81 + <form onSubmit={onSubmit} className="card p-6 space-y-5 max-w-3xl">
  82 + <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
  83 + <div>
  84 + <label className="block text-sm font-medium text-slate-700">Order code</label>
  85 + <input type="text" required value={code} onChange={(e) => setCode(e.target.value)}
  86 + placeholder="PO-2026-0002" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
  87 + </div>
  88 + <div>
  89 + <label className="block text-sm font-medium text-slate-700">Supplier</label>
  90 + <select required value={partnerCode} onChange={(e) => setPartnerCode(e.target.value)}
  91 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm">
  92 + {supplierList.map((p) => (
  93 + <option key={p.id} value={p.code}>{p.code} — {p.name}</option>
  94 + ))}
  95 + </select>
  96 + </div>
  97 + <div>
  98 + <label className="block text-sm font-medium text-slate-700">Expected date</label>
  99 + <input type="date" value={expectedDate} onChange={(e) => setExpectedDate(e.target.value)}
  100 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
  101 + </div>
  102 + </div>
  103 +
  104 + <div>
  105 + <div className="flex items-center justify-between mb-2">
  106 + <label className="text-sm font-medium text-slate-700">Order lines</label>
  107 + <button type="button" className="btn-secondary text-xs" onClick={addLine}>+ Add line</button>
  108 + </div>
  109 + <div className="space-y-2">
  110 + {lines.map((line, idx) => (
  111 + <div key={idx} className="flex items-center gap-2">
  112 + <span className="w-6 text-xs text-slate-400 text-right">{idx + 1}</span>
  113 + <select value={line.itemCode} onChange={(e) => updateLine(idx, 'itemCode', e.target.value)}
  114 + className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm">
  115 + <option value="">Select item...</option>
  116 + {items.map((it) => <option key={it.id} value={it.code}>{it.code} — {it.name}</option>)}
  117 + </select>
  118 + <input type="number" min="1" step="1" placeholder="Qty" value={line.quantity}
  119 + onChange={(e) => updateLine(idx, 'quantity', e.target.value)}
  120 + className="w-20 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" />
  121 + <input type="number" min="0" step="0.01" placeholder="Price" value={line.unitPrice}
  122 + onChange={(e) => updateLine(idx, 'unitPrice', e.target.value)}
  123 + className="w-24 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" />
  124 + <button type="button" className="text-slate-400 hover:text-rose-500"
  125 + onClick={() => removeLine(idx)} title="Remove line">&times;</button>
  126 + </div>
  127 + ))}
  128 + </div>
  129 + </div>
  130 +
  131 + {error && <ErrorBox error={error} />}
  132 + <button type="submit" className="btn-primary" disabled={submitting}>
  133 + {submitting ? 'Creating...' : 'Create Purchase Order'}
  134 + </button>
  135 + </form>
  136 + </div>
  137 + )
  138 +}
... ...
web/src/pages/CreateWorkOrderPage.tsx 0 → 100644
  1 +import { useEffect, useState, type FormEvent } from 'react'
  2 +import { useNavigate } from 'react-router-dom'
  3 +import { catalog, inventory, production } from '@/api/client'
  4 +import type { Item, Location } from '@/types/api'
  5 +import { PageHeader } from '@/components/PageHeader'
  6 +import { ErrorBox } from '@/components/ErrorBox'
  7 +
  8 +interface BomLine { itemCode: string; quantityPerUnit: string; sourceLocationCode: string }
  9 +interface OpLine { operationCode: string; workCenter: string; standardMinutes: string }
  10 +
  11 +export function CreateWorkOrderPage() {
  12 + const navigate = useNavigate()
  13 + const [code, setCode] = useState('')
  14 + const [outputItemCode, setOutputItemCode] = useState('')
  15 + const [outputQuantity, setOutputQuantity] = useState('')
  16 + const [dueDate, setDueDate] = useState('')
  17 + const [bom, setBom] = useState<BomLine[]>([])
  18 + const [ops, setOps] = useState<OpLine[]>([])
  19 + const [items, setItems] = useState<Item[]>([])
  20 + const [locations, setLocations] = useState<Location[]>([])
  21 + const [submitting, setSubmitting] = useState(false)
  22 + const [error, setError] = useState<Error | null>(null)
  23 +
  24 + useEffect(() => {
  25 + Promise.all([catalog.listItems(), inventory.listLocations()]).then(([i, l]) => {
  26 + setItems(i)
  27 + setLocations(l.filter((x) => x.active))
  28 + })
  29 + }, [])
  30 +
  31 + const addBom = () =>
  32 + setBom([...bom, { itemCode: '', quantityPerUnit: '1', sourceLocationCode: locations[0]?.code ?? '' }])
  33 + const removeBom = (i: number) => setBom(bom.filter((_, idx) => idx !== i))
  34 + const updateBom = (i: number, f: keyof BomLine, v: string) => {
  35 + const next = [...bom]; next[i] = { ...next[i], [f]: v }; setBom(next)
  36 + }
  37 +
  38 + const addOp = () =>
  39 + setOps([...ops, { operationCode: '', workCenter: '', standardMinutes: '30' }])
  40 + const removeOp = (i: number) => setOps(ops.filter((_, idx) => idx !== i))
  41 + const updateOp = (i: number, f: keyof OpLine, v: string) => {
  42 + const next = [...ops]; next[i] = { ...next[i], [f]: v }; setOps(next)
  43 + }
  44 +
  45 + const onSubmit = async (e: FormEvent) => {
  46 + e.preventDefault()
  47 + setError(null)
  48 + setSubmitting(true)
  49 + try {
  50 + const created = await production.createWorkOrder({
  51 + code, outputItemCode,
  52 + outputQuantity: Number(outputQuantity),
  53 + dueDate: dueDate || null,
  54 + inputs: bom.map((b, i) => ({
  55 + lineNo: i + 1, itemCode: b.itemCode,
  56 + quantityPerUnit: Number(b.quantityPerUnit),
  57 + sourceLocationCode: b.sourceLocationCode,
  58 + })),
  59 + operations: ops.map((o, i) => ({
  60 + lineNo: i + 1, operationCode: o.operationCode,
  61 + workCenter: o.workCenter,
  62 + standardMinutes: Number(o.standardMinutes),
  63 + })),
  64 + })
  65 + navigate(`/work-orders/${created.id}`)
  66 + } catch (err: unknown) {
  67 + setError(err instanceof Error ? err : new Error(String(err)))
  68 + } finally {
  69 + setSubmitting(false)
  70 + }
  71 + }
  72 +
  73 + return (
  74 + <div>
  75 + <PageHeader
  76 + title="New Work Order"
  77 + subtitle="Create a production work order with optional BOM inputs and routing operations."
  78 + actions={<button className="btn-secondary" onClick={() => navigate('/work-orders')}>Cancel</button>}
  79 + />
  80 + <form onSubmit={onSubmit} className="card p-6 space-y-5 max-w-3xl">
  81 + <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
  82 + <div>
  83 + <label className="block text-sm font-medium text-slate-700">WO code</label>
  84 + <input type="text" required value={code} onChange={(e) => setCode(e.target.value)}
  85 + placeholder="WO-PRINT-0002" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
  86 + </div>
  87 + <div>
  88 + <label className="block text-sm font-medium text-slate-700">Output item</label>
  89 + <select required value={outputItemCode} onChange={(e) => setOutputItemCode(e.target.value)}
  90 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm">
  91 + <option value="">Select item...</option>
  92 + {items.map((it) => <option key={it.id} value={it.code}>{it.code} — {it.name}</option>)}
  93 + </select>
  94 + </div>
  95 + <div>
  96 + <label className="block text-sm font-medium text-slate-700">Output qty</label>
  97 + <input type="number" required min="1" step="1" value={outputQuantity}
  98 + onChange={(e) => setOutputQuantity(e.target.value)}
  99 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-right" />
  100 + </div>
  101 + </div>
  102 + <div>
  103 + <label className="block text-sm font-medium text-slate-700">Due date (optional)</label>
  104 + <input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)}
  105 + className="mt-1 max-w-xs rounded-md border border-slate-300 px-3 py-2 text-sm" />
  106 + </div>
  107 +
  108 + {/* ─── BOM inputs ─────────────────────────────────────── */}
  109 + <div>
  110 + <div className="flex items-center justify-between mb-2">
  111 + <label className="text-sm font-medium text-slate-700">BOM inputs (materials consumed per unit of output)</label>
  112 + <button type="button" className="btn-secondary text-xs" onClick={addBom}>+ Add input</button>
  113 + </div>
  114 + {bom.length === 0 && <p className="text-xs text-slate-400">No BOM lines. Output will be produced without consuming materials.</p>}
  115 + <div className="space-y-2">
  116 + {bom.map((b, idx) => (
  117 + <div key={idx} className="flex items-center gap-2">
  118 + <span className="w-6 text-xs text-slate-400 text-right">{idx + 1}</span>
  119 + <select value={b.itemCode} onChange={(e) => updateBom(idx, 'itemCode', e.target.value)}
  120 + className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm">
  121 + <option value="">Item...</option>
  122 + {items.map((it) => <option key={it.id} value={it.code}>{it.code}</option>)}
  123 + </select>
  124 + <input type="number" min="0.01" step="0.01" placeholder="Qty/unit" value={b.quantityPerUnit}
  125 + onChange={(e) => updateBom(idx, 'quantityPerUnit', e.target.value)}
  126 + className="w-24 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" />
  127 + <select value={b.sourceLocationCode} onChange={(e) => updateBom(idx, 'sourceLocationCode', e.target.value)}
  128 + className="w-32 rounded-md border border-slate-300 px-2 py-1.5 text-sm">
  129 + {locations.map((l) => <option key={l.id} value={l.code}>{l.code}</option>)}
  130 + </select>
  131 + <button type="button" className="text-slate-400 hover:text-rose-500" onClick={() => removeBom(idx)}>&times;</button>
  132 + </div>
  133 + ))}
  134 + </div>
  135 + </div>
  136 +
  137 + {/* ─── Routing operations ─────────────────────────────── */}
  138 + <div>
  139 + <div className="flex items-center justify-between mb-2">
  140 + <label className="text-sm font-medium text-slate-700">Routing operations (sequential steps)</label>
  141 + <button type="button" className="btn-secondary text-xs" onClick={addOp}>+ Add operation</button>
  142 + </div>
  143 + {ops.length === 0 && <p className="text-xs text-slate-400">No routing. Work order completes in one step.</p>}
  144 + <div className="space-y-2">
  145 + {ops.map((o, idx) => (
  146 + <div key={idx} className="flex items-center gap-2">
  147 + <span className="w-6 text-xs text-slate-400 text-right">{idx + 1}</span>
  148 + <input type="text" required placeholder="Op code (e.g. CTP)" value={o.operationCode}
  149 + onChange={(e) => updateOp(idx, 'operationCode', e.target.value)}
  150 + className="w-28 rounded-md border border-slate-300 px-2 py-1.5 text-sm" />
  151 + <input type="text" required placeholder="Work center" value={o.workCenter}
  152 + onChange={(e) => updateOp(idx, 'workCenter', e.target.value)}
  153 + className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm" />
  154 + <input type="number" min="1" step="1" placeholder="Std min" value={o.standardMinutes}
  155 + onChange={(e) => updateOp(idx, 'standardMinutes', e.target.value)}
  156 + className="w-20 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" />
  157 + <button type="button" className="text-slate-400 hover:text-rose-500" onClick={() => removeOp(idx)}>&times;</button>
  158 + </div>
  159 + ))}
  160 + </div>
  161 + </div>
  162 +
  163 + {error && <ErrorBox error={error} />}
  164 + <button type="submit" className="btn-primary" disabled={submitting}>
  165 + {submitting ? 'Creating...' : 'Create Work Order'}
  166 + </button>
  167 + </form>
  168 + </div>
  169 + )
  170 +}
... ...
web/src/pages/ItemsPage.tsx
1 1 import { useEffect, useState } from 'react'
  2 +import { Link } from 'react-router-dom'
2 3 import { catalog } from '@/api/client'
3 4 import type { Item } from '@/types/api'
4 5 import { PageHeader } from '@/components/PageHeader'
... ... @@ -35,7 +36,8 @@ export function ItemsPage() {
35 36 <div>
36 37 <PageHeader
37 38 title="Items"
38   - subtitle="Catalog of items the framework can transact: raw materials, components, finished goods, services."
  39 + subtitle="Catalog of items the framework can transact: raw materials, finished goods, services."
  40 + actions={<Link to="/items/new" className="btn-primary">+ New Item</Link>}
39 41 />
40 42 {loading && <Loading />}
41 43 {error && <ErrorBox error={error} />}
... ...
web/src/pages/PartnersPage.tsx
1 1 import { useEffect, useState } from 'react'
  2 +import { Link } from 'react-router-dom'
2 3 import { partners } from '@/api/client'
3 4 import type { Partner } from '@/types/api'
4 5 import { PageHeader } from '@/components/PageHeader'
... ... @@ -36,7 +37,8 @@ export function PartnersPage() {
36 37 <div>
37 38 <PageHeader
38 39 title="Partners"
39   - subtitle="Customers, suppliers, and partners that play both roles. Seeded by the demo."
  40 + subtitle="Customers, suppliers, and dual-role partners."
  41 + actions={<Link to="/partners/new" className="btn-primary">+ New Partner</Link>}
40 42 />
41 43 {loading && <Loading />}
42 44 {error && <ErrorBox error={error} />}
... ...
web/src/pages/PurchaseOrdersPage.tsx
... ... @@ -51,7 +51,11 @@ export function PurchaseOrdersPage() {
51 51  
52 52 return (
53 53 <div>
54   - <PageHeader title="Purchase Orders" subtitle="Supplier-facing orders. Click a code to drill in." />
  54 + <PageHeader
  55 + title="Purchase Orders"
  56 + subtitle="Supplier-facing orders. Confirm and receive to credit inventory."
  57 + actions={<Link to="/purchase-orders/new" className="btn-primary">+ New Order</Link>}
  58 + />
55 59 {loading && <Loading />}
56 60 {error && <ErrorBox error={error} />}
57 61 {!loading && !error && <DataTable rows={rows} columns={columns} />}
... ...
web/src/pages/WorkOrdersPage.tsx
... ... @@ -52,7 +52,11 @@ export function WorkOrdersPage() {
52 52  
53 53 return (
54 54 <div>
55   - <PageHeader title="Work Orders" subtitle="Production orders the framework consumes inputs and produces outputs through." />
  55 + <PageHeader
  56 + title="Work Orders"
  57 + subtitle="Production orders with BOM inputs and routing operations."
  58 + actions={<Link to="/work-orders/new" className="btn-primary">+ New Work Order</Link>}
  59 + />
56 60 {loading && <Loading />}
57 61 {error && <ErrorBox error={error} />}
58 62 {!loading && !error && <DataTable rows={rows} columns={columns} />}
... ...