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,8 +14,10 @@ import { ProtectedRoute } from '@/components/ProtectedRoute'
14 import { LoginPage } from '@/pages/LoginPage' 14 import { LoginPage } from '@/pages/LoginPage'
15 import { DashboardPage } from '@/pages/DashboardPage' 15 import { DashboardPage } from '@/pages/DashboardPage'
16 import { ItemsPage } from '@/pages/ItemsPage' 16 import { ItemsPage } from '@/pages/ItemsPage'
  17 +import { CreateItemPage } from '@/pages/CreateItemPage'
17 import { UomsPage } from '@/pages/UomsPage' 18 import { UomsPage } from '@/pages/UomsPage'
18 import { PartnersPage } from '@/pages/PartnersPage' 19 import { PartnersPage } from '@/pages/PartnersPage'
  20 +import { CreatePartnerPage } from '@/pages/CreatePartnerPage'
19 import { LocationsPage } from '@/pages/LocationsPage' 21 import { LocationsPage } from '@/pages/LocationsPage'
20 import { BalancesPage } from '@/pages/BalancesPage' 22 import { BalancesPage } from '@/pages/BalancesPage'
21 import { MovementsPage } from '@/pages/MovementsPage' 23 import { MovementsPage } from '@/pages/MovementsPage'
@@ -23,8 +25,10 @@ import { SalesOrdersPage } from '@/pages/SalesOrdersPage' @@ -23,8 +25,10 @@ import { SalesOrdersPage } from '@/pages/SalesOrdersPage'
23 import { CreateSalesOrderPage } from '@/pages/CreateSalesOrderPage' 25 import { CreateSalesOrderPage } from '@/pages/CreateSalesOrderPage'
24 import { SalesOrderDetailPage } from '@/pages/SalesOrderDetailPage' 26 import { SalesOrderDetailPage } from '@/pages/SalesOrderDetailPage'
25 import { PurchaseOrdersPage } from '@/pages/PurchaseOrdersPage' 27 import { PurchaseOrdersPage } from '@/pages/PurchaseOrdersPage'
  28 +import { CreatePurchaseOrderPage } from '@/pages/CreatePurchaseOrderPage'
26 import { PurchaseOrderDetailPage } from '@/pages/PurchaseOrderDetailPage' 29 import { PurchaseOrderDetailPage } from '@/pages/PurchaseOrderDetailPage'
27 import { WorkOrdersPage } from '@/pages/WorkOrdersPage' 30 import { WorkOrdersPage } from '@/pages/WorkOrdersPage'
  31 +import { CreateWorkOrderPage } from '@/pages/CreateWorkOrderPage'
