Skip to content

release: develop → main — sales/CRM stack with per-role lock release (backend image v2026-10-22, V193–V199.1) - #1169

Merged
waritctd merged 315 commits into
mainfrom
release/2026-10-22-develop-to-main
Oct 22, 2026
Merged

waritctd merged 315 commits into
mainfrom
release/2026-10-22-develop-to-main

Conversation

@waritctd

Copy link
Copy Markdown
Owner

What this is

Owner decision (Ploy, 2026-10-22): full develop → main release of the sales/CRM stack, with the production self-service lock opened per role. main was fully contained in develop (0 commits ahead), so this is develop as of 51b5e906 (after #1166) plus three release commits.

Per-role lock release (owner ruling, same day):

  • sales, sales_manager: exempt from the lock; they see exactly what PATH_GUARDS already grants (the sales stack, never payroll / HR registers / การเงิน / งานนำเข้า).
  • import: stays locked, may reach ONLY /fulfilment (งานนำเข้า), /pricing-requests (คิวขอราคา) and the queue's per-deal /import/deals/:id.
  • account: stays locked, may reach ONLY /finance (การเงิน).
  • everyone else unchanged (hr/ceo full portal; employee/warehouse/qc/division manager self-service only).
  • VITE_SELF_SERVICE_ONLY and its locked-by-default direction are untouched. This is UI scoping; the backend enforces every endpoint as before.

⚠️ Deploy order — the backend must go first

Merging this PR deploys the frontend to Vercel immediately. The backend only changes when the Render service's image is switched. The frontend here calls endpoints and columns the current prod image (v2026-10-14) does not have, so:

  1. Build + push ghcr.io/waritctd/glr-hr-backend:v2026-10-22 from this branch head (scripts/build-push-backend-image.sh v2026-10-22, amd64, licensed fonts baked in).
  2. Render → gl-r-erp → Settings → Build → Existing Image → set v2026-10-22 → deploy. Flyway applies V193, V194, V197, V198, V199, V199.1 at boot. Prod hr.flyway_schema_history was checked read-only before this PR: contiguous through 192.5, all success, so every pending version is ABOVE the applied max (no silent out-of-order skip). No new db/migration-demo files. Everyone is logged out by the deploy (known).
  3. Verify prod Flyway set reads …192.5,193,194,197,198,199,199.1 and a fresh quotation PDF embeds AngsanaNew/Tahoma.
  4. Only then merge this PR.

Rollback: switch the Render image back to v2026-10-14 (migrations are additive; see each file) and revert the merge on main.

Files changed (release commits only; the develop content is 287 commits, 469 files — see the develop PR history)

  • frontend/src/app/permissions.js — SELF_SERVICE_EXEMPT_ROLES + sales roles; RELEASED_PATHS_BY_ROLE + isReleasedPath; canAccessPath honours it.
  • frontend/src/components/layout/AppShell.jsx — sidebar filter honours isReleasedPath.
  • frontend/src/app/permissions.test.js, components/layout/AppShell.test.jsx, App.test.jsx — tests (below).
  • render.yaml — image.url pinned to v2026-10-22.

Commands run

  • npx vitest run src/app/permissions.test.js src/components/layout/AppShell.test.jsx src/App.test.jsx → 125/125
  • npx eslint on the changed files → clean
  • npm run build on the release head (see checks)
  • Prod Flyway set check (read-only SQL via Supabase MCP on tdyzcqzxmhtxpbouewud).
  • Opus review of the scoping diff (findings addressed/noted in comments).

Authz evidence

No backend authz change. The lock is frontend scoping; every endpoint keeps its controller role checks. Frontend "cannot reach" cases are pinned wrong-way-round (import → /tickets, /finance, /catalog, /quotations, /commissions; account → /tickets, /fulfilment, /pricing-requests, /quotations; sales → /payroll, /hr, /employees, /finance, /fulfilment).

Known risks

Test coverage

  • permissions.test.js — releases the sales stack to sales and sales_manager, nothing beyond their guards; releases import only งานนำเข้า and คิวขอราคา; releases account only การเงิน; matches on the pathname… (released paths with query strings); locks every role except… revised (sales roles now exempt); activity-log admin tests use import as the locked fixture; quotation-release leak test keeps the qc grantee.
  • AppShell.test.jsx — sales gets รายการดีล/ใบเสนอราคา/แคตตาล็อก back and no งานการเงิน/งานนำเข้า; import shows คิวขอราคา + งานนำเข้า and nothing else from the sales group; account shows งานการเงิน only.
  • App.test.jsx — import lands on EmployeeSelfService, is refused /tickets and /quotations (a page its role permits), reaches /fulfilment; sales lands on SalesOverview.
  • Red before green: 6 new assertions failed on the missing behaviour before the implementation; the revised older tests were re-pointed from a sales fixture to an import one.
  • Deliberately NOT covered: a browser walk of the locked production build per role (the build-hash check for the flag direction is unchanged by this PR); the dashboard quick-action links for import/account under the lock.

🤖 Generated with Claude Code

waritctd and others added 30 commits September 30, 2026 21:11
…ckend

feat(sales): quotation ↔ deal linking slice 1 — list every origin, un-hide quotation-first deals, confirm-order
A deal's ช่องทางดีล (sales.ticket.entry_channel) now decides which stages it may
visit. Owner ruling 2026-09-30; design in .design/deal-route-staging/.

  DESIGNER_LED  S1..S9 in full      off-route: none
  OWNER_DIRECT  S1 S2 S3 S5 S6 ...  off-route: S4 (no designer to quote)
  BUYER_DIRECT  S1 S2 S3 S8 S9 ...  off-route: S4 S5 S6 S7

S3/SPEC_APPROVED is KEPT on all three routes: it means "the party who specifies
has agreed the spec", and only its wording differs per party (a frontend
concern). That is what lets one stage list serve every route.

