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,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 '@/pages/CreatePartnerPage' | @@ -26,18 +26,22 @@ import { CreatePartnerPage } from '@/pages/CreatePartnerPage' | ||
| 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<MessageKey, string> = { | @@ -275,6 +581,312 @@ export const zhCN: Record<MessageKey, string> = { | ||
| 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 | +} |