diff --git a/.agents/skills/ui-revamp/SKILL.md b/.agents/skills/ui-revamp/SKILL.md new file mode 100644 index 000000000..9b8fa3405 --- /dev/null +++ b/.agents/skills/ui-revamp/SKILL.md @@ -0,0 +1,147 @@ +--- +name: ui-revamp +description: Audit and fix web pages that look off — visual polish (type, layout, color, hierarchy, AI-slop removal) plus mobile-first responsive standards from 320px phones to wide desktops. Use when revising, critiquing, polishing or making any UI responsive. +--- + +# UI Revamp + +One skill for fixing pages that "don't look right" and making them work on every screen, from a 320px phone to a 1440px+ desktop. It condenses three open-source approaches into one workflow: a design-system-first reasoning pass, a command vocabulary for targeted visual fixes, and mobile-first responsive guardrails. + +## How to use + +Say what you want in plain words, or use a mode keyword followed by a target: + +``` +audit the pricing page +critique landing +fix layout on the dashboard +typeset the blog post +adapt checkout for mobile +polish settings +``` + +With no mode named, run **audit → fix → polish** on the target. + +--- + +## Step 1 — Gather context (always, before changing anything) + +1. **Read the existing code first.** Find the stack (React/Next, Vue, plain HTML, Tailwind or plain CSS, component library) and work inside it. Never introduce a new framework to fix a page. +2. **Find the existing design system.** Look for tokens, a theme file, `tailwind.config`, CSS variables, `DESIGN.md`, or shared components. If one exists, it wins: extend it and don't override it. If none exists, extract one from the most-used values in the code (Step 2). +3. **Know the product.** Who is the audience, what is the page for, and what is the one action a visitor should take? Check for `PRODUCT.md` or a README. Ask the user only when something material is missing, and ask once. +4. **See the page.** Look at a screenshot or rendered view at 375px, 768px, 1024px and 1440px if you can. Judge the rendered result, not just the code. + +## Step 2 — Lock a design system (small, explicit) + +Write down, or confirm, a compact system before editing. If the project has none, save it as `DESIGN.md` at the project root so later pages stay consistent. + +- **Type:** 1–2 families at most (a display face plus a text face, or a single family). Use a modular scale, e.g. 12 / 14 / 16 / 20 / 24 / 32 / 40 / 48. Body text is never below 16px on mobile. +- **Spacing:** a 4px or 8px base scale (4, 8, 12, 16, 24, 32, 48, 64, 96). No arbitrary values such as 13px or 27px. +- **Color:** a primary, an accent or CTA, a neutral ramp of 5–9 steps tinted toward the brand hue (not pure gray), plus success/warning/danger. Define these as tokens or CSS variables, and a dark set if the site has dark mode. +- **Radius and shadow:** 2–3 radius values and 2–3 elevation levels, used consistently. +- **Style direction:** one named direction that fits the product (e.g. calm and minimal for finance or health, bold and editorial for creative work, dense and functional for dashboards). Industry fit matters: trust-heavy products such as banking, health and legal avoid playful or neon styles. + +**Page overrides:** if one page truly needs to deviate, record only the deviation in `design/pages/.md`. Page rules override the master and everything else inherits. + +## Step 3 — Diagnose (audit + critique) + +Score each area **OK / Minor / Major** and list concrete findings with file and line where possible. + +**Visual hierarchy and clarity (critique)** +- Is there one clear focal point and one primary CTA per view? +- Does size, weight and color tell the reader what matters first, second and third? +- Is the copy clear? Are labels and buttons action-oriented and free of jargon? + +**Technical quality (audit)** +- Contrast is at least 4.5:1 for body text and 3:1 for large text and UI elements. +- Focus states are visible, keyboard order is logical, and headings run in order with none skipped. +- Images have alt text, and icon-only buttons have accessible labels. +- Layout breaks, horizontal scroll, overflow and clipped text are checked at every breakpoint. +- Performance: no huge images, no layout shift (reserve space for images and embeds), fonts load with `font-display: swap`. + +**AI-slop and generic-template tells (flag and remove)** +- Inter or system default used for everything, with no typographic personality. +- Purple-to-blue gradients, glowing dark-mode blobs, gradient text on every heading. +- Cards inside cards, or every section wrapped in a card. +- A rounded-square icon tile above every heading, and emoji used as icons (use SVG icons such as Lucide, Heroicons or Phosphor). +- Gray text on colored backgrounds, and pure `#000` or pure gray (tint neutrals instead). +- Side-accent borders on every card, bounce or elastic easing, everything centered. +- Three identical feature columns with no hierarchy. + +## Step 4 — Fix (pick the modes the diagnosis calls for) + +| Mode | Fixes | +|---|---| +| **typeset** | Font choice and pairing, scale, weights, line height (1.4–1.6 for body, 1.1–1.25 for headings), line length (45–75 characters), heading wrapping | +| **layout** | Spacing rhythm, alignment to a grid, grouping (related items closer, sections further apart), whitespace, density | +| **colorize** | Strategic color: a neutral base, one accent used sparingly for actions and emphasis, and meaning that never relies on color alone | +| **bolder** | Bland pages: stronger scale contrast, a confident hero, a real focal point, more decisive color | +| **quieter** | Noisy pages: fewer colors, effects, borders and shadows; calmer motion | +| **distill** | Cluttered pages: cut sections, merge duplicates, remove decoration that carries no meaning | +| **harden** | Edge cases: long text and URLs wrap instead of clipping, empty, loading and error states exist, chips and badges wrap or collapse to "+n", the layout survives 200% zoom and translated text | +| **clarify** | UX copy: specific headings, verb-first buttons, helpful error messages | +| **animate** | Purposeful motion only: 150–300ms, ease-out, honors `prefers-reduced-motion`, and the final state stays correct if an animation is interrupted | +| **adapt** | Make it responsive (see the standards below) | +| **polish** | Final pass: align to the design system, make spacing and radius consistent, finish hover, focus and active states, run the pre-delivery checklist | + +Make the smallest change that fixes the problem. Reuse existing components and tokens. Keep behavior and content intact unless asked otherwise. + +--- + +## Responsive standards (always on) + +**Mobile-first** +- Write base styles for the smallest screen, then add larger layouts with `min-width` media queries (Tailwind: unprefixed first, then `sm:`, `md:`, `lg:`, `xl:`). +- Always include ``. + +**Breakpoints:** use the project's existing set. Default to 640 / 768 / 1024 / 1280 / 1536px, matching Tailwind. Choose breakpoints where the content breaks, not for specific devices, and never invent one-off values. + +**Fluid layouts** +- Use Flexbox and Grid. Prefer `grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr))` over fixed column counts. +- Use relative units: `rem` for type and spacing, `%`, `fr`, `vw` and `ch` for widths. No fixed pixel widths on containers. Use `max-width` with `width: 100%`. +- Constrain the content width (e.g. a max of 1200–1280px) with responsive side padding: 16px on mobile, 24–32px on tablet, 48px+ on desktop. +- Use fluid type for headings with `clamp()`, e.g. `font-size: clamp(2rem, 1.2rem + 3vw, 3.5rem)`. +- Use container queries for components that appear in different-width slots. + +**Touch and interaction** +- Tap targets are at least 44×44px, with at least 8px between adjacent targets. +- Nothing depends on hover alone. Every hover reveal has a tap or focus equivalent. +- Form inputs use 16px or larger text (prevents iOS zoom), and use the correct `type` and `inputmode`. +- Add `cursor: pointer` to every clickable element. + +**Content adaptation** +- Navigation collapses to a menu or bottom bar on mobile, and the primary CTA stays reachable. +- Multi-column sections stack in a sensible reading order. Don't hide important content on mobile; reprioritize it instead. +- Tables scroll horizontally inside a wrapper, or turn into stacked cards on mobile. +- Images use `max-width: 100%; height: auto`, plus `srcset`/`sizes` or the framework's image component. Set `aspect-ratio` to prevent layout shift. +- Respect safe areas on notched phones with `env(safe-area-inset-*)` for fixed bars. +- Use `100dvh` instead of `100vh` for full-height mobile sections. + +**Test widths:** 320, 375, 768, 1024, 1440 and 1920px, in both orientations, plus 200% browser zoom. + +--- + +## Pre-delivery checklist + +- [ ] No horizontal scroll at 320px; layout verified at 375 / 768 / 1024 / 1440 +- [ ] Text contrast is at least 4.5:1 (3:1 for large text and UI), in light and dark modes +- [ ] Visible focus states and logical tab order +- [ ] Tap targets are at least 44×44px; no hover-only interactions +- [ ] Body text is at least 16px on mobile; line length is 45–75 characters +- [ ] All spacing, color, type and radius values come from the design system +- [ ] No AI-slop tells remain (see the list in Step 3) +- [ ] SVG icons instead of emoji; icon buttons are labeled +- [ ] Long text, empty, loading and error states handled +- [ ] `prefers-reduced-motion` respected +- [ ] Images sized and lazy-loaded, with no layout shift + +## Output format + +1. **Findings:** a short scored list (OK / Minor / Major) with the top 3–5 problems. +2. **Changes:** what you changed and why, grouped by mode, with file references. +3. **Before/after:** screenshots or a mockup at mobile and desktop widths when possible. +4. **Checklist:** the pre-delivery checklist with every item ticked or explained. + +--- + +*Credits: condensed and rewritten from ideas in UI UX Pro Max (nextlevelbuilder, MIT), Impeccable (Paul Bakaus, Apache-2.0) and Frontend Responsive Design Standards (am-will/codex-skills). No scripts or data files are required.* diff --git a/.design/cr1-factory-contact-flow/FRONTEND_TEST_COVERAGE.md b/.design/cr1-factory-contact-flow/FRONTEND_TEST_COVERAGE.md new file mode 100644 index 000000000..bff245a84 --- /dev/null +++ b/.design/cr1-factory-contact-flow/FRONTEND_TEST_COVERAGE.md @@ -0,0 +1,59 @@ +# CR-1 (GLA-167) frontend — test coverage list (written BEFORE implementation) + +All tests below were written first and run RED before any production code changed. Red = the +assertion/lookup that fails is the missing behaviour (missing API method, missing element/label, +wrong label), not an import or syntax error. Everything is UI/mock level: **role assertions are +UI-level only (mockApi authz is not authoritative)** — the real enforcement is the Java integration +tests for `FactoryQuoteService` / `LeadTimeChangeService`. + +## Red-run evidence (before implementation) + +| File | Red | +|---|---| +| `src/api/mockApi.factoryContact.test.js` (new, 24) | 24 failed — `api.pricingRequests.markFactoryQuoteContacted is not a function`, `api.leadTimeChanges` undefined, EUR/PER_SQM not echoed | +| `src/features/pricingRequests/PricingRequestDetailPage.test.jsx` | 38 failed (5 rewritten old-flow tests + 33 new) — e.g. `Unable to find role=button name=สร้างเมล`, `Unable to find testid pcr-factory-terms-91`, 3s `findByTestId` timeouts for lead-time UI | +| `src/features/pricingRequests/PricingRequestCreateModal.test.jsx` | 19 failed — 7 new + 12 existing happy-path tests that now need `สกุลเงิน (ราคาโรงงาน)` / `หน่วยราคา` (`fillRequiredFields` updated) | +| `src/features/tickets/DealFulfilmentPanel.test.jsx` | 5 failed — label `สร้างใบ IR`, 4 auto-download assertions (`download` never called) | +| `src/features/tickets/DealDocumentRegister.test.jsx` | 5 failed — `register-import-requests` section absent | +| `src/api/serverContract.test.js` | 3 failed — hrApi still calls `POST /api/factory-quotes/{}/send` (removed server side); 7 new server endpoints not called; `ui-reachable.json` stale | +| `src/features/fulfilment/ImportFulfilmentPage.test.jsx` | 1 new test **passes** before implementation (characterization of existing per-factory PDF buttons, pinned deliberately) | +| 8 existing `mockApi.*.test.js` files | now call `markFactoryQuoteContacted` after `generateFactoryEmailDrafts` (B-R2); red with `is not a function` until the mock exists | + +## Test cases and what they pin + +### `mockApi.factoryContact.test.js` (mirrors `FactoryQuoteService#markContacted/#receive`, `LeadTimeChangeService`) +- items echo `requestedCurrency`/`requestedPriceUnitBasis`; draft seeded from them (EUR / PER_SQM). +- `sendFactoryQuote` is gone; `markFactoryQuoteContacted`: DRAFT→REQUESTED + date/note/by/at + request → AWAITING_FACTORY_RESPONSE; 409 on second call and recorded note unchanged (R7); 400 future / missing date; sales 403, CEO allowed (also edit draft + generate drafts, B-R1). +- `receive`: 409 on DRAFT (B-R2); 409 on currency mismatch; 409 on unit mismatch; OK when matching; legacy line (no terms) accepts anything. +- lead-time: create snapshots old range / PENDING / version 1; line untouched until approved; one PENDING per quote (409); validation 400 (reason, empty lines, min>max); update bumps version; withdraw frees the factory; approve (owning rep) writes new range; stale `expectedVersion` 409 changes nothing; reject needs reason; **CEO and import cannot approve/reject (B-R3)**; only import creates/updates/withdraws; list readable by import/ceo/sales/sales_manager, not account. + +### `PricingRequestDetailPage.test.jsx` (factory section, UI) +- Card: chip `ยังไม่ติดต่อ`; `สร้างเมล` + `ติดต่อโรงงานแล้ว`; retired labels (`ร่างอีเมล`, `ดูอีเมล`, `ส่งแล้ว`) absent. +- Locked terms strip `EUR · ต่อ ตร.ม. · ตามคำขอของฝ่ายขาย`; currency/unit comboboxes removed for lines with terms; legacy line keeps both selects (characterization, passes pre-impl); inputs disabled + hint `กด ติดต่อโรงงานแล้ว ก่อนกรอกราคา`; contacted chip `ติดต่อแล้ว 1 ต.ค. · `, note shown, inputs unlocked, **no ติดต่อโรงงานแล้ว button and no undo (R7)**; `ยืนยันราคาแล้ว`; receive payload carries EUR/PER_SQM; CEO has no inputs/confirm/lead-time button. +- Rewritten old tests: draft edit via `สร้างเมล`/`บันทึกร่าง`; lock hint testid `pcr-await-contact-*`; DRAFT stays locked **past the window** (B-R2 reverses #1062) with no contact button; mail modal read-only when contacted / outside window. +- Contact dialog: date defaults to today (Bangkok) with `max`=today; confirm → `markFactoryQuoteContacted(id,{contactedOn,note})` + refetch of quotes and request (F); blank note omitted; future date → confirm disabled, nothing sent; cancel; 409 → error toast; CEO can use both buttons (R5). +- `สร้างเมล` modal: ถึง prefilled from factory email (R8), หัวข้อ, เนื้อหา; only `บันทึกร่าง`/`คัดลอกเมล`(+ปิด); copy changes no status; close changes no status; ticked request attachments listed. +- Lead-time (import): per-line `ระยะเวลานำเข้า`; panel with new min/max, required reason, non-stock lines pre-ticked (stock line never offered); submit sends ticked lines; unticked excluded; per-line override; min>max / empty reason blocks; pending shows `75–90 → 120–150 วัน · รอฝ่ายขายอนุมัติ`, `แก้ไข`/`ถอนคำขอ`, no second request; withdraw; edit → `update`; compact approved/rejected history with reason; **CEO read-only badge**. +- Lead-time (deciders): owning rep / sales_manager see one banner (factory, reason, old→new) with `อนุมัติ`/`ไม่อนุมัติ`; approve sends `expectedVersion` and refreshes the request; reject needs reason then sends reason+version; **409 → `คำขอถูกแก้ไขแล้ว กรุณาตรวจสอบอีกครั้ง` + refetch**; non-owning sales / CEO / import see no decide controls; history-only → no banner. +- IR row on the factory card (R9): `IR26001 · … · PDF` downloads, `ฉบับร่าง`, no create/issue/revise control for import, matches factory by id else name, ignores SUPERSEDED/other factories, absent when none. + +### `PricingRequestCreateModal.test.jsx` (F4) +selects blank for hand-typed line; blocked with per-row errors; catalogue pick auto-fills (EUR / `per_sqm`→PER_SQM) and sales may change before submit; unknown catalogue unit leaves unit blank; hand-typed line payload; edit seeds + resends; legacy persisted line must be completed before save. + +### `DealFulfilmentPanel.test.jsx` / `DealDocumentRegister.test.jsx` / `ImportFulfilmentPage.test.jsx` +`สร้างใบ IR` (+ `สร้างใบ IR โรงงานที่ยังไม่มี`) relabel; after create, PDF of **each newly created** IR downloads (existing ones skipped); failed PDF keeps the create + error toast; invalidation of stored IRs and `['tickets','list']` (characterization). Register: 5th section `ใบขอซื้อ (รายโรงงาน)`, one row per IR, `ฉบับร่าง`, SUPERSEDED hidden, PDF download, empty line, read gate (import/ceo/sales_manager/owner yes; other sales/account no and no fetch). `/fulfilment`: per-factory internal + factory PDF download pinned. + +### API parity +`contract.test.js` (hrApi↔mockApi, same arity) and `serverContract.test.js` (hrApi↔springdoc, ui-reachable.json) — red now, green after the API layer change. + +## Deliberately NOT covered +- **Authz correctness** (who may mark contacted / decide a lead-time change): UI conditionals only; real enforcement = Java integration tests. Mock authz is approximate and not evidence. +- Visual layout / 375px: not unit-testable here; listed for manual browser verification in the handoff. The mock-frontend e2e suite no longer exists. +- Real PDF bytes / clipboard in a real browser; Bangkok-vs-browser-timezone edge at 00:00–07:00 is covered only by computing "today" with `Intl` in Asia/Bangkok. +- `/fulfilment` still lists only deals with ISSUED rows: a deal whose IRs are all DRAFT is not shown there (unchanged; flagged in the handoff, not changed). +- Factory-master default currency/unit autofill for hand-typed lines: that endpoint is import/CEO-only, so sales has no source — the field stays blank + required. +- Quotation re-issue after acceptance (B-R5): out of CR-1 scope. + +## Green run (after implementation) +`cd frontend && npm run lint` 0 errors (1 pre-existing warning in `QuotationContactPicker.test.jsx`, untouched) · `npm test` 220 files / 3995 tests passed · `npm run build` ok. +Two existing tests (`PricingRequestPanel.test.jsx` edit-draft, `PricingRequestDetailPage.test.jsx` revision modal) needed their fixture line to carry `requestedCurrency`/`requestedPriceUnitBasis`: a legacy line must now be completed before it can be saved (intended, pinned in `PricingRequestCreateModal.test.jsx`). diff --git a/.design/cr1-factory-contact-flow/INFORMATION_ARCHITECTURE.md b/.design/cr1-factory-contact-flow/INFORMATION_ARCHITECTURE.md new file mode 100644 index 000000000..a1de02c87 --- /dev/null +++ b/.design/cr1-factory-contact-flow/INFORMATION_ARCHITECTURE.md @@ -0,0 +1,125 @@ +# Information Architecture: CR-1 — Pricing request, factory-contact flow + +Linear: GLA-167 (CR-1). Status: **UX DRAFT, for Ploy's review. Nothing is built.** +Base: `origin/develop`. Page: `frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx`. + +## Ploy's rulings (2026-10-01) + +| # | Ruling | +|---|---| +| R1 | Currency and หน่วยราคา come from the sales request and are **locked for import**. On the sales create form they are **auto-filled but sales can change them**. | +| R2 | Import may change the **lead time** (ระยะเวลานำเข้า). The change needs approval from the **owning rep or a sales manager**. It does **not block** the request: it can still go to the CEO, who sees a "pending" badge. | +| R3 | The flow is **per factory**, not per brand: **สร้างเมล** → modal with only **บันทึกร่าง / คัดลอกเมล** → a separate **ติดต่อโรงงานแล้ว** status button. That button unlocks price entry. | +| R4 | Existing quotes already marked "ส่งแล้ว" count as **ติดต่อโรงงานแล้ว**, and the sent date becomes the contacted date. | +| R5 | **Import and the CEO** can both mark a factory as contacted. | +| R6 | There is **one IR per factory**: a deal with three factories has three IRs. **Sales generates the IR** (owning rep or CEO), with the **สร้างใบ IR** button, once the quotation is approved. Whenever sales changes something afterwards (an approved lead-time change, a revised quotation), **sales regenerates the IR**. Import never creates one. | +| R7 | **No undo** of ติดต่อโรงงานแล้ว. | +| R8 | **Keep** the factory master's email field. It pre-fills ถึง in the สร้างเมล modal. | +| R9 | Import **receives the IR automatically**: no hand-off. Import downloads the PDF from the **/fulfilment** งานนำเข้า page. The IR is also listed on the deal **เอกสาร** tab and on the pricing-request factory card. | +| R10 | Lead-time changes use **option C**. The request is raised once per factory, and every line is pre-ticked with the new min/max. Import can untick a line or give it its own value. Sales approves or rejects the request **once, as a whole**. | + +## Site map (no new routes) + +- Pricing request detail `/pricing-requests/:id` — **changed** (factory section) +- Deal detail `/tickets/:id` → tab **เอกสาร** — **changed** (new ใบขอซื้อ section) +- Import deal page `/import/deals/:id` — unchanged; it already lists the IR for each factory +- Sales create modal (`PricingRequestCreateModal`) — **changed**: adds currency and price unit to each line +- Notification: a lead-time change request goes to the owning rep and to sales managers + +## Content hierarchy: the factory section (import and CEO) + +Section title: **ราคาจากโรงงาน**, with one card per factory. + +1. **Card header.** Contents: factory name, country, number of lines, and a **status chip**. The chip reads ยังไม่ติดต่อ, then ติดต่อแล้ว 1 ต.ค., then ยืนยันราคาแล้ว. It comes first because it answers "what do I do next with this factory". +2. **Header actions.** Contents: **สร้างเมล** (secondary) and **ติดต่อโรงงานแล้ว** (primary, until pressed). +3. **Terms strip (read-only).** Contents: `EUR · ต่อ ตร.ม.`, with the hint "ตามคำขอของฝ่ายขาย" and a 🔒 icon. It sits above the grid because it tells import how to read the price column. +4. **Price grid.** One row per line: product, qty, **ราคาต่อหน่วย** (input), **ระยะเวลานำเข้า** (value plus ✎ ขอเปลี่ยน), and หมายเหตุราคา. Before the factory is contacted, the price inputs are disabled with the hint "กด ติดต่อโรงงานแล้ว ก่อนกรอกราคา". +5. **Footer.** Contents: ยืนยันราคาเสนอ, and the contacted note ("โทรคุณ Marco…"). +6. **ใบขอซื้อ row.** This appears only once fulfilment stage 1 has started. Contents: `IR-2026-0012 · ฉบับร่าง · ดาวน์โหลด PDF`. + +## User flows + +### F1: Contact a factory and enter its price (import or CEO) +1. Request is in รับเรื่อง. Each card shows ยังไม่ติดต่อ, and its price inputs are locked. +2. *(optional)* **สร้างเมล** opens a modal with To (pre-filled from the factory master), Subject, Body, and attachments ticked. + - The modal has two buttons: **บันทึกร่าง** and **คัดลอกเมล**. There is no ส่งแล้ว. + - Closing the modal changes no status. +3. **ติดต่อโรงงานแล้ว** opens a small popover with a date (default today) and an optional note. Press ยืนยัน. + - The chip becomes "ติดต่อแล้ว 1 ต.ค. · โดย ฝ้าย" and the price inputs unlock. + - On the first factory, the request moves to เจรจาราคากับโรงงาน (AWAITING_FACTORY_RESPONSE, as today). + - **No undo** (R7). The status is final once confirmed. +4. Import enters prices and presses **ยืนยันราคาเสนอ**. This works as today: when every factory is confirmed, the request goes to the CEO. + +### F2: Import changes lead time for a factory (R2, R10 = option C) +1. On the factory card, **✎ ขอเปลี่ยนระยะเวลานำเข้า** opens a panel. + - At the top: new min/max days, plus a required reason. + - Below: the factory's lines, each **pre-ticked** with the new value. Import can untick a line or type its own min/max. +2. Import submits. The ticked lines show `75–90 → 120–150 วัน · รอฝ่ายขายอนุมัติ` in amber. + - There can be only **one pending request per factory**. Import can edit it or withdraw it while it is pending. +3. The rep and sales managers get a notification. The request page shows them one banner listing the whole request, with [อนุมัติ] and [ไม่อนุมัติ (ต้องระบุเหตุผล)]. + - **อนุมัติ:** the new values become the lines' lead times, and the old values are kept in the history. + - **ไม่อนุมัติ:** the old values stay, and import sees the reason. +4. Nothing is blocked. The CEO can review and approve the price while a request is pending, and sees an amber badge. +5. *(Assumed defaults, to confirm)* + - Until approval, the quotation uses the **old** value. + - A request that is approved after the quotation is issued updates the IR only. It does not revise the issued quotation. + - A **shorter** lead time follows the same approval flow. + +### F3: IR per factory (R6, R9) +1. The CEO approves the price, the quotation is issued and accepted, and the deal reaches fulfilment stage 1. +2. The **owning rep or CEO** presses **สร้างใบ IR** (deal page). One IR is created per factory; the PDF downloads straight away. +3. Import sees each IR at once: the /fulfilment งานนำเข้า card gets a **ดาวน์โหลด PDF** action, and the pricing-request factory card shows `IR-2026-0012 · ฉบับร่าง · PDF` (read-only for import). +4. When sales later approves a lead-time change or revises the quotation, sales presses **สร้างใบ IR** again. That makes a new revision, and import downloads the new PDF. +5. The **/fulfilment** งานนำเข้า card shows the same IR (it already reads import_request rows). +6. The deal page's **เอกสาร** tab gets a 5th section, **ใบขอซื้อ (รายโรงงาน)**, with one row per IR: number, factory, status, and PDF. + +### F4: Sales creates the request (R1) +- Each line gets **สกุลเงิน** and **หน่วยราคา**. + - For a catalogue line, both are auto-filled from the catalogue or factory. + - For a hand-typed line, they are auto-filled from the chosen factory's default currency/unit, or left blank and required. +- Sales can change them until submit. After submit they are locked for everyone except through a revision. + +## Naming + +| Concept | UI label | Notes | +|---|---|---| +| Mark factory contacted | **ติดต่อโรงงานแล้ว** | Replaces ส่งแล้ว everywhere | +| Generate email | **สร้างเมล** | Replaces สร้างร่างอีเมล / ร่างอีเมล / ดูอีเมล | +| Modal buttons | **บันทึกร่าง**, **คัดลอกเมล** | Only these two | +| Lead time | **ระยะเวลานำเข้า** | Same as today's read-only label | +| Pending change | **รอฝ่ายขายอนุมัติ** | | +| Purchase request | **ใบขอซื้อ** | Same as the deal page | + +## What this changes underneath (stated, per CLAUDE.md) + +- **Schema (forward-only Vnnn):** + - `factory_quote` gets `contacted_at`, `contacted_note` and `contacted_by`. Existing REQUESTED+ rows are backfilled from their sent date (R4). + - `pricing_request_item` gets currency and price unit. + - A new lead-time change-request table (pending/approved/rejected, with reason). +- **API:** new endpoints for contact and uncontact, and for the lead-time change request with approve/reject. `send` is retired or aliased. Currency/unit writes on the factory quote are refused. +- **Authz:** contact is allowed for import and the CEO. Lead-time approval is allowed for the owning rep and sales managers. IR creation stays with the rep and CEO (no change); the IR download opens to import on /fulfilment and the pricing-request page. Each of these needs a real-DB integration test. +- **Frontend:** the factory section of `PricingRequestDetailPage`, `FactoryEmailDraftModal`, the create modal, and `DealDocumentRegister` (the IR section). + +## Open questions + +- None. Rulings closed 2026-10-01. Branch off `develop` (ruled 2026-10-01 after seeing develop is 105 commits ahead of main on these files); migration is **V198** (authored as V196; renumbered on owner ruling 2026-10-02 so it sits above V197, already on develop). + +## Backend rulings (Ploy, 2026-10-01) + +- **B-R1:** the CEO may also `generateDrafts` and `updateDraft` (the email draft), not only mark contacted. This widens authz. +- **B-R2:** the backend refuses `receive` (price entry) on a quote that has not been marked contacted, returning 409. Today it is accepted. +- **B-R3:** the CEO may NOT approve or reject lead-time changes. Only the owning rep and sales managers can. +- **B-R4:** the IR takes its lead time from the deal's **new sales quotation**, not straight from the pricing-request lines. The chain is: + 1. The lead-time change is approved. + 2. The pricing-request lines are updated. + 3. Sales issues a new quotation. + 4. Sales creates or revises the IR. + + Only step 4 picks up the value. An approved change without a new quotation does not change the IR. +- **B-R5 (scope of B-R4):** the IR reads each factory's lead time from the deal's **current quotation items**. The current quotation is: + - on the pricing-request route, the ACCEPTED quotation, whose items link to their factory through `pricing_request_item_id`; + - on the direct route, the APPROVED quotation, with items resolved through `catalog_price_id`. Hand-typed direct lines fall back to the country default. + + CR-1 does **not** build a way to issue a new quotation after acceptance. That is filed as a separate issue. Until it exists, a lead-time change approved after acceptance does not reach the IR. +- **B-R6:** /fulfilment lists an IR only once sales has **issued** it. A DRAFT IR does not appear there, so this is unchanged. +- **B-R7 (2026-10-02, supersedes the UI half of R5 / B-R1):** the factory section of the pricing-request page is **import-only in the UI**, keeping #1103's gate. The CEO does not see the factory cards, สร้างเมล, ติดต่อโรงงานแล้ว or the lead-time badge. The backend still authorises the CEO for drafts and `/contacted`; the UI never calls them for the CEO. diff --git a/.design/deal-route-staging/INFORMATION_ARCHITECTURE.md b/.design/deal-route-staging/INFORMATION_ARCHITECTURE.md new file mode 100644 index 000000000..54b47cc46 --- /dev/null +++ b/.design/deal-route-staging/INFORMATION_ARCHITECTURE.md @@ -0,0 +1,523 @@ +# IA / UX flow: route-aware deal staging + +**Status:** DESIGN — awaiting rulings R1–R9. Nothing implemented. +**Ask (Ploy, 2026-09-30):** when a rep creates a deal and says who they are in contact with +(ผู้ออกแบบ / ผู้รับเหมา / เจ้าของ …), the deal should follow *that party's* route through the +stages — including re-wording S3 per party — instead of every deal walking one fixed 15-stage line. +**Sibling docs (do not duplicate):** `.design/quotation-deal-link/INFORMATION_ARCHITECTURE.md` +(D1–D7, approved 2026-09-30) and `SLICE-2-FLOW-A.md` (S2-B1…B5). Those move the stage from the +**quotation recipient**; this doc moves it from the **deal's entry channel**. §7 reconciles them. +**No test-first coverage list here:** docs-only, no testable behaviour. The coverage list belongs to +the implementation slice and is sketched in §9 so it is not forgotten. + +--- + +## 1. What already exists (build on this, do not reinvent) + +| Thing | Where | State | +|---|---|---| +| `entry_channel` on the deal | `sales.ticket.entry_channel`, V51 → V144 | **Already exists, already required in `TicketCreateModal`.** Values `DESIGNER_LED` / `OWNER_DIRECT` / `BUYER_DIRECT` / `UNSPECIFIED` (stored-only) | +| The 15 stages | `DealStage.java` (`ORDER`), `chk_ticket_sales_stage`, `dealStageCatalog.js` mirror | S1–S20 business numbering; `PROCUREMENT` covers S12–S17 | +| Thai labels | `DEAL_STAGE_LABELS` in `frontend/src/utils/format.js` | **static per code** — one label, no route awareness | +| Panel headline override | `STAGE_HEADLINE` in `stageMeta.js` | precedent exists: exactly one entry (`ORDER_RECEIVED`), panel-only | +| Six real routes | `DealStage.java` Javadoc (A–D) + `SalesRouteWalkIntegrationTest` (A–E, G) | **routes are already a first-class idea in the code** — they are just not *data* | +| Forward-only auto-advance | `TicketService.autoAdvanceStage` | monotonic on `DealStage.ORDER` index; the one safe write path | +| The only hard refusals | `requireStageFactsHold` (S10/S11/S19/S20) | plus the readiness gate (follow-up date + an activity) | + +**Two written rulings this ask reverses — flagged, not assumed:** +1. **V144 line 27:** *"NOT a behavioural flag: nothing reads entry_channel to decide anything + (DealStage in particular does not consult it). It stays purely descriptive by owner ruling."* +2. **`SalesRouteWalkIntegrationTest.routeB_…`** asserts `SPEC_APPROVED` (S3) **never happens** on an + owner-direct deal. Ploy's route *keeps* S3 and re-words it. That test's defining assertion changes. + +**One thing GLA-156 (merged `dd514c27`) just removed:** ช่องทางรับงาน is no longer displayed or +editable on the deal page; `POST /api/tickets/{id}/entry-channel` is now `UNREACHABLE_FROM_UI`. If the +channel decides the route, it has to become visible and correctable again — see R6. + +--- + +## 2. The case space — three independent axes, only one of them is this ask + +The six tested routes are not six alternatives; they are combinations of three axes: + +| Axis | Decided by | Routes | In scope? | +|---|---|---|---| +| **A — party** (S1–S9): who specs, who approves, who buys | `entry_channel` | designer-led / owner-direct / buyer-direct | **YES — this ask** | +| **B — fulfilment** (S12–S17) | line-item stock coverage, a *fact* | import / all-stock (D) / partial (E) | no — works, fact-driven | +| **C — payment** (S11, S20) | payment terms, a *fact* | deposit / credit-after-delivery (G) | no — works, fact-driven | + +**Scoping consequence:** axes B and C are discovered from recorded facts and must stay that way — +they are not declared up front and the channel says nothing about them. So "route" in this document +means **the party route only (S1–S9)**. A deal has one party route *and* independently one fulfilment +and one payment shape. Case F (paid in full before delivery) stays out of scope per existing ruling. + +--- + +## 3. The party routes + +Written as the rep experiences them. **`S3` is the pivot:** it is not inherently a designer stage — +it means *"the party who specifies has agreed the spec"*, and only its wording is party-specific. +That is the insight in Ploy's brief and it is what makes one stage list serve all three routes. + +### R-D · ผู้ออกแบบนำดีล (`DESIGNER_LED`) — the full spine +``` +S1 → S2 → S4 → S3 → S5 → S6 → S7 → S8 → S9 → S10 → … + ╰─ quote the designer, then they approve the spec (the one routine backward pair) +``` +- Visits every S1–S9 stage. Identical to documented **Case A**. Unchanged by this design. +- S3 label: **"ผู้ออกแบบอนุมัติสเปค"** (today's wording). + +### R-O · เจ้าของติดต่อโดยตรง (`OWNER_DIRECT`) +``` +S1 → S2 → S3* → S5 → S6 → ┬→ S7 → S8 → S9 → … (O-a: owner tenders it out / appoints a buyer) + ╰→ S9 → … (O-b: the owner buys themselves) +``` +- **S3 is KEPT, re-worded:** **"เจ้าของตกลงตามสเปคแล้ว"**. (Documented Case B skips S3 — superseded.) +- Off-route: **S4** (no designer to quote). +- **Two legitimate continuations after S6**, per Ploy: the owner may still put procurement out to a + contractor (O-a, S7/S8 on-route) or buy directly (O-b, straight to S9). Neither is an exception — + so the UI must not present O-b as a "skip" needing a reason. Both are the happy path. + +### R-B · ผู้ซื้อ/ผู้รับเหมาติดต่อโดยตรง (`BUYER_DIRECT`) +``` +S1 → S2 → S3* → S8 → S9 → … +``` +- **S3 kept, re-worded:** **"ผู้ซื้อ/ผู้รับเหมาตกลงตามสเปคแล้ว"**. +- Off-route: **S4** (no designer), **S5**, **S6** (the project owner is not our counterparty). +- **S7 — needs a ruling (R2).** Two readings: (i) off-route, because the buyer is already in hand so + there is no bid result to wait for; (ii) on-route, because a contractor who is *themselves* bidding + keeps us waiting. Documented Case C opens straight at S8 and skips S1–S7 entirely, which is a + *third* reading — and it conflicts with Ploy's "start from S1" for all three routes (R1). + +### R-U · ยังไม่ระบุช่องทาง (`UNSPECIFIED`, and legacy rows) +Not a route — an **absence**. It must resolve to something because ~every pre-V144 row reads +`DESIGNER_LED` whether chosen or defaulted (V144 deliberately did not backfill) and GLA-156 left +`UNSPECIFIED` deals uncorrectable in-portal. +**Proposed:** `UNSPECIFIED` ⇒ **all 15 stages on-route, no re-wording, no next-step opinion** — the +widest, least opinionated lens, identical to today's behaviour. A missing channel then degrades the +UI gracefully instead of asserting a route nobody chose. (R3) + +--- + +## 4. The load-bearing decision — RULED: the route is a GATE + +⚠️ **This section was written recommending a LENS. Ploy ruled for a GATE on 2026-09-30 (R10).** +The reasoning below is kept because it names the risks the gate now has to mitigate — see §13 for +how. It is not an argument to re-open; it is the list of things that must be got right. + +**Ruled: moving into an off-route stage is refused (409) until the deal's ช่องทางดีล is corrected, +and the refusal offers that correction inline.** + +**What was proposed instead (not adopted): the route never refuses a stage.** It would change what the +rep is *shown and advised*, never what they are *allowed* to do. + +The codebase already argued this case and won it. `TicketService.requireStageFactsHold`'s Javadoc +rejects a transition table because *"the business's real routes branch heavily… a table faithful to +them has to permit almost every forward edge"* — and `DealStage.MANDATORY` exists precisely so that +skipping a *route-dependent* stage costs nothing while skipping a *universal* one costs a sentence. +A route that gated would re-introduce the table that reasoning rejected, and would break the day a +designer turns up on an owner-direct deal. + +**This exact mistake has already been made and reverted once.** `stageMeta.js:15-18` records a +client-side `distance > 1` skip test that was deleted because it *"wrongly demanded reasons for three +of the business's four normal routes (an owner buying direct, a contractor arriving with a BOQ at S8, +an all-from-stock deal skipping procurement)"* — i.e. a naive route rule punished three of the four +real routes. That comment is the frontend's only surviving trace of the route concept, and it is a +direct warning against gating. + +| | Route as LENS (proposed) | Route as GATE (rejected) | +|---|---|---| +| Off-route stage | reachable, de-emphasised | 409 | +| Rep discovers a designer late | move to S4; route widens | blocked until channel edited | +| Wrong channel at creation | cosmetic until corrected | wrong data blocks real work | +| Legacy `UNSPECIFIED` | widest lens, no harm | must guess a route to function | +| New route discovered | a label + a list | a migration + a table | + +So the route drives exactly four things: **(1)** S3's wording, **(2)** which stages read as +*on this deal's path*, **(3)** the "ถัดไป" suggestion, **(4)** the progress denominator. + +--- + +## 5. User flows + +### Flow 1 — Create an owner-direct deal (happy path) +1. Rep opens สร้างดีล, fills ลูกค้า + โครงการ, reaches **ผู้ติดต่อ & ช่องทางดีล** (already required). +2. Picks **เจ้าของติดต่อโดยตรง**. → helper copy becomes route-specific: *"ดีลนี้จะข้าม + 'เสนอราคาผู้ออกแบบ' และใช้ 'เจ้าของตกลงตามสเปคแล้ว' เป็นขั้นอนุมัติสเปค"* — the consequence is + stated **at the point of choice**, not discovered later. +3. Submits. Deal is created at **S1** (unchanged — no jump; see R4). +4. Deal page: stage panel reads **S1**, ถัดไป reads **S2 นำเสนอสินค้า**, progress **"ขั้นที่ 1 จาก 7"** + (the route's length, not 15), and a **route chip** shows *เจ้าของตรง*. +5. Rep advances S1 → S2 → **S3 "เจ้าของตกลงตามสเปคแล้ว"** → S5 → S6. No note demanded anywhere: + every stage crossed is route-dependent, none is in `MANDATORY`. +6. After S6 the panel offers **both** continuations as equals — *"เจ้าของซื้อเอง → เจรจา (S9)"* and + *"ให้ผู้รับเหมาซื้อ → รอผู้ซื้อ (S7)"*. Neither is framed as a skip. + +### Flow 2 — Designer-led deal (happy path, unchanged) +Exactly today's behaviour: S1 → S2 → S4 → S3 → S5 → S6 → S7 → S8 → S9. The S4→S3 pair stays the +allowlisted routine backward move. **This flow must not regress** — it is the majority route. + +### Flow 3 — Buyer/contractor-direct (happy path) +S1 → S2 → **S3 "ผู้ซื้อ/ผู้รับเหมาตกลงตามสเปคแล้ว"** → S8 → S9. S7's membership pending R2. + +### Flow 4 (non-happy) — The channel was wrong +1. Deal is owner-direct at S5. A designer turns up and must be quoted. +2. Rep opens **ดูขั้นตอนทั้งหมด**; S4 is present but de-emphasised as *ไม่อยู่ในเส้นทางนี้*. +3. Moving to S4 is a **backward** move (S4 < S5 in `ORDER`) → today's rules demand a reason, and the + S4→S3 routine-pair allowlist does not cover it. **The rep gets a refusal-shaped prompt for + something legitimate.** → **R5**: either allowlist "into an off-route stage behind the current + one" as routine, or (better) make correcting the channel the primary affordance here and let the + widened route make S4 a normal on-route stage. +4. Rep corrects the channel to `DESIGNER_LED`. Route widens; the stepper re-renders; **stage does not + move** (forward-only). History keeps what happened. + +### Flow 5 (non-happy) — Channel corrected *backwards* +Deal is designer-led at S6; rep corrects it to owner-direct. S4 is now off-route **but already +visited**. The stepper must show S4 as **visited** (a fact) *and* off-route (a lens) — a visited +off-route stage is normal after a correction, not an error. Never hide a stage the deal actually +entered; history is the source of truth. + +### Flow 6 (non-happy) — Stage is already ahead of the whole route +A quotation-first deal promoted to **S10** (GLA-136) never walked S1–S9. Lens still applies: all of +S1–S9 render as *not visited*, progress reads honestly, and no back-fill is invented (IA §7 of the +sibling doc already rules: no data migration invents a stage). + +### Flow 7 (non-happy) — `UNSPECIFIED` / legacy deal +Widest lens (R3): 15 stages on-route, today's labels, no route chip, no next-step opinion. Plus a +quiet **"ระบุช่องทางดีล"** affordance, because GLA-156 removed the only way to fix it (R6). + +### Flow 8 (non-happy) — Lifecycle overrides +`ON_HOLD` / `DORMANT` / `CLOSED_LOST` already take over the panel headline. Route-aware labels must +render inside those branches too — the GLA-156 session flagged that those branches call +`dealStageLabel()` directly, which is exactly why R7 asks for **one** label function rather than +patched call sites. + +### Flow 9 (non-happy) — Fact-gated and readiness refusals are unchanged +S10/S11/S19/S20 still refuse without their fact; a forward move still needs a follow-up date and an +activity since the last stage change. The route must not appear to promise a stage these will refuse +— the "ถัดไป" suggestion has to keep consuming the existing server-side `stageDecisions` verdicts. + +### Flow 10 (non-happy) — Route vs. quotation recipient (agreed with the sibling session) +**Precedence, as proposed by the quotation↔deal-link session and adopted here:** the channel sets the +**floor**; the quotation recipient may only move the stage **forward**; both go through the single +`autoAdvanceStage` so neither can regress the other. +Worked case: owner-direct deal sitting at **S5**; a `DESIGNER` direct quotation is created (S2-B2 +targets S4). `autoAdvanceStage` is a **no-op** (S4 < S5) — correct. Reverse: a deal at S3 with a +`BUYER` quotation jumps to **S8** — also correct; at quotation time the recipient is the more +specific signal. To be pinned on this side: *"owner-direct deal at `QUOTE_OWNER`, then a `DESIGNER` +direct quotation → stage stays `QUOTE_OWNER`"*; that session pins the mirror. + +--- + +## 6. Content hierarchy — the deal stage panel, post-GLA-156 + +1. **Current stage headline** — route-worded. Highest priority; it is the answer to "where is this deal". +2. **Route chip** (*ผู้ออกแบบนำ / เจ้าของตรง / ผู้ซื้อ-ผู้รับเหมาตรง*) — second, because it is now the + thing that *explains* the headline and the skips. Reverses GLA-156's removal (R6). +3. **ถัดไป** — the route's next stage, still filtered by `stageDecisions`. +4. **Branch choice** where the route genuinely forks (R-O after S6) — two peer actions, not a skip. +5. **ดูขั้นตอนทั้งหมด** (expander) — full stepper, on-route emphasised, off-route de-emphasised. +6. Owner / follow-up / readiness hints — unchanged. + +--- + +## 7. Where the route lives — one definition, server-side + +The route is **sales workflow logic**, so per CLAUDE.md it is defined and enforced in Java and +verified by a real-DB integration test, never inferred from `mockApi.js`. + +- **Definition:** a `DealRoute` concept beside `DealStage` — for each channel, the ordered on-route + stage list, the S3 label key, and the fork points. One source of truth, mirrored nowhere. +- **Served:** extend `GET /api/meta/deal-stages` (or the per-deal `…/actions` payload, which already + carries per-stage verdicts) so the client is **told** the route rather than deriving it. This keeps + the existing no-Thai-text-in-the-catalog rule; the Thai stays in `format.js`. +- **Frontend anchors — the structure already exists; use it rather than inventing:** + - **`stagesInPhase(catalog, phaseId)`** (`stageCatalog.js:91`) is the **single chokepoint** every + stage list flows through (`DealStageStepper.jsx:51`, `:153`, `:188`). One natural insertion point + for on-route/off-route emphasis. + - **`procurementPath(currentCode, fromStock)`** (`stageMeta.js:109-120`) is the **working precedent** + for branch-aware step rendering, including an honest "branch unknowable → narrow to the shared + tail" fallback (which is exactly R-U's widest-lens behaviour). Its own comment prescribes the + durable form: *the backend serves the path, as `PaymentTrack.path(policy)` already does for + payments.* **`DealRoute.path(entryChannel)` should follow that shape.** + - **`stageDecisions`** (`GET …/actions`) already carries per-deal, per-user verdicts with + server-authored Thai prose, and `UpdateStageModal` already renders blocked stages **with reasons + rather than hiding them** (`:107-144`) — the established pattern for "visible but not now". A + sibling field (`onRoute` / `routeNote`) rides the same channel. `stageCatalog.js:17` and + `stageMeta.js:20` both **forbid re-deriving this client-side**. + - **`dealStageLabel(code)` is single-argument with 11 call sites** across 7 files (header chip, panel + headline, ถัดไป chip, stepper step, modal `