Rung 6 of TicketService.requireStageMoveAllowed refuses a manual move whose
TARGET is off-route (409), naming the remedy. It is last on purpose so a deal
missing its deposit reports the FACT gate rather than blaming the route, and it
tests the TARGET only, never the current stage — a deal may legitimately be
sitting on an off-route stage after its channel was corrected, and gating the
current stage would strand it with no legal move.

autoAdvanceStage no-ops silently on an off-route target rather than throwing,
so it can never fail an unrelated caller. DealRoute.ROUTE_VARIABLE pins the
only stages a route may ever exclude (S4-S8) with a class-load check, so an
OPERATIONAL advance (ORDER_RECEIVED, DEPOSIT_RECEIVED, PROCUREMENT, DELIVERED,
CLOSED_PAID) can never vanish there with its fact already written.

TWO DELIBERATE REVERSALS, stated rather than smuggled:
- V144 ruled entry_channel "NOT a behavioural flag: nothing reads entry_channel
  to decide anything". It is behavioural now, by owner ruling.
- SalesRouteWalkIntegrationTest.routeB asserted SPEC_APPROVED never happens on
  an owner-direct deal. It does now; S4 is the only party-axis exclusion.

UNSPECIFIED, unknown and null gate NOTHING. Pre-V144 rows were never backfilled
and read DESIGNER_LED whether chosen or defaulted, and GLA-156 removed the
in-portal way to correct a channel — gating them would brick legacy and
quotation-first deals with no remedy. DESIGNER_LED's empty off-route set also
makes this a no-op for the entire back catalogue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A rep could not tell which of three routes a deal was on, and the UI actively
misinformed them: every deal showed a 15-rung ladder including stages it would
never visit, and the progress denominator was always 15.

- Route line: "เส้นทาง · <ชื่อเส้นทาง> · ขั้นที่ p จาก t". Nothing renders when the
  channel is UNSPECIFIED/unknown/absent — a route is never invented.
- Route ribbon: one pip per ON-ROUTE stage (11 for buyer-direct, 15 for
  designer-led). Off-route stages are absent from it, and that absence is the
  feature — the path becomes countable at a glance.
- Pips carry the ROUTE ORDINAL (1..N), not the business S-number, so the ribbon
  and the "ขั้นที่ 4 จาก 11" line can never disagree. The business number still
  identifies a stage in the header, list and แก้ไขสถานะ modal.
- PhaseSummary's denominator is the route's length. It was
  (stageIndex+1)/catalog.stages.length, so a buyer-direct deal at S8 read
  "ขั้นที่ 8 จาก 15" and filled the bar 53% when its route is 11 stages and S8
  is its 4th. That is a bug fix, not a polish pass.
- Off-route group "ไม่อยู่ในเส้นทางนี้ (N)" renders the server's blockedReason
  verbatim, following UpdateStageModal's blocked list: a refused path explains
  itself rather than being silently dimmed. Status is text, never colour alone.
- dealStageLabel(code, entryChannel) words S3 per party. One-argument calls are
  unchanged, so the nine existing call sites keep working.
- nextOnRoute skips off-route stages.

onRoute absent/undefined is treated as on-route, so this degrades to today's
behaviour against an older backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he panel

