Commit 95ed53bd7ea451e1679906313d2a477908dac371

Authored by zichun
1 parent 24bf9acc

feat(web): edit pages for locations, sales orders, purchase orders, work orders

Backend:
- Add update() method + UpdateWorkOrderCommand to WorkOrderService
  (DRAFT-only, mutable fields: outputQuantity, dueDate, ext)
- Add PATCH /{id} endpoint + UpdateWorkOrderRequest to WorkOrderController
- Add production.work-order.update permission to metadata YAML
- Location, SalesOrder, PurchaseOrder already had PATCH endpoints

Frontend:
- Add getLocation, updateLocation to inventory client
- Add update methods to salesOrders, purchaseOrders, production client
- Create EditLocationPage (name, active, ext)
- Create EditSalesOrderPage (ext only; DRAFT-only guard)
- Create EditPurchaseOrderPage (ext only; DRAFT-only guard)
- Create EditWorkOrderPage (outputQuantity, dueDate, ext; DRAFT-only guard)
- Wire all four edit routes in App.tsx
- Fix duplicate i18n keys in messages.ts (label.actions, label.fieldKey)
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt
@@ -164,6 +164,36 @@ class WorkOrderService( @@ -164,6 +164,36 @@ class WorkOrderService(
164 fun parseExt(order: WorkOrder): Map<String, Any?> = 164 fun parseExt(order: WorkOrder): Map<String, Any?> =
165 extValidator.parseExt(order) 165 extValidator.parseExt(order)
166 166
  167 + /**
  168 + * Update a DRAFT work order's mutable fields (output quantity,
  169 + * due date, ext). The output item code, BOM inputs, and routing
  170 + * operations are immutable after creation in this version; a
  171 + * future chunk may relax that constraint for BOM editing before
  172 + * start(). Only DRAFT orders are mutable — once started, the
  173 + * shop floor is running and header edits are off-limits.
  174 + */
  175 + fun update(id: UUID, command: UpdateWorkOrderCommand): WorkOrder {
  176 + val order = orders.findById(id).orElseThrow {
  177 + NoSuchElementException("work order not found: $id")
  178 + }
  179 + require(order.status == WorkOrderStatus.DRAFT) {
  180 + "cannot update work order ${order.code} in status ${order.status}; only DRAFT orders are mutable"
  181 + }
  182 +
  183 + command.outputQuantity?.let {
  184 + require(it.signum() > 0) {
  185 + "output quantity must be positive (got $it)"
  186 + }
  187 + order.outputQuantity = it
  188 + }
  189 + command.dueDate?.let { order.dueDate = it }
  190 +
  191 + // applyTo() is null-safe — a null command.ext is a no-op.
  192 + extValidator.applyTo(order, command.ext)
  193 +
  194 + return order
  195 + }
  196 +
167 fun create(command: CreateWorkOrderCommand): WorkOrder { 197 fun create(command: CreateWorkOrderCommand): WorkOrder {
168 require(!orders.existsByCode(command.code)) { 198 require(!orders.existsByCode(command.code)) {
169 "work order code '${command.code}' is already taken" 199 "work order code '${command.code}' is already taken"
@@ -671,6 +701,20 @@ data class WorkOrderOperationCommand( @@ -671,6 +701,20 @@ data class WorkOrderOperationCommand(
671 ) 701 )
672 702
673 /** 703 /**
  704 + * Command for updating a DRAFT work order's mutable fields.
  705 + *
  706 + * All fields are nullable — a null field means "leave the current
  707 + * value unchanged" (standard PATCH semantics). The output item
  708 + * code, BOM inputs, and routing operations are NOT updatable in
  709 + * this version.
  710 + */
  711 +data class UpdateWorkOrderCommand(
  712 + val outputQuantity: BigDecimal? = null,
  713 + val dueDate: LocalDate? = null,
  714 + val ext: Map<String, Any?>? = null,
  715 +)
  716 +
  717 +/**
674 * One row in the shop-floor dashboard snapshot returned by 718 * One row in the shop-floor dashboard snapshot returned by
675 * [WorkOrderService.shopFloorSnapshot]. Carries enough info for a 719 * [WorkOrderService.shopFloorSnapshot]. Carries enough info for a
676 * dashboard UI to render "WO X is running step N of M at work 720 * dashboard UI to render "WO X is running step N of M at work
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt
@@ -9,6 +9,7 @@ import jakarta.validation.constraints.Size @@ -9,6 +9,7 @@ import jakarta.validation.constraints.Size
9 import org.springframework.http.HttpStatus 9 import org.springframework.http.HttpStatus
10 import org.springframework.http.ResponseEntity 10 import org.springframework.http.ResponseEntity
11 import org.springframework.web.bind.annotation.GetMapping 11 import org.springframework.web.bind.annotation.GetMapping
  12 +import org.springframework.web.bind.annotation.PatchMapping
12 import org.springframework.web.bind.annotation.PathVariable 13 import org.springframework.web.bind.annotation.PathVariable
13 import org.springframework.web.bind.annotation.PostMapping 14 import org.springframework.web.bind.annotation.PostMapping
14 import org.springframework.web.bind.annotation.RequestBody 15 import org.springframework.web.bind.annotation.RequestBody
@@ -17,6 +18,7 @@ import org.springframework.web.bind.annotation.ResponseStatus @@ -17,6 +18,7 @@ import org.springframework.web.bind.annotation.ResponseStatus
17 import org.springframework.web.bind.annotation.RestController 18 import org.springframework.web.bind.annotation.RestController
18 import org.vibeerp.pbc.production.application.CreateWorkOrderCommand 19 import org.vibeerp.pbc.production.application.CreateWorkOrderCommand
19 import org.vibeerp.pbc.production.application.ShopFloorEntry 20 import org.vibeerp.pbc.production.application.ShopFloorEntry
  21 +import org.vibeerp.pbc.production.application.UpdateWorkOrderCommand
20 import org.vibeerp.pbc.production.application.WorkOrderInputCommand 22 import org.vibeerp.pbc.production.application.WorkOrderInputCommand
21 import org.vibeerp.pbc.production.application.WorkOrderOperationCommand 23 import org.vibeerp.pbc.production.application.WorkOrderOperationCommand
22 import org.vibeerp.pbc.production.application.WorkOrderService 24 import org.vibeerp.pbc.production.application.WorkOrderService
@@ -92,6 +94,21 @@ class WorkOrderController( @@ -92,6 +94,21 @@ class WorkOrderController(
92 fun create(@RequestBody @Valid request: CreateWorkOrderRequest): WorkOrderResponse = 94 fun create(@RequestBody @Valid request: CreateWorkOrderRequest): WorkOrderResponse =
93 workOrderService.create(request.toCommand()).toResponse(workOrderService) 95 workOrderService.create(request.toCommand()).toResponse(workOrderService)
94 96
  97 + @PatchMapping("/{id}")
  98 + @RequirePermission("production.work-order.update")
  99 + fun update(
  100 + @PathVariable id: UUID,
  101 + @RequestBody @Valid request: UpdateWorkOrderRequest,
  102 + ): WorkOrderResponse =
  103 + workOrderService.update(
  104 + id,
  105 + UpdateWorkOrderCommand(
  106 + outputQuantity = request.outputQuantity,
  107 + dueDate = request.dueDate,
  108 + ext = request.ext,
  109 + ),
  110 + ).toResponse(workOrderService)
  111 +
95 /** 112 /**
96 * Start a DRAFT work order — flip to IN_PROGRESS. v2 state 113 * Start a DRAFT work order — flip to IN_PROGRESS. v2 state
97 * machine: DRAFT → IN_PROGRESS. Nothing is written to the 114 * machine: DRAFT → IN_PROGRESS. Nothing is written to the
@@ -213,6 +230,12 @@ data class CreateWorkOrderRequest( @@ -213,6 +230,12 @@ data class CreateWorkOrderRequest(
213 ) 230 )
214 } 231 }
215 232
  233 +data class UpdateWorkOrderRequest(
  234 + val outputQuantity: BigDecimal? = null,
  235 + val dueDate: LocalDate? = null,
  236 + val ext: Map<String, Any?>? = null,
  237 +)
  238 +
216 data class WorkOrderInputRequest( 239 data class WorkOrderInputRequest(
217 @field:NotNull val lineNo: Int, 240 @field:NotNull val lineNo: Int,
218 @field:NotBlank @field:Size(max = 64) val itemCode: String, 241 @field:NotBlank @field:Size(max = 64) val itemCode: String,
pbc/pbc-production/src/main/resources/META-INF/vibe-erp/metadata/production.yml
@@ -14,6 +14,8 @@ permissions: @@ -14,6 +14,8 @@ permissions:
14 description: Read work orders 14 description: Read work orders
15 - key: production.work-order.create 15 - key: production.work-order.create
16 description: Create draft work orders 16 description: Create draft work orders
  17 + - key: production.work-order.update
  18 + description: Update a DRAFT work order (output quantity, due date, ext)
17 - key: production.work-order.start 19 - key: production.work-order.start
18 description: Start a work order (DRAFT → IN_PROGRESS) 20 description: Start a work order (DRAFT → IN_PROGRESS)
19 - key: production.work-order.complete 21 - key: production.work-order.complete
web/src/App.tsx
@@ -26,18 +26,22 @@ import { CreatePartnerPage } from &#39;@/pages/CreatePartnerPage&#39; @@ -26,18 +26,22 @@ import { CreatePartnerPage } from &#39;@/pages/CreatePartnerPage&#39;
26 import { EditPartnerPage } from '@/pages/EditPartnerPage' 26 import { EditPartnerPage } from '@/pages/EditPartnerPage'
27 import { LocationsPage } from '@/pages/LocationsPage' 27 import { LocationsPage } from '@/pages/LocationsPage'
28 import { CreateLocationPage } from '@/pages/CreateLocationPage' 28 import { CreateLocationPage } from '@/pages/CreateLocationPage'
  29 +import { EditLocationPage } from '@/pages/EditLocationPage'
29 import { BalancesPage } from '@/pages/BalancesPage' 30 import { BalancesPage } from '@/pages/BalancesPage'
30 import { AdjustStockPage } from '@/pages/AdjustStockPage' 31 import { AdjustStockPage } from '@/pages/AdjustStockPage'
31 import { MovementsPage } from '@/pages/MovementsPage' 32 import { MovementsPage } from '@/pages/MovementsPage'
32 import { SalesOrdersPage } from '@/pages/SalesOrdersPage' 33 import { SalesOrdersPage } from '@/pages/SalesOrdersPage'
33 import { CreateSalesOrderPage } from '@/pages/CreateSalesOrderPage' 34 import { CreateSalesOrderPage } from '@/pages/CreateSalesOrderPage'
34 import { SalesOrderDetailPage } from '@/pages/SalesOrderDetailPage' 35 import { SalesOrderDetailPage } from '@/pages/SalesOrderDetailPage'
  36 +import { EditSalesOrderPage } from '@/pages/EditSalesOrderPage'
35 import { PurchaseOrdersPage } from '@/pages/PurchaseOrdersPage' 37 import { PurchaseOrdersPage } from '@/pages/PurchaseOrdersPage'
36 import { CreatePurchaseOrderPage } from '@/pages/CreatePurchaseOrderPage' 38 import { CreatePurchaseOrderPage } from '@/pages/CreatePurchaseOrderPage'
37 import { PurchaseOrderDetailPage } from '@/pages/PurchaseOrderDetailPage' 39 import { PurchaseOrderDetailPage } from '@/pages/PurchaseOrderDetailPage'
  40 +import { EditPurchaseOrderPage } from '@/pages/EditPurchaseOrderPage'
38 import { WorkOrdersPage } from '@/pages/WorkOrdersPage' 41 import { WorkOrdersPage } from '@/pages/WorkOrdersPage'
39 import { CreateWorkOrderPage } from '@/pages/CreateWorkOrderPage' 42 import { CreateWorkOrderPage } from '@/pages/CreateWorkOrderPage'
40 import { WorkOrderDetailPage } from '@/pages/WorkOrderDetailPage' 43 import { WorkOrderDetailPage } from '@/pages/WorkOrderDetailPage'
  44 +import { EditWorkOrderPage } from '@/pages/EditWorkOrderPage'
41 import { ShopFloorPage } from '@/pages/ShopFloorPage' 45 import { ShopFloorPage } from '@/pages/ShopFloorPage'
42 import { AccountsPage } from '@/pages/AccountsPage' 46 import { AccountsPage } from '@/pages/AccountsPage'
43 import { JournalEntriesPage } from '@/pages/JournalEntriesPage' 47 import { JournalEntriesPage } from '@/pages/JournalEntriesPage'
@@ -73,18 +77,22 @@ export default function App() { @@ -73,18 +77,22 @@ export default function App() {
73 <Route path="partners/:id/edit" element={<EditPartnerPage />} /> 77 <Route path="partners/:id/edit" element={<EditPartnerPage />} />
74 <Route path="locations" element={<LocationsPage />} /> 78 <Route path="locations" element={<LocationsPage />} />
75 <Route path="locations/new" element={<CreateLocationPage />} /> 79 <Route path="locations/new" element={<CreateLocationPage />} />
  80 + <Route path="locations/:id/edit" element={<EditLocationPage />} />
76 <Route path="balances" element={<BalancesPage />} /> 81 <Route path="balances" element={<BalancesPage />} />
77 <Route path="balances/adjust" element={<AdjustStockPage />} /> 82 <Route path="balances/adjust" element={<AdjustStockPage />} />
78 <Route path="movements" element={<MovementsPage />} /> 83 <Route path="movements" element={<MovementsPage />} />
79 <Route path="sales-orders" element={<SalesOrdersPage />} /> 84 <Route path="sales-orders" element={<SalesOrdersPage />} />
80 <Route path="sales-orders/new" element={<CreateSalesOrderPage />} /> 85 <Route path="sales-orders/new" element={<CreateSalesOrderPage />} />
81 <Route path="sales-orders/:id" element={<SalesOrderDetailPage />} /> 86 <Route path="sales-orders/:id" element={<SalesOrderDetailPage />} />
  87 + <Route path="sales-orders/:id/edit" element={<EditSalesOrderPage />} />
82 <Route path="purchase-orders" element={<PurchaseOrdersPage />} /> 88 <Route path="purchase-orders" element={<PurchaseOrdersPage />} />
83 <Route path="purchase-orders/new" element={<CreatePurchaseOrderPage />} /> 89 <Route path="purchase-orders/new" element={<CreatePurchaseOrderPage />} />
84 <Route path="purchase-orders/:id" element={<PurchaseOrderDetailPage />} /> 90 <Route path="purchase-orders/:id" element={<PurchaseOrderDetailPage />} />
  91 + <Route path="purchase-orders/:id/edit" element={<EditPurchaseOrderPage />} />
85 <Route path="work-orders" element={<WorkOrdersPage />} /> 92 <Route path="work-orders" element={<WorkOrdersPage />} />
86 <Route path="work-orders/new" element={<CreateWorkOrderPage />} /> 93 <Route path="work-orders/new" element={<CreateWorkOrderPage />} />
87 <Route path="work-orders/:id" element={<WorkOrderDetailPage />} /> 94 <Route path="work-orders/:id" element={<WorkOrderDetailPage />} />
  95 + <Route path="work-orders/:id/edit" element={<EditWorkOrderPage />} />
88 <Route path="shop-floor" element={<ShopFloorPage />} /> 96 <Route path="shop-floor" element={<ShopFloorPage />} />
89 <Route path="workflow/tasks" element={<UserTasksPage />} /> 97 <Route path="workflow/tasks" element={<UserTasksPage />} />
90 <Route path="workflow/tasks/:taskId" element={<TaskDetailPage />} /> 98 <Route path="workflow/tasks/:taskId" element={<TaskDetailPage />} />
web/src/api/client.ts
@@ -202,8 +202,13 @@ export const partners = { @@ -202,8 +202,13 @@ export const partners = {
202 202
203 export const inventory = { 203 export const inventory = {
204 listLocations: () => apiFetch<Location[]>('/api/v1/inventory/locations'), 204 listLocations: () => apiFetch<Location[]>('/api/v1/inventory/locations'),
  205 + getLocation: (id: string) => apiFetch<Location>(`/api/v1/inventory/locations/${id}`),
205 createLocation: (body: { code: string; name: string; type: string }) => 206 createLocation: (body: { code: string; name: string; type: string }) =>
206 apiFetch<Location>('/api/v1/inventory/locations', { method: 'POST', body: JSON.stringify(body) }), 207 apiFetch<Location>('/api/v1/inventory/locations', { method: 'POST', body: JSON.stringify(body) }),
  208 + updateLocation: (id: string, body: {
  209 + name?: string; type?: string; active?: boolean;
  210 + ext?: Record<string, unknown>
  211 + }) => apiFetch<Location>(`/api/v1/inventory/locations/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
207 listBalances: () => apiFetch<StockBalance[]>('/api/v1/inventory/balances'), 212 listBalances: () => apiFetch<StockBalance[]>('/api/v1/inventory/balances'),
208 adjustBalance: (body: { itemCode: string; locationId: string; quantity: number }) => 213 adjustBalance: (body: { itemCode: string; locationId: string; quantity: number }) =>
209 apiFetch<StockBalance>('/api/v1/inventory/balances/adjust', { method: 'POST', body: JSON.stringify(body) }), 214 apiFetch<StockBalance>('/api/v1/inventory/balances/adjust', { method: 'POST', body: JSON.stringify(body) }),
@@ -226,6 +231,11 @@ export const salesOrders = { @@ -226,6 +231,11 @@ export const salesOrders = {
226 method: 'POST', 231 method: 'POST',
227 body: JSON.stringify(body), 232 body: JSON.stringify(body),
228 }), 233 }),
  234 + update: (id: string, body: {
  235 + partnerCode?: string; orderDate?: string; currencyCode?: string;
  236 + lines?: { lineNo: number; itemCode: string; quantity: number; unitPrice: number; currencyCode: string }[];
  237 + ext?: Record<string, unknown>
  238 + }) => apiFetch<SalesOrder>(`/api/v1/orders/sales-orders/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
229 confirm: (id: string) => 239 confirm: (id: string) =>
230 apiFetch<SalesOrder>(`/api/v1/orders/sales-orders/${id}/confirm`, { 240 apiFetch<SalesOrder>(`/api/v1/orders/sales-orders/${id}/confirm`, {
231 method: 'POST', 241 method: 'POST',
@@ -251,6 +261,11 @@ export const purchaseOrders = { @@ -251,6 +261,11 @@ export const purchaseOrders = {
251 expectedDate?: string | null; currencyCode: string; 261 expectedDate?: string | null; currencyCode: string;
252 lines: { lineNo: number; itemCode: string; quantity: number; unitPrice: number; currencyCode: string }[] 262 lines: { lineNo: number; itemCode: string; quantity: number; unitPrice: number; currencyCode: string }[]
253 }) => apiFetch<PurchaseOrder>('/api/v1/orders/purchase-orders', { method: 'POST', body: JSON.stringify(body) }), 263 }) => apiFetch<PurchaseOrder>('/api/v1/orders/purchase-orders', { method: 'POST', body: JSON.stringify(body) }),
  264 + update: (id: string, body: {
  265 + partnerCode?: string; orderDate?: string; expectedDate?: string | null; currencyCode?: string;
  266 + lines?: { lineNo: number; itemCode: string; quantity: number; unitPrice: number; currencyCode: string }[];
  267 + ext?: Record<string, unknown>
  268 + }) => apiFetch<PurchaseOrder>(`/api/v1/orders/purchase-orders/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
254 confirm: (id: string) => 269 confirm: (id: string) =>
255 apiFetch<PurchaseOrder>(`/api/v1/orders/purchase-orders/${id}/confirm`, { 270 apiFetch<PurchaseOrder>(`/api/v1/orders/purchase-orders/${id}/confirm`, {
256 method: 'POST', 271 method: 'POST',
@@ -278,6 +293,10 @@ export const production = { @@ -278,6 +293,10 @@ export const production = {
278 inputs?: { lineNo: number; itemCode: string; quantityPerUnit: number; sourceLocationCode: string }[]; 293 inputs?: { lineNo: number; itemCode: string; quantityPerUnit: number; sourceLocationCode: string }[];
279 operations?: { lineNo: number; operationCode: string; workCenter: string; standardMinutes: number }[]; 294 operations?: { lineNo: number; operationCode: string; workCenter: string; standardMinutes: number }[];
280 }) => apiFetch<WorkOrder>('/api/v1/production/work-orders', { method: 'POST', body: JSON.stringify(body) }), 295 }) => apiFetch<WorkOrder>('/api/v1/production/work-orders', { method: 'POST', body: JSON.stringify(body) }),
  296 + updateWorkOrder: (id: string, body: {
  297 + outputQuantity?: number; dueDate?: string | null;
  298 + ext?: Record<string, unknown>
  299 + }) => apiFetch<WorkOrder>(`/api/v1/production/work-orders/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
281 startWorkOrder: (id: string) => 300 startWorkOrder: (id: string) =>
282 apiFetch<WorkOrder>(`/api/v1/production/work-orders/${id}/start`, { 301 apiFetch<WorkOrder>(`/api/v1/production/work-orders/${id}/start`, {
283 method: 'POST', 302 method: 'POST',
web/src/i18n/messages.ts
@@ -142,6 +142,312 @@ export const en = { @@ -142,6 +142,312 @@ export const en = {
142 'label.enabled': 'Enabled', 142 'label.enabled': 'Enabled',
143 'label.conditionLogic': 'Logic', 143 'label.conditionLogic': 'Logic',
144 'action.newRule': 'New Rule', 144 'action.newRule': 'New Rule',
  145 + 'action.saving': 'Saving...',
  146 + 'action.adjusting': 'Adjusting...',
  147 + 'action.setBalance': 'Set Balance',
  148 + 'action.saveChanges': 'Save Changes',
  149 + 'action.newForm': '+ New Form',
  150 + 'action.newListView': '+ New List View',
  151 + 'action.addColumn': '+ Add Column',
  152 + 'action.addFilter': '+ Add Filter',
  153 + 'action.addCondition': '+ Add Condition',
  154 + 'action.addAction': '+ Add Action',
  155 + 'action.addInput': '+ Add input',
  156 + 'action.addOperation': '+ Add operation',
  157 + 'action.addSectionDivider': '+ Add Section Divider',
  158 + 'action.saveListView': 'Save List View',
  159 + 'action.edit': 'Edit',
  160 + 'action.balances': 'Balances',
  161 + 'action.selectItem': 'Select item...',
  162 +
  163 + // ─── Page titles & subtitles ──────────────────────────────
  164 + 'page.dashboard.welcome': 'Welcome',
  165 + 'page.dashboard.subtitle': "The framework's buy-make-sell loop, end to end through the same Postgres.",
  166 + 'page.dashboard.gettingStarted': 'Getting started',
  167 + 'page.dashboard.gettingStartedDesc': "The framework's buy-make-sell loop, end to end.",
  168 + 'page.dashboard.step1': 'Set up master data',
  169 + 'page.dashboard.step1Desc': 'create',
  170 + 'page.dashboard.step1DescItems': 'items',
  171 + 'page.dashboard.step1DescPartners': 'partners',
  172 + 'page.dashboard.step1DescAnd': ', and',
  173 + 'page.dashboard.step1DescLocations': 'locations',
  174 + 'page.dashboard.step1DescThen': '. Then',
  175 + 'page.dashboard.step1DescAdjust': 'adjust stock',
  176 + 'page.dashboard.step1DescEnd': 'to set opening balances.',
  177 + 'page.dashboard.step2': 'Create a sales order',
  178 + 'page.dashboard.step2Link': 'new order',
  179 + '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).',
  180 + 'page.dashboard.step3': 'Walk the work order',
  181 + 'page.dashboard.step3Desc1': 'start it, walk routing operations on the',
  182 + 'page.dashboard.step3ShopFloor': 'Shop Floor',
  183 + 'page.dashboard.step3Desc2': ', then complete it. Materials are consumed, finished goods credited.',
  184 + 'page.dashboard.step4': 'Ship the sales order',
  185 + 'page.dashboard.step4Desc1': 'stock leaves the warehouse, the AR journal entry settles. View the ledger in',
  186 + 'page.dashboard.step4Movements': 'Movements',
  187 + 'page.dashboard.step4Desc2': 'and double-entry lines in',
  188 + 'page.dashboard.step4JE': 'Journal Entries',
  189 + 'page.dashboard.step5': 'Restock via purchase',
  190 + 'page.dashboard.step5Desc1': 'create a',
  191 + 'page.dashboard.step5Link': 'purchase order',
  192 + 'page.dashboard.step5Desc2': ', confirm, and receive into a warehouse. AP journal entry posts and settles.',
  193 + 'page.dashboard.cardItems': 'Items',
  194 + 'page.dashboard.cardPartners': 'Partners',
  195 + 'page.dashboard.cardLocations': 'Locations',
  196 + 'page.dashboard.cardWoInProgress': 'Work orders in progress',
  197 + 'page.dashboard.cardSalesOrders': 'Sales orders',
  198 + 'page.dashboard.cardPurchaseOrders': 'Purchase orders',
  199 + 'page.dashboard.cardWorkOrders': 'Work orders',
  200 + 'page.dashboard.cardJournalEntries': 'Journal entries',
  201 +
  202 + 'page.items.title': 'Items',
  203 + 'page.items.subtitle': 'Catalog of items the framework can transact: raw materials, finished goods, services.',
  204 + 'page.partners.title': 'Partners',
  205 + 'page.partners.subtitle': 'Customers, suppliers, and dual-role partners.',
  206 + 'page.locations.title': 'Locations',
  207 + 'page.locations.subtitle': 'Warehouses, bins, and virtual locations the inventory PBC tracks.',
  208 + 'page.balances.title': 'Stock Balances',
  209 + 'page.balances.subtitle': 'On-hand quantities per (item, location). Updates atomically with every movement.',
  210 + 'page.movements.title': 'Stock Movements',
  211 + 'page.movements.subtitle': 'Append-only ledger of every change to inventory. Most-recent first.',
  212 + 'page.salesOrders.title': 'Sales Orders',
  213 + 'page.salesOrders.subtitle': 'Customer-facing orders. Confirm to auto-generate production work orders.',
  214 + 'page.purchaseOrders.title': 'Purchase Orders',
  215 + 'page.purchaseOrders.subtitle': 'Supplier-facing orders. Confirm and receive to credit inventory.',
  216 + 'page.workOrders.title': 'Work Orders',
  217 + 'page.workOrders.subtitle': 'Production orders with BOM inputs and routing operations.',
  218 + 'page.users.title': 'Users',
  219 + 'page.users.subtitle': 'User accounts in this instance. The admin role has all permissions.',
  220 + 'page.roles.title': 'Roles',
  221 + 'page.roles.subtitle': "Named bundles of permissions. The 'admin' role has all permissions by default.",
  222 + 'page.accounts.title': 'Chart of Accounts',
  223 + 'page.accounts.subtitle': 'GL accounts that journal entries debit and credit. 6 accounts seeded by default.',
  224 + 'page.journalEntries.title': 'Journal Entries',
  225 + 'page.journalEntries.subtitle': 'Double-entry GL entries posted by pbc-finance from order lifecycle events. Click a row to see debit/credit lines.',
  226 + 'page.uoms.title': 'Units of Measure',
  227 + 'page.uoms.subtitle': 'Seeded set of UoMs the catalog and inventory PBCs use to quantify items.',
  228 + 'page.shopFloor.title': 'Shop Floor',
  229 + 'page.shopFloor.subtitle': 'Live view of every work order in progress',
  230 + 'page.shopFloor.refreshInterval': 'refreshes every {seconds}s',
  231 + 'page.shopFloor.lastUpdate': 'last update',
  232 + 'page.shopFloor.noWorkOrders': 'No work orders are in progress right now.',
  233 + 'page.shopFloor.currentOp': 'Current operation',
  234 + 'page.shopFloor.noRouting': 'No routing',
  235 + 'page.shopFloor.actualMin': 'actual min',
  236 + 'page.shopFloor.stdMin': 'std min',
  237 + 'page.shopFloor.ops': 'ops',
  238 +
  239 + // ─── Create / edit page titles ────────────────────────────
  240 + 'page.createSalesOrder.title': 'New Sales Order',
  241 + 'page.createSalesOrder.subtitle': 'Create a sales order. Confirming it will auto-generate production work orders.',
  242 + 'page.createSalesOrder.submit': 'Create Sales Order',
  243 + 'page.createSalesOrder.afterHint': 'After creation, confirm the order to auto-generate work orders.',
  244 + 'page.createPurchaseOrder.title': 'New Purchase Order',
  245 + 'page.createPurchaseOrder.subtitle': 'Order materials from a supplier. Confirm and receive to credit inventory.',
  246 + 'page.createPurchaseOrder.submit': 'Create Purchase Order',
  247 + 'page.createWorkOrder.title': 'New Work Order',
  248 + 'page.createWorkOrder.subtitle': 'Create a production work order with optional BOM inputs and routing operations.',
  249 + 'page.createWorkOrder.submit': 'Create Work Order',
  250 + 'page.createLocation.title': 'New Location',
  251 + 'page.createLocation.subtitle': 'Add a warehouse, bin, or virtual location for inventory tracking.',
  252 + 'page.createLocation.submit': 'Create Location',
  253 + 'page.createItem.title': 'New Item',
  254 + 'page.createItem.subtitle': 'Add a raw material, finished good, or service to the catalog.',
  255 + 'page.createItem.submit': 'Create Item',
  256 + 'page.createPartner.title': 'New Partner',
  257 + 'page.createPartner.subtitle': 'Add a customer, supplier, or dual-role partner.',
  258 + 'page.createPartner.submit': 'Create Partner',
  259 + 'page.createUser.title': 'New User',
  260 + 'page.createUser.subtitle': 'Create a user account. Assign roles on the detail page after creation.',
  261 + 'page.createUser.submit': 'Create User',
  262 + 'page.editItem.title': 'Edit {code}',
  263 + 'page.editItem.subtitle': 'Base UoM: {uom} (read-only after creation)',
  264 + 'page.editPartner.title': 'Edit {code}',
  265 + 'page.editPartner.subtitle': 'Partner code is read-only after creation',
  266 +
  267 + // ─── Detail page strings ──────────────────────────────────
  268 + 'page.salesOrderDetail.title': 'Sales Order {code}',
  269 + 'page.salesOrderDetail.subtitle': 'Customer {partner}',
  270 + 'page.salesOrderDetail.confirmMsg': 'Confirmed. pbc-finance has posted an AR journal entry.',
  271 + 'page.salesOrderDetail.shipMsg': 'Shipped from {location}. Stock debited, journal entry settled.',
  272 + 'page.salesOrderDetail.cancelMsg': 'Cancelled. pbc-finance has reversed any open journal entry.',
  273 + 'page.salesOrderDetail.pickLocation': 'Pick a shipping location first.',
  274 + 'page.purchaseOrderDetail.title': 'Purchase Order {code}',
  275 + 'page.purchaseOrderDetail.subtitle': 'Supplier {partner}',
  276 + 'page.purchaseOrderDetail.confirmMsg': 'Confirmed. pbc-finance has posted an AP journal entry.',
  277 + 'page.purchaseOrderDetail.receiveMsg': 'Received into {location}. Stock credited, journal entry settled.',
  278 + 'page.purchaseOrderDetail.cancelMsg': 'Cancelled. pbc-finance has reversed any open journal entry.',
  279 + 'page.purchaseOrderDetail.pickLocation': 'Pick a receiving location first.',
  280 + 'page.workOrderDetail.title': 'Work Order {code}',
  281 + 'page.workOrderDetail.startMsg': 'Started. Operations can now be walked from the Shop Floor screen.',
  282 + 'page.workOrderDetail.completeMsg': 'Completed. Materials issued, finished goods credited to {location}.',
  283 + 'page.workOrderDetail.pickLocation': 'Pick an output location first.',
  284 + 'page.userDetail.roles': 'Roles',
  285 + 'page.userDetail.rolesHint': "Toggle roles on/off. Changes take effect on the user's next login.",
  286 + 'page.userDetail.noRoles': 'No roles defined yet. Create one on the Roles page.',
  287 +
  288 + // ─── Section / heading labels ─────────────────────────────
  289 + 'label.lines': 'Lines',
  290 + 'label.item': 'Item',
  291 + 'label.lineNo': '#',
  292 + 'label.qty': 'Qty',
  293 + 'label.lineTotal': 'Line total',
  294 + 'label.inventoryMovements': 'Inventory movements',
  295 + 'label.journalEntries': 'Journal entries',
  296 + 'label.noMovementsYet': 'No movements yet.',
  297 + 'label.noEntriesYet': 'No entries yet.',
  298 + 'label.viewAllJournalEntries': 'View all journal entries',
  299 + 'label.delta': '\u0394',
  300 + 'label.reason': 'Reason',
  301 + 'label.reference': 'Reference',
  302 + 'label.amount': 'Amount',
  303 + 'label.bomInputs': 'BOM inputs',
  304 + 'label.noBomLines': 'No BOM lines.',
  305 + 'label.routingOperations': 'Routing operations',
  306 + 'label.noRouting': 'No routing.',
  307 + 'label.qtyPerUnit': 'Qty / unit',
  308 + 'label.sourceLoc': 'Source loc',
  309 + 'label.operation': 'Operation',
  310 + 'label.workCenter': 'Work center',
  311 + 'label.stdMin': 'Std min',
  312 + 'label.orderCode': 'Order code',
  313 + 'label.customer': 'Customer',
  314 + 'label.supplier': 'Supplier',
  315 + 'label.orderLines': 'Order lines',
  316 + 'label.expectedDate': 'Expected date',
  317 + 'label.woCode': 'WO code',
  318 + 'label.outputItem': 'Output item',
  319 + 'label.outputQty': 'Output qty',
  320 + 'label.dueDate': 'Due date (optional)',
  321 + 'label.bomInputsDesc': 'BOM inputs (materials consumed per unit of output)',
  322 + 'label.noBomHint': 'No BOM lines. Output will be produced without consuming materials.',
  323 + 'label.routingOpsDesc': 'Routing operations (sequential steps)',
  324 + 'label.noRoutingHint': 'No routing. Work order completes in one step.',
  325 + 'label.locationCode': 'Location code',
  326 + 'label.locationType': 'Type',
  327 + 'label.itemCode': 'Item code',
  328 + 'label.itemType': 'Type',
  329 + 'label.baseUom': 'Base UoM',
  330 + 'label.descriptionOptional': 'Description (optional)',
  331 + 'label.partnerCode': 'Partner code',
  332 + 'label.emailOptional': 'Email (optional)',
  333 + 'label.phoneOptional': 'Phone (optional)',
  334 + 'label.displayName': 'Display name',
  335 + 'label.quantityAbsolute': 'Quantity (absolute, not delta)',
  336 + 'label.location': 'Location',
  337 + 'label.occurred': 'Occurred',
  338 + 'label.posted': 'Posted',
  339 + 'label.order': 'Order',
  340 + 'label.partner': 'Partner',
  341 + 'label.output': 'Output',
  342 + 'label.sourceSO': 'Source SO',
  343 + 'label.inputsOps': 'Inputs / Ops',
  344 + 'label.orderDate': 'Order date',
  345 + 'label.expected': 'Expected',
  346 + 'label.dimension': 'Dimension',
  347 + 'label.accountType': 'Account Type',
  348 + 'label.debit': 'Debit',
  349 + 'label.credit': 'Credit',
  350 + 'label.noJournalEntriesYet': 'No journal entries yet.',
  351 + 'label.version': 'Version',
  352 + 'label.noPendingTasks': 'No pending tasks',
  353 +
  354 + // ─── Form designer labels ────────────────────────────────
  355 + 'page.formDesigner.editTitle': 'Edit Form Definition',
  356 + 'page.formDesigner.newTitle': 'New Form Definition',
  357 + 'page.formDesigner.subtitle': 'Design a metadata-driven form with a live preview.',
  358 + 'label.title': 'Title',
  359 + 'label.entityName': 'Entity name',
  360 + 'label.fields': 'Fields',
  361 + 'label.livePreview': 'Live Preview',
  362 + 'label.addFieldHint': 'Add at least one field with a key to see the preview.',
  363 + 'label.section': 'Section',
  364 + 'label.sectionTitle': 'Section title',
  365 + 'label.removeSection': 'Remove section',
  366 + 'label.label': 'Label',
  367 + 'label.required': 'Required',
  368 + 'label.req': 'Req',
  369 + 'label.colSpan': 'Column span',
  370 + 'label.toggleDetails': 'Toggle details',
  371 + 'label.removeField': 'Remove field',
  372 + 'label.labelEnglish': 'Label (English)',
  373 + 'label.placeholder': 'Placeholder',
  374 + 'label.helpText': 'Help text',
  375 + 'label.widgetOverride': 'Widget override',
  376 + 'label.widgetDefault': '(default)',
  377 + 'label.visibilityCondition': 'Visibility condition',
  378 + 'label.showWhen': 'Show when',
  379 + 'label.always': '(always)',
  380 + 'label.equals': 'equals',
  381 + 'label.moveUp': 'Move up',
  382 + 'label.moveDown': 'Move down',
  383 + 'label.col': '{n} col',
  384 + 'label.cols': '{n} cols',
  385 +
  386 + // ─── List view designer labels ────────────────────────────
  387 + 'page.listViewDesigner.editTitle': 'Edit List View',
  388 + 'page.listViewDesigner.newTitle': 'New List View',
  389 + 'page.listViewDesigner.subtitle': 'Configure columns, filters, sorting, and pagination for an entity list view.',
  390 + 'label.general': 'General',
  391 + 'label.show': 'Show',
  392 + 'label.field': 'Field',
  393 + 'label.format': 'Format',
  394 + 'label.sortable': 'Sortable',
  395 + 'label.noColumnsHint': 'No columns defined yet. Click "Add Column" to start.',
  396 + 'label.noFiltersHint': 'No filters defined. Click "Add Filter" to add filterable fields.',
  397 + 'label.fieldName': 'Field name',
  398 + 'label.displayLabel': 'Display label',
  399 + 'label.sortingPagination': 'Sorting & Pagination',
  400 + 'label.defaultSortField': 'Default Sort Field',
  401 + 'label.none': '-- none --',
  402 + 'label.direction': 'Direction',
  403 + 'label.ascending': 'Ascending',
  404 + 'label.descending': 'Descending',
  405 + 'label.addColumnsHint': 'Add visible columns with a field name to see a preview.',
  406 + 'label.removeColumn': 'Remove column',
  407 + 'label.removeFilter': 'Remove filter',
  408 +
  409 + // ─── Metadata admin extras ────────────────────────────────
  410 + 'page.metadataAdmin.subtitle': 'Browse and manage metadata definitions',
  411 + 'label.pbc': 'PBC',
  412 + 'label.table': 'Table',
  413 + 'label.key': 'Key',
  414 + 'label.path': 'Path',
  415 + 'label.icon': 'Icon',
  416 + 'label.sectionMeta': 'Section',
  417 + 'label.orderMeta': 'Order',
  418 + 'label.pii': 'PII',
  419 + 'label.yes': 'Yes',
  420 + 'label.no': 'No',
  421 + 'label.allowedValues': 'Allowed values (comma-separated)',
  422 + 'label.maxLength': 'Max length',
  423 + 'label.labelEn': 'Label EN',
  424 + 'label.labelZhCn': 'Label zh-CN',
  425 +
  426 + // ─── Login page ───────────────────────────────────────────
  427 + 'page.login.tagline': 'Composable ERP framework for the printing industry',
  428 + 'page.login.passwordHint': 'The bootstrap admin password is printed to the application boot log on first start.',
  429 + 'page.login.connectedTo': 'Connected to',
  430 + 'page.login.connecting': 'Connecting...',
  431 +
  432 + // ─── Adjust stock ─────────────────────────────────────────
  433 + 'page.adjustStock.title': 'Adjust Stock',
  434 + 'page.adjustStock.subtitle': "Set the on-hand quantity for an item at a location. Creates the balance row if it doesn't exist.",
  435 + 'page.adjustStock.result': 'Balance set: {itemCode} @ location = {quantity}',
  436 +
  437 + // ─── Task detail extras ───────────────────────────────────
  438 + 'label.taskId': 'Task ID',
  439 + 'label.process': 'Process',
  440 + 'label.created': 'Created',
  441 + 'label.assignee': 'Assignee',
  442 + 'label.formKey': 'Form Key',
  443 + 'label.variables': 'Variables',
  444 +
  445 + // ─── User tasks column headers ────────────────────────────
  446 + 'label.taskName': 'Task Name',
  447 +
  448 + // ─── User status labels ───────────────────────────────────
  449 + 'label.activeStatus': 'Active',
  450 + 'label.disabled': 'Disabled',
145 } as const 451 } as const
146 452
147 export const zhCN: Record<MessageKey, string> = { 453 export const zhCN: Record<MessageKey, string> = {
@@ -275,6 +581,312 @@ export const zhCN: Record&lt;MessageKey, string&gt; = { @@ -275,6 +581,312 @@ export const zhCN: Record&lt;MessageKey, string&gt; = {
275 'label.enabled': '启用', 581 'label.enabled': '启用',
276 'label.conditionLogic': '逻辑', 582 'label.conditionLogic': '逻辑',
277 'action.newRule': '新建规则', 583 'action.newRule': '新建规则',
  584 + 'action.saving': '保存中...',
  585 + 'action.adjusting': '调整中...',
  586 + 'action.setBalance': '设置余额',
  587 + 'action.saveChanges': '保存更改',
  588 + 'action.newForm': '+ 新建表单',
  589 + 'action.newListView': '+ 新建列表视图',
  590 + 'action.addColumn': '+ 添加列',
  591 + 'action.addFilter': '+ 添加筛选',
  592 + 'action.addCondition': '+ 添加条件',
  593 + 'action.addAction': '+ 添加动作',
  594 + 'action.addInput': '+ 添加投入',
  595 + 'action.addOperation': '+ 添加工序',
  596 + 'action.addSectionDivider': '+ 添加分区线',
  597 + 'action.saveListView': '保存列表视图',
  598 + 'action.edit': '编辑',
  599 + 'action.balances': '余额',
  600 + 'action.selectItem': '选择物料...',
  601 +
  602 + // ─── 页面标题与副标题 ─────────────────────────────────────
  603 + 'page.dashboard.welcome': '欢迎',
  604 + 'page.dashboard.subtitle': '框架的采购-生产-销售循环,端到端使用同一 Postgres。',
  605 + 'page.dashboard.gettingStarted': '快速入门',
  606 + 'page.dashboard.gettingStartedDesc': '框架的采购-生产-销售循环,端到端。',
  607 + 'page.dashboard.step1': '设置主数据',
  608 + 'page.dashboard.step1Desc': '创建',
  609 + 'page.dashboard.step1DescItems': '物料',
  610 + 'page.dashboard.step1DescPartners': '合作伙伴',
  611 + 'page.dashboard.step1DescAnd': '和',
  612 + 'page.dashboard.step1DescLocations': '库位',
  613 + 'page.dashboard.step1DescThen': '。然后',
  614 + 'page.dashboard.step1DescAdjust': '调整库存',
  615 + 'page.dashboard.step1DescEnd': '设置期初余额。',
  616 + 'page.dashboard.step2': '创建销售订单',
  617 + 'page.dashboard.step2Link': '新订单',
  618 + 'page.dashboard.step2Desc': '添加行项目。确认后系统自动生成生产工单,并过账 AR 日记账分录(借方应收账款,贷方收入)。',
  619 + 'page.dashboard.step3': '执行工单',
  620 + 'page.dashboard.step3Desc1': '启动它,在',
  621 + 'page.dashboard.step3ShopFloor': '车间看板',
  622 + 'page.dashboard.step3Desc2': '上完成工序。材料被消耗,成品入库。',
  623 + 'page.dashboard.step4': '发货销售订单',
  624 + 'page.dashboard.step4Desc1': '库存出库,AR 日记账结算。在',
  625 + 'page.dashboard.step4Movements': '库存变动',
  626 + 'page.dashboard.step4Desc2': '和',
  627 + 'page.dashboard.step4JE': '日记账',
  628 + 'page.dashboard.step5': '采购补货',
  629 + 'page.dashboard.step5Desc1': '创建',
  630 + 'page.dashboard.step5Link': '采购订单',
  631 + 'page.dashboard.step5Desc2': ',确认并收货入库。AP 日记账过账并结算。',
  632 + 'page.dashboard.cardItems': '物料',
  633 + 'page.dashboard.cardPartners': '合作伙伴',
  634 + 'page.dashboard.cardLocations': '库位',
  635 + 'page.dashboard.cardWoInProgress': '进行中的工单',
  636 + 'page.dashboard.cardSalesOrders': '销售订单',
  637 + 'page.dashboard.cardPurchaseOrders': '采购订单',
  638 + 'page.dashboard.cardWorkOrders': '工单',
  639 + 'page.dashboard.cardJournalEntries': '日记账分录',
  640 +
  641 + 'page.items.title': '物料',
  642 + 'page.items.subtitle': '框架可交易的物料目录:原材料、成品、服务。',
  643 + 'page.partners.title': '合作伙伴',
  644 + 'page.partners.subtitle': '客户、供应商及双重角色合作伙伴。',
  645 + 'page.locations.title': '库位',
  646 + 'page.locations.subtitle': '库存 PBC 跟踪的仓库、货位和虚拟库位。',
  647 + 'page.balances.title': '库存余额',
  648 + 'page.balances.subtitle': '按(物料, 库位)的在手数量。每次变动时原子更新。',
  649 + 'page.movements.title': '库存变动',
  650 + 'page.movements.subtitle': '库存变更的仅追加账本。最新的排在前面。',
  651 + 'page.salesOrders.title': '销售订单',
  652 + 'page.salesOrders.subtitle': '面向客户的订单。确认后自动生成生产工单。',
  653 + 'page.purchaseOrders.title': '采购订单',
  654 + 'page.purchaseOrders.subtitle': '面向供应商的订单。确认并收货以入库。',
  655 + 'page.workOrders.title': '工单',
  656 + 'page.workOrders.subtitle': '包含 BOM 投入和工艺路线的生产工单。',
  657 + 'page.users.title': '用户',
  658 + 'page.users.subtitle': '此实例中的用户账户。管理员角色拥有所有权限。',
  659 + 'page.roles.title': '角色',
  660 + 'page.roles.subtitle': "权限的命名组合。'admin' 角色默认拥有所有权限。",
  661 + 'page.accounts.title': '科目表',
  662 + 'page.accounts.subtitle': '日记账分录借贷的总账科目。默认预置 6 个科目。',
  663 + 'page.journalEntries.title': '日记账分录',
  664 + 'page.journalEntries.subtitle': 'pbc-finance 从订单生命周期事件过账的复式记账分录。点击行查看借贷明细。',
  665 + 'page.uoms.title': '计量单位',
  666 + 'page.uoms.subtitle': '产品目录和库存 PBC 用于量化物料的预置计量单位。',
  667 + 'page.shopFloor.title': '车间看板',
  668 + 'page.shopFloor.subtitle': '所有进行中工单的实时视图',
  669 + 'page.shopFloor.refreshInterval': '每 {seconds} 秒刷新',
  670 + 'page.shopFloor.lastUpdate': '最后更新',
  671 + 'page.shopFloor.noWorkOrders': '目前没有进行中的工单。',
  672 + 'page.shopFloor.currentOp': '当前工序',
  673 + 'page.shopFloor.noRouting': '无工艺路线',
  674 + 'page.shopFloor.actualMin': '实际分钟',
  675 + 'page.shopFloor.stdMin': '标准分钟',
  676 + 'page.shopFloor.ops': '工序',
  677 +
  678 + // ─── 创建/编辑页面标题 ────────────────────────────────────
  679 + 'page.createSalesOrder.title': '新建销售订单',
  680 + 'page.createSalesOrder.subtitle': '创建销售订单。确认后将自动生成生产工单。',
  681 + 'page.createSalesOrder.submit': '创建销售订单',
  682 + 'page.createSalesOrder.afterHint': '创建后,确认订单以自动生成工单。',
  683 + 'page.createPurchaseOrder.title': '新建采购订单',
  684 + 'page.createPurchaseOrder.subtitle': '从供应商采购材料。确认并收货以入库。',
  685 + 'page.createPurchaseOrder.submit': '创建采购订单',
  686 + 'page.createWorkOrder.title': '新建工单',
  687 + 'page.createWorkOrder.subtitle': '创建包含可选 BOM 投入和工艺路线的生产工单。',
  688 + 'page.createWorkOrder.submit': '创建工单',
  689 + 'page.createLocation.title': '新建库位',
  690 + 'page.createLocation.subtitle': '添加仓库、货位或虚拟库位用于库存跟踪。',
  691 + 'page.createLocation.submit': '创建库位',
  692 + 'page.createItem.title': '新建物料',
  693 + 'page.createItem.subtitle': '向目录添加原材料、成品或服务。',
  694 + 'page.createItem.submit': '创建物料',
  695 + 'page.createPartner.title': '新建合作伙伴',
  696 + 'page.createPartner.subtitle': '添加客户、供应商或双重角色合作伙伴。',
  697 + 'page.createPartner.submit': '创建合作伙伴',
  698 + 'page.createUser.title': '新建用户',
  699 + 'page.createUser.subtitle': '创建用户账户。创建后在详情页分配角色。',
  700 + 'page.createUser.submit': '创建用户',
  701 + 'page.editItem.title': '编辑 {code}',
  702 + 'page.editItem.subtitle': '基础计量单位: {uom}(创建后只读)',
  703 + 'page.editPartner.title': '编辑 {code}',
  704 + 'page.editPartner.subtitle': '合作伙伴编码创建后只读',
  705 +
  706 + // ─── 详情页文字 ──────────────────────────────────────────
  707 + 'page.salesOrderDetail.title': '销售订单 {code}',
  708 + 'page.salesOrderDetail.subtitle': '客户 {partner}',
  709 + 'page.salesOrderDetail.confirmMsg': '已确认。pbc-finance 已过账 AR 日记账分录。',
  710 + 'page.salesOrderDetail.shipMsg': '已从 {location} 发货。库存已扣减,日记账已结算。',
  711 + 'page.salesOrderDetail.cancelMsg': '已取消。pbc-finance 已冲销所有未结日记账分录。',
  712 + 'page.salesOrderDetail.pickLocation': '请先选择发货库位。',
  713 + 'page.purchaseOrderDetail.title': '采购订单 {code}',
  714 + 'page.purchaseOrderDetail.subtitle': '供应商 {partner}',
  715 + 'page.purchaseOrderDetail.confirmMsg': '已确认。pbc-finance 已过账 AP 日记账分录。',
  716 + 'page.purchaseOrderDetail.receiveMsg': '已收货至 {location}。库存已增加,日记账已结算。',
  717 + 'page.purchaseOrderDetail.cancelMsg': '已取消。pbc-finance 已冲销所有未结日记账分录。',
  718 + 'page.purchaseOrderDetail.pickLocation': '请先选择收货库位。',
  719 + 'page.workOrderDetail.title': '工单 {code}',
  720 + 'page.workOrderDetail.startMsg': '已启动。现在可以在车间看板上执行工序。',
  721 + 'page.workOrderDetail.completeMsg': '已完成。材料已领用,成品已入库至 {location}。',
  722 + 'page.workOrderDetail.pickLocation': '请先选择产出库位。',
  723 + 'page.userDetail.roles': '角色',
  724 + 'page.userDetail.rolesHint': '切换角色开关。更改在用户下次登录时生效。',
  725 + 'page.userDetail.noRoles': '尚未定义角色。请先在角色页面创建角色。',
  726 +
  727 + // ─── 区域/标题标签 ────────────────────────────────────────
  728 + 'label.lines': '行项目',
  729 + 'label.item': '物料',
  730 + 'label.lineNo': '#',
  731 + 'label.qty': '数量',
  732 + 'label.lineTotal': '行合计',
  733 + 'label.inventoryMovements': '库存变动',
  734 + 'label.journalEntries': '日记账分录',
  735 + 'label.noMovementsYet': '暂无变动。',
  736 + 'label.noEntriesYet': '暂无分录。',
  737 + 'label.viewAllJournalEntries': '查看全部日记账分录',
  738 + 'label.delta': '\u0394',
  739 + 'label.reason': '原因',
  740 + 'label.reference': '参考',
  741 + 'label.amount': '金额',
  742 + 'label.bomInputs': 'BOM 投入',
  743 + 'label.noBomLines': '无 BOM 行。',
  744 + 'label.routingOperations': '工艺路线',
  745 + 'label.noRouting': '无路线。',
  746 + 'label.qtyPerUnit': '每单位用量',
  747 + 'label.sourceLoc': '来源库位',
  748 + 'label.operation': '工序',
  749 + 'label.workCenter': '工作中心',
  750 + 'label.stdMin': '标准分钟',
  751 + 'label.orderCode': '订单编码',
  752 + 'label.customer': '客户',
  753 + 'label.supplier': '供应商',
  754 + 'label.orderLines': '订单行',
  755 + 'label.expectedDate': '预计日期',
  756 + 'label.woCode': '工单编码',
  757 + 'label.outputItem': '产出物料',
  758 + 'label.outputQty': '产出数量',
  759 + 'label.dueDate': '交期(可选)',
  760 + 'label.bomInputsDesc': 'BOM 投入(每单位产出消耗的材料)',
  761 + 'label.noBomHint': '无 BOM 行。产出将不消耗材料直接生产。',
  762 + 'label.routingOpsDesc': '工艺路线(顺序步骤)',
  763 + 'label.noRoutingHint': '无工艺路线。工单将一步完成。',
  764 + 'label.locationCode': '库位编码',
  765 + 'label.locationType': '类型',
  766 + 'label.itemCode': '物料编码',
  767 + 'label.itemType': '类型',
  768 + 'label.baseUom': '基础计量单位',
  769 + 'label.descriptionOptional': '描述(可选)',
  770 + 'label.partnerCode': '合作伙伴编码',
  771 + 'label.emailOptional': '邮箱(可选)',
  772 + 'label.phoneOptional': '电话(可选)',
  773 + 'label.displayName': '显示名称',
  774 + 'label.quantityAbsolute': '数量(绝对值,非增量)',
  775 + 'label.location': '库位',
  776 + 'label.occurred': '发生时间',
  777 + 'label.posted': '过账时间',
  778 + 'label.order': '订单',
  779 + 'label.partner': '合作伙伴',
  780 + 'label.output': '产出',
  781 + 'label.sourceSO': '源销售订单',
  782 + 'label.inputsOps': '投入 / 工序',
  783 + 'label.orderDate': '订单日期',
  784 + 'label.expected': '预计',
  785 + 'label.dimension': '量纲',
  786 + 'label.accountType': '科目类型',
  787 + 'label.debit': '借方',
  788 + 'label.credit': '贷方',
  789 + 'label.noJournalEntriesYet': '暂无日记账分录。',
  790 + 'label.version': '版本',
  791 + 'label.noPendingTasks': '无待办任务',
  792 +
  793 + // ─── 表单设计器标签 ──────────────────────────────────────
  794 + 'page.formDesigner.editTitle': '编辑表单定义',
  795 + 'page.formDesigner.newTitle': '新建表单定义',
  796 + 'page.formDesigner.subtitle': '设计带实时预览的元数据驱动表单。',
  797 + 'label.title': '标题',
  798 + 'label.entityName': '实体名称',
  799 + 'label.fields': '字段',
  800 + 'label.livePreview': '实时预览',
  801 + 'label.addFieldHint': '添加至少一个带键名的字段以查看预览。',
  802 + 'label.section': '分区',
  803 + 'label.sectionTitle': '分区标题',
  804 + 'label.removeSection': '移除分区',
  805 + 'label.label': '标签',
  806 + 'label.required': '必填',
  807 + 'label.req': '必填',
  808 + 'label.colSpan': '列跨度',
  809 + 'label.toggleDetails': '切换详情',
  810 + 'label.removeField': '移除字段',
  811 + 'label.labelEnglish': '标签(英文)',
  812 + 'label.placeholder': '占位符',
  813 + 'label.helpText': '帮助文本',
  814 + 'label.widgetOverride': '组件覆盖',
  815 + 'label.widgetDefault': '(默认)',
  816 + 'label.visibilityCondition': '可见性条件',
  817 + 'label.showWhen': '当...时显示',
  818 + 'label.always': '(始终)',
  819 + 'label.equals': '等于',
  820 + 'label.moveUp': '上移',
  821 + 'label.moveDown': '下移',
  822 + 'label.col': '{n} 列',
  823 + 'label.cols': '{n} 列',
  824 +
  825 + // ─── 列表视图设计器标签 ──────────────────────────────────
  826 + 'page.listViewDesigner.editTitle': '编辑列表视图',
  827 + 'page.listViewDesigner.newTitle': '新建列表视图',
  828 + 'page.listViewDesigner.subtitle': '配置实体列表视图的列、筛选、排序和分页。',
  829 + 'label.general': '基本信息',
  830 + 'label.show': '显示',
  831 + 'label.field': '字段',
  832 + 'label.format': '格式',
  833 + 'label.sortable': '可排序',
  834 + 'label.noColumnsHint': '尚未定义列。点击"添加列"开始。',
  835 + 'label.noFiltersHint': '尚未定义筛选。点击"添加筛选"添加可筛选字段。',
  836 + 'label.fieldName': '字段名称',
  837 + 'label.displayLabel': '显示标签',
  838 + 'label.sortingPagination': '排序与分页',
  839 + 'label.defaultSortField': '默认排序字段',
  840 + 'label.none': '-- 无 --',
  841 + 'label.direction': '方向',
  842 + 'label.ascending': '升序',
  843 + 'label.descending': '降序',
  844 + 'label.addColumnsHint': '添加带字段名的可见列以查看预览。',
  845 + 'label.removeColumn': '移除列',
  846 + 'label.removeFilter': '移除筛选',
  847 +
  848 + // ─── 元数据管理额外 ──────────────────────────────────────
  849 + 'page.metadataAdmin.subtitle': '浏览和管理元数据定义',
  850 + 'label.pbc': 'PBC',
  851 + 'label.table': '表',
  852 + 'label.key': '键',
  853 + 'label.path': '路径',
  854 + 'label.icon': '图标',
  855 + 'label.sectionMeta': '分组',
  856 + 'label.orderMeta': '排序',
  857 + 'label.pii': '个人信息',
  858 + 'label.yes': '是',
  859 + 'label.no': '否',
  860 + 'label.allowedValues': '允许值(逗号分隔)',
  861 + 'label.maxLength': '最大长度',
  862 + 'label.labelEn': '英文标签',
  863 + 'label.labelZhCn': '中文标签',
  864 +
  865 + // ─── 登录页 ──────────────────────────────────────────────
  866 + 'page.login.tagline': '面向印刷行业的可组合 ERP 框架',
  867 + 'page.login.passwordHint': '引导管理员密码在首次启动时输出到应用启动日志。',
  868 + 'page.login.connectedTo': '已连接到',
  869 + 'page.login.connecting': '连接中...',
  870 +
  871 + // ─── 库存调整 ────────────────────────────────────────────
  872 + 'page.adjustStock.title': '库存调整',
  873 + 'page.adjustStock.subtitle': '设置某库位某物料的在手数量。如果余额行不存在则自动创建。',
  874 + 'page.adjustStock.result': '余额已设置: {itemCode} @ 库位 = {quantity}',
  875 +
  876 + // ─── 任务详情额外 ────────────────────────────────────────
  877 + 'label.taskId': '任务 ID',
  878 + 'label.process': '流程',
  879 + 'label.created': '创建时间',
  880 + 'label.assignee': '经办人',
  881 + 'label.formKey': '表单键',
  882 + 'label.variables': '变量',
  883 +
  884 + // ─── 用户任务列头 ────────────────────────────────────────
  885 + 'label.taskName': '任务名称',
  886 +
  887 + // ─── 用户状态标签 ────────────────────────────────────────
  888 + 'label.activeStatus': '已启用',
  889 + 'label.disabled': '已禁用',
278 } 890 }
279 891
280 export const locales = { 892 export const locales = {
web/src/pages/EditLocationPage.tsx 0 → 100644
  1 +import { useEffect, useState, type FormEvent } from 'react'
  2 +import { useNavigate, useParams } from 'react-router-dom'
  3 +import { inventory } from '@/api/client'
  4 +import type { Location } from '@/types/api'
  5 +import { PageHeader } from '@/components/PageHeader'
  6 +import { Loading } from '@/components/Loading'
  7 +import { ErrorBox } from '@/components/ErrorBox'
  8 +import { DynamicExtFields } from '@/components/DynamicExtFields'
  9 +
  10 +export function EditLocationPage() {
  11 + const { id = '' } = useParams<{ id: string }>()
  12 + const navigate = useNavigate()
  13 + const [location, setLocation] = useState<Location | null>(null)
  14 + const [name, setName] = useState('')
  15 + const [active, setActive] = useState(true)
  16 + const [loading, setLoading] = useState(true)
  17 + const [ext, setExt] = useState<Record<string, unknown>>({})
  18 + const [submitting, setSubmitting] = useState(false)
  19 + const [error, setError] = useState<Error | null>(null)
  20 +
  21 + useEffect(() => {
  22 + inventory.getLocation(id)
  23 + .then((loc) => {
  24 + setLocation(loc)
  25 + setName(loc.name)
  26 + setActive(loc.active)
  27 + setExt(loc.ext || {})
  28 + })
  29 + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e))))
  30 + .finally(() => setLoading(false))
  31 + }, [id])
  32 +
  33 + const onSubmit = async (e: FormEvent) => {
  34 + e.preventDefault()
  35 + setError(null)
  36 + setSubmitting(true)
  37 + try {
  38 + await inventory.updateLocation(id, {
  39 + name, active,
  40 + ...(Object.keys(ext).length > 0 ? { ext } : {}),
  41 + })
  42 + navigate('/locations')
  43 + } catch (err: unknown) {
  44 + setError(err instanceof Error ? err : new Error(String(err)))
  45 + } finally {
  46 + setSubmitting(false)
  47 + }
  48 + }
  49 +
  50 + if (loading) return <Loading />
  51 + if (!location) return <ErrorBox error={error ?? 'Location not found'} />
  52 +
  53 + return (
  54 + <div>
  55 + <PageHeader
  56 + title={`Edit ${location.code}`}
  57 + subtitle={`Type: ${location.type} (read-only after creation)`}
  58 + actions={<button className="btn-secondary" onClick={() => navigate('/locations')}>Cancel</button>}
  59 + />
  60 + <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl">
  61 + <div>
  62 + <label className="block text-sm font-medium text-slate-700">Name</label>
  63 + <input type="text" required value={name} onChange={(e) => setName(e.target.value)}
  64 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
  65 + </div>
  66 + <div className="flex items-center gap-2">
  67 + <input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)}
  68 + className="rounded border-slate-300" id="active" />
  69 + <label htmlFor="active" className="text-sm text-slate-700">Active</label>
  70 + </div>
  71 + <DynamicExtFields entityName="Location" values={ext} onChange={(k, v) => setExt(prev => ({ ...prev, [k]: v }))} />
  72 + {error && <ErrorBox error={error} />}
  73 + <button type="submit" className="btn-primary" disabled={submitting}>
  74 + {submitting ? 'Saving\u2026' : 'Save Changes'}
  75 + </button>
  76 + </form>
  77 + </div>
  78 + )
  79 +}
web/src/pages/EditPurchaseOrderPage.tsx 0 → 100644
  1 +import { useEffect, useState, type FormEvent } from 'react'
  2 +import { useNavigate, useParams } from 'react-router-dom'
  3 +import { purchaseOrders } from '@/api/client'
  4 +import type { PurchaseOrder } from '@/types/api'
  5 +import { PageHeader } from '@/components/PageHeader'
  6 +import { Loading } from '@/components/Loading'
  7 +import { ErrorBox } from '@/components/ErrorBox'
  8 +import { DynamicExtFields } from '@/components/DynamicExtFields'
  9 +
  10 +export function EditPurchaseOrderPage() {
  11 + const { id = '' } = useParams<{ id: string }>()
  12 + const navigate = useNavigate()
  13 + const [order, setOrder] = useState<PurchaseOrder | null>(null)
  14 + const [loading, setLoading] = useState(true)
  15 + const [ext, setExt] = useState<Record<string, unknown>>({})
  16 + const [submitting, setSubmitting] = useState(false)
  17 + const [error, setError] = useState<Error | null>(null)
  18 +
  19 + useEffect(() => {
  20 + purchaseOrders.get(id)
  21 + .then((o) => {
  22 + setOrder(o)
  23 + setExt(o.ext || {})
  24 + })
  25 + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e))))
  26 + .finally(() => setLoading(false))
  27 + }, [id])
  28 +
  29 + const onSubmit = async (e: FormEvent) => {
  30 + e.preventDefault()
  31 + setError(null)
  32 + setSubmitting(true)
  33 + try {
  34 + await purchaseOrders.update(id, {
  35 + ...(Object.keys(ext).length > 0 ? { ext } : {}),
  36 + })
  37 + navigate(`/purchase-orders/${id}`)
  38 + } catch (err: unknown) {
  39 + setError(err instanceof Error ? err : new Error(String(err)))
  40 + } finally {
  41 + setSubmitting(false)
  42 + }
  43 + }
  44 +
  45 + if (loading) return <Loading />
  46 + if (!order) return <ErrorBox error={error ?? 'Purchase order not found'} />
  47 +
  48 + const editable = order.status === 'DRAFT'
  49 +
  50 + return (
  51 + <div>
  52 + <PageHeader
  53 + title={`Edit ${order.code}`}
  54 + subtitle={`Supplier: ${order.partnerCode} \u00b7 ${order.orderDate} \u00b7 ${order.currencyCode}`}
  55 + actions={<button className="btn-secondary" onClick={() => navigate(`/purchase-orders/${id}`)}>Cancel</button>}
  56 + />
  57 + {!editable ? (
  58 + <div className="card p-6 max-w-2xl">
  59 + <ErrorBox error={`This purchase order is in ${order.status} status and cannot be edited. Only DRAFT orders are editable.`} />
  60 + </div>
  61 + ) : (
  62 + <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl">
  63 + <div className="rounded-md border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
  64 + Order code, partner, lines, and currency are read-only after creation.
  65 + Use this form to update custom fields.
  66 + </div>
  67 + <DynamicExtFields entityName="PurchaseOrder" values={ext} onChange={(k, v) => setExt(prev => ({ ...prev, [k]: v }))} />
  68 + {error && <ErrorBox error={error} />}
  69 + <button type="submit" className="btn-primary" disabled={submitting}>
  70 + {submitting ? 'Saving\u2026' : 'Save Changes'}
  71 + </button>
  72 + </form>
  73 + )}
  74 + </div>
  75 + )
  76 +}
web/src/pages/EditSalesOrderPage.tsx 0 → 100644
  1 +import { useEffect, useState, type FormEvent } from 'react'
  2 +import { useNavigate, useParams } from 'react-router-dom'
  3 +import { salesOrders } from '@/api/client'
  4 +import type { SalesOrder } from '@/types/api'
  5 +import { PageHeader } from '@/components/PageHeader'
  6 +import { Loading } from '@/components/Loading'
  7 +import { ErrorBox } from '@/components/ErrorBox'
  8 +import { DynamicExtFields } from '@/components/DynamicExtFields'
  9 +
  10 +export function EditSalesOrderPage() {
  11 + const { id = '' } = useParams<{ id: string }>()
  12 + const navigate = useNavigate()
  13 + const [order, setOrder] = useState<SalesOrder | null>(null)
  14 + const [loading, setLoading] = useState(true)
  15 + const [ext, setExt] = useState<Record<string, unknown>>({})
  16 + const [submitting, setSubmitting] = useState(false)
  17 + const [error, setError] = useState<Error | null>(null)
  18 +
  19 + useEffect(() => {
  20 + salesOrders.get(id)
  21 + .then((o) => {
  22 + setOrder(o)
  23 + setExt(o.ext || {})
  24 + })
  25 + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e))))
  26 + .finally(() => setLoading(false))
  27 + }, [id])
  28 +
  29 + const onSubmit = async (e: FormEvent) => {
  30 + e.preventDefault()
  31 + setError(null)
  32 + setSubmitting(true)
  33 + try {
  34 + await salesOrders.update(id, {
  35 + ...(Object.keys(ext).length > 0 ? { ext } : {}),
  36 + })
  37 + navigate(`/sales-orders/${id}`)
  38 + } catch (err: unknown) {
  39 + setError(err instanceof Error ? err : new Error(String(err)))
  40 + } finally {
  41 + setSubmitting(false)
  42 + }
  43 + }
  44 +
  45 + if (loading) return <Loading />
  46 + if (!order) return <ErrorBox error={error ?? 'Sales order not found'} />
  47 +
  48 + const editable = order.status === 'DRAFT'
  49 +
  50 + return (
  51 + <div>
  52 + <PageHeader
  53 + title={`Edit ${order.code}`}
  54 + subtitle={`Customer: ${order.partnerCode} \u00b7 ${order.orderDate} \u00b7 ${order.currencyCode}`}
  55 + actions={<button className="btn-secondary" onClick={() => navigate(`/sales-orders/${id}`)}>Cancel</button>}
  56 + />
  57 + {!editable ? (
  58 + <div className="card p-6 max-w-2xl">
  59 + <ErrorBox error={`This sales order is in ${order.status} status and cannot be edited. Only DRAFT orders are editable.`} />
  60 + </div>
  61 + ) : (
  62 + <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl">
  63 + <div className="rounded-md border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
  64 + Order code, partner, lines, and currency are read-only after creation.
  65 + Use this form to update custom fields.
  66 + </div>
  67 + <DynamicExtFields entityName="SalesOrder" values={ext} onChange={(k, v) => setExt(prev => ({ ...prev, [k]: v }))} />
  68 + {error && <ErrorBox error={error} />}
  69 + <button type="submit" className="btn-primary" disabled={submitting}>
  70 + {submitting ? 'Saving\u2026' : 'Save Changes'}
  71 + </button>
  72 + </form>
  73 + )}
  74 + </div>
  75 + )
  76 +}
web/src/pages/EditWorkOrderPage.tsx 0 → 100644
  1 +import { useEffect, useState, type FormEvent } from 'react'
  2 +import { useNavigate, useParams } from 'react-router-dom'
  3 +import { production } from '@/api/client'
  4 +import type { WorkOrder } from '@/types/api'
  5 +import { PageHeader } from '@/components/PageHeader'
  6 +import { Loading } from '@/components/Loading'
  7 +import { ErrorBox } from '@/components/ErrorBox'
  8 +import { DynamicExtFields } from '@/components/DynamicExtFields'
  9 +
  10 +export function EditWorkOrderPage() {
  11 + const { id = '' } = useParams<{ id: string }>()
  12 + const navigate = useNavigate()
  13 + const [order, setOrder] = useState<WorkOrder | null>(null)
  14 + const [outputQuantity, setOutputQuantity] = useState('')
  15 + const [dueDate, setDueDate] = useState('')
  16 + const [loading, setLoading] = useState(true)
  17 + const [ext, setExt] = useState<Record<string, unknown>>({})
  18 + const [submitting, setSubmitting] = useState(false)
  19 + const [error, setError] = useState<Error | null>(null)
  20 +
  21 + useEffect(() => {
  22 + production.getWorkOrder(id)
  23 + .then((wo) => {
  24 + setOrder(wo)
  25 + setOutputQuantity(String(wo.outputQuantity))
  26 + setDueDate(wo.dueDate ?? '')
  27 + setExt(wo.ext || {})
  28 + })
  29 + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e))))
  30 + .finally(() => setLoading(false))
  31 + }, [id])
  32 +
  33 + const onSubmit = async (e: FormEvent) => {
  34 + e.preventDefault()
  35 + setError(null)
  36 + setSubmitting(true)
  37 + try {
  38 + await production.updateWorkOrder(id, {
  39 + outputQuantity: Number(outputQuantity),
  40 + dueDate: dueDate || null,
  41 + ...(Object.keys(ext).length > 0 ? { ext } : {}),
  42 + })
  43 + navigate(`/work-orders/${id}`)
  44 + } catch (err: unknown) {
  45 + setError(err instanceof Error ? err : new Error(String(err)))
  46 + } finally {
  47 + setSubmitting(false)
  48 + }
  49 + }
  50 +
  51 + if (loading) return <Loading />
  52 + if (!order) return <ErrorBox error={error ?? 'Work order not found'} />
  53 +
  54 + const editable = order.status === 'DRAFT'
  55 +
  56 + return (
  57 + <div>
  58 + <PageHeader
  59 + title={`Edit ${order.code}`}
  60 + subtitle={`Output: ${order.outputItemCode}`}
  61 + actions={<button className="btn-secondary" onClick={() => navigate(`/work-orders/${id}`)}>Cancel</button>}
  62 + />
  63 + {!editable ? (
  64 + <div className="card p-6 max-w-2xl">
  65 + <ErrorBox error={`This work order is in ${order.status} status and cannot be edited. Only DRAFT orders are editable.`} />
  66 + </div>
  67 + ) : (
  68 + <form onSubmit={onSubmit} className="card p-6 space-y-4 max-w-2xl">
  69 + <div className="rounded-md border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
  70 + Order code, output item, BOM inputs, and routing operations are read-only after creation.
  71 + </div>
  72 + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
  73 + <div>
  74 + <label className="block text-sm font-medium text-slate-700">Output quantity</label>
  75 + <input type="number" required min="1" step="any" value={outputQuantity}
  76 + onChange={(e) => setOutputQuantity(e.target.value)}
  77 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
  78 + </div>
  79 + <div>
  80 + <label className="block text-sm font-medium text-slate-700">Due date</label>
  81 + <input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)}
  82 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
  83 + </div>
  84 + </div>
  85 + <DynamicExtFields entityName="WorkOrder" values={ext} onChange={(k, v) => setExt(prev => ({ ...prev, [k]: v }))} />
  86 + {error && <ErrorBox error={error} />}
  87 + <button type="submit" className="btn-primary" disabled={submitting}>
  88 + {submitting ? 'Saving\u2026' : 'Save Changes'}
  89 + </button>
  90 + </form>
  91 + )}
  92 + </div>
  93 + )
  94 +}