Commit 25353240e8ce29cee6982f17981576c0d4d38281
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.
Showing
10 changed files
with
524 additions
and
4 deletions
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">×</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)}>×</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)}>×</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} />} | ... | ... |
-
mentioned in commit 34c8c92f