diff --git a/web/src/App.tsx b/web/src/App.tsx index 9bf7dda..eb62e7f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -14,8 +14,10 @@ import { ProtectedRoute } from '@/components/ProtectedRoute' import { LoginPage } from '@/pages/LoginPage' import { DashboardPage } from '@/pages/DashboardPage' import { ItemsPage } from '@/pages/ItemsPage' +import { CreateItemPage } from '@/pages/CreateItemPage' import { UomsPage } from '@/pages/UomsPage' import { PartnersPage } from '@/pages/PartnersPage' +import { CreatePartnerPage } from '@/pages/CreatePartnerPage' import { LocationsPage } from '@/pages/LocationsPage' import { BalancesPage } from '@/pages/BalancesPage' import { MovementsPage } from '@/pages/MovementsPage' @@ -23,8 +25,10 @@ import { SalesOrdersPage } from '@/pages/SalesOrdersPage' import { CreateSalesOrderPage } from '@/pages/CreateSalesOrderPage' import { SalesOrderDetailPage } from '@/pages/SalesOrderDetailPage' import { PurchaseOrdersPage } from '@/pages/PurchaseOrdersPage' +import { CreatePurchaseOrderPage } from '@/pages/CreatePurchaseOrderPage' import { PurchaseOrderDetailPage } from '@/pages/PurchaseOrderDetailPage' import { WorkOrdersPage } from '@/pages/WorkOrdersPage' +import { CreateWorkOrderPage } from '@/pages/CreateWorkOrderPage' import { WorkOrderDetailPage } from '@/pages/WorkOrderDetailPage' import { ShopFloorPage } from '@/pages/ShopFloorPage' import { JournalEntriesPage } from '@/pages/JournalEntriesPage' @@ -43,8 +47,10 @@ export default function App() { > } /> } /> + } /> } /> } /> + } /> } /> } /> } /> @@ -52,8 +58,10 @@ export default function App() { } /> } /> } /> + } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index e879b3c..369778c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -136,6 +136,10 @@ export const auth = { export const catalog = { listItems: () => apiFetch('/api/v1/catalog/items'), getItem: (id: string) => apiFetch(`/api/v1/catalog/items/${id}`), + createItem: (body: { + code: string; name: string; description?: string | null; + itemType: string; baseUomCode: string; active?: boolean + }) => apiFetch('/api/v1/catalog/items', { method: 'POST', body: JSON.stringify(body) }), listUoms: () => apiFetch('/api/v1/catalog/uoms'), } @@ -144,6 +148,11 @@ export const catalog = { export const partners = { list: () => apiFetch('/api/v1/partners/partners'), get: (id: string) => apiFetch(`/api/v1/partners/partners/${id}`), + create: (body: { + code: string; name: string; type: string; + email?: string | null; phone?: string | null; + taxId?: string | null; website?: string | null + }) => apiFetch('/api/v1/partners/partners', { method: 'POST', body: JSON.stringify(body) }), } // ─── Inventory ─────────────────────────────────────────────────────── @@ -190,6 +199,11 @@ export const salesOrders = { export const purchaseOrders = { list: () => apiFetch('/api/v1/orders/purchase-orders'), get: (id: string) => apiFetch(`/api/v1/orders/purchase-orders/${id}`), + create: (body: { + code: string; partnerCode: string; orderDate: string; + expectedDate?: string | null; currencyCode: string; + lines: { lineNo: number; itemCode: string; quantity: number; unitPrice: number; currencyCode: string }[] + }) => apiFetch('/api/v1/orders/purchase-orders', { method: 'POST', body: JSON.stringify(body) }), confirm: (id: string) => apiFetch(`/api/v1/orders/purchase-orders/${id}/confirm`, { method: 'POST', @@ -211,6 +225,12 @@ export const production = { listWorkOrders: () => apiFetch('/api/v1/production/work-orders'), getWorkOrder: (id: string) => apiFetch(`/api/v1/production/work-orders/${id}`), + createWorkOrder: (body: { + code: string; outputItemCode: string; outputQuantity: number; + dueDate?: string | null; sourceSalesOrderCode?: string | null; + inputs?: { lineNo: number; itemCode: string; quantityPerUnit: number; sourceLocationCode: string }[]; + operations?: { lineNo: number; operationCode: string; workCenter: string; standardMinutes: number }[]; + }) => apiFetch('/api/v1/production/work-orders', { method: 'POST', body: JSON.stringify(body) }), startWorkOrder: (id: string) => apiFetch(`/api/v1/production/work-orders/${id}/start`, { method: 'POST', diff --git a/web/src/pages/CreateItemPage.tsx b/web/src/pages/CreateItemPage.tsx new file mode 100644 index 0000000..6016f99 --- /dev/null +++ b/web/src/pages/CreateItemPage.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { catalog } from '@/api/client' +import type { Uom } from '@/types/api' +import { PageHeader } from '@/components/PageHeader' +import { ErrorBox } from '@/components/ErrorBox' + +const ITEM_TYPES = ['GOOD', 'SERVICE', 'DIGITAL'] as const + +export function CreateItemPage() { + const navigate = useNavigate() + const [code, setCode] = useState('') + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [itemType, setItemType] = useState('GOOD') + const [baseUomCode, setBaseUomCode] = useState('') + const [uoms, setUoms] = useState([]) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + catalog.listUoms().then((u) => { + setUoms(u) + if (u.length > 0 && !baseUomCode) setBaseUomCode(u[0].code) + }) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const onSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(null) + setSubmitting(true) + try { + await catalog.createItem({ + code, name, itemType, baseUomCode, + description: description || null, + }) + navigate('/items') + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setSubmitting(false) + } + } + + return ( +
+ navigate('/items')}>Cancel} + /> +
+
+
+ + setCode(e.target.value)} + placeholder="PAPER-120G-A3" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> +
+
+ + setName(e.target.value)} + placeholder="120g A3 coated paper" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> +
+
+ + +
+
+ + +
+
+
+ + setDescription(e.target.value)} + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> +
+ {error && } + + +
+ ) +} diff --git a/web/src/pages/CreatePartnerPage.tsx b/web/src/pages/CreatePartnerPage.tsx new file mode 100644 index 0000000..c57a9aa --- /dev/null +++ b/web/src/pages/CreatePartnerPage.tsx @@ -0,0 +1,81 @@ +import { useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { partners } from '@/api/client' +import { PageHeader } from '@/components/PageHeader' +import { ErrorBox } from '@/components/ErrorBox' + +const PARTNER_TYPES = ['CUSTOMER', 'SUPPLIER', 'BOTH'] as const + +export function CreatePartnerPage() { + const navigate = useNavigate() + const [code, setCode] = useState('') + const [name, setName] = useState('') + const [type, setType] = useState('CUSTOMER') + const [email, setEmail] = useState('') + const [phone, setPhone] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const onSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(null) + setSubmitting(true) + try { + await partners.create({ + code, name, type, + email: email || null, + phone: phone || null, + }) + navigate('/partners') + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setSubmitting(false) + } + } + + return ( +
+ navigate('/partners')}>Cancel} + /> +
+
+
+ + setCode(e.target.value)} + placeholder="CUST-NEWCO" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> +
+
+ + setName(e.target.value)} + placeholder="New Company Ltd." className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> +
+
+ + +
+
+ + setEmail(e.target.value)} + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> +
+
+ + setPhone(e.target.value)} + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> +
+
+ {error && } + + +
+ ) +} diff --git a/web/src/pages/CreatePurchaseOrderPage.tsx b/web/src/pages/CreatePurchaseOrderPage.tsx new file mode 100644 index 0000000..a2fc1de --- /dev/null +++ b/web/src/pages/CreatePurchaseOrderPage.tsx @@ -0,0 +1,138 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { catalog, partners, purchaseOrders } from '@/api/client' +import type { Item, Partner } from '@/types/api' +import { PageHeader } from '@/components/PageHeader' +import { ErrorBox } from '@/components/ErrorBox' + +interface LineInput { + itemCode: string + quantity: string + unitPrice: string +} + +export function CreatePurchaseOrderPage() { + const navigate = useNavigate() + const [code, setCode] = useState('') + const [partnerCode, setPartnerCode] = useState('') + const [expectedDate, setExpectedDate] = useState('') + const [currencyCode] = useState('USD') + const [lines, setLines] = useState([{ itemCode: '', quantity: '', unitPrice: '' }]) + const [items, setItems] = useState([]) + const [supplierList, setSupplierList] = useState([]) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + Promise.all([catalog.listItems(), partners.list()]).then(([i, p]) => { + setItems(i) + const suppliers = p.filter((x) => x.type === 'SUPPLIER' || x.type === 'BOTH') + setSupplierList(suppliers) + if (suppliers.length > 0 && !partnerCode) setPartnerCode(suppliers[0].code) + }) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const addLine = () => + setLines([...lines, { itemCode: items[0]?.code ?? '', quantity: '1', unitPrice: '1.00' }]) + + const removeLine = (idx: number) => { + if (lines.length <= 1) return + setLines(lines.filter((_, i) => i !== idx)) + } + + const updateLine = (idx: number, field: keyof LineInput, value: string) => { + const next = [...lines] + next[idx] = { ...next[idx], [field]: value } + setLines(next) + } + + const onSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(null) + setSubmitting(true) + try { + const created = await purchaseOrders.create({ + code, partnerCode, currencyCode, + orderDate: new Date().toISOString().slice(0, 10), + expectedDate: expectedDate || null, + lines: lines.map((l, i) => ({ + lineNo: i + 1, + itemCode: l.itemCode, + quantity: Number(l.quantity), + unitPrice: Number(l.unitPrice), + currencyCode, + })), + }) + navigate(`/purchase-orders/${created.id}`) + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setSubmitting(false) + } + } + + return ( +
+ navigate('/purchase-orders')}>Cancel} + /> +
+
+
+ + setCode(e.target.value)} + placeholder="PO-2026-0002" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> +
+
+ + +
+
+ + setExpectedDate(e.target.value)} + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> +
+
+ +
+
+ + +
+
+ {lines.map((line, idx) => ( +
+ {idx + 1} + + updateLine(idx, 'quantity', e.target.value)} + className="w-20 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" /> + updateLine(idx, 'unitPrice', e.target.value)} + className="w-24 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" /> + +
+ ))} +
+
+ + {error && } + + +
+ ) +} diff --git a/web/src/pages/CreateWorkOrderPage.tsx b/web/src/pages/CreateWorkOrderPage.tsx new file mode 100644 index 0000000..05afd25 --- /dev/null +++ b/web/src/pages/CreateWorkOrderPage.tsx @@ -0,0 +1,170 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { catalog, inventory, production } from '@/api/client' +import type { Item, Location } from '@/types/api' +import { PageHeader } from '@/components/PageHeader' +import { ErrorBox } from '@/components/ErrorBox' + +interface BomLine { itemCode: string; quantityPerUnit: string; sourceLocationCode: string } +interface OpLine { operationCode: string; workCenter: string; standardMinutes: string } + +export function CreateWorkOrderPage() { + const navigate = useNavigate() + const [code, setCode] = useState('') + const [outputItemCode, setOutputItemCode] = useState('') + const [outputQuantity, setOutputQuantity] = useState('') + const [dueDate, setDueDate] = useState('') + const [bom, setBom] = useState([]) + const [ops, setOps] = useState([]) + const [items, setItems] = useState([]) + const [locations, setLocations] = useState([]) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + Promise.all([catalog.listItems(), inventory.listLocations()]).then(([i, l]) => { + setItems(i) + setLocations(l.filter((x) => x.active)) + }) + }, []) + + const addBom = () => + setBom([...bom, { itemCode: '', quantityPerUnit: '1', sourceLocationCode: locations[0]?.code ?? '' }]) + const removeBom = (i: number) => setBom(bom.filter((_, idx) => idx !== i)) + const updateBom = (i: number, f: keyof BomLine, v: string) => { + const next = [...bom]; next[i] = { ...next[i], [f]: v }; setBom(next) + } + + const addOp = () => + setOps([...ops, { operationCode: '', workCenter: '', standardMinutes: '30' }]) + const removeOp = (i: number) => setOps(ops.filter((_, idx) => idx !== i)) + const updateOp = (i: number, f: keyof OpLine, v: string) => { + const next = [...ops]; next[i] = { ...next[i], [f]: v }; setOps(next) + } + + const onSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(null) + setSubmitting(true) + try { + const created = await production.createWorkOrder({ + code, outputItemCode, + outputQuantity: Number(outputQuantity), + dueDate: dueDate || null, + inputs: bom.map((b, i) => ({ + lineNo: i + 1, itemCode: b.itemCode, + quantityPerUnit: Number(b.quantityPerUnit), + sourceLocationCode: b.sourceLocationCode, + })), + operations: ops.map((o, i) => ({ + lineNo: i + 1, operationCode: o.operationCode, + workCenter: o.workCenter, + standardMinutes: Number(o.standardMinutes), + })), + }) + navigate(`/work-orders/${created.id}`) + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setSubmitting(false) + } + } + + return ( +
+ navigate('/work-orders')}>Cancel} + /> +
+
+
+ + setCode(e.target.value)} + placeholder="WO-PRINT-0002" className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> +
+
+ + +
+
+ + setOutputQuantity(e.target.value)} + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-right" /> +
+
+
+ + setDueDate(e.target.value)} + className="mt-1 max-w-xs rounded-md border border-slate-300 px-3 py-2 text-sm" /> +
+ + {/* ─── BOM inputs ─────────────────────────────────────── */} +
+
+ + +
+ {bom.length === 0 &&

No BOM lines. Output will be produced without consuming materials.

} +
+ {bom.map((b, idx) => ( +
+ {idx + 1} + + updateBom(idx, 'quantityPerUnit', e.target.value)} + className="w-24 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" /> + + +
+ ))} +
+
+ + {/* ─── Routing operations ─────────────────────────────── */} +
+
+ + +
+ {ops.length === 0 &&

No routing. Work order completes in one step.

} +
+ {ops.map((o, idx) => ( +
+ {idx + 1} + updateOp(idx, 'operationCode', e.target.value)} + className="w-28 rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> + updateOp(idx, 'workCenter', e.target.value)} + className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> + updateOp(idx, 'standardMinutes', e.target.value)} + className="w-20 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" /> + +
+ ))} +
+
+ + {error && } + + +
+ ) +} diff --git a/web/src/pages/ItemsPage.tsx b/web/src/pages/ItemsPage.tsx index 43ec005..eadf650 100644 --- a/web/src/pages/ItemsPage.tsx +++ b/web/src/pages/ItemsPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' import { catalog } from '@/api/client' import type { Item } from '@/types/api' import { PageHeader } from '@/components/PageHeader' @@ -35,7 +36,8 @@ export function ItemsPage() {
+ New Item} /> {loading && } {error && } diff --git a/web/src/pages/PartnersPage.tsx b/web/src/pages/PartnersPage.tsx index 7e75e75..1044e59 100644 --- a/web/src/pages/PartnersPage.tsx +++ b/web/src/pages/PartnersPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' import { partners } from '@/api/client' import type { Partner } from '@/types/api' import { PageHeader } from '@/components/PageHeader' @@ -36,7 +37,8 @@ export function PartnersPage() {
+ New Partner} /> {loading && } {error && } diff --git a/web/src/pages/PurchaseOrdersPage.tsx b/web/src/pages/PurchaseOrdersPage.tsx index 0850f23..b8419c4 100644 --- a/web/src/pages/PurchaseOrdersPage.tsx +++ b/web/src/pages/PurchaseOrdersPage.tsx @@ -51,7 +51,11 @@ export function PurchaseOrdersPage() { return (
- + + New Order} + /> {loading && } {error && } {!loading && !error && } diff --git a/web/src/pages/WorkOrdersPage.tsx b/web/src/pages/WorkOrdersPage.tsx index df5fa15..a928016 100644 --- a/web/src/pages/WorkOrdersPage.tsx +++ b/web/src/pages/WorkOrdersPage.tsx @@ -52,7 +52,11 @@ export function WorkOrdersPage() { return (
- + + New Work Order} + /> {loading && } {error && } {!loading && !error && }