These three called dealStageLabel(code) with one argument, so on an owner-direct
deal the header chip read "ผู้ออกแบบอนุมัติสเปค" while the panel three inches
below read "เจ้าของตกลงตามสเปคแล้ว" — on the same screen.

TicketDetailPage:1233 was worse than a label mismatch. `next` came from the
route-blind nextStageIn, and addStageActions only advertises ADVANCE_STAGE for
stages whose decision is `allowed`. Off-route stages come back allowed:false, so
on an owner-direct deal at S3 the code targeted S4, S4 was never advertised, and
the "เลื่อนไป" item DISAPPEARED — a rep simply lost the ability to advance from
the overflow menu. The comment above that block says the item must not "invite a
click it's about to reject"; it now keeps that promise.

The list also gains a route marker on the row and the mobile card, so a rep can
tell a designer-led deal from a buyer-direct one while scanning rather than
having to open it. Nothing renders for UNSPECIFIED.

DealStateHeader had no test file at all; one is added.

Measured before accepting: "ผู้ซื้อ/ผู้รับเหมาตกลงตามสเปคแล้ว" is 190px against
the existing widest label at 188px, so it is tied, not longer, and wraps to two
lines in the 174px chip column with no overflow at 140/160/174/200px. No new
chip, no truncation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s read

The route gate refuses an off-route move with a message ending
"— แก้ช่องทางดีลก่อน". That remedy did not exist anywhere in the portal: GLA-156
removed EntryChannelControl and listed POST /api/tickets/{}/entry-channel as
unreachable from the UI. A rep was refused, told to fix the channel, and could
not. Design §13.1a: the control ships in the SAME slice as the gate.

Placed contextually — inline on each off-route row in the stepper group and on
each off-route row in UpdateStageModal's blocked list — NOT as a permanent panel
row, because GLA-156 stripped that panel to "stage, owner, next step" on purpose.
Inline rather than a dialog, since the modal case would otherwise nest a modal.

Three rules taken from the server, never recomputed (the service comments say the
advertisement and the 400 must not disagree):
- UNSPECIFIED is valid STORED but rejected as INPUT, so only the three real
  channels are offered.
- The reason field appears only when the advertised action's requiredFields
  includes 'note'. Both DESIGNER_LED and UNSPECIFIED count as UNSTATED after
  V144's non-backfill, so a legacy deal's FIRST correction needs no note.
- The control renders only when SET_ENTRY_CHANNEL is advertised.

Deliberately NOT offered on rows blocked for other reasons (a missing fact, a
permission gate) — changing the channel cannot clear those, and a remedy that
does nothing is worse than none. A test pins the exclusion.

Save is disabled with a visible reason why, never silently dead. On success the
ticket, actions, list and dashboard invalidate, so the route, ribbon, denominator
and now-unblocked stage refresh without a reload.

docs/api/ui-reachable.json regenerated via UI_REACHABLE_UPDATE=1 (300 -> 301).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The deal list still read "ขั้นตอน 8/15" for a buyer-direct deal whose own page
said "ขั้นที่ 4 จาก 11" — the same 15-denominator lie, one screen earlier.

The panel's fix does not transfer: it reads onRoute from the per-deal
/actions payload, and a list row has none. Fetching one per row would be an N+1
on the hottest sales endpoint. So the backend serves the route instead, as
PaymentTrack.path(policy) already does for payments (design §7): a "routes" map
of channel -> DealRoute.path(channel), stage CODES only, on an endpoint the
client already caches with staleTime: Infinity. UNSPECIFIED maps to all 15.

Mirroring the table in frontend logic was rejected: stageCatalog.js forbids
growing derivation there, and a hand-copied table drifts.

DealStageMetaController had no test; nine are added, including that every route
keeps the tail from NEGOTIATION onward and that anything a route drops sits
inside DealRoute.ROUTE_VARIABLE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Until now the mock omitted onRoute, so under VITE_USE_MOCKS=true every stage
read as on-route: the whole route feature was INVISIBLE in mock mode, and any
mock-driven test of it would have passed while proving nothing. That is the
third failure shape in CLAUDE.md's table — "mock OMITS a field the feature keys
on" — and the one that only surfaces in prod.

Mirrors DealRoute, with the route branch placed after the fact gate so the mock
follows the same ladder order as TicketService: a deal missing its deposit
reports the FACT gate, not the route.

Scope is deliberately one function. The GLA-136 quotationOnly branch in it is
untouched, since the quotation<->deal-link session is deleting it.

