First runnable end-to-end demo: open the browser, log in, click
through every PBC, and walk a sales order DRAFT → CONFIRMED →
SHIPPED. Stock balances drop, the SALES_SHIPMENT row appears in the
ledger, and the AR journal entry settles — all visible in the SPA
without touching curl.
Bumps version to 0.29.0-SNAPSHOT.
What landed
-----------
* New `:web` Gradle subproject — Vite + React 18 + TypeScript +
Tailwind 3.4. The Gradle wrapper is two `Exec` tasks
(`npmInstall`, `npmBuild`) with proper inputs/outputs declared
for incremental builds. Deliberately no node-gradle plugin —
one less moving piece.
* SPA architecture: hand-written typed REST client over `fetch`
(auth header injection + 401 handler), AuthContext that decodes
the JWT for display, ProtectedRoute, AppLayout with sidebar
grouped by PBC, 16 page components covering the full v1 surface
(Items, UoMs, Partners, Locations, Stock Balances + Movements,
Sales Orders + detail w/ confirm/ship/cancel, Purchase Orders +
detail w/ confirm/receive/cancel, Work Orders + detail w/
start/complete, Shop-Floor dashboard with 5s polling, Journal
Entries). 211 KB JS / 21 KB CSS gzipped.
* Sales-order detail page: confirm/ship/cancel verbs each refresh
the order, the (SO-filtered) movements list, and the
(SO-filtered) journal entries — so an operator watches the
ledger row appear and the AR row settle in real time after a
single click. Same pattern on the purchase-order detail page
for the AP/RECEIPT side.
* Shop-floor dashboard polls /api/v1/production/work-orders/shop-
floor every 5s and renders one card per IN_PROGRESS WO with
current operation, planned vs actual minutes (progress bar),
and operations-completed.
* `:distribution` consumes the SPA dist via a normal Gradle
outgoing/incoming configuration: `:web` exposes
`webStaticBundle`, `:distribution`'s `bundleWebStatic` Sync
task copies it into `${buildDir}/web-static/static/`, and
that parent directory is added to the main resources source
set so Spring Boot serves the SPA from `classpath:/static/`
out of the same fat-jar. Single artifact, no nginx, no CORS.
* New `SpaController` in platform-bootstrap forwards every
known SPA route prefix to `/index.html` so React Router's
HTML5 history mode works on hard refresh / deep-link entry.
Explicit list (12 prefixes) rather than catch-all so typoed
API URLs still get an honest 404 instead of the SPA shell.
* SecurityConfiguration restructured: keeps the public allowlist
for /api/v1/auth + /api/v1/_meta + /v3/api-docs + /swagger-ui,
then `/api/**` is `.authenticated()`, then SPA static assets
+ every SPA route prefix are `.permitAll()`. The order is
load-bearing — putting `.authenticated()` for /api/** BEFORE
the SPA permitAll preserves the framework's "API is always
authenticated" invariant even with the SPA bundled in the
same fat-jar. The SPA bundle itself is just HTML+CSS+JS so
permitting it is correct; secrets are gated by /api/**.
* New `DemoSeedRunner` in `:distribution` (gated behind
`vibeerp.demo.seed=true`, set in application-dev.yaml only).
Idempotent — the runner short-circuits if its sentinel item
(DEMO-PAPER-A4) already exists. Seeds 5 items, 2 warehouses,
4 partners, opening stock for every item, one open
DEMO-SO-0001 (50× business cards + 20× brochures, $720), one
open DEMO-PO-0001 (10000× paper, $400). Every row carries
the DEMO- prefix so it's trivially distinguishable from
hand-created data; a future "delete demo data" command has
an obvious filter. Production deploys never set the property,
so the @ConditionalOnProperty bean stays absent from the
context.
How to run
----------
docker compose up -d db
./gradlew :distribution:bootRun
open http://localhost:8080
# Read the bootstrap admin password from the boot log,
# log in as admin, and walk DEMO-SO-0001 through the
# confirm + ship flow to see the buy-sell loop in the UI.
What was caught by the smoke test
---------------------------------
* TypeScript strict mode + `error: unknown` in React state →
`{error && <X/>}` evaluates to `unknown` and JSX rejects it.
Fixed by typing the state as `Error | null` and converting
in catches with `e instanceof Error ? e : new Error(String(e))`.
Affected 16 page files; the conversion is now uniform.
* DataTable's `T extends Record<string, unknown>` constraint was
too restrictive for typed row interfaces; relaxed to
unconstrained `T` with `(row as unknown as Record<…>)[key]`
for the unkeyed cell read fallback.
* `vite.config.ts` needs `@types/node` for `node:path` +
`__dirname`; added to devDependencies and tsconfig.node.json
declares `"types": ["node"]`.
* KDoc nested-comment trap (4th time): SpaController's KDoc
had `/api/v1/...` in backticks; the `/*` inside backticks
starts a nested block comment and breaks Kotlin compilation
with "Unclosed comment". Rephrased to "the api-v1 prefix".
* SecurityConfiguration order: a draft version that put the
SPA permit-all rules BEFORE `/api/**` authenticated() let
unauthenticated requests reach API endpoints. Caught by an
explicit smoke test (curl /api/v1/some-bogus-endpoint should
return 401, not 404 from a missing static file). Reordered
so /api/** authentication runs first.
End-to-end smoke (real Postgres, fresh DB)
------------------------------------------
- bootRun starts in 7.6s
- DemoSeedRunner reports "populating starter dataset… done"
- GET / returns the SPA HTML
- GET /sales-orders, /sales-orders/<uuid>, /journal-entries
all return 200 (SPA shell — React Router takes over)
- GET /assets/index-*.js / /assets/index-*.css both 200
- GET /api/v1/some-bogus-endpoint → 401 (Spring Security
rejects before any controller mapping)
- admin login via /api/v1/auth/login → 200 + JWT
- GET /catalog/items → 5 DEMO-* rows
- GET /partners/partners → 4 DEMO-* rows
- GET /inventory/locations → 2 DEMO-* warehouses
- GET /inventory/balances → 5 starting balances
- POST /orders/sales-orders/<id>/confirm → CONFIRMED;
GET /finance/journal-entries shows AR POSTED 720 USD
- POST /orders/sales-orders/<id>/ship {"shippingLocationCode":
"DEMO-WH-FG"} → SHIPPED; balances drop to 150 + 80;
journal entry flips to SETTLED
- POST /orders/purchase-orders/<id>/confirm → CONFIRMED;
AP POSTED 400 USD appears
- POST /orders/purchase-orders/<id>/receive → RECEIVED;
PAPER balance grows from 5000 to 15000; AP row SETTLED
- 8 stock_movement rows in the ledger total