diff --git a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt index a22a231..73d4c62 100644 --- a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt +++ b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt @@ -164,6 +164,36 @@ class WorkOrderService( fun parseExt(order: WorkOrder): Map = extValidator.parseExt(order) + /** + * Update a DRAFT work order's mutable fields (output quantity, + * due date, ext). The output item code, BOM inputs, and routing + * operations are immutable after creation in this version; a + * future chunk may relax that constraint for BOM editing before + * start(). Only DRAFT orders are mutable — once started, the + * shop floor is running and header edits are off-limits. + */ + fun update(id: UUID, command: UpdateWorkOrderCommand): WorkOrder { + val order = orders.findById(id).orElseThrow { + NoSuchElementException("work order not found: $id") + } + require(order.status == WorkOrderStatus.DRAFT) { + "cannot update work order ${order.code} in status ${order.status}; only DRAFT orders are mutable" + } + + command.outputQuantity?.let { + require(it.signum() > 0) { + "output quantity must be positive (got $it)" + } + order.outputQuantity = it + } + command.dueDate?.let { order.dueDate = it } + + // applyTo() is null-safe — a null command.ext is a no-op. + extValidator.applyTo(order, command.ext) + + return order + } + fun create(command: CreateWorkOrderCommand): WorkOrder { require(!orders.existsByCode(command.code)) { "work order code '${command.code}' is already taken" @@ -671,6 +701,20 @@ data class WorkOrderOperationCommand( ) /** + * Command for updating a DRAFT work order's mutable fields. + * + * All fields are nullable — a null field means "leave the current + * value unchanged" (standard PATCH semantics). The output item + * code, BOM inputs, and routing operations are NOT updatable in + * this version. + */ +data class UpdateWorkOrderCommand( + val outputQuantity: BigDecimal? = null, + val dueDate: LocalDate? = null, + val ext: Map? = null, +) + +/** * One row in the shop-floor dashboard snapshot returned by * [WorkOrderService.shopFloorSnapshot]. Carries enough info for a * dashboard UI to render "WO X is running step N of M at work diff --git a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt index 1d4c2ae..a016e01 100644 --- a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt +++ b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt @@ -9,6 +9,7 @@ import jakarta.validation.constraints.Size import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -17,6 +18,7 @@ import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import org.vibeerp.pbc.production.application.CreateWorkOrderCommand import org.vibeerp.pbc.production.application.ShopFloorEntry +import org.vibeerp.pbc.production.application.UpdateWorkOrderCommand import org.vibeerp.pbc.production.application.WorkOrderInputCommand import org.vibeerp.pbc.production.application.WorkOrderOperationCommand import org.vibeerp.pbc.production.application.WorkOrderService @@ -92,6 +94,21 @@ class WorkOrderController( fun create(@RequestBody @Valid request: CreateWorkOrderRequest): WorkOrderResponse = workOrderService.create(request.toCommand()).toResponse(workOrderService) + @PatchMapping("/{id}") + @RequirePermission("production.work-order.update") + fun update( + @PathVariable id: UUID, + @RequestBody @Valid request: UpdateWorkOrderRequest, + ): WorkOrderResponse = + workOrderService.update( + id, + UpdateWorkOrderCommand( + outputQuantity = request.outputQuantity, + dueDate = request.dueDate, + ext = request.ext, + ), + ).toResponse(workOrderService) + /** * Start a DRAFT work order — flip to IN_PROGRESS. v2 state * machine: DRAFT → IN_PROGRESS. Nothing is written to the @@ -213,6 +230,12 @@ data class CreateWorkOrderRequest( ) } +data class UpdateWorkOrderRequest( + val outputQuantity: BigDecimal? = null, + val dueDate: LocalDate? = null, + val ext: Map? = null, +) + data class WorkOrderInputRequest( @field:NotNull val lineNo: Int, @field:NotBlank @field:Size(max = 64) val itemCode: String, diff --git a/pbc/pbc-production/src/main/resources/META-INF/vibe-erp/metadata/production.yml b/pbc/pbc-production/src/main/resources/META-INF/vibe-erp/metadata/production.yml index 2b0e3f8..f842027 100644 --- a/pbc/pbc-production/src/main/resources/META-INF/vibe-erp/metadata/production.yml +++ b/pbc/pbc-production/src/main/resources/META-INF/vibe-erp/metadata/production.yml @@ -14,6 +14,8 @@ permissions: description: Read work orders - key: production.work-order.create description: Create draft work orders + - key: production.work-order.update + description: Update a DRAFT work order (output quantity, due date, ext) - key: production.work-order.start description: Start a work order (DRAFT → IN_PROGRESS) - key: production.work-order.complete diff --git a/web/src/App.tsx b/web/src/App.tsx index dd0baeb..905192f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -26,18 +26,22 @@ import { CreatePartnerPage } from '@/pages/CreatePartnerPage' import { EditPartnerPage } from '@/pages/EditPartnerPage' import { LocationsPage } from '@/pages/LocationsPage' import { CreateLocationPage } from '@/pages/CreateLocationPage' +import { EditLocationPage } from '@/pages/EditLocationPage' import { BalancesPage } from '@/pages/BalancesPage' import { AdjustStockPage } from '@/pages/AdjustStockPage' import { MovementsPage } from '@/pages/MovementsPage' import { SalesOrdersPage } from '@/pages/SalesOrdersPage' import { CreateSalesOrderPage } from '@/pages/CreateSalesOrderPage' import { SalesOrderDetailPage } from '@/pages/SalesOrderDetailPage' +import { EditSalesOrderPage } from '@/pages/EditSalesOrderPage' import { PurchaseOrdersPage } from '@/pages/PurchaseOrdersPage' import { CreatePurchaseOrderPage } from '@/pages/CreatePurchaseOrderPage' import { PurchaseOrderDetailPage } from '@/pages/PurchaseOrderDetailPage' +import { EditPurchaseOrderPage } from '@/pages/EditPurchaseOrderPage' import { WorkOrdersPage } from '@/pages/WorkOrdersPage' import { CreateWorkOrderPage } from '@/pages/CreateWorkOrderPage' import { WorkOrderDetailPage } from '@/pages/WorkOrderDetailPage' +import { EditWorkOrderPage } from '@/pages/EditWorkOrderPage' import { ShopFloorPage } from '@/pages/ShopFloorPage' import { AccountsPage } from '@/pages/AccountsPage' import { JournalEntriesPage } from '@/pages/JournalEntriesPage' @@ -73,18 +77,22 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> } /> } /> } /> + } /> } /> } /> } /> + } /> } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 28dbf28..2563d68 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -202,8 +202,13 @@ export const partners = { export const inventory = { listLocations: () => apiFetch('/api/v1/inventory/locations'), + getLocation: (id: string) => apiFetch(`/api/v1/inventory/locations/${id}`), createLocation: (body: { code: string; name: string; type: string }) => apiFetch('/api/v1/inventory/locations', { method: 'POST', body: JSON.stringify(body) }), + updateLocation: (id: string, body: { + name?: string; type?: string; active?: boolean; + ext?: Record + }) => apiFetch(`/api/v1/inventory/locations/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), listBalances: () => apiFetch('/api/v1/inventory/balances'), adjustBalance: (body: { itemCode: string; locationId: string; quantity: number }) => apiFetch('/api/v1/inventory/balances/adjust', { method: 'POST', body: JSON.stringify(body) }), @@ -226,6 +231,11 @@ export const salesOrders = { method: 'POST', body: JSON.stringify(body), }), + update: (id: string, body: { + partnerCode?: string; orderDate?: string; currencyCode?: string; + lines?: { lineNo: number; itemCode: string; quantity: number; unitPrice: number; currencyCode: string }[]; + ext?: Record + }) => apiFetch(`/api/v1/orders/sales-orders/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), confirm: (id: string) => apiFetch(`/api/v1/orders/sales-orders/${id}/confirm`, { method: 'POST', @@ -251,6 +261,11 @@ export const purchaseOrders = { 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) }), + update: (id: string, body: { + partnerCode?: string; orderDate?: string; expectedDate?: string | null; currencyCode?: string; + lines?: { lineNo: number; itemCode: string; quantity: number; unitPrice: number; currencyCode: string }[]; + ext?: Record + }) => apiFetch(`/api/v1/orders/purchase-orders/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), confirm: (id: string) => apiFetch(`/api/v1/orders/purchase-orders/${id}/confirm`, { method: 'POST', @@ -278,6 +293,10 @@ export const production = { 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) }), + updateWorkOrder: (id: string, body: { + outputQuantity?: number; dueDate?: string | null; + ext?: Record + }) => apiFetch(`/api/v1/production/work-orders/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), startWorkOrder: (id: string) => apiFetch(`/api/v1/production/work-orders/${id}/start`, { method: 'POST', diff --git a/web/src/i18n/messages.ts b/web/src/i18n/messages.ts index 00af7ef..189e50d 100644 --- a/web/src/i18n/messages.ts +++ b/web/src/i18n/messages.ts @@ -142,6 +142,312 @@ export const en = { 'label.enabled': 'Enabled', 'label.conditionLogic': 'Logic', 'action.newRule': 'New Rule', + 'action.saving': 'Saving...', + 'action.adjusting': 'Adjusting...', + 'action.setBalance': 'Set Balance', + 'action.saveChanges': 'Save Changes', + 'action.newForm': '+ New Form', + 'action.newListView': '+ New List View', + 'action.addColumn': '+ Add Column', + 'action.addFilter': '+ Add Filter', + 'action.addCondition': '+ Add Condition', + 'action.addAction': '+ Add Action', + 'action.addInput': '+ Add input', + 'action.addOperation': '+ Add operation', + 'action.addSectionDivider': '+ Add Section Divider', + 'action.saveListView': 'Save List View', + 'action.edit': 'Edit', + 'action.balances': 'Balances', + 'action.selectItem': 'Select item...', + + // ─── Page titles & subtitles ────────────────────────────── + 'page.dashboard.welcome': 'Welcome', + 'page.dashboard.subtitle': "The framework's buy-make-sell loop, end to end through the same Postgres.", + 'page.dashboard.gettingStarted': 'Getting started', + 'page.dashboard.gettingStartedDesc': "The framework's buy-make-sell loop, end to end.", + 'page.dashboard.step1': 'Set up master data', + 'page.dashboard.step1Desc': 'create', + 'page.dashboard.step1DescItems': 'items', + 'page.dashboard.step1DescPartners': 'partners', + 'page.dashboard.step1DescAnd': ', and', + 'page.dashboard.step1DescLocations': 'locations', + 'page.dashboard.step1DescThen': '. Then', + 'page.dashboard.step1DescAdjust': 'adjust stock', + 'page.dashboard.step1DescEnd': 'to set opening balances.', + 'page.dashboard.step2': 'Create a sales order', + 'page.dashboard.step2Link': 'new order', + 'page.dashboard.step2Desc': 'with line items. Confirm it — the system auto-generates production work orders and posts an AR journal entry with double-entry lines (DR Accounts Receivable, CR Revenue).', + 'page.dashboard.step3': 'Walk the work order', + 'page.dashboard.step3Desc1': 'start it, walk routing operations on the', + 'page.dashboard.step3ShopFloor': 'Shop Floor', + 'page.dashboard.step3Desc2': ', then complete it. Materials are consumed, finished goods credited.', + 'page.dashboard.step4': 'Ship the sales order', + 'page.dashboard.step4Desc1': 'stock leaves the warehouse, the AR journal entry settles. View the ledger in', + 'page.dashboard.step4Movements': 'Movements', + 'page.dashboard.step4Desc2': 'and double-entry lines in', + 'page.dashboard.step4JE': 'Journal Entries', + 'page.dashboard.step5': 'Restock via purchase', + 'page.dashboard.step5Desc1': 'create a', + 'page.dashboard.step5Link': 'purchase order', + 'page.dashboard.step5Desc2': ', confirm, and receive into a warehouse. AP journal entry posts and settles.', + 'page.dashboard.cardItems': 'Items', + 'page.dashboard.cardPartners': 'Partners', + 'page.dashboard.cardLocations': 'Locations', + 'page.dashboard.cardWoInProgress': 'Work orders in progress', + 'page.dashboard.cardSalesOrders': 'Sales orders', + 'page.dashboard.cardPurchaseOrders': 'Purchase orders', + 'page.dashboard.cardWorkOrders': 'Work orders', + 'page.dashboard.cardJournalEntries': 'Journal entries', + + 'page.items.title': 'Items', + 'page.items.subtitle': 'Catalog of items the framework can transact: raw materials, finished goods, services.', + 'page.partners.title': 'Partners', + 'page.partners.subtitle': 'Customers, suppliers, and dual-role partners.', + 'page.locations.title': 'Locations', + 'page.locations.subtitle': 'Warehouses, bins, and virtual locations the inventory PBC tracks.', + 'page.balances.title': 'Stock Balances', + 'page.balances.subtitle': 'On-hand quantities per (item, location). Updates atomically with every movement.', + 'page.movements.title': 'Stock Movements', + 'page.movements.subtitle': 'Append-only ledger of every change to inventory. Most-recent first.', + 'page.salesOrders.title': 'Sales Orders', + 'page.salesOrders.subtitle': 'Customer-facing orders. Confirm to auto-generate production work orders.', + 'page.purchaseOrders.title': 'Purchase Orders', + 'page.purchaseOrders.subtitle': 'Supplier-facing orders. Confirm and receive to credit inventory.', + 'page.workOrders.title': 'Work Orders', + 'page.workOrders.subtitle': 'Production orders with BOM inputs and routing operations.', + 'page.users.title': 'Users', + 'page.users.subtitle': 'User accounts in this instance. The admin role has all permissions.', + 'page.roles.title': 'Roles', + 'page.roles.subtitle': "Named bundles of permissions. The 'admin' role has all permissions by default.", + 'page.accounts.title': 'Chart of Accounts', + 'page.accounts.subtitle': 'GL accounts that journal entries debit and credit. 6 accounts seeded by default.', + 'page.journalEntries.title': 'Journal Entries', + 'page.journalEntries.subtitle': 'Double-entry GL entries posted by pbc-finance from order lifecycle events. Click a row to see debit/credit lines.', + 'page.uoms.title': 'Units of Measure', + 'page.uoms.subtitle': 'Seeded set of UoMs the catalog and inventory PBCs use to quantify items.', + 'page.shopFloor.title': 'Shop Floor', + 'page.shopFloor.subtitle': 'Live view of every work order in progress', + 'page.shopFloor.refreshInterval': 'refreshes every {seconds}s', + 'page.shopFloor.lastUpdate': 'last update', + 'page.shopFloor.noWorkOrders': 'No work orders are in progress right now.', + 'page.shopFloor.currentOp': 'Current operation', + 'page.shopFloor.noRouting': 'No routing', + 'page.shopFloor.actualMin': 'actual min', + 'page.shopFloor.stdMin': 'std min', + 'page.shopFloor.ops': 'ops', + + // ─── Create / edit page titles ──────────────────────────── + 'page.createSalesOrder.title': 'New Sales Order', + 'page.createSalesOrder.subtitle': 'Create a sales order. Confirming it will auto-generate production work orders.', + 'page.createSalesOrder.submit': 'Create Sales Order', + 'page.createSalesOrder.afterHint': 'After creation, confirm the order to auto-generate work orders.', + 'page.createPurchaseOrder.title': 'New Purchase Order', + 'page.createPurchaseOrder.subtitle': 'Order materials from a supplier. Confirm and receive to credit inventory.', + 'page.createPurchaseOrder.submit': 'Create Purchase Order', + 'page.createWorkOrder.title': 'New Work Order', + 'page.createWorkOrder.subtitle': 'Create a production work order with optional BOM inputs and routing operations.', + 'page.createWorkOrder.submit': 'Create Work Order', + 'page.createLocation.title': 'New Location', + 'page.createLocation.subtitle': 'Add a warehouse, bin, or virtual location for inventory tracking.', + 'page.createLocation.submit': 'Create Location', + 'page.createItem.title': 'New Item', + 'page.createItem.subtitle': 'Add a raw material, finished good, or service to the catalog.', + 'page.createItem.submit': 'Create Item', + 'page.createPartner.title': 'New Partner', + 'page.createPartner.subtitle': 'Add a customer, supplier, or dual-role partner.', + 'page.createPartner.submit': 'Create Partner', + 'page.createUser.title': 'New User', + 'page.createUser.subtitle': 'Create a user account. Assign roles on the detail page after creation.', + 'page.createUser.submit': 'Create User', + 'page.editItem.title': 'Edit {code}', + 'page.editItem.subtitle': 'Base UoM: {uom} (read-only after creation)', + 'page.editPartner.title': 'Edit {code}', + 'page.editPartner.subtitle': 'Partner code is read-only after creation', + + // ─── Detail page strings ────────────────────────────────── + 'page.salesOrderDetail.title': 'Sales Order {code}', + 'page.salesOrderDetail.subtitle': 'Customer {partner}', + 'page.salesOrderDetail.confirmMsg': 'Confirmed. pbc-finance has posted an AR journal entry.', + 'page.salesOrderDetail.shipMsg': 'Shipped from {location}. Stock debited, journal entry settled.', + 'page.salesOrderDetail.cancelMsg': 'Cancelled. pbc-finance has reversed any open journal entry.', + 'page.salesOrderDetail.pickLocation': 'Pick a shipping location first.', + 'page.purchaseOrderDetail.title': 'Purchase Order {code}', + 'page.purchaseOrderDetail.subtitle': 'Supplier {partner}', + 'page.purchaseOrderDetail.confirmMsg': 'Confirmed. pbc-finance has posted an AP journal entry.', + 'page.purchaseOrderDetail.receiveMsg': 'Received into {location}. Stock credited, journal entry settled.', + 'page.purchaseOrderDetail.cancelMsg': 'Cancelled. pbc-finance has reversed any open journal entry.', + 'page.purchaseOrderDetail.pickLocation': 'Pick a receiving location first.', + 'page.workOrderDetail.title': 'Work Order {code}', + 'page.workOrderDetail.startMsg': 'Started. Operations can now be walked from the Shop Floor screen.', + 'page.workOrderDetail.completeMsg': 'Completed. Materials issued, finished goods credited to {location}.', + 'page.workOrderDetail.pickLocation': 'Pick an output location first.', + 'page.userDetail.roles': 'Roles', + 'page.userDetail.rolesHint': "Toggle roles on/off. Changes take effect on the user's next login.", + 'page.userDetail.noRoles': 'No roles defined yet. Create one on the Roles page.', + + // ─── Section / heading labels ───────────────────────────── + 'label.lines': 'Lines', + 'label.item': 'Item', + 'label.lineNo': '#', + 'label.qty': 'Qty', + 'label.lineTotal': 'Line total', + 'label.inventoryMovements': 'Inventory movements', + 'label.journalEntries': 'Journal entries', + 'label.noMovementsYet': 'No movements yet.', + 'label.noEntriesYet': 'No entries yet.', + 'label.viewAllJournalEntries': 'View all journal entries', + 'label.delta': '\u0394', + 'label.reason': 'Reason', + 'label.reference': 'Reference', + 'label.amount': 'Amount', + 'label.bomInputs': 'BOM inputs', + 'label.noBomLines': 'No BOM lines.', + 'label.routingOperations': 'Routing operations', + 'label.noRouting': 'No routing.', + 'label.qtyPerUnit': 'Qty / unit', + 'label.sourceLoc': 'Source loc', + 'label.operation': 'Operation', + 'label.workCenter': 'Work center', + 'label.stdMin': 'Std min', + 'label.orderCode': 'Order code', + 'label.customer': 'Customer', + 'label.supplier': 'Supplier', + 'label.orderLines': 'Order lines', + 'label.expectedDate': 'Expected date', + 'label.woCode': 'WO code', + 'label.outputItem': 'Output item', + 'label.outputQty': 'Output qty', + 'label.dueDate': 'Due date (optional)', + 'label.bomInputsDesc': 'BOM inputs (materials consumed per unit of output)', + 'label.noBomHint': 'No BOM lines. Output will be produced without consuming materials.', + 'label.routingOpsDesc': 'Routing operations (sequential steps)', + 'label.noRoutingHint': 'No routing. Work order completes in one step.', + 'label.locationCode': 'Location code', + 'label.locationType': 'Type', + 'label.itemCode': 'Item code', + 'label.itemType': 'Type', + 'label.baseUom': 'Base UoM', + 'label.descriptionOptional': 'Description (optional)', + 'label.partnerCode': 'Partner code', + 'label.emailOptional': 'Email (optional)', + 'label.phoneOptional': 'Phone (optional)', + 'label.displayName': 'Display name', + 'label.quantityAbsolute': 'Quantity (absolute, not delta)', + 'label.location': 'Location', + 'label.occurred': 'Occurred', + 'label.posted': 'Posted', + 'label.order': 'Order', + 'label.partner': 'Partner', + 'label.output': 'Output', + 'label.sourceSO': 'Source SO', + 'label.inputsOps': 'Inputs / Ops', + 'label.orderDate': 'Order date', + 'label.expected': 'Expected', + 'label.dimension': 'Dimension', + 'label.accountType': 'Account Type', + 'label.debit': 'Debit', + 'label.credit': 'Credit', + 'label.noJournalEntriesYet': 'No journal entries yet.', + 'label.version': 'Version', + 'label.noPendingTasks': 'No pending tasks', + + // ─── Form designer labels ──────────────────────────────── + 'page.formDesigner.editTitle': 'Edit Form Definition', + 'page.formDesigner.newTitle': 'New Form Definition', + 'page.formDesigner.subtitle': 'Design a metadata-driven form with a live preview.', + 'label.title': 'Title', + 'label.entityName': 'Entity name', + 'label.fields': 'Fields', + 'label.livePreview': 'Live Preview', + 'label.addFieldHint': 'Add at least one field with a key to see the preview.', + 'label.section': 'Section', + 'label.sectionTitle': 'Section title', + 'label.removeSection': 'Remove section', + 'label.label': 'Label', + 'label.required': 'Required', + 'label.req': 'Req', + 'label.colSpan': 'Column span', + 'label.toggleDetails': 'Toggle details', + 'label.removeField': 'Remove field', + 'label.labelEnglish': 'Label (English)', + 'label.placeholder': 'Placeholder', + 'label.helpText': 'Help text', + 'label.widgetOverride': 'Widget override', + 'label.widgetDefault': '(default)', + 'label.visibilityCondition': 'Visibility condition', + 'label.showWhen': 'Show when', + 'label.always': '(always)', + 'label.equals': 'equals', + 'label.moveUp': 'Move up', + 'label.moveDown': 'Move down', + 'label.col': '{n} col', + 'label.cols': '{n} cols', + + // ─── List view designer labels ──────────────────────────── + 'page.listViewDesigner.editTitle': 'Edit List View', + 'page.listViewDesigner.newTitle': 'New List View', + 'page.listViewDesigner.subtitle': 'Configure columns, filters, sorting, and pagination for an entity list view.', + 'label.general': 'General', + 'label.show': 'Show', + 'label.field': 'Field', + 'label.format': 'Format', + 'label.sortable': 'Sortable', + 'label.noColumnsHint': 'No columns defined yet. Click "Add Column" to start.', + 'label.noFiltersHint': 'No filters defined. Click "Add Filter" to add filterable fields.', + 'label.fieldName': 'Field name', + 'label.displayLabel': 'Display label', + 'label.sortingPagination': 'Sorting & Pagination', + 'label.defaultSortField': 'Default Sort Field', + 'label.none': '-- none --', + 'label.direction': 'Direction', + 'label.ascending': 'Ascending', + 'label.descending': 'Descending', + 'label.addColumnsHint': 'Add visible columns with a field name to see a preview.', + 'label.removeColumn': 'Remove column', + 'label.removeFilter': 'Remove filter', + + // ─── Metadata admin extras ──────────────────────────────── + 'page.metadataAdmin.subtitle': 'Browse and manage metadata definitions', + 'label.pbc': 'PBC', + 'label.table': 'Table', + 'label.key': 'Key', + 'label.path': 'Path', + 'label.icon': 'Icon', + 'label.sectionMeta': 'Section', + 'label.orderMeta': 'Order', + 'label.pii': 'PII', + 'label.yes': 'Yes', + 'label.no': 'No', + 'label.allowedValues': 'Allowed values (comma-separated)', + 'label.maxLength': 'Max length', + 'label.labelEn': 'Label EN', + 'label.labelZhCn': 'Label zh-CN', + + // ─── Login page ─────────────────────────────────────────── + 'page.login.tagline': 'Composable ERP framework for the printing industry', + 'page.login.passwordHint': 'The bootstrap admin password is printed to the application boot log on first start.', + 'page.login.connectedTo': 'Connected to', + 'page.login.connecting': 'Connecting...', + + // ─── Adjust stock ───────────────────────────────────────── + 'page.adjustStock.title': 'Adjust Stock', + 'page.adjustStock.subtitle': "Set the on-hand quantity for an item at a location. Creates the balance row if it doesn't exist.", + 'page.adjustStock.result': 'Balance set: {itemCode} @ location = {quantity}', + + // ─── Task detail extras ─────────────────────────────────── + 'label.taskId': 'Task ID', + 'label.process': 'Process', + 'label.created': 'Created', + 'label.assignee': 'Assignee', + 'label.formKey': 'Form Key', + 'label.variables': 'Variables', + + // ─── User tasks column headers ──────────────────────────── + 'label.taskName': 'Task Name', + + // ─── User status labels ─────────────────────────────────── + 'label.activeStatus': 'Active', + 'label.disabled': 'Disabled', } as const export const zhCN: Record = { @@ -275,6 +581,312 @@ export const zhCN: Record = { 'label.enabled': '启用', 'label.conditionLogic': '逻辑', 'action.newRule': '新建规则', + 'action.saving': '保存中...', + 'action.adjusting': '调整中...', + 'action.setBalance': '设置余额', + 'action.saveChanges': '保存更改', + 'action.newForm': '+ 新建表单', + 'action.newListView': '+ 新建列表视图', + 'action.addColumn': '+ 添加列', + 'action.addFilter': '+ 添加筛选', + 'action.addCondition': '+ 添加条件', + 'action.addAction': '+ 添加动作', + 'action.addInput': '+ 添加投入', + 'action.addOperation': '+ 添加工序', + 'action.addSectionDivider': '+ 添加分区线', + 'action.saveListView': '保存列表视图', + 'action.edit': '编辑', + 'action.balances': '余额', + 'action.selectItem': '选择物料...', + + // ─── 页面标题与副标题 ───────────────────────────────────── + 'page.dashboard.welcome': '欢迎', + 'page.dashboard.subtitle': '框架的采购-生产-销售循环,端到端使用同一 Postgres。', + 'page.dashboard.gettingStarted': '快速入门', + 'page.dashboard.gettingStartedDesc': '框架的采购-生产-销售循环,端到端。', + 'page.dashboard.step1': '设置主数据', + 'page.dashboard.step1Desc': '创建', + 'page.dashboard.step1DescItems': '物料', + 'page.dashboard.step1DescPartners': '合作伙伴', + 'page.dashboard.step1DescAnd': '和', + 'page.dashboard.step1DescLocations': '库位', + 'page.dashboard.step1DescThen': '。然后', + 'page.dashboard.step1DescAdjust': '调整库存', + 'page.dashboard.step1DescEnd': '设置期初余额。', + 'page.dashboard.step2': '创建销售订单', + 'page.dashboard.step2Link': '新订单', + 'page.dashboard.step2Desc': '添加行项目。确认后系统自动生成生产工单,并过账 AR 日记账分录(借方应收账款,贷方收入)。', + 'page.dashboard.step3': '执行工单', + 'page.dashboard.step3Desc1': '启动它,在', + 'page.dashboard.step3ShopFloor': '车间看板', + 'page.dashboard.step3Desc2': '上完成工序。材料被消耗,成品入库。', + 'page.dashboard.step4': '发货销售订单', + 'page.dashboard.step4Desc1': '库存出库,AR 日记账结算。在', + 'page.dashboard.step4Movements': '库存变动', + 'page.dashboard.step4Desc2': '和', + 'page.dashboard.step4JE': '日记账', + 'page.dashboard.step5': '采购补货', + 'page.dashboard.step5Desc1': '创建', + 'page.dashboard.step5Link': '采购订单', + 'page.dashboard.step5Desc2': ',确认并收货入库。AP 日记账过账并结算。', + 'page.dashboard.cardItems': '物料', + 'page.dashboard.cardPartners': '合作伙伴', + 'page.dashboard.cardLocations': '库位', + 'page.dashboard.cardWoInProgress': '进行中的工单', + 'page.dashboard.cardSalesOrders': '销售订单', + 'page.dashboard.cardPurchaseOrders': '采购订单', + 'page.dashboard.cardWorkOrders': '工单', + 'page.dashboard.cardJournalEntries': '日记账分录', + + 'page.items.title': '物料', + 'page.items.subtitle': '框架可交易的物料目录:原材料、成品、服务。', + 'page.partners.title': '合作伙伴', + 'page.partners.subtitle': '客户、供应商及双重角色合作伙伴。', + 'page.locations.title': '库位', + 'page.locations.subtitle': '库存 PBC 跟踪的仓库、货位和虚拟库位。', + 'page.balances.title': '库存余额', + 'page.balances.subtitle': '按(物料, 库位)的在手数量。每次变动时原子更新。', + 'page.movements.title': '库存变动', + 'page.movements.subtitle': '库存变更的仅追加账本。最新的排在前面。', + 'page.salesOrders.title': '销售订单', + 'page.salesOrders.subtitle': '面向客户的订单。确认后自动生成生产工单。', + 'page.purchaseOrders.title': '采购订单', + 'page.purchaseOrders.subtitle': '面向供应商的订单。确认并收货以入库。', + 'page.workOrders.title': '工单', + 'page.workOrders.subtitle': '包含 BOM 投入和工艺路线的生产工单。', + 'page.users.title': '用户', + 'page.users.subtitle': '此实例中的用户账户。管理员角色拥有所有权限。', + 'page.roles.title': '角色', + 'page.roles.subtitle': "权限的命名组合。'admin' 角色默认拥有所有权限。", + 'page.accounts.title': '科目表', + 'page.accounts.subtitle': '日记账分录借贷的总账科目。默认预置 6 个科目。', + 'page.journalEntries.title': '日记账分录', + 'page.journalEntries.subtitle': 'pbc-finance 从订单生命周期事件过账的复式记账分录。点击行查看借贷明细。', + 'page.uoms.title': '计量单位', + 'page.uoms.subtitle': '产品目录和库存 PBC 用于量化物料的预置计量单位。', + 'page.shopFloor.title': '车间看板', + 'page.shopFloor.subtitle': '所有进行中工单的实时视图', + 'page.shopFloor.refreshInterval': '每 {seconds} 秒刷新', + 'page.shopFloor.lastUpdate': '最后更新', + 'page.shopFloor.noWorkOrders': '目前没有进行中的工单。', + 'page.shopFloor.currentOp': '当前工序', + 'page.shopFloor.noRouting': '无工艺路线', + 'page.shopFloor.actualMin': '实际分钟', + 'page.shopFloor.stdMin': '标准分钟', + 'page.shopFloor.ops': '工序', + + // ─── 创建/编辑页面标题 ──────────────────────────────────── + 'page.createSalesOrder.title': '新建销售订单', + 'page.createSalesOrder.subtitle': '创建销售订单。确认后将自动生成生产工单。', + 'page.createSalesOrder.submit': '创建销售订单', + 'page.createSalesOrder.afterHint': '创建后,确认订单以自动生成工单。', + 'page.createPurchaseOrder.title': '新建采购订单', + 'page.createPurchaseOrder.subtitle': '从供应商采购材料。确认并收货以入库。', + 'page.createPurchaseOrder.submit': '创建采购订单', + 'page.createWorkOrder.title': '新建工单', + 'page.createWorkOrder.subtitle': '创建包含可选 BOM 投入和工艺路线的生产工单。', + 'page.createWorkOrder.submit': '创建工单', + 'page.createLocation.title': '新建库位', + 'page.createLocation.subtitle': '添加仓库、货位或虚拟库位用于库存跟踪。', + 'page.createLocation.submit': '创建库位', + 'page.createItem.title': '新建物料', + 'page.createItem.subtitle': '向目录添加原材料、成品或服务。', + 'page.createItem.submit': '创建物料', + 'page.createPartner.title': '新建合作伙伴', + 'page.createPartner.subtitle': '添加客户、供应商或双重角色合作伙伴。', + 'page.createPartner.submit': '创建合作伙伴', + 'page.createUser.title': '新建用户', + 'page.createUser.subtitle': '创建用户账户。创建后在详情页分配角色。', + 'page.createUser.submit': '创建用户', + 'page.editItem.title': '编辑 {code}', + 'page.editItem.subtitle': '基础计量单位: {uom}(创建后只读)', + 'page.editPartner.title': '编辑 {code}', + 'page.editPartner.subtitle': '合作伙伴编码创建后只读', + + // ─── 详情页文字 ────────────────────────────────────────── + 'page.salesOrderDetail.title': '销售订单 {code}', + 'page.salesOrderDetail.subtitle': '客户 {partner}', + 'page.salesOrderDetail.confirmMsg': '已确认。pbc-finance 已过账 AR 日记账分录。', + 'page.salesOrderDetail.shipMsg': '已从 {location} 发货。库存已扣减,日记账已结算。', + 'page.salesOrderDetail.cancelMsg': '已取消。pbc-finance 已冲销所有未结日记账分录。', + 'page.salesOrderDetail.pickLocation': '请先选择发货库位。', + 'page.purchaseOrderDetail.title': '采购订单 {code}', + 'page.purchaseOrderDetail.subtitle': '供应商 {partner}', + 'page.purchaseOrderDetail.confirmMsg': '已确认。pbc-finance 已过账 AP 日记账分录。', + 'page.purchaseOrderDetail.receiveMsg': '已收货至 {location}。库存已增加,日记账已结算。', + 'page.purchaseOrderDetail.cancelMsg': '已取消。pbc-finance 已冲销所有未结日记账分录。', + 'page.purchaseOrderDetail.pickLocation': '请先选择收货库位。', + 'page.workOrderDetail.title': '工单 {code}', + 'page.workOrderDetail.startMsg': '已启动。现在可以在车间看板上执行工序。', + 'page.workOrderDetail.completeMsg': '已完成。材料已领用,成品已入库至 {location}。', + 'page.workOrderDetail.pickLocation': '请先选择产出库位。', + 'page.userDetail.roles': '角色', + 'page.userDetail.rolesHint': '切换角色开关。更改在用户下次登录时生效。', + 'page.userDetail.noRoles': '尚未定义角色。请先在角色页面创建角色。', + + // ─── 区域/标题标签 ──────────────────────────────────────── + 'label.lines': '行项目', + 'label.item': '物料', + 'label.lineNo': '#', + 'label.qty': '数量', + 'label.lineTotal': '行合计', + 'label.inventoryMovements': '库存变动', + 'label.journalEntries': '日记账分录', + 'label.noMovementsYet': '暂无变动。', + 'label.noEntriesYet': '暂无分录。', + 'label.viewAllJournalEntries': '查看全部日记账分录', + 'label.delta': '\u0394', + 'label.reason': '原因', + 'label.reference': '参考', + 'label.amount': '金额', + 'label.bomInputs': 'BOM 投入', + 'label.noBomLines': '无 BOM 行。', + 'label.routingOperations': '工艺路线', + 'label.noRouting': '无路线。', + 'label.qtyPerUnit': '每单位用量', + 'label.sourceLoc': '来源库位', + 'label.operation': '工序', + 'label.workCenter': '工作中心', + 'label.stdMin': '标准分钟', + 'label.orderCode': '订单编码', + 'label.customer': '客户', + 'label.supplier': '供应商', + 'label.orderLines': '订单行', + 'label.expectedDate': '预计日期', + 'label.woCode': '工单编码', + 'label.outputItem': '产出物料', + 'label.outputQty': '产出数量', + 'label.dueDate': '交期(可选)', + 'label.bomInputsDesc': 'BOM 投入(每单位产出消耗的材料)', + 'label.noBomHint': '无 BOM 行。产出将不消耗材料直接生产。', + 'label.routingOpsDesc': '工艺路线(顺序步骤)', + 'label.noRoutingHint': '无工艺路线。工单将一步完成。', + 'label.locationCode': '库位编码', + 'label.locationType': '类型', + 'label.itemCode': '物料编码', + 'label.itemType': '类型', + 'label.baseUom': '基础计量单位', + 'label.descriptionOptional': '描述(可选)', + 'label.partnerCode': '合作伙伴编码', + 'label.emailOptional': '邮箱(可选)', + 'label.phoneOptional': '电话(可选)', + 'label.displayName': '显示名称', + 'label.quantityAbsolute': '数量(绝对值,非增量)', + 'label.location': '库位', + 'label.occurred': '发生时间', + 'label.posted': '过账时间', + 'label.order': '订单', + 'label.partner': '合作伙伴', + 'label.output': '产出', + 'label.sourceSO': '源销售订单', + 'label.inputsOps': '投入 / 工序', + 'label.orderDate': '订单日期', + 'label.expected': '预计', + 'label.dimension': '量纲', + 'label.accountType': '科目类型', + 'label.debit': '借方', + 'label.credit': '贷方', + 'label.noJournalEntriesYet': '暂无日记账分录。', + 'label.version': '版本', + 'label.noPendingTasks': '无待办任务', + + // ─── 表单设计器标签 ────────────────────────────────────── + 'page.formDesigner.editTitle': '编辑表单定义', + 'page.formDesigner.newTitle': '新建表单定义', + 'page.formDesigner.subtitle': '设计带实时预览的元数据驱动表单。', + 'label.title': '标题', + 'label.entityName': '实体名称', + 'label.fields': '字段', + 'label.livePreview': '实时预览', + 'label.addFieldHint': '添加至少一个带键名的字段以查看预览。', + 'label.section': '分区', + 'label.sectionTitle': '分区标题', + 'label.removeSection': '移除分区', + 'label.label': '标签', + 'label.required': '必填', + 'label.req': '必填', + 'label.colSpan': '列跨度', + 'label.toggleDetails': '切换详情', + 'label.removeField': '移除字段', + 'label.labelEnglish': '标签(英文)', + 'label.placeholder': '占位符', + 'label.helpText': '帮助文本', + 'label.widgetOverride': '组件覆盖', + 'label.widgetDefault': '(默认)', + 'label.visibilityCondition': '可见性条件', + 'label.showWhen': '当...时显示', + 'label.always': '(始终)', + 'label.equals': '等于', + 'label.moveUp': '上移', + 'label.moveDown': '下移', + 'label.col': '{n} 列', + 'label.cols': '{n} 列', + + // ─── 列表视图设计器标签 ────────────────────────────────── + 'page.listViewDesigner.editTitle': '编辑列表视图', + 'page.listViewDesigner.newTitle': '新建列表视图', + 'page.listViewDesigner.subtitle': '配置实体列表视图的列、筛选、排序和分页。', + 'label.general': '基本信息', + 'label.show': '显示', + 'label.field': '字段', + 'label.format': '格式', + 'label.sortable': '可排序', + 'label.noColumnsHint': '尚未定义列。点击"添加列"开始。', + 'label.noFiltersHint': '尚未定义筛选。点击"添加筛选"添加可筛选字段。', + 'label.fieldName': '字段名称', + 'label.displayLabel': '显示标签', + 'label.sortingPagination': '排序与分页', + 'label.defaultSortField': '默认排序字段', + 'label.none': '-- 无 --', + 'label.direction': '方向', + 'label.ascending': '升序', + 'label.descending': '降序', + 'label.addColumnsHint': '添加带字段名的可见列以查看预览。', + 'label.removeColumn': '移除列', + 'label.removeFilter': '移除筛选', + + // ─── 元数据管理额外 ────────────────────────────────────── + 'page.metadataAdmin.subtitle': '浏览和管理元数据定义', + 'label.pbc': 'PBC', + 'label.table': '表', + 'label.key': '键', + 'label.path': '路径', + 'label.icon': '图标', + 'label.sectionMeta': '分组', + 'label.orderMeta': '排序', + 'label.pii': '个人信息', + 'label.yes': '是', + 'label.no': '否', + 'label.allowedValues': '允许值(逗号分隔)', + 'label.maxLength': '最大长度', + 'label.labelEn': '英文标签', + 'label.labelZhCn': '中文标签', + + // ─── 登录页 ────────────────────────────────────────────── + 'page.login.tagline': '面向印刷行业的可组合 ERP 框架', + 'page.login.passwordHint': '引导管理员密码在首次启动时输出到应用启动日志。', + 'page.login.connectedTo': '已连接到', + 'page.login.connecting': '连接中...', + + // ─── 库存调整 ──────────────────────────────────────────── + 'page.adjustStock.title': '库存调整', + 'page.adjustStock.subtitle': '设置某库位某物料的在手数量。如果余额行不存在则自动创建。', + 'page.adjustStock.result': '余额已设置: {itemCode} @ 库位 = {quantity}', + + // ─── 任务详情额外 ──────────────────────────────────────── + 'label.taskId': '任务 ID', + 'label.process': '流程', + 'label.created': '创建时间', + 'label.assignee': '经办人', + 'label.formKey': '表单键', + 'label.variables': '变量', + + // ─── 用户任务列头 ──────────────────────────────────────── + 'label.taskName': '任务名称', + + // ─── 用户状态标签 ──────────────────────────────────────── + 'label.activeStatus': '已启用', + 'label.disabled': '已禁用', } export const locales = { diff --git a/web/src/pages/EditLocationPage.tsx b/web/src/pages/EditLocationPage.tsx new file mode 100644 index 0000000..2594e19 --- /dev/null +++ b/web/src/pages/EditLocationPage.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { inventory } from '@/api/client' +import type { Location } from '@/types/api' +import { PageHeader } from '@/components/PageHeader' +import { Loading } from '@/components/Loading' +import { ErrorBox } from '@/components/ErrorBox' +import { DynamicExtFields } from '@/components/DynamicExtFields' + +export function EditLocationPage() { + const { id = '' } = useParams<{ id: string }>() + const navigate = useNavigate() + const [location, setLocation] = useState(null) + const [name, setName] = useState('') + const [active, setActive] = useState(true) + const [loading, setLoading] = useState(true) + const [ext, setExt] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + inventory.getLocation(id) + .then((loc) => { + setLocation(loc) + setName(loc.name) + setActive(loc.active) + setExt(loc.ext || {}) + }) + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e)))) + .finally(() => setLoading(false)) + }, [id]) + + const onSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(null) + setSubmitting(true) + try { + await inventory.updateLocation(id, { + name, active, + ...(Object.keys(ext).length > 0 ? { ext } : {}), + }) + navigate('/locations') + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setSubmitting(false) + } + } + + if (loading) return + if (!location) return + + return ( +
+ navigate('/locations')}>Cancel} + /> +
+
+ + setName(e.target.value)} + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> +
+
+ setActive(e.target.checked)} + className="rounded border-slate-300" id="active" /> + +
+ setExt(prev => ({ ...prev, [k]: v }))} /> + {error && } + + +
+ ) +} diff --git a/web/src/pages/EditPurchaseOrderPage.tsx b/web/src/pages/EditPurchaseOrderPage.tsx new file mode 100644 index 0000000..765406c --- /dev/null +++ b/web/src/pages/EditPurchaseOrderPage.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { purchaseOrders } from '@/api/client' +import type { PurchaseOrder } from '@/types/api' +import { PageHeader } from '@/components/PageHeader' +import { Loading } from '@/components/Loading' +import { ErrorBox } from '@/components/ErrorBox' +import { DynamicExtFields } from '@/components/DynamicExtFields' + +export function EditPurchaseOrderPage() { + const { id = '' } = useParams<{ id: string }>() + const navigate = useNavigate() + const [order, setOrder] = useState(null) + const [loading, setLoading] = useState(true) + const [ext, setExt] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + purchaseOrders.get(id) + .then((o) => { + setOrder(o) + setExt(o.ext || {}) + }) + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e)))) + .finally(() => setLoading(false)) + }, [id]) + + const onSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(null) + setSubmitting(true) + try { + await purchaseOrders.update(id, { + ...(Object.keys(ext).length > 0 ? { ext } : {}), + }) + navigate(`/purchase-orders/${id}`) + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setSubmitting(false) + } + } + + if (loading) return + if (!order) return + + const editable = order.status === 'DRAFT' + + return ( +
+ navigate(`/purchase-orders/${id}`)}>Cancel} + /> + {!editable ? ( +
+ +
+ ) : ( +
+
+ Order code, partner, lines, and currency are read-only after creation. + Use this form to update custom fields. +
+ setExt(prev => ({ ...prev, [k]: v }))} /> + {error && } + + + )} +
+ ) +} diff --git a/web/src/pages/EditSalesOrderPage.tsx b/web/src/pages/EditSalesOrderPage.tsx new file mode 100644 index 0000000..3b6a9dd --- /dev/null +++ b/web/src/pages/EditSalesOrderPage.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { salesOrders } from '@/api/client' +import type { SalesOrder } from '@/types/api' +import { PageHeader } from '@/components/PageHeader' +import { Loading } from '@/components/Loading' +import { ErrorBox } from '@/components/ErrorBox' +import { DynamicExtFields } from '@/components/DynamicExtFields' + +export function EditSalesOrderPage() { + const { id = '' } = useParams<{ id: string }>() + const navigate = useNavigate() + const [order, setOrder] = useState(null) + const [loading, setLoading] = useState(true) + const [ext, setExt] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + salesOrders.get(id) + .then((o) => { + setOrder(o) + setExt(o.ext || {}) + }) + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e)))) + .finally(() => setLoading(false)) + }, [id]) + + const onSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(null) + setSubmitting(true) + try { + await salesOrders.update(id, { + ...(Object.keys(ext).length > 0 ? { ext } : {}), + }) + navigate(`/sales-orders/${id}`) + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setSubmitting(false) + } + } + + if (loading) return + if (!order) return + + const editable = order.status === 'DRAFT' + + return ( +
+ navigate(`/sales-orders/${id}`)}>Cancel} + /> + {!editable ? ( +
+ +
+ ) : ( +
+
+ Order code, partner, lines, and currency are read-only after creation. + Use this form to update custom fields. +
+ setExt(prev => ({ ...prev, [k]: v }))} /> + {error && } + + + )} +
+ ) +} diff --git a/web/src/pages/EditWorkOrderPage.tsx b/web/src/pages/EditWorkOrderPage.tsx new file mode 100644 index 0000000..e2086e4 --- /dev/null +++ b/web/src/pages/EditWorkOrderPage.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { production } from '@/api/client' +import type { WorkOrder } from '@/types/api' +import { PageHeader } from '@/components/PageHeader' +import { Loading } from '@/components/Loading' +import { ErrorBox } from '@/components/ErrorBox' +import { DynamicExtFields } from '@/components/DynamicExtFields' + +export function EditWorkOrderPage() { + const { id = '' } = useParams<{ id: string }>() + const navigate = useNavigate() + const [order, setOrder] = useState(null) + const [outputQuantity, setOutputQuantity] = useState('') + const [dueDate, setDueDate] = useState('') + const [loading, setLoading] = useState(true) + const [ext, setExt] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + production.getWorkOrder(id) + .then((wo) => { + setOrder(wo) + setOutputQuantity(String(wo.outputQuantity)) + setDueDate(wo.dueDate ?? '') + setExt(wo.ext || {}) + }) + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e)))) + .finally(() => setLoading(false)) + }, [id]) + + const onSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(null) + setSubmitting(true) + try { + await production.updateWorkOrder(id, { + outputQuantity: Number(outputQuantity), + dueDate: dueDate || null, + ...(Object.keys(ext).length > 0 ? { ext } : {}), + }) + navigate(`/work-orders/${id}`) + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setSubmitting(false) + } + } + + if (loading) return + if (!order) return + + const editable = order.status === 'DRAFT' + + return ( +
+ navigate(`/work-orders/${id}`)}>Cancel} + /> + {!editable ? ( +
+ +
+ ) : ( +
+
+ Order code, output item, BOM inputs, and routing operations are read-only after creation. +
+
+
+ + setOutputQuantity(e.target.value)} + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> +
+
+ + setDueDate(e.target.value)} + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" /> +
+
+ setExt(prev => ({ ...prev, [k]: v }))} /> + {error && } + + + )} +
+ ) +}