This is plumbing evidence only. Route ENFORCEMENT is proved against the real
Java service by DealRouteGateIntegrationTest — a mock can validate plumbing, it
can never validate an algorithm it mirrors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The design behind this branch, with all ten owner rulings recorded (R1-R10,
2026-09-30) and the reasoning that produced them.

§12 enumerates the full transition case space — 15 stages, 210 ordered pairs per
route, 630 triples — because the question "can a rep jump from any stage to any
stage?" deserved an answer with numbers. Even with no route at all only 42 of
210 moves were ever free; the gate takes buyer-direct to 112 of 210 refused.

§12.1 is the nine-rung precedence ladder. Eight rungs already existed; the route
is rung 6, and that framing is what keeps this a small change.

§13 records what the hard gate forces: the remedy must ship in the same slice,
the owning rep must be able to correct the channel, and UNSPECIFIED must gate
nothing. §13.3 records the recommendation I made against the ruling, and why the
inline remedy makes the objection largely dissolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI's lint-and-build failed on jsx-a11y/aria-role: DealStateHeader takes `role`
as a component prop (the VIEWER's role — sales/ceo/hr), but the rule reads any
literal `role="..."` as the DOM ARIA attribute and rejects "sales" as invalid.

Production never hit this because TicketDetailPage passes `role={role}`, an
expression the rule cannot statically evaluate. The test now does the same via a
named constant, which also documents why.

Caught only by CI: this was the lint run skipped locally under the
targeted-tests-only rule. `npm run lint` and `npm run build` now both pass, and
DealStateHeader.test.jsx is 23/23.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One-click sign-in for the nine accounts the backend's demo profile seeds
(db/migration-demo V21 + V139), shown only when import.meta.env.DEV is true
and VITE_USE_MOCKS is not, so it is dead-code-eliminated from production
builds (verified: no "Demo@2026" in dist). Sends ordinary email+password
logins, never a mock { role }.

Tests: LoginPage.devQuickLogin.test.jsx (13) - per-persona credentials,
absent when DEV=false (mutation-checked), absent in mock mode, disabled
while loading. Not covered: that the seeded accounts exist in the DB.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…al (backend)

Owner ruling 2026-09-30: import gets its own per-deal surface and must
never be handed the whole deal or its pricing.

API/authz changes (stated explicitly):
- NEW GET /api/import/deals/{id} — import-only DTO (customer/project,
  items, per-factory import-requests + steps, delivery status read-only,
  comments). No pricing/cost/margin fields. Roles import+ceo, row-scoped
  to import's list scope (TicketRepository.isInImportScope) -> 403.
- GET /api/tickets/{id} now 403s role import (paired with the new page;
  NOT independently deployable until the frontend repoints import).
- projectForRole strips item cost fields and non-COMMENTED events for
  import (closes the comment-response cost leak).
- comment() is row-scoped to import's scope (no out-of-scope commenting).
- Attachments: import may read/download all four types (PO,
  SIGNED_QUOTATION, INVOICE, OTHER) for in-scope deals; upload still 403.

Tests: real-DB integration through the Java service (ImportDealAuthz*,
ImportCommentAndDocumentAuthz*, ImportDealDtoNoPricingFields*), wrong-way-
round, per-guard mutation-checked. api-surface.json +1 (362).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(tickets): route the deal pipeline by entry channel
… (unreviewed)

Paused mid-verification under host load. Owner rulings not yet applied
(off-route note, channel pre-fills recipient, เสนอแก่ labels).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One conflict, TicketDetailPage.test.jsx: both sides appended a describe block
whose last test ended inside the conflict; kept both and restored the closer
the shared suffix only supplied once. File: 142/142.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner ruling 2026-09-30: import's view is procurement-only. Null
amountPayable/amountPaid/amountOutstanding for role import on every
TicketSummaryDto it receives — projectForRole (comment response) and the
worklist list/listPage paths. Applied after the query, so ordering,
paging and counts are unaffected; non-import roles unchanged.

Tests: 4 wrong-way-round cases added (import summary money null on comment
+ both list paths; positive controls keep them), mutation-checked N1-N5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ging merge

develop's deal-route-staging rework dropped the quotation_only=FALSE
(PIPELINE_ONLY) filter from findSummaries/countSummaries and removed the
constant. isInImportScope mirrored the list, so it followed suit: predicate
is now id + appendRoleScope(import), matching the merged list exactly. The
quotation_only flag is cleared on promote, so a container never reaches
import's stage/PCR scope anyway. Full import authz suite + edited existing
tests green on the merged tree (ImportDeal* 20+15+4, TicketServiceTest 201,
TicketScope 23, TicketIaAuthzMatrix 44, DealDocumentRegister 10, TicketAccessPolicy 15).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ayer + mock

routes/hrApi (importDeals.get) + queryKeys (importDeal) + mockApi mirror
(cost-free, assembled from the mock seed) + serverContract. 7 mock tests;
contract + serverContract + api folder green (402). UI slice consumes it
next (and must drop the temporary UNREACHABLE_FROM_UI entry).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lfilment tab

Frontend-only: relabel S12-S17 to Yang's wording (codes unchanged), add
the ถึงไทย X/N โรงงาน rollup chip, and make ImportRequestFactoryCard a
compact card with document controls in a collapsed <details>. Backend,
schema, mockApi and routes untouched; all data-testids preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ills recipient

Owner rulings 2026-09-30 on top of the slice-2 checkpoint:
- #1 off-route recipient: allow + inline note. QuotationRecipientField reads the
  deal's route from the served stage catalog (stageCatalog.routeForChannel) and,
  when the chosen recipient's quote stage is off it, shows "ผู้รับนี้ไม่อยู่ใน
  เส้นทางของดีล — ขั้นของดีลจะไม่ขยับ" (13px, info tone, role=status). No block,
  no modal. New deal: the channel being chosen; picked deal / existing DRAFT: the
  deal's own entryChannel.
- #2 channel pre-fills recipient on the new-deal card (DESIGNER_LED/OWNER_DIRECT/
  BUYER_DIRECT -> DESIGNER/OWNER/BUYER), re-filling only until the rep picks one.
  ?ticket= keeps the stage preselect and falls back to the channel mapping.
