Commit 95ed53bd7ea451e1679906313d2a477908dac371
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)
Showing
10 changed files
with
1033 additions
and
0 deletions
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt
| ... | ... | @@ -164,6 +164,36 @@ class WorkOrderService( |
| 164 | 164 | fun parseExt(order: WorkOrder): Map<String, Any?> = |
| 165 | 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 | 197 | fun create(command: CreateWorkOrderCommand): WorkOrder { |
| 168 | 198 | require(!orders.existsByCode(command.code)) { |
| 169 | 199 | "work order code '${command.code}' is already taken" |
| ... | ... | @@ -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 | 718 | * One row in the shop-floor dashboard snapshot returned by |
| 675 | 719 | * [WorkOrderService.shopFloorSnapshot]. Carries enough info for a |
| 676 | 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 | 9 | import org.springframework.http.HttpStatus |
| 10 | 10 | import org.springframework.http.ResponseEntity |
| 11 | 11 | import org.springframework.web.bind.annotation.GetMapping |
| 12 | +import org.springframework.web.bind.annotation.PatchMapping | |
| 12 | 13 | import org.springframework.web.bind.annotation.PathVariable |
| 13 | 14 | import org.springframework.web.bind.annotation.PostMapping |
| 14 | 15 | import org.springframework.web.bind.annotation.RequestBody |
| ... | ... | @@ -17,6 +18,7 @@ import org.springframework.web.bind.annotation.ResponseStatus |
| 17 | 18 | import org.springframework.web.bind.annotation.RestController |
| 18 | 19 | import org.vibeerp.pbc.production.application.CreateWorkOrderCommand |
| 19 | 20 | import org.vibeerp.pbc.production.application.ShopFloorEntry |
| 21 | +import org.vibeerp.pbc.production.application.UpdateWorkOrderCommand | |
| 20 | 22 | import org.vibeerp.pbc.production.application.WorkOrderInputCommand |
| 21 | 23 | import org.vibeerp.pbc.production.application.WorkOrderOperationCommand |
| 22 | 24 | import org.vibeerp.pbc.production.application.WorkOrderService |
| ... | ... | @@ -92,6 +94,21 @@ class WorkOrderController( |
| 92 | 94 | fun create(@RequestBody @Valid request: CreateWorkOrderRequest): WorkOrderResponse = |
| 93 | 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 | 113 | * Start a DRAFT work order — flip to IN_PROGRESS. v2 state |
| 97 | 114 | * machine: DRAFT → IN_PROGRESS. Nothing is written to the |
| ... | ... | @@ -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 | 239 | data class WorkOrderInputRequest( |
| 217 | 240 | @field:NotNull val lineNo: Int, |
| 218 | 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 | 14 | description: Read work orders |
| 15 | 15 | - key: production.work-order.create |
| 16 | 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 | 19 | - key: production.work-order.start |
| 18 | 20 | description: Start a work order (DRAFT → IN_PROGRESS) |
| 19 | 21 | - key: production.work-order.complete | ... | ... |
web/src/App.tsx
| ... | ... | @@ -26,18 +26,22 @@ import { CreatePartnerPage } from '@/pages/CreatePartnerPage' |
| 26 | 26 | import { EditPartnerPage } from '@/pages/EditPartnerPage' |
| 27 | 27 | import { LocationsPage } from '@/pages/LocationsPage' |
| 28 | 28 | import { CreateLocationPage } from '@/pages/CreateLocationPage' |
| 29 | +import { EditLocationPage } from '@/pages/EditLocationPage' | |
| 29 | 30 | import { BalancesPage } from '@/pages/BalancesPage' |
| 30 | 31 | import { AdjustStockPage } from '@/pages/AdjustStockPage' |
| 31 | 32 | import { MovementsPage } from '@/pages/MovementsPage' |
| 32 | 33 | import { SalesOrdersPage } from '@/pages/SalesOrdersPage' |
| 33 | 34 | import { CreateSalesOrderPage } from '@/pages/CreateSalesOrderPage' |
| 34 | 35 | import { SalesOrderDetailPage } from '@/pages/SalesOrderDetailPage' |
| 36 | +import { EditSalesOrderPage } from '@/pages/EditSalesOrderPage' | |
| 35 | 37 | import { PurchaseOrdersPage } from '@/pages/PurchaseOrdersPage' |
| 36 | 38 | import { CreatePurchaseOrderPage } from '@/pages/CreatePurchaseOrderPage' |
| 37 | 39 | import { PurchaseOrderDetailPage } from '@/pages/PurchaseOrderDetailPage' |
| 40 | +import { EditPurchaseOrderPage } from '@/pages/EditPurchaseOrderPage' | |
| 38 | 41 | import { WorkOrdersPage } from '@/pages/WorkOrdersPage' |
| 39 | 42 | import { CreateWorkOrderPage } from '@/pages/CreateWorkOrderPage' |
| 40 | 43 | import { WorkOrderDetailPage } from '@/pages/WorkOrderDetailPage' |
| 44 | +import { EditWorkOrderPage } from '@/pages/EditWorkOrderPage' | |
| 41 | 45 | import { ShopFloorPage } from '@/pages/ShopFloorPage' |
| 42 | 46 | import { AccountsPage } from '@/pages/AccountsPage' |
| 43 | 47 | import { JournalEntriesPage } from '@/pages/JournalEntriesPage' |
| ... | ... | @@ -73,18 +77,22 @@ export default function App() { |
| 73 | 77 | <Route path="partners/:id/edit" element={<EditPartnerPage />} /> |
| 74 | 78 | <Route path="locations" element={<LocationsPage />} /> |
| 75 | 79 | <Route path="locations/new" element={<CreateLocationPage />} /> |
| 80 | + <Route path="locations/:id/edit" element={<EditLocationPage />} /> | |
| 76 | 81 | <Route path="balances" element={<BalancesPage />} /> |
| 77 | 82 | <Route path="balances/adjust" element={<AdjustStockPage />} /> |
| 78 | 83 | <Route path="movements" element={<MovementsPage />} /> |
| 79 | 84 | <Route path="sales-orders" element={<SalesOrdersPage />} /> |
| 80 | 85 | <Route path="sales-orders/new" element={<CreateSalesOrderPage />} /> |
| 81 | 86 | <Route path="sales-orders/:id" element={<SalesOrderDetailPage />} /> |
| 87 | + <Route path="sales-orders/:id/edit" element={<EditSalesOrderPage />} /> | |
| 82 | 88 | <Route path="purchase-orders" element={<PurchaseOrdersPage />} /> |
| 83 | 89 | <Route path="purchase-orders/new" element={<CreatePurchaseOrderPage />} /> |
| 84 | 90 | <Route path="purchase-orders/:id" element={<PurchaseOrderDetailPage />} /> |
| 91 | + <Route path="purchase-orders/:id/edit" element={<EditPurchaseOrderPage />} /> | |
| 85 | 92 | <Route path="work-orders" element={<WorkOrdersPage />} /> |
| 86 | 93 | <Route path="work-orders/new" element={<CreateWorkOrderPage />} /> |
| 87 | 94 | <Route path="work-orders/:id" element={<WorkOrderDetailPage />} /> |
| 95 | + <Route path="work-orders/:id/edit" element={<EditWorkOrderPage />} /> | |
| 88 | 96 | <Route path="shop-floor" element={<ShopFloorPage />} /> |
| 89 | 97 | <Route path="workflow/tasks" element={<UserTasksPage />} /> |
| 90 | 98 | <Route path="workflow/tasks/:taskId" element={<TaskDetailPage />} /> | ... | ... |
web/src/api/client.ts
| ... | ... | @@ -202,8 +202,13 @@ export const partners = { |
| 202 | 202 | |
| 203 | 203 | export const inventory = { |
| 204 | 204 | listLocations: () => apiFetch<Location[]>('/api/v1/inventory/locations'), |
| 205 | + getLocation: (id: string) => apiFetch<Location>(`/api/v1/inventory/locations/${id}`), | |
| 205 | 206 | createLocation: (body: { code: string; name: string; type: string }) => |
| 206 | 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 | 212 | listBalances: () => apiFetch<StockBalance[]>('/api/v1/inventory/balances'), |
| 208 | 213 | adjustBalance: (body: { itemCode: string; locationId: string; quantity: number }) => |
| 209 | 214 | apiFetch<StockBalance>('/api/v1/inventory/balances/adjust', { method: 'POST', body: JSON.stringify(body) }), |
| ... | ... | @@ -226,6 +231,11 @@ export const salesOrders = { |
| 226 | 231 | method: 'POST', |
| 227 | 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 | 239 | confirm: (id: string) => |
| 230 | 240 | apiFetch<SalesOrder>(`/api/v1/orders/sales-orders/${id}/confirm`, { |
| 231 | 241 | method: 'POST', |
| ... | ... | @@ -251,6 +261,11 @@ export const purchaseOrders = { |
| 251 | 261 | expectedDate?: string | null; currencyCode: string; |
| 252 | 262 | lines: { lineNo: number; itemCode: string; quantity: number; unitPrice: number; currencyCode: string }[] |
| 253 | 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 | 269 | confirm: (id: string) => |
| 255 | 270 | apiFetch<PurchaseOrder>(`/api/v1/orders/purchase-orders/${id}/confirm`, { |
| 256 | 271 | method: 'POST', |
| ... | ... | @@ -278,6 +293,10 @@ export const production = { |
| 278 | 293 | inputs?: { lineNo: number; itemCode: string; quantityPerUnit: number; sourceLocationCode: string }[]; |
| 279 | 294 | operations?: { lineNo: number; operationCode: string; workCenter: string; standardMinutes: number }[]; |
| 280 | 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 | 300 | startWorkOrder: (id: string) => |
| 282 | 301 | apiFetch<WorkOrder>(`/api/v1/production/work-orders/${id}/start`, { |
| 283 | 302 | method: 'POST', | ... | ... |
web/src/i18n/messages.ts
| ... | ... | @@ -142,6 +142,312 @@ export const en = { |
| 142 | 142 | 'label.enabled': 'Enabled', |
| 143 | 143 | 'label.conditionLogic': 'Logic', |
| 144 | 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 | 451 | } as const |
| 146 | 452 | |
| 147 | 453 | export const zhCN: Record<MessageKey, string> = { |
| ... | ... | @@ -275,6 +581,312 @@ export const zhCN: Record<MessageKey, string> = { |
| 275 | 581 | 'label.enabled': '启用', |
| 276 | 582 | 'label.conditionLogic': '逻辑', |
| 277 | 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 | 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 | +} | ... | ... |