Commit 7b0d2f8c2cd7ec814fe37e519885a5d8845c16cc

Authored by zichun
1 parent 87399862

feat(demo): printing-company seed + create-order form matching EBC-PP-001

Reworks the demo seed and SPA to match the reference customer's
work-order management process (EBC-PP-001 from raw/ docs).

Demo seed (DemoSeedRunner):
  - 7 printing-specific items: paper stock, 4-color ink, CTP plates,
    lamination film, business cards, brochures, posters
  - 4 partners: 2 customers (Wucai Advertising, Globe Marketing),
    2 suppliers (Huazhong Paper, InkPro Industries)
  - 2 warehouses with opening stock for all items
  - Pre-seeded WO-PRINT-0001 with full BOM (3 inputs: paper +
    ink + CTP plates from WH-RAW) and 3-step routing (CTP
    plate-making @ CTP-ROOM-01 -> offset printing @ PRESS-A ->
    post-press finishing @ BIND-01) matching EBC-PP-001 steps
    C-010/C-040
  - 2 DRAFT sales orders: SO-2026-0001 (100x business cards +
    500x brochures, $1950), SO-2026-0002 (200x posters, $760)
  - 1 DRAFT purchase order: PO-2026-0001 (10000x paper + 50kg
    ink, $2550) from Huazhong Paper

SPA additions:
  - New CreateSalesOrderPage with customer dropdown, item
    selector, dynamic line add/remove, quantity + price inputs.
    Navigates to the detail page on creation.
  - "+ New Order" button on the SalesOrdersPage header
  - Dashboard "Try the demo" section rewritten to walk the
    EBC-PP-001 flow: create SO -> confirm (auto-spawns WOs) ->
    walk WO routing -> complete (material issue + production
    receipt) -> ship SO (stock debit + AR settle)
  - salesOrders.create() added to the typed API client

The key demo beat: confirming SO-2026-0001 auto-spawns
WO-FROM-SO-2026-0001-L1 and -L2 via SalesOrderConfirmedSubscriber
(EBC-PP-001 step B-010). The pre-seeded WO-PRINT-0001 shows
the full BOM + routing story separately. Together they
demonstrate that the framework expresses the customer's
production workflow through configuration, not code.

Smoke verified on fresh Postgres: all 7 items seeded, WO with
3 BOM + 3 ops created, SO confirm spawns 2 WOs with source
traceability, SPA /sales-orders/new renders and creates orders.
distribution/src/main/kotlin/org/vibeerp/demo/DemoSeedRunner.kt
... ... @@ -21,63 +21,28 @@ import org.vibeerp.pbc.orders.sales.application.SalesOrderService
21 21 import org.vibeerp.pbc.partners.application.CreatePartnerCommand
22 22 import org.vibeerp.pbc.partners.application.PartnerService
23 23 import org.vibeerp.pbc.partners.domain.PartnerType
  24 +import org.vibeerp.pbc.production.application.CreateWorkOrderCommand
  25 +import org.vibeerp.pbc.production.application.WorkOrderInputCommand
  26 +import org.vibeerp.pbc.production.application.WorkOrderOperationCommand
  27 +import org.vibeerp.pbc.production.application.WorkOrderService