- Fix found in the browser: submit/approve/reject/revise/reorder/cancel on a
  direct quotation now also invalidate ['tickets'], so the deal page CTA keyed
  on liveDirectQuotation is not left stale.

confirm-order still calls the promote-to-deal alias (untouched).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…c + legacy actions

Frontend slice of the import-own-page feature:
- New /import/deals/:ticketId (ImportDealPage) on GET /api/import/deals/{id}:
  header, per-factory ใบขอซื้อ tracker (shared ImportRequestFactoryCard),
  delivery read-only, items, comments, attachments. No pricing anywhere.
- Cut import off /tickets/:id (canViewWholeDeal); RequireAccess redirects
  import to /import/deals/:id. Worklist/overview/pricing links repointed.
- Legacy pre-per-factory actions (issue/shipping/goods-received) surfaced on
  the page for legacy deals only, test-first.
- Sync: every per-factory + comment + legacy mutation invalidates importDeal
  alongside storedImportRequests/ticketDetail/ticketActions/list.
- Hallmark polish (existing tokens only): status strip, focal tracker panel.
- api layer wired (importDeals.get + queryKey + mock mirror), serverContract
  UNREACHABLE line removed, ui-reachable.json regenerated (302).
- Mock tightened: tickets.get 403s import; comment/attachments row-scoped.

lint 0, npm test green (3603), build clean. Browser-verified: two-way sync
(advance on import page <-> deal tab), cost-free page, import redirected off
the deal page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…page + worklist)

Yang's clean per-factory tracker (FactoryProgressBar) now shows the expected
arrival window next to the step chip — ETA <from>–<to>, derived from the
factory's lead time (issueDate + leadTimeMin/Max, mock and backend both
derive expectedArrivalFrom/To). Before issue it falls back to the raw
lead-time span. Read-only: sales/CEO/import all see it. Wired the arrival +
lead-time fields through ImportRequestFactoryCard's FactoryProgressBar row
(ImportFulfilmentPage already passed the full row). New FactoryProgressBar.test.jsx (5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ื้อ; one pricing route per deal

Review finding (QuotationEditorPage): "already confirmed → ไปที่ดีล" was decided from
ticket.quotationOnly === false, which since #1086 is provenance only and false on every
flow-B deal — hiding the confirm on an APPROVED direct quotation whose order was never
confirmed. It now keys on the backend's own precondition, the deal's status === 'draft'
(confirmOrderFromDirectQuotation 409s otherwise; confirming flips it to quotation_issued).
Unknown status (403 / absent) keeps the confirm offered. The action, its dialog and toasts
read "ยืนยันคำสั่งซื้อ" (IA §7/§8, D3), matching the deal page CTA. Endpoint call unchanged
(promote-to-deal alias).

Owner ruling 2026-09-30 — one pricing route per deal, both directions. While a deal holds
a LIVE pricing request (any status but CANCELLED/SUPERSEDED), no direct quotation may be
started:
- quotationMeta.hasLivePricingRequest + LIVE_PRICING_REQUEST_BLOCK_MESSAGE (one definition)
  and a blocking livePricingRequest checklist entry.
- Deal page hides ใบเสนอราคาตรง (and while pricing requests load).
- /quotations/new?ticket= shows an inline notice and disables บันทึกร่าง with the reason,
  reading the deal's requests via the existing pricingRequests.listForTicket.
- mockApi dealQuotations.create 409s (after the recipient 400, before N6); test-only seams
  setPricingRequestStatusForTests / retireLivePricingRequestsForTests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…one system)