28 import { WorkOrderDetailPage } from '@/pages/WorkOrderDetailPage' 32 import { WorkOrderDetailPage } from '@/pages/WorkOrderDetailPage'
29 import { ShopFloorPage } from '@/pages/ShopFloorPage' 33 import { ShopFloorPage } from '@/pages/ShopFloorPage'
30 import { JournalEntriesPage } from '@/pages/JournalEntriesPage' 34 import { JournalEntriesPage } from '@/pages/JournalEntriesPage'
@@ -43,8 +47,10 @@ export default function App() { @@ -43,8 +47,10 @@ export default function App() {
43 > 47 >
44 <Route index element={<DashboardPage />} /> 48 <Route index element={<DashboardPage />} />
45 <Route path="items" element={<ItemsPage />} /> 49 <Route path="items" element={<ItemsPage />} />
  50 + <Route path="items/new" element={<CreateItemPage />} />
46 <Route path="uoms" element={<UomsPage />} /> 51 <Route path="uoms" element={<UomsPage />} />
47 <Route path="partners" element={<PartnersPage />} /> 52 <Route path="partners" element={<PartnersPage />} />
  53 + <Route path="partners/new" element={<CreatePartnerPage />} />
48 <Route path="locations" element={<LocationsPage />} /> 54 <Route path="locations" element={<LocationsPage />} />
49 <Route path="balances" element={<BalancesPage />} /> 55 <Route path="balances" element={<BalancesPage />} />
50 <Route path="movements" element={<MovementsPage />} /> 56 <Route path="movements" element={<MovementsPage />} />
@@ -52,8 +58,10 @@ export default function App() { @@ -52,8 +58,10 @@ export default function App() {
52 <Route path="sales-orders/new" element={<CreateSalesOrderPage />} /> 58 <Route path="sales-orders/new" element={<CreateSalesOrderPage />} />
53 <Route path="sales-orders/:id" element={<SalesOrderDetailPage />} /> 59 <Route path="sales-orders/:id" element={<SalesOrderDetailPage />} />
54 <Route path="purchase-orders" element={<PurchaseOrdersPage />} /> 60 <Route path="purchase-orders" element={<PurchaseOrdersPage />} />
  61 + <Route path="purchase-orders/new" element={<CreatePurchaseOrderPage />} />
55 <Route path="purchase-orders/:id" element={<PurchaseOrderDetailPage />} /> 62 <Route path="purchase-orders/:id" element={<PurchaseOrderDetailPage />} />
56 <Route path="work-orders" element={<WorkOrdersPage />} /> 63 <Route path="work-orders" element={<WorkOrdersPage />} />
  64 + <Route path="work-orders/new" element={<CreateWorkOrderPage />} />
57 <Route path="work-orders/:id" element={<WorkOrderDetailPage />} /> 65 <Route path="work-orders/:id" element={<WorkOrderDetailPage />} />
58 <Route path="shop-floor" element={<ShopFloorPage />} /> 66 <Route path="shop-floor" element={<ShopFloorPage />} />
59 <Route path="journal-entries" element={<JournalEntriesPage />} /> 67 <Route path="journal-entries" element={<JournalEntriesPage />} />
web/src/api/client.ts
@@ -136,6 +136,10 @@ export const auth = { @@ -136,6 +136,10 @@ export const auth = {
136 export const catalog = { 136 export const catalog = {
137 listItems: () => apiFetch<Item[]>('/api/v1/catalog/items'), 137 listItems: () => apiFetch<Item[]>('/api/v1/catalog/items'),
138 getItem: (id: string) => apiFetch<Item>(`/api/v1/catalog/items/${id}`), 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 listUoms: () => apiFetch<Uom[]>('/api/v1/catalog/uoms'), 143 listUoms: () => apiFetch<Uom[]>('/api/v1/catalog/uoms'),
140 } 144 }
141 145
@@ -144,6 +148,11 @@ export const catalog = { @@ -144,6 +148,11 @@ export const catalog = {
144 export const partners = { 148 export const partners = {
145 list: () => apiFetch<Partner[]>('/api/v1/partners/partners'), 149 list: () => apiFetch<Partner[]>('/api/v1/partners/partners'),
146 get: (id: string) => apiFetch<Partner>(`/api/v1/partners/partners/${id}`), 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 // ─── Inventory ─────────────────────────────────────────────────────── 158 // ─── Inventory ───────────────────────────────────────────────────────
@@ -190,6 +199,11 @@ export const salesOrders = { @@ -190,6 +199,11 @@ export const salesOrders = {
190 export const purchaseOrders = { 199 export const purchaseOrders = {
191 list: () => apiFetch<PurchaseOrder[]>('/api/v1/orders/purchase-orders'), 200 list: () => apiFetch<PurchaseOrder[]>('/api/v1/orders/purchase-orders'),
192 get: (id: string) => apiFetch<PurchaseOrder>(`/api/v1/orders/purchase-orders/${id}`), 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 confirm: (id: string) => 207 confirm: (id: string) =>
194 apiFetch<PurchaseOrder>(`/api/v1/orders/purchase-orders/${id}/confirm`, { 208 apiFetch<PurchaseOrder>(`/api/v1/orders/purchase-orders/${id}/confirm`, {
195 method: 'POST', 209 method: 'POST',
@@ -211,6 +225,12 @@ export const production = { @@ -211,6 +225,12 @@ export const production = {
211 listWorkOrders: () => apiFetch<WorkOrder[]>('/api/v1/production/work-orders'), 225 listWorkOrders: () => apiFetch<WorkOrder[]>('/api/v1/production/work-orders'),
212 getWorkOrder: (id: string) => 226 getWorkOrder: (id: string) =>
213 apiFetch<WorkOrder>(`/api/v1/production/work-orders/${id}`), 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 startWorkOrder: (id: string) => 234 startWorkOrder: (id: string) =>
215 apiFetch<WorkOrder>(`/api/v1/production/work-orders/${id}/start`, { 235 apiFetch<WorkOrder>(`/api/v1/production/work-orders/${id}/start`, {
216 method: 'POST', 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 import { useEffect, useState } from 'react' 1 import { useEffect, useState } from 'react'
  2 +import { Link } from 'react-router-dom'
2 import { catalog } from '@/api/client' 3 import { catalog } from '@/api/client'
3 import type { Item } from '@/types/api' 4 import type { Item } from '@/types/api'
4 import { PageHeader } from '@/components/PageHeader' 5 import { PageHeader } from '@/components/PageHeader'
@@ -35,7 +36,8 @@ export function ItemsPage() { @@ -35,7 +36,8 @@ export function ItemsPage() {
35 <div> 36 <div>
36 <PageHeader 37 <PageHeader
37 title="Items" 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 {loading && <Loading />} 42 {loading && <Loading />}
41 {error && <ErrorBox error={error} />} 43 {error && <ErrorBox error={error} />}
web/src/pages/PartnersPage.tsx
1 import { useEffect, useState } from 'react' 1 import { useEffect, useState } from 'react'
  2 +import { Link } from 'react-router-dom'
2 import { partners } from '@/api/client' 3 import { partners } from '@/api/client'
3 import type { Partner } from '@/types/api' 4 import type { Partner } from '@/types/api'
4 import { PageHeader } from '@/components/PageHeader' 5 import { PageHeader } from '@/components/PageHeader'
@@ -36,7 +37,8 @@ export function PartnersPage() { @@ -36,7 +37,8 @@ export function PartnersPage() {
36 <div> 37 <div>
37 <PageHeader 38 <PageHeader
38 title="Partners" 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 {loading && <Loading />} 43 {loading && <Loading />}
42 {error && <ErrorBox error={error} />} 44 {error && <ErrorBox error={error} />}
web/src/pages/PurchaseOrdersPage.tsx
@@ -51,7 +51,11 @@ export function PurchaseOrdersPage() { @@ -51,7 +51,11 @@ export function PurchaseOrdersPage() {
51 51
52 return ( 52 return (
53 <div> 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 {loading && <Loading />} 59 {loading && <Loading />}
56 {error && <ErrorBox error={error} />} 60 {error && <ErrorBox error={error} />}
57 {!loading && !error && <DataTable rows={rows} columns={columns} />} 61 {!loading && !error && <DataTable rows={rows} columns={columns} />}
web/src/pages/WorkOrdersPage.tsx
@@ -52,7 +52,11 @@ export function WorkOrdersPage() { @@ -52,7 +52,11 @@ export function WorkOrdersPage() {
52 52
53 return ( 53 return (
54 <div> 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 {loading && <Loading />} 60 {loading && <Loading />}
57 {error && <ErrorBox error={error} />} 61 {error && <ErrorBox error={error} />}
58 {!loading && !error && <DataTable rows={rows} columns={columns} />} 62 {!loading && !error && <DataTable rows={rows} columns={columns} />}