24 28 import org.vibeerp.platform.persistence.security.PrincipalContext
25 29 import java.math.BigDecimal
26 30 import java.time.LocalDate
27 31  
28 32 /**
29   - * One-shot demo data seeder, gated behind `vibeerp.demo.seed=true`.
  33 + * One-shot demo data seeder matching the EBC-PP-001 work-order
  34 + * management process from the reference printing company (昆明五彩印务).
30 35 *
31   - * **Why this exists.** Out-of-the-box, vibe_erp boots against an
32   - * empty Postgres and the SPA dashboard shows zeros for every PBC.
33   - * Onboarding a new operator (or running a tomorrow-morning demo)
34   - * needs a couple of minutes of clicking to create items,
35   - * locations, partners, and a starting inventory before the
36   - * interesting screens become useful. This runner stages a tiny
37   - * but representative dataset on first boot so the moment the
38   - * bootstrap admin lands on `/`, every page already has rows.
  36 + * Gated behind `vibeerp.demo.seed=true` (dev profile only).
  37 + * Idempotent via sentinel item check.
39 38 *
40   - * **Opt-in by property.** `@ConditionalOnProperty` keeps this
41   - * bean entirely absent from production deployments — only the
42   - * dev profile (`application-dev.yaml` sets `vibeerp.demo.seed:
43   - * true`) opts in. A future release can ship a `--demo` CLI flag
44   - * or a one-time admin "Load demo data" button that flips the
45   - * same property at runtime; for v1 the dev profile is enough.
46   - *
47   - * **Idempotent.** The runner checks for one of its own seeded
48   - * item codes and short-circuits if already present. Restarting
49   - * the dev server is a no-op; deleting the demo data has to
50   - * happen via SQL or by dropping the DB. Idempotency on the
51   - * sentinel item is intentional (vs. on every entity it creates):
52   - * a half-seeded DB from a crashed first run will *not* recover
53   - * cleanly, but that case is exotic and we can clear and retry
54   - * in dev.
55   - *
56   - * **All seeded data shares the `DEMO-` prefix.** Items, partners,
57   - * locations, and order codes all start with `DEMO-`. This makes
58   - * the seeded data trivially distinguishable from anything an
59   - * operator creates by hand later — and gives a future
60   - * "delete demo data" command an obvious filter.
61   - *
62   - * **System principal.** Audit columns need a non-blank
63   - * `created_by`; `PrincipalContext.runAs("__demo_seed__")` wraps
64   - * the entire seed so every row carries that sentinel. The
65   - * authorization aspect (`@RequirePermission`) lives on
66   - * controllers, not services — calling services directly bypasses
67   - * it cleanly, which is correct for system-level seeders.
68   - *
69   - * **Why CommandLineRunner.** Equivalent to `ApplicationRunner`
70   - * here — there are no command-line args this seeder cares about.
71   - * Spring runs every CommandLineRunner once, after the application
72   - * context is fully initialized but before serving traffic, which
73   - * is exactly the right window: services are wired, the schema is
74   - * applied, but the first HTTP request hasn't arrived yet.
75   - *
76   - * **Lives in distribution.** This is the only module that
77   - * already depends on every PBC, which is what the seeder needs
78   - * to compose. It's gated behind a property the production
79   - * application.yaml never sets, so its presence in the fat-jar
80   - * is dormant unless explicitly opted in.
  39 + * The seeded data demonstrates the core flow from the doc:
  40 + * A-010 Sales order confirmed (SO already in DRAFT, ready to confirm)
  41 + * B-010 System auto-generates work orders from confirmed SO lines
  42 + * B-020 Work order with BOM + routing (pre-seeded WO-PRINT-0001)
  43 + * C-040 Material requisition (BOM inputs consumed on WO complete)
  44 + * C-050 Material picking from warehouse (inventory ledger writes)
  45 + * E-010 Shop floor tracking (shop-floor dashboard polls IN_PROGRESS WOs)
81 46 */
82 47 @Component
83 48 @ConditionalOnProperty(prefix = "vibeerp.demo", name = ["seed"], havingValue = "true")
... ... @@ -88,166 +53,170 @@ class DemoSeedRunner(
88 53 private val partnerService: PartnerService,
89 54 private val salesOrderService: SalesOrderService,
90 55 private val purchaseOrderService: PurchaseOrderService,
  56 + private val workOrderService: WorkOrderService,
91 57 ) : CommandLineRunner {
92 58  
93 59 private val log = LoggerFactory.getLogger(DemoSeedRunner::class.java)
94 60  
95 61 @Transactional
96 62 override fun run(vararg args: String?) {
97   - if (itemService.findByCode(SENTINEL_ITEM_CODE) != null) {
98   - log.info("Demo seed: data already present (sentinel item {} found); skipping", SENTINEL_ITEM_CODE)
  63 + if (itemService.findByCode(SENTINEL) != null) {
  64 + log.info("Demo seed: already present ({}); skipping", SENTINEL)
99 65 return
100 66 }
101   -
102   - log.info("Demo seed: populating starter dataset…")
  67 + log.info("Demo seed: populating printing-company dataset...")
103 68 PrincipalContext.runAs("__demo_seed__") {
104 69 seedItems()
105 70 seedLocations()
106 71 seedPartners()
107 72 seedStock()
108   - seedSalesOrder()
  73 + seedWorkOrder()
  74 + seedSalesOrders()
109 75 seedPurchaseOrder()
110 76 }
111 77 log.info("Demo seed: done")
112 78 }
113 79  
114   - // ─── Items ───────────────────────────────────────────────────────
  80 + // ─── Catalog items (printing industry) ───────────────────────────
115 81  
116 82 private fun seedItems() {
117   - item(SENTINEL_ITEM_CODE, "A4 paper, 80gsm, white", ItemType.GOOD, "sheet")
118   - item("DEMO-INK-CYAN", "Cyan offset ink", ItemType.GOOD, "kg")
119   - item("DEMO-INK-MAGENTA", "Magenta offset ink", ItemType.GOOD, "kg")
120   - item("DEMO-CARD-BIZ", "Business cards, 100/pack", ItemType.GOOD, "pack")
121   - item("DEMO-BROCHURE-A5", "Folded A5 brochure", ItemType.GOOD, "ea")
  83 + // Raw materials
  84 + item(SENTINEL, "80g A4 white card stock", ItemType.GOOD, "sheet")
  85 + item("INK-4C", "4-color offset ink set (CMYK)", ItemType.GOOD, "kg")
  86 + item("PLATE-CTP", "CTP printing plate", ItemType.GOOD, "ea")
  87 + item("COVER-MATT", "Matt lamination film", ItemType.GOOD, "m2")
  88 + // Finished goods
  89 + item("BIZ-CARD-250", "Business cards, 250gsm coated, 100/box", ItemType.GOOD, "pack")
  90 + item("BROCHURE-A5", "A5 tri-fold brochure, full color", ItemType.GOOD, "ea")
  91 + item("POSTER-A3", "A3 promotional poster, glossy", ItemType.GOOD, "ea")
122 92 }
123 93  
124   - private fun item(code: String, name: String, type: ItemType, baseUomCode: String) {
125   - itemService.create(
126   - CreateItemCommand(
127   - code = code,
128   - name = name,
129   - description = null,
130   - itemType = type,
131   - baseUomCode = baseUomCode,
132   - active = true,
133   - ),
134   - )
  94 + private fun item(code: String, name: String, type: ItemType, uom: String) {
  95 + itemService.create(CreateItemCommand(code = code, name = name, description = null, itemType = type, baseUomCode = uom))
135 96 }
136 97  
137 98 // ─── Locations ───────────────────────────────────────────────────
138 99  
139 100 private fun seedLocations() {
140   - location("DEMO-WH-RAW", "Raw materials warehouse")
141   - location("DEMO-WH-FG", "Finished goods warehouse")
  101 + location("WH-RAW", "Raw materials warehouse")
  102 + location("WH-FG", "Finished goods warehouse")
142 103 }
143 104  
144 105 private fun location(code: String, name: String) {
145   - locationService.create(
146   - CreateLocationCommand(
147   - code = code,
148   - name = name,
149   - type = LocationType.WAREHOUSE,
150   - active = true,
151   - ),
152   - )
  106 + locationService.create(CreateLocationCommand(code = code, name = name, type = LocationType.WAREHOUSE))
153 107 }
154 108  
155 109 // ─── Partners ────────────────────────────────────────────────────
156 110  
157 111 private fun seedPartners() {
158   - partner("DEMO-CUST-ACME", "Acme Print Co.", PartnerType.CUSTOMER, "ap@acme.example")
159   - partner("DEMO-CUST-GLOBE", "Globe Marketing", PartnerType.CUSTOMER, "ops@globe.example")
160   - partner("DEMO-SUPP-PAPERWORLD", "Paper World Ltd.", PartnerType.SUPPLIER, "sales@paperworld.example")
161   - partner("DEMO-SUPP-INKCO", "InkCo Industries", PartnerType.SUPPLIER, "orders@inkco.example")
  112 + partner("CUST-WCAD", "Wucai Advertising Co.", PartnerType.CUSTOMER, "info@wucai-ad.example")
  113 + partner("CUST-GLOBE", "Globe Marketing Ltd.", PartnerType.CUSTOMER, "ops@globe.example")
  114 + partner("SUPP-HZPAPER", "Huazhong Paper Co.", PartnerType.SUPPLIER, "sales@hzpaper.example")
  115 + partner("SUPP-INKPRO", "InkPro Industries", PartnerType.SUPPLIER, "orders@inkpro.example")
162 116 }
163 117  
164 118 private fun partner(code: String, name: String, type: PartnerType, email: String) {
165   - partnerService.create(
166   - CreatePartnerCommand(
167   - code = code,
168   - name = name,
169   - type = type,
170   - email = email,
171   - active = true,
172   - ),
173   - )
  119 + partnerService.create(CreatePartnerCommand(code = code, name = name, type = type, email = email))
174 120 }
175 121  
176   - // ─── Initial stock ───────────────────────────────────────────────
  122 + // ─── Opening stock ───────────────────────────────────────────────
177 123  
178 124 private fun seedStock() {
179   - val rawWh = locationService.findByCode("DEMO-WH-RAW")!!
180   - val fgWh = locationService.findByCode("DEMO-WH-FG")!!
  125 + val raw = locationService.findByCode("WH-RAW")!!
  126 + val fg = locationService.findByCode("WH-FG")!!
  127 +
  128 + stockBalanceService.adjust(SENTINEL, raw.id, BigDecimal("5000"))
  129 + stockBalanceService.adjust("INK-4C", raw.id, BigDecimal("80"))
  130 + stockBalanceService.adjust("PLATE-CTP", raw.id, BigDecimal("100"))
  131 + stockBalanceService.adjust("COVER-MATT", raw.id, BigDecimal("500"))
181 132  
182   - stockBalanceService.adjust(SENTINEL_ITEM_CODE, rawWh.id, BigDecimal("5000"))
183   - stockBalanceService.adjust("DEMO-INK-CYAN", rawWh.id, BigDecimal("50"))
184   - stockBalanceService.adjust("DEMO-INK-MAGENTA", rawWh.id, BigDecimal("50"))
  133 + stockBalanceService.adjust("BIZ-CARD-250", fg.id, BigDecimal("200"))
  134 + stockBalanceService.adjust("BROCHURE-A5", fg.id, BigDecimal("150"))
  135 + stockBalanceService.adjust("POSTER-A3", fg.id, BigDecimal("50"))
  136 + }
185 137  
186   - stockBalanceService.adjust("DEMO-CARD-BIZ", fgWh.id, BigDecimal("200"))
187   - stockBalanceService.adjust("DEMO-BROCHURE-A5", fgWh.id, BigDecimal("100"))
  138 + // ─── Pre-seeded work order with BOM + routing ────────────────────
  139 + //
  140 + // Matches the EBC-PP-001 flow: a production work order for
  141 + // business cards with 3 BOM inputs and a 3-step routing
  142 + // (CTP plate-making → offset printing → post-press finishing).
  143 + // Left in DRAFT so the demo operator can start it, walk the
  144 + // operations on the shop-floor dashboard, and complete it.
  145 +
  146 + private fun seedWorkOrder() {
  147 + workOrderService.create(
  148 + CreateWorkOrderCommand(
  149 + code = "WO-PRINT-0001",
  150 + outputItemCode = "BIZ-CARD-250",
  151 + outputQuantity = BigDecimal("50"),
  152 + dueDate = LocalDate.now().plusDays(3),
  153 + inputs = listOf(
  154 + WorkOrderInputCommand(lineNo = 1, itemCode = SENTINEL, quantityPerUnit = BigDecimal("10"), sourceLocationCode = "WH-RAW"),
  155 + WorkOrderInputCommand(lineNo = 2, itemCode = "INK-4C", quantityPerUnit = BigDecimal("0.2"), sourceLocationCode = "WH-RAW"),
  156 + WorkOrderInputCommand(lineNo = 3, itemCode = "PLATE-CTP", quantityPerUnit = BigDecimal("1"), sourceLocationCode = "WH-RAW"),
  157 + ),
  158 + operations = listOf(
  159 + WorkOrderOperationCommand(lineNo = 1, operationCode = "CTP", workCenter = "CTP-ROOM-01", standardMinutes = BigDecimal("30")),
  160 + WorkOrderOperationCommand(lineNo = 2, operationCode = "PRINT", workCenter = "PRESS-A", standardMinutes = BigDecimal("45")),
  161 + WorkOrderOperationCommand(lineNo = 3, operationCode = "FINISH", workCenter = "BIND-01", standardMinutes = BigDecimal("20")),
  162 + ),
  163 + ),
  164 + )
188 165 }
189 166  
190   - // ─── Open sales order (DRAFT — ready to confirm + ship) ──────────
  167 + // ─── Sales orders (DRAFT — confirm to auto-spawn work orders) ───
  168 + //
  169 + // Two orders for different customers. Confirming either one
  170 + // triggers SalesOrderConfirmedSubscriber in pbc-production,
  171 + // which auto-creates one DRAFT work order per SO line (the
  172 + // B-010 step from EBC-PP-001). The demo operator watches the
  173 + // work order count jump on the dashboard after clicking Confirm.
191 174  
192   - private fun seedSalesOrder() {
  175 + private fun seedSalesOrders() {
  176 + salesOrderService.create(
  177 + CreateSalesOrderCommand(
  178 + code = "SO-2026-0001",
  179 + partnerCode = "CUST-WCAD",
  180 + orderDate = LocalDate.now(),
  181 + currencyCode = "USD",
  182 + lines = listOf(
  183 + SalesOrderLineCommand(lineNo = 1, itemCode = "BIZ-CARD-250", quantity = BigDecimal("100"), unitPrice = BigDecimal("8.50"), currencyCode = "USD"),
  184 + SalesOrderLineCommand(lineNo = 2, itemCode = "BROCHURE-A5", quantity = BigDecimal("500"), unitPrice = BigDecimal("2.20"), currencyCode = "USD"),
  185 + ),
  186 + ),
  187 + )
193 188 salesOrderService.create(
194 189 CreateSalesOrderCommand(
195   - code = "DEMO-SO-0001",
196   - partnerCode = "DEMO-CUST-ACME",
  190 + code = "SO-2026-0002",
  191 + partnerCode = "CUST-GLOBE",
197 192 orderDate = LocalDate.now(),
198 193 currencyCode = "USD",
199 194 lines = listOf(
200   - SalesOrderLineCommand(
201   - lineNo = 1,
202   - itemCode = "DEMO-CARD-BIZ",
203   - quantity = BigDecimal("50"),
204   - unitPrice = BigDecimal("12.50"),
205   - currencyCode = "USD",
206   - ),
207   - SalesOrderLineCommand(
208   - lineNo = 2,
209   - itemCode = "DEMO-BROCHURE-A5",
210   - quantity = BigDecimal("20"),
211   - unitPrice = BigDecimal("4.75"),
212   - currencyCode = "USD",
213   - ),
  195 + SalesOrderLineCommand(lineNo = 1, itemCode = "POSTER-A3", quantity = BigDecimal("200"), unitPrice = BigDecimal("3.80"), currencyCode = "USD"),
214 196 ),
215 197 ),
216 198 )
217 199 }
218 200  
219   - // ─── Open purchase order (DRAFT — ready to confirm + receive) ────
  201 + // ─── Purchase order (DRAFT — confirm + receive to restock) ───────
220 202  
221 203 private fun seedPurchaseOrder() {
222 204 purchaseOrderService.create(
223 205 CreatePurchaseOrderCommand(
224   - code = "DEMO-PO-0001",
225   - partnerCode = "DEMO-SUPP-PAPERWORLD",
  206 + code = "PO-2026-0001",
  207 + partnerCode = "SUPP-HZPAPER",
226 208 orderDate = LocalDate.now(),
227   - expectedDate = LocalDate.now().plusDays(7),
  209 + expectedDate = LocalDate.now().plusDays(5),
228 210 currencyCode = "USD",
229 211 lines = listOf(
230   - PurchaseOrderLineCommand(
231   - lineNo = 1,
232   - itemCode = SENTINEL_ITEM_CODE,
233   - quantity = BigDecimal("10000"),
234   - unitPrice = BigDecimal("0.04"),
235   - currencyCode = "USD",
236   - ),
  212 + PurchaseOrderLineCommand(lineNo = 1, itemCode = SENTINEL, quantity = BigDecimal("10000"), unitPrice = BigDecimal("0.03"), currencyCode = "USD"),
  213 + PurchaseOrderLineCommand(lineNo = 2, itemCode = "INK-4C", quantity = BigDecimal("50"), unitPrice = BigDecimal("45.00"), currencyCode = "USD"),
237 214 ),
238 215 ),
239 216 )
240 217 }
241 218  
242 219 companion object {
243   - /**
244   - * The seeder uses the presence of this item as the
245   - * idempotency marker — re-running the seeder against a
246   - * Postgres that already contains it short-circuits. The
247   - * choice of "the very first item the seeder creates" is
248   - * deliberate: if the seed transaction commits at all, this
249   - * row is in the DB; if it doesn't, nothing is.
250   - */
251   - const val SENTINEL_ITEM_CODE: String = "DEMO-PAPER-A4"
  220 + const val SENTINEL: String = "PAPER-80G-A4"
252 221 }
253 222 }
... ...
web/src/App.tsx
... ... @@ -20,6 +20,7 @@ import { LocationsPage } from '@/pages/LocationsPage'
20 20 import { BalancesPage } from '@/pages/BalancesPage'
21 21 import { MovementsPage } from '@/pages/MovementsPage'
22 22 import { SalesOrdersPage } from '@/pages/SalesOrdersPage'
  23 +import { CreateSalesOrderPage } from '@/pages/CreateSalesOrderPage'
23 24 import { SalesOrderDetailPage } from '@/pages/SalesOrderDetailPage'
24 25 import { PurchaseOrdersPage } from '@/pages/PurchaseOrdersPage'
25 26 import { PurchaseOrderDetailPage } from '@/pages/PurchaseOrderDetailPage'
... ... @@ -48,6 +49,7 @@ export default function App() {
48 49 <Route path="balances" element={<BalancesPage />} />
49 50 <Route path="movements" element={<MovementsPage />} />
50 51 <Route path="sales-orders" element={<SalesOrdersPage />} />
  52 + <Route path="sales-orders/new" element={<CreateSalesOrderPage />} />
51 53 <Route path="sales-orders/:id" element={<SalesOrderDetailPage />} />
52 54 <Route path="purchase-orders" element={<PurchaseOrdersPage />} />
53 55 <Route path="purchase-orders/:id" element={<PurchaseOrderDetailPage />} />
... ...
web/src/api/client.ts
... ... @@ -159,6 +159,17 @@ export const inventory = {
159 159 export const salesOrders = {
160 160 list: () => apiFetch<SalesOrder[]>('/api/v1/orders/sales-orders'),
161 161 get: (id: string) => apiFetch<SalesOrder>(`/api/v1/orders/sales-orders/${id}`),
  162 + create: (body: {
  163 + code: string
  164 + partnerCode: string
  165 + orderDate: string
  166 + currencyCode: string
  167 + lines: { lineNo: number; itemCode: string; quantity: number; unitPrice: number; currencyCode: string }[]
  168 + }) =>
  169 + apiFetch<SalesOrder>('/api/v1/orders/sales-orders', {
  170 + method: 'POST',
  171 + body: JSON.stringify(body),
  172 + }),
162 173 confirm: (id: string) =>
163 174 apiFetch<SalesOrder>(`/api/v1/orders/sales-orders/${id}/confirm`, {
164 175 method: 'POST',
... ...
web/src/pages/CreateSalesOrderPage.tsx 0 → 100644
  1 +import { useEffect, useState, type FormEvent } from 'react'
  2 +import { useNavigate } from 'react-router-dom'
  3 +import { catalog, partners, salesOrders } from '@/api/client'
  4 +import type { Item, Partner } from '@/types/api'
  5 +import { PageHeader } from '@/components/PageHeader'
  6 +import { ErrorBox } from '@/components/ErrorBox'
  7 +
  8 +interface LineInput {
  9 + itemCode: string
  10 + quantity: string
  11 + unitPrice: string
  12 +}
  13 +
  14 +export function CreateSalesOrderPage() {
  15 + const navigate = useNavigate()
  16 + const [code, setCode] = useState('')
  17 + const [partnerCode, setPartnerCode] = useState('')
  18 + const [currencyCode] = useState('USD')
  19 + const [lines, setLines] = useState<LineInput[]>([
  20 + { itemCode: '', quantity: '', unitPrice: '' },
  21 + ])
  22 + const [items, setItems] = useState<Item[]>([])
  23 + const [partnerList, setPartnerList] = useState<Partner[]>([])
  24 + const [submitting, setSubmitting] = useState(false)
  25 + const [error, setError] = useState<Error | null>(null)
  26 +
  27 + useEffect(() => {
  28 + Promise.all([catalog.listItems(), partners.list()]).then(([i, p]) => {
  29 + setItems(i)
  30 + const customers = p.filter((x) => x.type === 'CUSTOMER' || x.type === 'BOTH')
  31 + setPartnerList(customers)
  32 + if (customers.length > 0 && !partnerCode) setPartnerCode(customers[0].code)
  33 + })
  34 + }, []) // eslint-disable-line react-hooks/exhaustive-deps
  35 +
  36 + const addLine = () =>
  37 + setLines([...lines, { itemCode: items[0]?.code ?? '', quantity: '1', unitPrice: '1.00' }])
  38 +
  39 + const removeLine = (idx: number) => {
  40 + if (lines.length <= 1) return
  41 + setLines(lines.filter((_, i) => i !== idx))
  42 + }
  43 +
  44 + const updateLine = (idx: number, field: keyof LineInput, value: string) => {
  45 + const next = [...lines]
  46 + next[idx] = { ...next[idx], [field]: value }
  47 + setLines(next)
  48 + }
  49 +
  50 + const onSubmit = async (e: FormEvent) => {
  51 + e.preventDefault()
  52 + setError(null)
  53 + setSubmitting(true)
  54 + try {
  55 + const created = await salesOrders.create({
  56 + code,
  57 + partnerCode,
  58 + orderDate: new Date().toISOString().slice(0, 10),
  59 + currencyCode,
  60 + lines: lines.map((l, i) => ({
  61 + lineNo: i + 1,
  62 + itemCode: l.itemCode,
  63 + quantity: Number(l.quantity),
  64 + unitPrice: Number(l.unitPrice),
  65 + currencyCode,
  66 + })),
  67 + })
  68 + navigate(`/sales-orders/${created.id}`)
  69 + } catch (err: unknown) {
  70 + setError(err instanceof Error ? err : new Error(String(err)))
  71 + } finally {
  72 + setSubmitting(false)
  73 + }
  74 + }
  75 +
  76 + return (
  77 + <div>
  78 + <PageHeader
  79 + title="New Sales Order"
  80 + subtitle="Create a sales order. Confirming it will auto-generate production work orders."
  81 + actions={
  82 + <button className="btn-secondary" onClick={() => navigate('/sales-orders')}>
  83 + Cancel
  84 + </button>
  85 + }
  86 + />
  87 + <form onSubmit={onSubmit} className="card p-6 space-y-5 max-w-3xl">
  88 + <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
  89 + <div>
  90 + <label className="block text-sm font-medium text-slate-700">Order code</label>
  91 + <input
  92 + type="text"
  93 + required
  94 + value={code}
  95 + onChange={(e) => setCode(e.target.value)}
  96 + placeholder="SO-2026-0003"
  97 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-brand-500"
  98 + />
  99 + </div>
  100 + <div>
  101 + <label className="block text-sm font-medium text-slate-700">Customer</label>
  102 + <select
  103 + required
  104 + value={partnerCode}
  105 + onChange={(e) => setPartnerCode(e.target.value)}
  106 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-brand-500"
  107 + >
  108 + {partnerList.map((p) => (
  109 + <option key={p.id} value={p.code}>
  110 + {p.code} — {p.name}
  111 + </option>
  112 + ))}
  113 + </select>
  114 + </div>
  115 + <div>
  116 + <label className="block text-sm font-medium text-slate-700">Currency</label>
  117 + <input
  118 + type="text"
  119 + value={currencyCode}
  120 + disabled
  121 + className="mt-1 w-full rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-sm"
  122 + />
  123 + </div>
  124 + </div>
  125 +
  126 + <div>
  127 + <div className="flex items-center justify-between mb-2">
  128 + <label className="text-sm font-medium text-slate-700">Order lines</label>
  129 + <button type="button" className="btn-secondary text-xs" onClick={addLine}>
  130 + + Add line
  131 + </button>
  132 + </div>
  133 + <div className="space-y-2">
  134 + {lines.map((line, idx) => (
  135 + <div key={idx} className="flex items-center gap-2">
  136 + <span className="w-6 text-xs text-slate-400 text-right">{idx + 1}</span>
  137 + <select
  138 + value={line.itemCode}
  139 + onChange={(e) => updateLine(idx, 'itemCode', e.target.value)}
  140 + className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm"
  141 + >
  142 + <option value="">Select item...</option>
  143 + {items.map((it) => (
  144 + <option key={it.id} value={it.code}>
  145 + {it.code} — {it.name}
  146 + </option>
  147 + ))}
  148 + </select>
  149 + <input
  150 + type="number"
  151 + min="1"
  152 + step="1"
  153 + placeholder="Qty"
  154 + value={line.quantity}
  155 + onChange={(e) => updateLine(idx, 'quantity', e.target.value)}
  156 + className="w-20 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right"
  157 + />
  158 + <input
  159 + type="number"
  160 + min="0"
  161 + step="0.01"
  162 + placeholder="Price"
  163 + value={line.unitPrice}
  164 + onChange={(e) => updateLine(idx, 'unitPrice', e.target.value)}
  165 + className="w-24 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right"
  166 + />
  167 + <button
  168 + type="button"
  169 + className="text-slate-400 hover:text-rose-500"
  170 + onClick={() => removeLine(idx)}
  171 + title="Remove line"
  172 + >
  173 + &times;
  174 + </button>
  175 + </div>
  176 + ))}
  177 + </div>
  178 + </div>
  179 +
  180 + {error && <ErrorBox error={error} />}
  181 +
  182 + <div className="flex items-center gap-3 pt-2">
  183 + <button type="submit" className="btn-primary" disabled={submitting}>
  184 + {submitting ? 'Creating...' : 'Create Sales Order'}
  185 + </button>
  186 + <span className="text-xs text-slate-400">
  187 + After creation, confirm the order to auto-generate work orders.
  188 + </span>
  189 + </div>
  190 + </form>
  191 + </div>
  192 + )
  193 +}
... ...
web/src/pages/DashboardPage.tsx
... ... @@ -100,25 +100,48 @@ export function DashboardPage() {
100 100 </div>
101 101 )}
102 102 <div className="mt-8 card p-5 text-sm text-slate-600">
103   - <h2 className="mb-2 text-base font-semibold text-slate-800">Try the demo</h2>
104   - <ol className="list-decimal space-y-1 pl-5">
  103 + <h2 className="mb-2 text-base font-semibold text-slate-800">
  104 + Demo: EBC-PP-001 Work Order Management Flow
  105 + </h2>
  106 + <p className="mb-3 text-slate-500">
  107 + Walk the printing company's production flow end-to-end — sales order to finished goods.
  108 + </p>
  109 + <ol className="list-decimal space-y-2 pl-5">
105 110 <li>
106   - Open a <Link to="/sales-orders" className="text-brand-600 hover:underline">sales order</Link>{' '}
107   - in DRAFT, click <span className="font-mono">Confirm</span>.
  111 + <strong>Create or open a sales order</strong> —{' '}
  112 + <Link to="/sales-orders/new" className="text-brand-600 hover:underline">
  113 + create a new order
  114 + </Link>{' '}
  115 + or open an existing one from the{' '}
  116 + <Link to="/sales-orders" className="text-brand-600 hover:underline">list</Link>.
108 117 </li>
109 118 <li>
110   - With the same sales order CONFIRMED, click <span className="font-mono">Ship</span>.
111   - The framework atomically debits stock, flips status to SHIPPED, and emits a domain
112   - event.
  119 + <strong>Confirm the order</strong> — click{' '}
  120 + <span className="font-mono bg-slate-100 px-1 rounded">Confirm</span>. The system
  121 + auto-generates one production work order per line (EBC-PP-001 step B-010). Check the{' '}
  122 + <Link to="/work-orders" className="text-brand-600 hover:underline">Work Orders</Link>{' '}
  123 + page to see them appear. Finance posts an AR entry automatically.
113 124 </li>
114 125 <li>
115   - Watch <Link to="/balances" className="text-brand-600 hover:underline">stock balances</Link>{' '}
116   - drop, <Link to="/movements" className="text-brand-600 hover:underline">movements</Link>{' '}
117   - grow by one row, and{' '}
118   - <Link to="/journal-entries" className="text-brand-600 hover:underline">
119   - journal entries
120   - </Link>{' '}
121   - settle from POSTED to SETTLED — all from the cross-PBC event subscriber in pbc-finance.
  126 + <strong>Walk the pre-seeded work order</strong> — open{' '}
  127 + <Link to="/work-orders" className="text-brand-600 hover:underline">WO-PRINT-0001</Link>.
  128 + It has a 3-step routing (CTP plate-making → offset printing → post-press finishing) and
  129 + a BOM with paper, ink, and CTP plates. Click <span className="font-mono bg-slate-100 px-1 rounded">Start</span>{' '}
  130 + to put it in progress, then watch it on the{' '}
  131 + <Link to="/shop-floor" className="text-brand-600 hover:underline">Shop Floor</Link> dashboard.
  132 + </li>
  133 + <li>
  134 + <strong>Complete the work order</strong> — materials are consumed from the warehouse,
  135 + finished goods are credited. Check{' '}
  136 + <Link to="/balances" className="text-brand-600 hover:underline">Stock Balances</Link>{' '}
  137 + and{' '}
  138 + <Link to="/movements" className="text-brand-600 hover:underline">Movements</Link>{' '}
  139 + to see the ledger entries.
  140 + </li>
  141 + <li>
  142 + <strong>Ship the sales order</strong> — finished goods leave the warehouse, the AR
  143 + journal entry settles from POSTED to SETTLED in{' '}
  144 + <Link to="/journal-entries" className="text-brand-600 hover:underline">Finance</Link>.
122 145 </li>
123 146 </ol>
124 147 </div>
... ...
web/src/pages/SalesOrdersPage.tsx
... ... @@ -56,7 +56,13 @@ export function SalesOrdersPage() {
56 56  
57 57 return (
58 58 <div>
59   - <PageHeader title="Sales Orders" subtitle="Customer-facing orders. Click a code to drill in." />
  59 + <PageHeader
  60 + title="Sales Orders"
  61 + subtitle="Customer-facing orders. Confirm to auto-generate production work orders."
  62 + actions={
  63 + <Link to="/sales-orders/new" className="btn-primary">+ New Order</Link>
  64 + }
  65 + />
60 66 {loading && <Loading />}
61 67 {error && <ErrorBox error={error} />}
62 68 {!loading && !error && <DataTable rows={rows} columns={columns} />}
... ...