Visual layer only, tokens-only, motion-cut, no behavior/testid/logic change.
- New shared ImportStatusStrip (fulfilment status · ถึงไทย X/N · ส่งมอบ N%)
  used on both the deal tab and the import page.
- FactoryProgressBar: clean header (name · step chip · ETA · updated),
  refined 6-step bar (larger current dot + ring, aria-current), aligned
  action cluster; new presentational props bare/titleExtra.
- ImportRequestFactoryCard: single frame, bar owns the header for ISSUED
  rows (no double name), de-boxed email row, consistent block order.
- DealFulfilmentPanel + ImportDealPage: consistent STEP_BOX framing, tracker
  leads, supporting sections quieted.
- Latent fixes surfaced: advance <select> was stretching full-width (added
  w-auto); StatusBadge tones 'blue'/'green' (no CSS) -> 'info'/'success'.

Tests: 3612 green, lint 0, build 0. 375px checked (scrollWidth==clientWidth
at 320/375/414). New ImportStatusStrip.test.jsx (4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Measured contrast (all text >=4.5:1, no colour change) + surgical sizing/
alignment fixes only: unified chip heights (step/ETA/status-strip chips to
26px), advance <select> height to match buttons, step-bar label wrap fix
(min-w 600), mobile touch targets >=44px (details summary, footer edit,
lead-time save), whitespace-nowrap on three deal-tab buttons, error-link
now button-shaped. Tokens-only, motion-cut, no behavior/testid/logic change.
3612 tests green, lint 0, build 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… page

Import asked to download each factory's ใบขอซื้อ (F-SM-001) from their own
page without opening the details expander. ImportRequestFactoryCard gets an
optional showInlineDownload prop (default false, so the deal tab is
unchanged); ImportDealPage passes it, rendering a visible 'ดาวน์โหลด PDF'
per issued factory card (internal copy — import's working reference; both
copies remain in the details). Advancing steps is already import's own
control. New ImportDealPage test; targeted tests + build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ations/new

Slice 2 made the new-quotation page open on the "ดีล" step with
เลือกดีลที่มีอยู่ selected; the customer typeahead now lives in the
สร้างดีลใหม่ branch, so this spec timed out waiting for it (also after its
own reload, which resets the choice).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ontend

feat(sales): quotation ↔ deal linking slice 2 (frontend) — flow A, recipient, one pricing route, ยืนยันคำสั่งซื้อ
…overage

The route-coverage sweep flagged import's /tickets/:id as 'denied but
rendered' because RequireAccess redirects import off the whole-deal page to
its own /import/deals/:id (intended; pinned in RequireAccess.test.jsx)
rather than showing the AccessDenied heading. A denied route that lands on a
DIFFERENT path has refused cleanly, so only a same-URL render now counts as a
leak. No product-code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(import): import's own per-deal page (view-only deal, per-factory IR tracker) — backend + frontend
waritctd and others added 20 commits October 20, 2026 05:25
…m narration

Comment-only. The dealQuotations field and setter docs now name the deposit
route's new-engine branch as a second user (and that an unwired instance answers
409 there). Drops two narration comments the calls already say, states the 409
rule directly, rewords findDealQuotationItems' circular Javadoc, and reflows one
ragged wrap in findDealQuotationDepositPercent's Javadoc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e commit sha from the test comment

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
feat(sales): deal form is one page — accordion sections, no ผู้ติดต่อ; "+ เพิ่ม…" first in every dropdown
…n — implementation plan

The plan the branch was executed from (subagent-driven). Its "items = null" shape
for the new-engine branch was superseded during review by a pricing-request-scoped
lookup with items from that quotation; the commits and their tests carry the final
shape, the plan carries the reasoning and the coverage list. No test: docs-only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…icing request's quotation (reproduces the sibling's percent)

createDepositNoticeFromQuotation_newEngineAccepted_whenASiblingPricingRequestHasALegacyIssuedQuotation_draftsThisQuotationsLinesAndPercent
(renamed from ..._draftsThisQuotationsLines): the new-engine quotation carries
30%, the sibling pricing request's legacy ISSUED row carries 70%, and the draft
must bill 0.30. Red on a32b02b: 0.7000, because createDraft's ticket-scoped,
legacy-first percent lookup reads the sibling's row.

No code path writes deposit_percent on a legacy row today, so the test seeds it
through jdbc; without the seed the lookup already falls through to this
quotation's own percent.

The issuedNewOriginQuotation/acceptedNewOriginQuotation/upsertWithValidityDays
helpers gain a deposit-percent overload; the existing call shapes are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pted new-engine quotation's own

The new-engine drive can now set the quotation's depositPercent before submit;
the first new-engine case sets 30 and expects the draft to carry 0.3. Red on the
current mock: 0.5, because the route takes payload.depositPercent ?? 0.5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…quotation percent, not the ticket's

Cloud review: the new-engine branch took reference and items from this pricing
request's ACCEPTED quotation, but the percent still came from createDraft's
ticket-scoped, legacy-first lookup, so a sibling pricing request's legacy row
carrying a percent would be billed under this quotation's number.

- DepositNoticeService#createDraftFromQuotation(ticketId, req, sourceDepositPercent,
  actor): a non-null source percent is the draft's percent; createDraft keeps its
  signature and delegates with null (per-ticket lookup, unchanged).
- OrderConfirmationService#createDepositNoticeFromQuotation passes the chosen
  new-engine quotation's own depositPercent. The legacy branch passes null:
  CustomerQuotationDto carries no percent, so it keeps today's derivation.
- mockApi pricingRequests.createDepositNoticeFromQuotation mirrors it: the
  new-engine branch bills newEngine.depositPercent / 100; the legacy branch and
  the ticket-level createDraft are unchanged.

Test: createDepositNoticeFromQuotation_newEngineAccepted_whenASiblingPricingRequestHasALegacyIssuedQuotation_draftsThisQuotationsLinesAndPercent
(red 0.7000, now 0.30) and the mock new-engine case (red 0.5, now 0.3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d; qualify the percent Javadoc

The 70% legacy seed now asserts it updated exactly one row, so the test cannot
pass vacuously if the seed misses. The createDepositNoticeFromQuotation Javadoc
now says the source quotation's percent wins only when it sets one; a null
percent still falls back to the per-ticket lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.2.1 to 8.3.4.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.4/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.3.4
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
…nd/vite-8.3.4

chore(deps-dev): bump vite from 8.2.1 to 8.3.4 in /frontend
Bumps [@vitest/mocker](https://github.com/vitest-dev/vitest/tree/HEAD/packages/mocker) to 4.1.12 and updates ancestor dependency [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). These dependencies need to be updated together.


Updates `@vitest/mocker` from 4.1.11 to 4.1.12
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.12/packages/mocker)

Updates `vitest` from 4.1.11 to 4.1.12
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.12/packages/vitest)

---
updated-dependencies:
- dependency-name: "@vitest/mocker"
  dependency-version: 4.1.12
  dependency-type: indirect
- dependency-name: vitest
  dependency-version: 4.1.12
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
…gine quotation

A 0% ("ไม่รับมัดจำ", full payment) quotation has nothing to bill. The new-engine branch
of createDepositNoticeFromQuotation passed depositPercent 0 straight into the draft,
producing a 0-baht notice that nothing downstream refuses to issue (issue() reads the
ticket's deposit_policy, which no path syncs from the quotation). Now 409 with the
quotation number. Mock mirrored.

Test-first: DealQuotationOutcomeIntegrationTest
.createDepositNoticeFromQuotation_newEngineAcceptedZeroDeposit_409AndNoDraft (real DB,
red: "Expecting code to raise a throwable" → green, class 33/33) and
mockApi.depositNotices.test.js "rejects a 0% (full-payment) new-engine quotation with
409 and creates no draft" (red: promise resolved with a draft → green, file 9/9).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the handoff

CLAUDE.md retired handoff docs in 2026-07; this was the first file under docs/superpowers
and already described the superseded first design. No test: docs-only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ine deposit test fixtures

mockDepositNoticeItemsFromDealQuotation built the whole deal-quotation DTO to read six
fields that come straight off the stored row (mockDealQuotationItemDto only adds ceo* /
priceChangedFromCeo on top of the spread). The test drops a redundant id after a spread,
two conditional spreads the mock already gates on != null, and folds five single-field
expects into one toMatchObject. No behaviour or message change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…of each reason

Comment-only. The "accepted, not latest" reasoning now lives once, on
DealQuotationRepository#findAcceptedForPricingRequest; the resolved-columns paragraph moves
onto findDealQuotationItems, the method that actually reads them; createDraft's inline
comment stops restating createDraftFromQuotation's Javadoc; one misplaced clause in
createDepositNoticeFromQuotation's Javadoc moves next to the sentence it qualifies.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fix(sales): pricing-request มัดจำ draft accepts the new-engine ACCEPTED quotation of that pricing request
…lock

Owner ruling (Ploy, 2026-10-22): sales and sales_manager are exempt from the lock; import
reaches only งานนำเข้า + คิวขอราคา (and the queue's /import/deals drill-down); account only
การเงิน. Wrong-way-round cases first: every other sales-group path and nav item stays hidden
for import/account, and sales still cannot reach payroll, the HR registers, /finance or
/fulfilment. The older lock tests that used a sales fixture as 'a locked role' now use import.

Red before the implementation: 6 failures, all assertion failures on the missing behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t get a per-role allowlist

UI scoping only (the backend enforces every endpoint as before). SELF_SERVICE_EXEMPT_ROLES
gains sales and sales_manager, so PATH_GUARDS alone decides what they see. import and account
stay locked and pass through RELEASED_PATHS_BY_ROLE (import: /fulfilment, /pricing-requests,
/import/deals; account: /finance), applied by canAccessPath and the sidebar filter with the
same pathname-prefix rule as SELF_SERVICE_PATHS. The lock flag and its default direction are
untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Keeps image.url in step with the image built from this release head (develop → main,
V193–V199.1). The Render dashboard decides what runs; this is the record of what should.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
demo-glr Ready Ready Preview Oct 22, 2026 8:20am UTC

Request Review

… and price-import hidden under the lock

Opus review: the /fulfilment and /leave App tests asserted the access-denied heading was absent
before any route had rendered (auth.me still pending), so they passed with the path locked too;
they now await a positive marker first. The sidebar tests also assert แคตตาล็อกสินค้า (account)
and นำเข้าราคา (import) stay hidden — both pass their own show: condition and are removed only
by the lock filter. Mutation-checked: dropping /fulfilment from import's allowlist turns exactly
these two tests red.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@waritctd

Copy link
Copy Markdown
Owner Author

Backend image ready — deploy this BEFORE merging.

  • ghcr.io/waritctd/glr-hr-backend:v2026-10-22
  • digest sha256:ab6d037cbbba397eefb706cbfa92834fe6b227e5ec71488f83a13bdd145562fe, platform linux/amd64, revision label 31faed13 (this branch's backend content; the later commit 4287ff21 is frontend tests only, so the image is current).
  • Built with the 12 licensed font faces; the Dockerfile's fontconfig check passed ("All 12 required font faces resolve exactly").
  • Verified by copying app.jar OUT of the pushed image: it contains db/migration/V193, V194, V197, V198, V199, V199.1 and the fix(sales): pricing-request มัดจำ draft accepts the new-engine ACCEPTED quotation of that pricing request #1166 code (DealQuotationRepository#findAcceptedForPricingRequest, DepositNoticeService#createDraftFromQuotation).
  • Prod hr.flyway_schema_history read-only check before this PR: contiguous through 192.5, all success → the six pending versions are all above the applied max (no silent out-of-order skip).

Opus review of the lock-release diff: no blocker. Two test gaps fixed in 4287ff21 (vacuous /fulfilment and /leave App tests; sidebar tests now pin แคตตาล็อกสินค้า/นำเข้าราคา hidden), mutation-checked. Noted, not changed: PATH_GUARDS matches the raw path including a query string, so for an exempt role canAccessPath('/payroll?x', sales) fails open — unreachable today because RequireAccess passes location.pathname; stale "only hr/ceo exempt" comments remain in permissions.js/App.jsx/features.js and CLAUDE.md's lockdown section; import/account land on the self-service landing with the sidebar as the only way into their released pages; a few in-page links (เปิดใบเสนอราคา, /tickets/:id/deposit, role-wide notifications to /tickets/:id) dead-end for locked import/account.

Deploy order: Render → gl-r-erp → Settings → Build → Existing Image → v2026-10-22 → deploy → confirm Flyway set ends …192.5,193,194,197,198,199,199.1 → merge this PR.

…nd/multi-456d42d8d4

chore(deps): bump @vitest/mocker and vitest in /frontend
@waritctd
waritctd merged commit 0d36985 into main Oct 22, 2026
7 checks passed

This branch was successfully deployed

1 active deployment
Preview — 963bc0bd Deployed Oct 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants