diff --git a/CLAUDE.md b/CLAUDE.md index 5589e86..bd9ad5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,43 @@ npx tsc --noEmit # Type-check only — run after every significant chang npx shadcn@latest add [name] # Add shadcn/ui component ``` +### Key Dependencies + +| Package | Version | Purpose | +|---------|---------|---------| +| next | 16.x | Framework (App Router) | +| react | 19.x | UI library | +| @tanstack/react-query | 5.x | Server state | +| zustand | 5.x | Client state | +| react-hook-form | 7.x | Forms | +| zod | 3.x | Validation | +| @grpc/grpc-js | 1.x | gRPC client (BFF) | +| tailwindcss | 4.x | Styling | +| sonner | — | Toast notifications | +| lucide-react | — | Icons | +| recharts | — | Charts | + +### Testing Stack + +- Vitest + Testing Library + MSW (Mock Service Worker) +- Setup file: `src/__tests__/setup.ts` — auto-cleanup, MSW server, `matchMedia`/`ResizeObserver` mocks +- Test files: `src/__tests__/**/*.test.{ts,tsx}` +- Current coverage: query-key unit tests + component filter tests (see §Improvement Notes for gaps) + +### Environment Variables (`.env.local`) + +```bash +# gRPC endpoints (server-side only, NOT exposed to the browser) +IAM_GRPC_HOST=localhost +IAM_GRPC_PORT=50052 +FINANCE_GRPC_HOST=localhost +FINANCE_GRPC_PORT=50051 + +# Public variables (exposed to the browser) +NEXT_PUBLIC_API_URL= # Leave empty for relative paths (recommended) +NEXT_PUBLIC_APP_URL=http://localhost:3000 +``` + --- ## 2. Architecture Overview @@ -96,10 +133,31 @@ The frontend **never** calls the Go backend directly. All requests go through `/ QueryProvider → ThemeProvider → AuthProvider → PermissionProvider → {children} ``` -- `AuthProvider` — user state, login/logout, auto token refresh every 10 min +- `AuthProvider` — user state, login/logout, auto token refresh every 10 min, **plus a silent refresh on mount** (attempts a token refresh once when the app loads, ahead of the 10-min interval) - `PermissionProvider` — `hasPermission(code)`, `hasAnyRole(...roles)` - `QueryProvider` — TanStack Query client (staleTime 60s, refetchOnWindowFocus false) +### Route Protection (`src/proxy.ts`) + +Next.js 16 route protection (replaces the old `middleware.ts`): + +- **Public routes**: `/login`, `/forgot-password`, `/reset-password`, `/verify-otp` +- **Protected routes**: everything else — redirect to `/login?callbackUrl=...` when unauthenticated +- **API routes**: pass through untouched — each BFF route handles its own auth +- Auth check is based on the presence of `access_token` + `refresh_token` cookies + +### Dynamic Sidebar System + +The sidebar is **fully dynamic** — driven by database menu data, not a hardcoded nav config: + +1. Backend `GetMenuTree` RPC returns hierarchical menu data already filtered by the current user's permissions +2. BFF route `/api/v1/iam/menus/tree` proxies the RPC +3. `useMenuTree()` hook (`src/hooks/iam/use-menu.ts`) fetches the tree → `menuTreeToNavGroups()` converts it to sidebar nav-group shape +4. Icons lazy-load via `preloadMenuIcons()` + `resolveIcon()` in `src/types/iam/menu.ts` (dynamic imports from `lucide-react`) +5. `AppSidebar` renders the resulting nav groups + +**Menu permission visibility rule**: a menu with **no** rows in `menu_permissions` is visible to **all** authenticated users; a menu **with** rows requires the user to hold at least one matching permission. Parent menus filtered out by permission drop their children too — `buildMenuTree()` only keeps children under a parent that itself survived the filter. + --- ## 3. Directory Structure @@ -502,19 +560,20 @@ Centered dashed-border box. Use whenever a list or section has no data. - Resolves UUID → full name via `useUser()` hook (TanStack Query, cached) - Never display raw UUIDs to users -### 6.7 UserInitials (avatar) +### 6.7 UserAvatar (photo or initials) -**File**: `src/components/finance/cost-request-comment/comments-panel.tsx` (exported) +**File**: `src/components/common/user-avatar.tsx` ```tsx -import { UserInitials } from "@/components/finance/cost-request-comment/comments-panel" +import { UserAvatar } from "@/components/common/user-avatar" - + ``` -- Resolves full name via `useUser()` → derives initials ("Ilham Ramadhan" → "IR") -- Deterministic color per userId (hash-based from 6-color palette) +- Resolves full name + `profilePictureUrl` via `useUser()` → renders the real photo (`AvatarImage`) when set, else falls back to initials ("Ilham Ramadhan" → "IR") +- `colorHash` prop enables the deterministic color-per-userId fallback (hash-based from 6-color palette); omit it to use the plain shadcn muted fallback (used by `nav-user.tsx`/`profile-header.tsx`) - Falls back to first char of userId if name not yet resolved +- Supersedes the former `UserInitials` component (removed 2026-07-03) — same hook, same deterministic-color logic, now also renders a real photo when available ### 6.8 ScrollableDialog (tall forms) @@ -946,6 +1005,20 @@ export const ACTIVE_FILTER_OPTIONS = [ **Never** edit files in `src/types/generated/` — they are auto-generated from proto. +### Response normalization (camelCase + snake_case) + +Backend responses aren't guaranteed to arrive in one casing consistently, so normalizer functions read both: + +```ts +function normalizeUOM(raw: RawUOM): NormalizedUOM { + return { + uomId: raw.uomId || raw.uom_id || "", + uomCode: raw.uomCode || raw.uom_code || "", + // ... + } +} +``` + --- ## 12. BFF API Route Pattern @@ -1042,6 +1115,23 @@ getCompanyClient() // IAM Organization All clients are singletons via `globalThis` (survive HMR in dev). +### gRPC → HTTP error mapping + +**File**: `src/lib/grpc/errors.ts` + +```ts +const GRPC_TO_HTTP = { + [grpc.status.NOT_FOUND]: 404, + [grpc.status.ALREADY_EXISTS]: 409, + [grpc.status.INVALID_ARGUMENT]: 400, + [grpc.status.PERMISSION_DENIED]: 403, + [grpc.status.UNAUTHENTICATED]: 401, + // ... +} +``` + +`isGrpcError()` + `handleGrpcError()` use this table to translate a caught gRPC error into the right `NextResponse` status before returning the standard `{ base, data }` envelope. + --- ## 13. Loading & Skeleton Pattern @@ -1147,11 +1237,11 @@ const STATUS_OPTIONS = [ |----------|---------------------| | Display full name | `` | | Display full name (compact, no @username) | `` | -| Avatar circle with initials | `` | +| Avatar circle with photo or initials | `` | | Department / org unit name | `` | -- `UserName` and `UserInitials` both call `useUser(userId)` internally — TanStack Query caches the result, so the same user appearing 10× in a list triggers only one network request -- `UserInitials` derives initials from full name word boundaries: "Ilham Ramadhan" → "IR", single word → "I" +- `UserName` and `UserAvatar` both call `useUser(userId)` internally — TanStack Query caches the result, so the same user appearing 10× in a list triggers only one network request +- `UserAvatar` derives initials from full name word boundaries: "Ilham Ramadhan" → "IR", single word → "I"; renders the real photo instead when `profilePictureUrl` is set - Colors are deterministic per userId (hash mod 6 palette) — same user always gets same color --- @@ -1217,3 +1307,17 @@ When building a new CRUD feature from scratch, complete in this order: 9. `src/app/(dashboard)/{module}/{resource}/page.tsx` — server component 10. `src/app/(dashboard)/{module}/{resource}/loading.tsx` — skeleton 11. `src/app/(dashboard)/{module}/{resource}/{resource}-page-client.tsx` — client component wiring everything together + +--- + +## 18. Improvement Notes + +Known gaps identified during a 2026-07 documentation audit — not blockers, but worth knowing before assuming full coverage: + +| Area | Severity | Note | +|------|----------|------| +| Limited test coverage | Medium | Only 2 test files exist (query keys + component filter) | +| No E2E tests | Medium | No Playwright/Cypress — only unit/component tests | +| Generated proto types in git | Low | Could be generated in CI instead of committed | +| No error boundary components | Low | Missing React error boundaries | +| No i18n/internationalization | Low | All strings hardcoded | diff --git a/e2e/cpr-full-workflow.spec.ts b/e2e/cpr-full-workflow.spec.ts index f28bb6f..3d2c30d 100644 --- a/e2e/cpr-full-workflow.spec.ts +++ b/e2e/cpr-full-workflow.spec.ts @@ -30,23 +30,23 @@ import { loginAs, logout, createDraftRequest, - findRequestInList, gotoRequestDetail, - submitRequest, + submitAndDecide, + submitViaApi, + startReviewViaApi, startReview, decideFeasibility, - useExistingCosting, + useExistingCostingViaApi, + findAnyProductSysId, rejectRequest, reviseRequest, cancelRequest, - createProductAndRoute, promoteRoute, openFillTrackingTab, claimFillTask, submitFillTask, approveFillTask, rejectFillTask, - markParametersComplete, expectStatus, expectButtonVisible, expectButtonHidden, @@ -84,11 +84,10 @@ test.describe("E2E-01: Full new product workflow", () => { await expectButtonVisible(page, /^edit$/i) await expectButtonHidden(page, /^submit$/i) // Routing buttons must NOT appear at DRAFT - await expectButtonHidden(page, /create new routing/i) - await expectButtonHidden(page, /pick existing product/i) + await expectButtonHidden(page, /attach product routing/i) }) - test("1.3 marketingmgr sees Submit and submits", async ({ page }) => { + test("1.3 marketingmgr sees Submit, decides classification+feasibility inline", async ({ page }) => { await loginAs(page, "marketingmgr") await gotoRequestDetail(page, createdRequestId) @@ -96,36 +95,10 @@ test.describe("E2E-01: Full new product workflow", () => { await expectButtonHidden(page, /^edit$/i) await expectButtonVisible(page, /^submit$/i) - await submitRequest(page) - // Status should be SUBMITTED after submit - await expectStatus(page, "SUBMITTED") - }) - - test("1.4 marketing01 cannot see Start Review button at SUBMITTED", async ({ page }) => { - await loginAs(page, "marketing01") - await gotoRequestDetail(page, createdRequestId) - - await expectStatus(page, "SUBMITTED") - await expectButtonHidden(page, /start review/i) - await expectButtonHidden(page, /reject/i) - }) - - test("1.5 finance01 starts review and decides FEASIBLE", async ({ page }) => { - await loginAs(page, "finance01") - await gotoRequestDetail(page, createdRequestId) - - await expectButtonVisible(page, /start review/i) - await startReview(page) - await expectStatus(page, "UNDER_REVIEW") - - // "Verify Classification" button should NOT appear (design decision: removed) - await expectButtonHidden(page, /verify classification/i) - - // Use Existing Costing button should be visible (no longer needs pre-verification) - await expectButtonVisible(page, /use existing costing/i) - - // Decide FEASIBLE → new product - await decideFeasibility(page, "FEASIBLE", "Product specifications are clear") + // B3 merge: Submit opens ClassificationAndFeasibilityDialog(mode="submit"), which + // folds Submit + StartReview + VerifyClassification + DecideFeasibility + LinkRoute + // into one SubmitAndDecide RPC — final status is ROUTING_DEFINED (FEASIBLE path). + await submitAndDecide(page) await expectStatus(page, "ROUTING_DEFINED") }) @@ -134,34 +107,16 @@ test.describe("E2E-01: Full new product workflow", () => { await gotoRequestDetail(page, createdRequestId) await expectStatus(page, "ROUTING_DEFINED") - // Routing panel should be visible for engineers + // Routing panel should be visible for engineers, already resolved by 1.3's submitAndDecide const routingPanel = page.locator('[data-testid="routing-panel"]') await expect(routingPanel).toBeVisible() - - await expectButtonVisible(page, /create new routing/i) - }) - - test("1.7 production01 creates product master and routing", async ({ page }) => { - await loginAs(page, "production01") - await gotoRequestDetail(page, createdRequestId) - - const routeId = await createProductAndRoute(page, `E2E FG Product ${Date.now()}`) - expect(routeId).toBeTruthy() - - // Should be in route editor — navigate back to request after - await gotoRequestDetail(page, createdRequestId) - await expectStatus(page, "ROUTING_DEFINED") }) test("1.8 production01 promotes route → PARAMETER_PENDING + fill tasks created", async ({ page }) => { await loginAs(page, "production01") await gotoRequestDetail(page, createdRequestId) - // Route must be linked — find the link button or already-linked route - const routePanel = page.locator('[data-testid="routing-panel"]') - await expect(routePanel).toBeVisible() - - // Try to promote if route is already linked and complete + // Route already linked by 1.3's submitAndDecide() — go straight to promotion. await promoteRoute(page) await expectStatus(page, "PARAMETER_PENDING") @@ -243,7 +198,7 @@ test.describe("E2E-02: Existing product path (Use Existing Costing)", () => { const requestTitle = `E2E-02 Existing Product ${Date.now()}` let requestId: string - test("2.1 Create and submit request", async ({ page }) => { + test("2.1 Create request and submit via API (setup only — not the thing under test)", async ({ page }) => { await loginAs(page, "marketing01") requestId = await createDraftRequest(page, { title: requestTitle, @@ -253,42 +208,27 @@ test.describe("E2E-02: Existing product path (Use Existing Costing)", () => { }) await expectStatus(page, "DRAFT") - await logout(page) - await loginAs(page, "marketingmgr") - await gotoRequestDetail(page, requestId) - await submitRequest(page) + // Use Existing Costing is a review-phase action, reachable from UNDER_REVIEW regardless + // of how classification/feasibility were decided — API-bypass Submit + StartReview here + // since the dialog flow itself is already covered by E2E-01's submitAndDecide() test. + await submitViaApi(page, requestId) + await startReviewViaApi(page, requestId) + await page.reload() + await expectStatus(page, "UNDER_REVIEW") }) - test("2.2 finance01 uses existing costing path — no verify step needed", async ({ page }) => { + test("2.2 finance01 uses existing costing via API — bypasses removed UI", async ({ page }) => { + // UseExistingCostingDialog has no live call site in request-detail-panel.tsx anymore + // (DialogKind no longer has a "useExisting" variant) — the RPC/BFF route is still + // reachable directly, so this test drives it via API rather than asserting on dead UI. await loginAs(page, "finance01") await gotoRequestDetail(page, requestId) - - await startReview(page) await expectStatus(page, "UNDER_REVIEW") - // "Use Existing Costing" should be directly visible (no Verify Classification needed) - await expectButtonVisible(page, /use existing costing/i) - - // Use existing costing with a product from CSTFG26BLK0001 (seeded product) - await page.getByRole("button", { name: /use existing costing/i }).click() - await page.waitForSelector('[role="dialog"]', { state: "visible" }) - - // Try to find the existing product - const searchInput = page.getByPlaceholder(/search|product/i).first() - await searchInput.fill("CSTFG") - await page.waitForTimeout(600) // debounce - - const option = page.getByRole("option").first() - const hasOption = await option.isVisible({ timeout: 3000 }).catch(() => false) - if (hasOption) { - await option.click() - await page.getByRole("button", { name: /confirm|select|use/i }).last().click() - await page.waitForLoadState("load") - await expectStatus(page, "QUOTE_READY") - } else { - // No existing product in test env — skip to QUOTE_READY manually would need admin - test.skip() - } + const productSysId = await findAnyProductSysId(page) + await useExistingCostingViaApi(page, requestId, productSysId) + await page.reload() + await expectStatus(page, "QUOTE_READY") }) test("2.3 QUOTE_READY: routing buttons not visible", async ({ page }) => { @@ -296,8 +236,8 @@ test.describe("E2E-02: Existing product path (Use Existing Costing)", () => { await gotoRequestDetail(page, requestId) await expectStatus(page, "QUOTE_READY") - await expectButtonHidden(page, /create new routing/i) - await expectButtonHidden(page, /decide feasibility/i) + await expectButtonHidden(page, /attach product routing/i) + await expectButtonHidden(page, /review.*decide/i) // Fill tracking tab should NOT be present (no fill tasks for existing path) const fillTab = page.getByRole("tab", { name: /fill tracking/i }) await expect(fillTab).not.toBeVisible() @@ -318,10 +258,8 @@ test.describe("E2E-03: Reject and revise flow", () => { classification: "existing", urgency: "NORMAL", }) - await logout(page) - await loginAs(page, "marketingmgr") - await gotoRequestDetail(page, requestId) - await submitRequest(page) + // API-bypass — this suite tests reject/revise, not the submit dialog itself. + await submitViaApi(page, requestId) await page.close() }) @@ -393,19 +331,16 @@ test.describe("E2E-04: Permission gates", () => { }) test("4.2 After submit: marketing01 cannot see reviewer buttons", async ({ page }) => { - // Submit as marketingmgr first - await loginAs(page, "marketingmgr") - await gotoRequestDetail(page, requestId) - await submitRequest(page) + // API-bypass — this test is about button visibility for marketing01, not the submit dialog. + await submitViaApi(page, requestId) // Now check as marketing01 - await logout(page) await loginAs(page, "marketing01") await gotoRequestDetail(page, requestId) await expectStatus(page, "SUBMITTED") await expectButtonHidden(page, /start review/i) - await expectButtonHidden(page, /decide feasibility/i) + await expectButtonHidden(page, /review.*decide/i) await expectButtonHidden(page, /reject/i) }) @@ -415,14 +350,15 @@ test.describe("E2E-04: Permission gates", () => { await expectStatus(page, "SUBMITTED") await expectButtonHidden(page, /start review/i) - await expectButtonHidden(page, /decide feasibility/i) + await expectButtonHidden(page, /review.*decide/i) await expectButtonHidden(page, /submit/i) // Routing panel only appears at ROUTING_DEFINED — not at SUBMITTED await expect(page.locator('[data-testid="routing-panel"]')).not.toBeVisible() }) test("4.4 production01 at ROUTING_DEFINED: routing panel visible", async ({ page }) => { - // Get to ROUTING_DEFINED first + // Get to ROUTING_DEFINED first — "review" mode path via startReview + decideFeasibility + // (distinct from E2E-01's "submit" mode path via submitAndDecide). await loginAs(page, "finance01") await gotoRequestDetail(page, requestId) await startReview(page) @@ -436,22 +372,18 @@ test.describe("E2E-04: Permission gates", () => { await expectStatus(page, "ROUTING_DEFINED") const routingPanel = page.locator('[data-testid="routing-panel"]') await expect(routingPanel).toBeVisible() - await expectButtonVisible(page, /create new routing/i) + // Already resolved by decideFeasibility's inline RoutingResolver — no "attach" button left. + await expectButtonHidden(page, /attach product routing/i) }) - test("4.5 finance01 at ROUTING_DEFINED: no routing create button (not engineer)", async ({ page }) => { + test("4.5 finance01 at ROUTING_DEFINED: no routing attach button (not engineer)", async ({ page }) => { await loginAs(page, "finance01") await gotoRequestDetail(page, requestId) - // finance01 has CPR_REVIEWER but NOT CPR_ENGINEER, so no create routing + // Routing is already resolved by 4.4 — the "Attach product routing" button only ever + // appears when unresolved, so this always evaluates hidden regardless of role. await expectStatus(page, "ROUTING_DEFINED") - // Only reviewer actions: Close, Cancel (no routing create for non-engineers) - // NOTE: finance01 might have route.view but not route.create - // This depends on role assignment — adjust if finance01 also gets CPR_ENGINEER - const createRoutingBtn = page.getByRole("button", { name: /create new routing/i }) - const hasButton = await createRoutingBtn.isVisible({ timeout: 1000 }).catch(() => false) - // If finance01 has CPR_ADMIN which includes route.create, this test may need adjustment - expect(typeof hasButton).toBe("boolean") // just verify the test runs + await expectButtonHidden(page, /attach product routing/i) }) }) @@ -514,45 +446,21 @@ test.describe("E2E-07: Fill task claim → submit → approve", () => { urgency: "NORMAL", }) - // Submit - await logout(page) - await loginAs(page, "marketingmgr") - await gotoRequestDetail(page, requestId) - await submitRequest(page) + // Submit (as marketingmgr) — API-bypass, this suite tests fill tasks, not the submit dialog. + await submitViaApi(page, requestId) - // Review and decide FEASIBLE - await logout(page) + // Review and decide FEASIBLE — decideFeasibility's inline RoutingResolver already + // resolves + links routing as part of this step (see resolveRoutingInline). await loginAs(page, "finance01") await gotoRequestDetail(page, requestId) await startReview(page) - await decideFeasibility(page, "FEASIBLE", "Feasible") + await decideFeasibility(page, "FEASIBLE", "Feasible", { newProductName: `E2E-07 Product ${Date.now()}` }) await expectStatus(page, "ROUTING_DEFINED") - // Create routing as production01 + // Promote to PARAMETER_PENDING as production01 — routing already linked above. await logout(page) await loginAs(page, "production01") await gotoRequestDetail(page, requestId) - - // If a pre-existing route is available, link it - const linkRouteBtn = page.getByRole("button", { name: /link.*route|use.*existing.*route/i }) - const hasLinkBtn = await linkRouteBtn.isVisible({ timeout: 2000 }).catch(() => false) - if (hasLinkBtn) { - await linkRouteBtn.click() - await page.waitForSelector('[role="dialog"]', { state: "visible" }) - // Select first available route - const firstRoute = page.getByRole("row").nth(1) - await firstRoute.click() - const confirmBtn = page.getByRole("button", { name: /confirm|link|select/i }).last() - await confirmBtn.click() - await page.waitForLoadState("load") - } else { - // Create new route - await createProductAndRoute(page, `E2E-07 Product ${Date.now()}`) - // Navigate back - await gotoRequestDetail(page, requestId) - } - - // Promote to PARAMETER_PENDING await promoteRoute(page) await expectStatus(page, "PARAMETER_PENDING") }) @@ -701,27 +609,17 @@ test.describe("E2E-FULL: Complete workflow smoke test", () => { }) await expectStatus(page, "DRAFT") - // Step 2: Submit (as marketingmgr) - await logout(page) - await loginAs(page, "marketingmgr") - await gotoRequestDetail(page, requestId) - await submitRequest(page) - await expectStatus(page, "SUBMITTED") + // Step 2: Submit (API-bypass — smoke test targets the overall status chain) + await submitViaApi(page, requestId) // Step 3: Review - await logout(page) await loginAs(page, "finance01") await gotoRequestDetail(page, requestId) await startReview(page) await expectStatus(page, "UNDER_REVIEW") - // Verify classification button absent - await expectButtonHidden(page, /verify classification/i) - // Use Existing Costing button present without pre-verification - await expectButtonVisible(page, /use existing costing/i) - - // Step 4: Decide FEASIBLE - await decideFeasibility(page, "FEASIBLE", "OK") + // Step 4: Decide FEASIBLE — inline RoutingResolver resolves + links routing. + await decideFeasibility(page, "FEASIBLE", "OK", { newProductName: `E2E-FULL Product ${Date.now()}` }) await expectStatus(page, "ROUTING_DEFINED") // Step 5: Verify routing panel visible for engineer @@ -823,7 +721,8 @@ test.describe("E2E-12: A6 — Approval trace history timeline", () => { urgency: "NORMAL", }) await gotoRequestDetail(page, requestId) - await submitRequest(page) + await submitViaApi(page, requestId) + await page.reload() await expectStatus(page, "SUBMITTED") // Expand history section. @@ -841,10 +740,10 @@ test.describe("E2E-12: A6 — Approval trace history timeline", () => { urgency: "NORMAL", }) await gotoRequestDetail(page, requestId) - await submitRequest(page) + await submitViaApi(page, requestId) + await page.reload() await expectStatus(page, "SUBMITTED") - await logout(page) await loginAs(page, "finance01") await gotoRequestDetail(page, requestId) await startReview(page) diff --git a/e2e/helpers/cpr-helpers.ts b/e2e/helpers/cpr-helpers.ts index 14eb151..95fef8e 100644 --- a/e2e/helpers/cpr-helpers.ts +++ b/e2e/helpers/cpr-helpers.ts @@ -152,22 +152,101 @@ export async function createDraftRequest( // ─── Status Transitions ─────────────────────────────────────────────────────── -export async function submitRequest(page: Page) { +// B3 merge: the DRAFT "Submit" button now opens ClassificationAndFeasibilityDialog in +// mode="submit" (single SubmitAndDecide RPC — Submit + StartReview + VerifyClassification +// + DecideFeasibility + (conditional) LinkRoute, see handlers.go's SubmitAndDecide) instead +// of firing a bare submit mutation. This drives the full dialog flow: classification -> +// routing (RoutingResolver, inline, FEASIBLE only) -> feasibility -> "Submit for review". +// Final status is ROUTING_DEFINED (FEASIBLE) or REJECTED (NOT_FEASIBLE) — SubmitAndDecide's +// chain ends at DecideFeasibility, it does not stop at UNDER_REVIEW. +export interface SubmitAndDecideOptions { + classification?: "existing" | "new" + overrideReason?: string + decision?: "FEASIBLE" | "NOT_FEASIBLE" + productCode?: string + newProductName?: string + note?: string +} + +export async function submitAndDecide(page: Page, opts: SubmitAndDecideOptions = {}) { + const decision = opts.decision ?? "FEASIBLE" const btn = page.getByRole("button", { name: /^submit$/i }) await expect(btn).toBeVisible() - // Wait for the BFF mutation response before asserting UI update - const [response] = await Promise.all([ - page.waitForResponse( - (r) => r.url().includes("/submit") && r.request().method() === "POST", - { timeout: 15000 }, - ).catch(() => null), - btn.click(), - ]) - if (response && !response.ok()) { - const body = await response.json().catch(() => ({})) - throw new Error(`Submit failed: ${response.status()} — ${JSON.stringify(body)}`) + await btn.click() + await page.waitForSelector('[role="dialog"]', { state: "visible" }) + + if (decision === "NOT_FEASIBLE") { + await page.locator('[role="radio"][value="NOT_FEASIBLE"]').click() } - await expectStatus(page, "SUBMITTED") + if (opts.note || decision === "NOT_FEASIBLE") { + await page.getByLabel(/^note/i).fill(opts.note ?? "Test note") + } + if (opts.classification) { + await page.locator(`[role="radio"][value="${opts.classification}"]`).click() + } + if (opts.overrideReason) { + const overrideField = page.getByLabel(/override reason/i) + if (await overrideField.isVisible({ timeout: 1000 }).catch(() => false)) { + await overrideField.fill(opts.overrideReason) + } + } + + if (decision === "FEASIBLE") { + await resolveRoutingInline(page, { productCode: opts.productCode, newProductName: opts.newProductName }) + } + + const submitBtn = decision === "FEASIBLE" + ? page.getByRole("button", { name: /submit for review/i }) + : page.getByRole("button", { name: /reject as infeasible/i }) + await expect(submitBtn).toBeEnabled({ timeout: 10000 }) + await submitBtn.click() + await page.waitForSelector('[role="dialog"]', { state: "hidden", timeout: 15000 }) + await page.waitForLoadState("load") + await expectStatus(page, decision === "FEASIBLE" ? "ROUTING_DEFINED" : "REJECTED") +} + +// Direct API-bypass helpers — skip the UI for test setup steps that aren't the +// thing under test. All hit the same BFF routes the UI mutations call. +export async function submitViaApi(page: Page, requestId: string | number) { + const baseUrl = process.env.E2E_BASE_URL ?? "http://localhost:3000" + const resp = await page.request.post(`${baseUrl}/api/v1/finance/cost-product-requests/${requestId}/submit`, { + data: {}, + }) + if (!resp.ok()) throw new Error(`submitViaApi failed: ${resp.status()} — ${await resp.text()}`) +} + +export async function startReviewViaApi(page: Page, requestId: string | number) { + const baseUrl = process.env.E2E_BASE_URL ?? "http://localhost:3000" + const resp = await page.request.post( + `${baseUrl}/api/v1/finance/cost-product-requests/${requestId}/start-review`, + { data: {} }, + ) + if (!resp.ok()) throw new Error(`startReviewViaApi failed: ${resp.status()} — ${await resp.text()}`) +} + +export async function useExistingCostingViaApi( + page: Page, + requestId: string | number, + existingProductSysId: number, +) { + const baseUrl = process.env.E2E_BASE_URL ?? "http://localhost:3000" + const resp = await page.request.post( + `${baseUrl}/api/v1/finance/cost-product-requests/${requestId}/use-existing-costing`, + { data: { existingProductSysId } }, + ) + if (!resp.ok()) throw new Error(`useExistingCostingViaApi failed: ${resp.status()} — ${await resp.text()}`) +} + +// Looks up any active product master row's sysId — avoids hardcoding a seed-data ID that +// may not exist in every environment. +export async function findAnyProductSysId(page: Page): Promise { + const baseUrl = process.env.E2E_BASE_URL ?? "http://localhost:3000" + const resp = await page.request.get(`${baseUrl}/api/v1/finance/cost-product-masters?pageSize=1&activeFilter=active`) + if (!resp.ok()) throw new Error(`findAnyProductSysId failed: ${resp.status()} — ${await resp.text()}`) + const body = await resp.json() + const sysId = body?.data?.[0]?.productSysId + if (!sysId) throw new Error("findAnyProductSysId: no active product masters found in this environment") + return sysId } export async function startReview(page: Page) { @@ -176,12 +255,66 @@ export async function startReview(page: Page) { await expectStatus(page, "UNDER_REVIEW") } +// Drives RoutingResolver inline (used by both the "submitDecide" and "reviewDecide" +// variants of ClassificationAndFeasibilityDialog, and by RoutingPanel's standalone +// "Attach product routing" dialog). Only runs when the resolver form is actually +// visible — the FEASIBLE section renders it, NOT_FEASIBLE skips it entirely. +export interface ResolveRoutingOptions { + productCode?: string + newProductName?: string + newProductTypeIndex?: number +} + +export async function resolveRoutingInline(page: Page, opts: ResolveRoutingOptions = {}) { + const resolveBtn = page.getByRole("button", { name: /^resolve routing$/i }) + if (!(await resolveBtn.isVisible({ timeout: 1000 }).catch(() => false))) { + return // NOT_FEASIBLE path, or already resolved (routeLinked) — nothing to do + } + + if (opts.newProductName) { + const newProductCheckbox = page.locator("#rr-new-product") + if (await newProductCheckbox.isVisible({ timeout: 1000 }).catch(() => false)) { + await newProductCheckbox.check() + } + await page.getByPlaceholder(/PTY 75\/72 SD BRIGHT/i).fill(opts.newProductName) + const typeCombobox = page.getByRole("combobox", { name: /select product type/i }) + await typeCombobox.click() + await page.waitForSelector('[role="option"]', { state: "visible", timeout: 5000 }) + const options = page.getByRole("option") + const idx = opts.newProductTypeIndex ?? 0 + await options.nth(idx).click() + } else { + const productCombobox = page.getByRole("combobox", { name: /search product by code or name/i }) + await productCombobox.click() + await page.waitForSelector('[role="option"]', { state: "visible", timeout: 5000 }) + if (opts.productCode) { + const searchInput = page.getByPlaceholder(/search by code or name/i) + await searchInput.fill(opts.productCode) + await page.waitForTimeout(400) // debounce + await page.getByRole("option", { name: new RegExp(opts.productCode, "i") }).first().click() + } else { + await page.getByRole("option").first().click() + } + } + + await resolveBtn.click() + // Two different hosts react differently on success: ClassificationAndFeasibilityDialog + // keeps RoutingResolver mounted and shows a "Routing resolved — head #N…" message, + // while RoutingPanel's standalone dialog just closes (onResolved => setResolverOpen(false)). + // Race both signals instead of assuming either one. + await Promise.race([ + page.getByText(/rout(e|ing) #?\d* ?resolved/i).waitFor({ state: "visible", timeout: 15000 }), + resolveBtn.waitFor({ state: "hidden", timeout: 15000 }), + ]) +} + export async function decideFeasibility( page: Page, decision: "FEASIBLE" | "NOT_FEASIBLE", note = "Test note", + routingOpts: ResolveRoutingOptions = {}, ) { - await page.getByRole("button", { name: /decide feasibility/i }).click() + await page.getByRole("button", { name: /review.*decide/i }).click() await page.waitForSelector('[role="dialog"]', { state: "visible" }) // Select decision — use value attribute to avoid /FEASIBLE/i matching both "Feasible" and "Not feasible" @@ -189,16 +322,23 @@ export async function decideFeasibility( await radio.click() // Fill note - const noteField = page.getByLabel(/note/i) + const noteField = page.getByLabel(/^note/i) if (await noteField.isVisible({ timeout: 1000 }).catch(() => false)) { await noteField.fill(note) } - // Button label is "Approve" for FEASIBLE, "Reject as infeasible" for NOT_FEASIBLE + if (decision === "FEASIBLE") { + await resolveRoutingInline(page, routingOpts) + } + + // Submit label is dynamic: "Save & continue" (first pass), "Reject as infeasible" + // (NOT_FEASIBLE), or a retry label after a partial failure. const submitBtn = decision === "FEASIBLE" - ? page.getByRole("button", { name: /^approve$/i }) + ? page.getByRole("button", { name: /save.*continue|retry/i }) : page.getByRole("button", { name: /reject as infeasible/i }) + await expect(submitBtn).toBeEnabled({ timeout: 10000 }) await submitBtn.click() + await page.waitForSelector('[role="dialog"]', { state: "hidden", timeout: 15000 }) await page.waitForLoadState("load") if (decision === "FEASIBLE") { @@ -208,23 +348,6 @@ export async function decideFeasibility( } } -export async function useExistingCosting(page: Page, productCode: string) { - await page.getByRole("button", { name: /use existing costing/i }).click() - await page.waitForSelector('[role="dialog"]', { state: "visible" }) - - // Search for product - const searchInput = page.getByPlaceholder(/search.*product|product.*code/i) - await searchInput.fill(productCode) - await page.waitForTimeout(500) // debounce - - // Select from dropdown - await page.getByRole("option", { name: new RegExp(productCode, "i") }).first().click() - - await page.getByRole("button", { name: /^(confirm|select|use)$/i }).last().click() - await page.waitForLoadState("load") - await expectStatus(page, "QUOTE_READY") -} - export async function rejectRequest(page: Page, reason: string) { await page.getByRole("button", { name: /^reject$/i }).click() await page.waitForSelector('[role="dialog"]', { state: "visible" }) @@ -278,35 +401,28 @@ export interface RouteMaterial { ratio: number } -export async function createProductAndRoute( +// Drives RoutingPanel's standalone "Attach product routing" button (shown when a +// request has no linked route yet), which opens a Dialog containing an inline +// RoutingResolver — the same component used by ClassificationAndFeasibilityDialog's +// Routing section. Does NOT navigate to a separate route editor URL; it just closes +// the dialog once RoutingResolver's onResolved fires. Returns nothing resolvable as +// a route ID from the UI — callers needing the head ID should read it from the panel +// (e.g. via `page.getByText(/route #(\d+)/i)`) or use the API directly. +export async function attachProductRouting( page: Page, fgProductName: string, -): Promise { - // Click Create new routing button in routing panel - await page.getByRole("button", { name: /create new routing/i }).click() + opts: { productTypeIndex?: number } = {}, +) { + await page.getByRole("button", { name: /attach product routing/i }).click() await page.waitForSelector('[role="dialog"]', { state: "visible" }) - // Step 1: switch to "Create new product master" mode (default is "existing") - await page.locator('[role="radio"][value="new"]').click() - // Click Next to go to step 2 - await page.getByRole("button", { name: /^next$/i }).click() - - // Step 2: fill product name via placeholder (Label has no htmlFor) - await page.getByPlaceholder(/PTY|product name/i).first().fill(fgProductName) - - // Select product type — only one role="combobox" on step 2 - await page.getByRole("combobox").click() - await page.waitForSelector('[role="option"]', { state: "visible", timeout: 10000 }) - await page.getByRole("option").first().click() - - // Click "Create product + route" - await page.getByRole("button", { name: /create product.*route/i }).click() + await resolveRoutingInline(page, { + newProductName: fgProductName, + newProductTypeIndex: opts.productTypeIndex, + }) - // Wizard navigates to route editor at /finance/routes/[id] - await page.waitForURL(/\/finance\/routes\/\d+/, { timeout: 15000 }) - const url = page.url() - const match = url.match(/routes\/(\d+)/) - return match?.[1] ?? "" + await page.waitForSelector('[role="dialog"]', { state: "hidden", timeout: 15000 }) + await page.waitForLoadState("load") } export async function promoteRoute(page: Page) { diff --git a/src/__tests__/components/classification-and-feasibility-dialog.test.tsx b/src/__tests__/components/classification-and-feasibility-dialog.test.tsx new file mode 100644 index 0000000..289d4b8 --- /dev/null +++ b/src/__tests__/components/classification-and-feasibility-dialog.test.tsx @@ -0,0 +1,204 @@ +/** + * Tests for ClassificationAndFeasibilityDialog's extended phase state machine + * (P3-T3, design.md §3 Area B / B1): idle -> classifying -> classified -> + * routing -> deciding -> done, with RoutingResolver rendered inline when the + * decision is FEASIBLE and the submit button gated on `resolvedHeadId`. + * + * Covers: + * - happy path: Classification -> Routing resolve -> LinkRoute -> Feasibility + * - submit is disabled until RoutingResolver has resolved a head id + * - a routing-link failure leaves classification saved and only retries the + * routing step (not a full restart) + * - a feasibility failure after a successful routing link only retries + * feasibility, never re-submitting the already-linked route + * - verified === "new" forces RoutingResolver straight into the new-product + * form (no existing-product picker) + */ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" + +// ─── Module mocks ───────────────────────────────────────────────────────────── + +const verifyMutateAsync = vi.fn() +const linkRouteMutateAsync = vi.fn() +const feasibilityMutateAsync = vi.fn() +const submitAndDecideMutateAsync = vi.fn() + +vi.mock("@/hooks/finance/use-cost-product-request", () => ({ + useVerifyClassification: () => ({ mutateAsync: verifyMutateAsync, isPending: false }), + useDecideFeasibility: () => ({ mutateAsync: feasibilityMutateAsync, isPending: false }), + // P3-T6 wires mode="submit" through useSubmitAndDecide; this test file only + // exercises mode="reviewDecide" (the pre-existing UNDER_REVIEW flow), but the + // dialog calls the hook unconditionally at the top level regardless of mode, + // so it must always be mocked here even though these tests never invoke it. + useSubmitAndDecide: () => ({ mutateAsync: submitAndDecideMutateAsync, isPending: false }), +})) + +vi.mock("@/hooks/finance/use-link-route", () => ({ + useLinkExistingRoute: () => ({ mutateAsync: linkRouteMutateAsync, isPending: false }), +})) + +vi.mock("@/hooks/finance/use-cost-product-master", () => ({ + useCostProductMaster: () => ({ data: undefined }), + useCostProductMasters: () => ({ data: { items: [] }, isLoading: false }), +})) + +// RoutingResolver is exercised by its own test file (routing-resolver.test.tsx); +// here it's stubbed to a single "Resolve routing" button that immediately +// invokes onResolved(999), or nothing when `failOnce` mode is toggled on via a +// second button — this keeps the dialog's own phase-machine tests focused on +// the dialog, not RoutingResolver's internal branching. +let forceNewProductSeen: boolean | undefined +vi.mock("@/components/finance/cost-product-request/routing-resolver", () => ({ + RoutingResolver: ({ + onResolved, + forceNewProduct, + }: { + onResolved: (headId: number) => void + forceNewProduct?: boolean + }) => { + forceNewProductSeen = forceNewProduct + return ( + + ) + }, +})) + +// ─── Import under test (after all mocks are registered) ────────────────────── + +import { ClassificationAndFeasibilityDialog } from "@/components/finance/cost-product-request/transition-dialogs" + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function renderDialog(overrides: Partial[0]> = {}) { + const onOpenChange = vi.fn() + render( + , + ) + return { onOpenChange } +} + +async function resolveRoutingViaUI() { + await userEvent.click(screen.getByRole("button", { name: "resolve-routing" })) +} + +function submitButton() { + return screen.getByRole("button", { name: /save & continue|retry routing link|retry feasibility/i }) +} + +describe("ClassificationAndFeasibilityDialog — extended phase machine (routing)", () => { + beforeEach(() => { + verifyMutateAsync.mockReset() + linkRouteMutateAsync.mockReset() + feasibilityMutateAsync.mockReset() + forceNewProductSeen = undefined + }) + + it("gates submit on resolvedHeadId being set", async () => { + renderDialog() + + // Before routing is resolved, submit is disabled. + expect(submitButton()).toBeDisabled() + + await resolveRoutingViaUI() + + expect(submitButton()).toBeEnabled() + }) + + it("runs the full chain Classification -> Routing resolve -> LinkRoute -> Feasibility on success", async () => { + verifyMutateAsync.mockResolvedValue({}) + linkRouteMutateAsync.mockResolvedValue({}) + feasibilityMutateAsync.mockResolvedValue({}) + const { onOpenChange } = renderDialog() + + await resolveRoutingViaUI() + await userEvent.click(submitButton()) + + expect(verifyMutateAsync).toHaveBeenCalledTimes(1) + expect(linkRouteMutateAsync).toHaveBeenCalledTimes(1) + expect(linkRouteMutateAsync).toHaveBeenCalledWith({ requestId: 7, routeHeadId: 999 }) + expect(feasibilityMutateAsync).toHaveBeenCalledTimes(1) + + // Ordering: verify called before link, link called before feasibility. + const verifyOrder = verifyMutateAsync.mock.invocationCallOrder[0] + const linkOrder = linkRouteMutateAsync.mock.invocationCallOrder[0] + const feasibilityOrder = feasibilityMutateAsync.mock.invocationCallOrder[0] + expect(verifyOrder).toBeLessThan(linkOrder) + expect(linkOrder).toBeLessThan(feasibilityOrder) + + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it("routing-failure-then-retry: LinkRoute failing leaves classification saved and only re-runs routing on retry", async () => { + verifyMutateAsync.mockResolvedValue({}) + linkRouteMutateAsync.mockRejectedValueOnce(new Error("link failed")).mockResolvedValueOnce({}) + feasibilityMutateAsync.mockResolvedValue({}) + renderDialog() + + await resolveRoutingViaUI() + await userEvent.click(submitButton()) + + // First attempt: classification saved, routing failed — inline error shown, + // classification section now read-only, submit button relabeled to retry routing. + expect(await screen.findByText(/linking the route failed/i)).toBeInTheDocument() + expect(verifyMutateAsync).toHaveBeenCalledTimes(1) + expect(linkRouteMutateAsync).toHaveBeenCalledTimes(1) + expect(feasibilityMutateAsync).not.toHaveBeenCalled() + expect(screen.getByRole("button", { name: /retry routing link/i })).toBeInTheDocument() + + // Retry: classification must NOT be re-submitted; routing link retried; then feasibility runs. + await userEvent.click(screen.getByRole("button", { name: /retry routing link/i })) + + expect(verifyMutateAsync).toHaveBeenCalledTimes(1) + expect(linkRouteMutateAsync).toHaveBeenCalledTimes(2) + expect(feasibilityMutateAsync).toHaveBeenCalledTimes(1) + }) + + it("feasibility-failure-then-retry: once routing already linked, retry never re-submits LinkRoute", async () => { + verifyMutateAsync.mockResolvedValue({}) + linkRouteMutateAsync.mockResolvedValue({}) + feasibilityMutateAsync.mockRejectedValueOnce(new Error("feasibility failed")).mockResolvedValueOnce({}) + renderDialog() + + await resolveRoutingViaUI() + await userEvent.click(submitButton()) + + expect(await screen.findByText(/feasibility decision failed/i)).toBeInTheDocument() + expect(linkRouteMutateAsync).toHaveBeenCalledTimes(1) + expect(feasibilityMutateAsync).toHaveBeenCalledTimes(1) + expect(screen.getByRole("button", { name: /retry feasibility/i })).toBeInTheDocument() + + await userEvent.click(screen.getByRole("button", { name: /retry feasibility/i })) + + // LinkRoute must not be called again — routeLinked already true. + expect(linkRouteMutateAsync).toHaveBeenCalledTimes(1) + expect(feasibilityMutateAsync).toHaveBeenCalledTimes(2) + }) + + it("forces RoutingResolver into new-product mode when verified classification is 'new'", async () => { + renderDialog({ currentClassification: "pending" }) + + // Switch the classification radio to "new". + await userEvent.click(screen.getByRole("radio", { name: "New" })) + + expect(forceNewProductSeen).toBe(true) + }) + + it("does not render the routing section at all when NOT_FEASIBLE is chosen", async () => { + renderDialog() + + await userEvent.click(screen.getByRole("radio", { name: "Not feasible" })) + + expect(screen.queryByRole("button", { name: "resolve-routing" })).not.toBeInTheDocument() + }) +}) diff --git a/src/__tests__/components/cost-product-request-import-dialog.test.tsx b/src/__tests__/components/cost-product-request-import-dialog.test.tsx new file mode 100644 index 0000000..df607cc --- /dev/null +++ b/src/__tests__/components/cost-product-request-import-dialog.test.tsx @@ -0,0 +1,123 @@ +/** + * Tests for CostProductRequestImportDialog (P4-T4, design.md §4 Area D6): + * - error rows render as "Row {rowNumber}: {field} - {message}" + * - success/failure counts render correctly after an import completes + * - a fully successful import (no errors) shows zero failures and no error list + */ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" + +// jsdom doesn't implement scrollIntoView / hasPointerCapture — some shared UI +// primitives touch these during interaction; polyfill defensively like other +// dialog tests in this suite. +if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false +} +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {} +} + +// ─── Module mocks ───────────────────────────────────────────────────────────── + +const importMutateAsync = vi.fn() +const templateMutateAsync = vi.fn().mockResolvedValue({}) + +vi.mock("@/hooks/finance/use-cost-product-request", () => ({ + useImportCostProductRequests: () => ({ mutateAsync: importMutateAsync, isPending: false }), + useDownloadCostProductRequestTemplate: () => ({ mutateAsync: templateMutateAsync, isPending: false }), +})) + +vi.mock("@/lib/api", () => ({ + readFileAsBytes: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), +})) + +// ─── Import under test (after all mocks are registered) ────────────────────── + +import { CostProductRequestImportDialog } from "@/components/finance/cost-product-request/cost-product-request-import-dialog" + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function renderDialog(onSuccess = vi.fn()) { + return render( + {}} onSuccess={onSuccess} />, + ) +} + +async function selectFile() { + const file = new File(["dummy content"], "requests.xlsx", { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const input = document.querySelector('input[type="file"]') as HTMLInputElement + await userEvent.upload(input, file) +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("CostProductRequestImportDialog", () => { + beforeEach(() => { + importMutateAsync.mockReset() + templateMutateAsync.mockClear() + }) + + it("renders error rows as 'Row {rowNumber}: {field} - {message}'", async () => { + importMutateAsync.mockResolvedValue({ + successCount: 2, + skippedCount: 0, + updatedCount: 0, + failedCount: 1, + errors: [ + { rowNumber: 3, field: "requestTypeId", message: "unknown request type code 'BOGUS'" }, + ], + }) + + renderDialog() + await selectFile() + await userEvent.click(screen.getByRole("button", { name: "Import" })) + + expect( + await screen.findByText("Row 3: requestTypeId - unknown request type code 'BOGUS'"), + ).toBeInTheDocument() + }) + + it("renders success and failure counts after import completes", async () => { + importMutateAsync.mockResolvedValue({ + successCount: 5, + skippedCount: 0, + updatedCount: 0, + failedCount: 2, + errors: [ + { rowNumber: 2, field: "spec.productDescription", message: "required" }, + { rowNumber: 4, field: "spec.shadeId", message: "shade code or id required" }, + ], + }) + + const onSuccess = vi.fn() + renderDialog(onSuccess) + await selectFile() + await userEvent.click(screen.getByRole("button", { name: "Import" })) + + expect(await screen.findByText("Import Complete")).toBeInTheDocument() + expect(screen.getByText("5")).toBeInTheDocument() + expect(screen.getByText("2")).toBeInTheDocument() + expect(onSuccess).toHaveBeenCalled() + }) + + it("renders zero failures and no error list for a fully successful import", async () => { + importMutateAsync.mockResolvedValue({ + successCount: 4, + skippedCount: 0, + updatedCount: 0, + failedCount: 0, + errors: [], + }) + + renderDialog() + await selectFile() + await userEvent.click(screen.getByRole("button", { name: "Import" })) + + expect(await screen.findByText("Import Complete")).toBeInTheDocument() + expect(screen.getByText("4")).toBeInTheDocument() + expect(screen.queryByText(/^Errors:$/)).not.toBeInTheDocument() + }) +}) diff --git a/src/__tests__/components/fill-tracking-drawer.test.tsx b/src/__tests__/components/fill-tracking-drawer.test.tsx new file mode 100644 index 0000000..005adc1 --- /dev/null +++ b/src/__tests__/components/fill-tracking-drawer.test.tsx @@ -0,0 +1,174 @@ +/** + * Tests for FillTrackingDrawer — verifies each task row renders product-code + * badges for its route level, sourced via useRouteGraph + getProductsAtLevel. + */ +import { describe, it, expect, vi } from "vitest" +import { render, screen } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import type { FillTask } from "@/types/finance/fill-assignment" +import type { RouteGraph } from "@/types/finance/cost-route" + +// ─── Module mocks ───────────────────────────────────────────────────────────── + +vi.mock("@/hooks/finance/use-fill-assignment", () => ({ + useFillTasks: vi.fn(), +})) + +vi.mock("@/hooks/finance/use-cost-route", () => ({ + useRouteGraph: vi.fn(), +})) + +vi.mock("@/components/common/user-name", () => ({ + UserName: ({ userId }: { userId: string }) => {userId}, +})) + +vi.mock("@/components/common/dept-name", () => ({ + DeptName: ({ deptCode }: { deptCode: string }) => {deptCode}, +})) + +// ─── Import under test (after all mocks are registered) ────────────────────── + +import { FillTrackingDrawer } from "@/components/finance/fill-assignment/FillTrackingDrawer" +import { useFillTasks } from "@/hooks/finance/use-fill-assignment" +import { useRouteGraph } from "@/hooks/finance/use-cost-route" + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function baseTask(overrides: Partial = {}): FillTask { + return { + taskId: 1, + requestId: 42, + routeHeadId: 100, + routeLevel: 1, + fillerType: "FILL_ACTOR_TYPE_USER", + fillerValue: "user-1", + approverType: "FILL_ACTOR_TYPE_USER", + approverValue: "user-2", + status: "FILL_TASK_STATUS_ACTIVE", + claimedBy: "", + reapproveOnChange: false, + slaFillHours: 24, + slaApproveHours: 12, + totalParams: 5, + filledParams: 0, + activatedAt: "", + approvals: [], + ...overrides, + } +} + +function baseGraph(): RouteGraph { + return { + head: { + headId: 100, + productSysId: 10, + routingStatus: "COMPLETE", + version: 1, + lockedBy: "", + lockedAt: "", + unlockedBy: "", + unlockedAt: "", + levelCount: 1, + rmCount: 0, + }, + seqs: [ + { + uid: "seq-1", + seqId: 1, + headId: 100, + productSysId: 10, + productCode: "PRD-001", + productName: "Product One", + routeLevel: 1, + routeSeq: 1, + positionX: 0, + positionY: 0, + rms: [], + }, + ], + } +} + +function renderDrawer() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + {}} + requestId={42} + requestNo="REQ-001" + /> + , + ) +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("FillTrackingDrawer", () => { + it("renders a product-code badge for the task's route level", () => { + vi.mocked(useFillTasks).mockReturnValue({ + data: [baseTask()], + isLoading: false, + } as unknown as ReturnType) + + vi.mocked(useRouteGraph).mockReturnValue({ + data: baseGraph(), + isLoading: false, + } as unknown as ReturnType) + + renderDrawer() + + expect(screen.getByText("PRD-001")).toBeInTheDocument() + expect(useRouteGraph).toHaveBeenCalledWith(100) + }) + + it("renders no product badge when the graph has no product for the task's level", () => { + vi.mocked(useFillTasks).mockReturnValue({ + data: [baseTask({ routeLevel: 2 })], + isLoading: false, + } as unknown as ReturnType) + + vi.mocked(useRouteGraph).mockReturnValue({ + data: baseGraph(), + isLoading: false, + } as unknown as ReturnType) + + renderDrawer() + + expect(screen.queryByText("PRD-001")).not.toBeInTheDocument() + expect(screen.getByText("Level 2")).toBeInTheDocument() + }) + + it("shows the loading skeleton unchanged while tasks are loading", () => { + vi.mocked(useFillTasks).mockReturnValue({ + data: undefined, + isLoading: true, + } as unknown as ReturnType) + + vi.mocked(useRouteGraph).mockReturnValue({ + data: undefined, + isLoading: false, + } as unknown as ReturnType) + + renderDrawer() + + expect(screen.getByText("Loading tasks…")).toBeInTheDocument() + }) + + it("shows the empty state unchanged when there are no tasks", () => { + vi.mocked(useFillTasks).mockReturnValue({ + data: [], + isLoading: false, + } as unknown as ReturnType) + + vi.mocked(useRouteGraph).mockReturnValue({ + data: undefined, + isLoading: false, + } as unknown as ReturnType) + + renderDrawer() + + expect(screen.getByText("No fill tasks yet")).toBeInTheDocument() + }) +}) diff --git a/src/__tests__/components/paper-tube-name.test.tsx b/src/__tests__/components/paper-tube-name.test.tsx new file mode 100644 index 0000000..02fc49b --- /dev/null +++ b/src/__tests__/components/paper-tube-name.test.tsx @@ -0,0 +1,62 @@ +/** + * Tests for PaperTubeName — P2-T13: fixed Paper/Plastic tube classification. + * Verifies both display branches: + * - tubeType set (PAPER/PLASTIC) -> static label, no master-lookup hook data needed. + * - tubeType unset, legacy paperTubeTypeId set -> falls back to master-lookup display. + */ +import { describe, it, expect, vi } from "vitest" +import { render, screen } from "@testing-library/react" + +const mockUseCostPaperTubeTypes = vi.fn() + +vi.mock("@/hooks/finance/use-cost-paper-tube-type", () => ({ + useCostPaperTubeTypes: () => mockUseCostPaperTubeTypes(), +})) + +import { PaperTubeName } from "@/components/common/paper-tube-name" +import { TubeType } from "@/types/generated/finance/v1/cost_product_request" + +describe("PaperTubeName", () => { + it("renders a static 'Paper' label when tubeType is TUBE_TYPE_PAPER, without needing lookup data", () => { + mockUseCostPaperTubeTypes.mockReturnValue({ data: undefined, isLoading: true }) + + render() + + expect(screen.getByText("Paper")).toBeInTheDocument() + }) + + it("renders a static 'Plastic' label when tubeType is TUBE_TYPE_PLASTIC", () => { + mockUseCostPaperTubeTypes.mockReturnValue({ data: undefined, isLoading: true }) + + render() + + expect(screen.getByText("Plastic")).toBeInTheDocument() + }) + + it("falls back to legacy master-lookup display when tubeType is unset but paperTubeTypeId is set", () => { + mockUseCostPaperTubeTypes.mockReturnValue({ + data: [{ paperTubeTypeId: 3, code: "PT-3", displayName: "3 inch jumbo" }], + isLoading: false, + }) + + render() + + expect(screen.getByText(/PT-3 — 3 inch jumbo/)).toBeInTheDocument() + }) + + it("renders an em dash when neither tubeType nor paperTubeTypeId is set", () => { + mockUseCostPaperTubeTypes.mockReturnValue({ data: [], isLoading: false }) + + render() + + expect(screen.getByText("—")).toBeInTheDocument() + }) + + it("renders 'Unknown paper tube' for a legacy id with no lookup match", () => { + mockUseCostPaperTubeTypes.mockReturnValue({ data: [], isLoading: false }) + + render() + + expect(screen.getByText("Unknown paper tube")).toBeInTheDocument() + }) +}) diff --git a/src/__tests__/components/request-detail-panel.test.tsx b/src/__tests__/components/request-detail-panel.test.tsx index 220460f..59d13fd 100644 --- a/src/__tests__/components/request-detail-panel.test.tsx +++ b/src/__tests__/components/request-detail-panel.test.tsx @@ -17,8 +17,6 @@ vi.mock("@/hooks/finance/use-cost-product-request", () => { useReviseRequest: stub, useReopenRequest: stub, useUseExistingCosting: stub, - useVerifyClassification: stub, - useDecideFeasibility: stub, useRejectRequest: stub, useCancelRequest: stub, useCloseRequest: stub, @@ -70,12 +68,11 @@ vi.mock( vi.mock( "@/components/finance/cost-product-request/transition-dialogs", () => ({ - CloseDialog: () => null, - ConfirmActionDialog: () => null, - FeasibilityDialog: () => null, - ReasonDialog: () => null, - UseExistingCostingDialog: () => null, - VerifyClassificationDialog: () => null, + ClassificationAndFeasibilityDialog: () => null, + CloseDialog: () => null, + ConfirmActionDialog: () => null, + ReasonDialog: () => null, + UseExistingCostingDialog: () => null, }), ) @@ -149,9 +146,9 @@ describe("RequestDetailPanel — DRAFT status", () => { expect(screen.queryByRole("button", { name: /^submit$/i })).not.toBeInTheDocument() }) - it("shows Cancel and Close when owner", () => { + it("shows Close when owner", () => { renderPanel(baseRequest({ status: "DRAFT" }), ["finance.product.request.create"]) - expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument() + expect(screen.queryByRole("button", { name: /cancel/i })).not.toBeInTheDocument() expect(screen.getByRole("button", { name: /^close$/i })).toBeInTheDocument() }) }) @@ -187,30 +184,30 @@ describe("RequestDetailPanel — UNDER_REVIEW status", () => { mockHasPermission.mockReset() }) - it("shows Decide feasibility when user has resolve permission", () => { + it("shows Review & decide when user has resolve permission", () => { renderPanel(baseRequest({ status: "UNDER_REVIEW" }), ["finance.product.request.resolve"]) - expect(screen.getByRole("button", { name: /decide feasibility/i })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /review & decide/i })).toBeInTheDocument() }) - it("hides Decide feasibility when user lacks resolve permission", () => { + it("hides Review & decide when user lacks resolve permission", () => { renderPanel(baseRequest({ status: "UNDER_REVIEW" }), []) - expect(screen.queryByRole("button", { name: /decide feasibility/i })).not.toBeInTheDocument() + expect(screen.queryByRole("button", { name: /review & decide/i })).not.toBeInTheDocument() }) - it("shows Use existing costing when verifiedClassification is existing", () => { + it("hides Use existing costing even when verifiedClassification is existing (item #1: hidden from UI)", () => { renderPanel( baseRequest({ status: "UNDER_REVIEW", verifiedClassification: "existing" }), ["finance.product.request.resolve"], ) - expect(screen.getByRole("button", { name: /use existing costing/i })).toBeInTheDocument() + expect(screen.queryByRole("button", { name: /use existing costing/i })).not.toBeInTheDocument() }) - it("shows Use existing costing when productClassification is existing and verifiedClassification is unset", () => { + it("hides Use existing costing even when productClassification is existing and verifiedClassification is unset", () => { renderPanel( baseRequest({ status: "UNDER_REVIEW", productClassification: "existing", verifiedClassification: undefined }), ["finance.product.request.resolve"], ) - expect(screen.getByRole("button", { name: /use existing costing/i })).toBeInTheDocument() + expect(screen.queryByRole("button", { name: /use existing costing/i })).not.toBeInTheDocument() }) it("hides Use existing costing when classification is new", () => { diff --git a/src/__tests__/components/request-form-dialog.test.tsx b/src/__tests__/components/request-form-dialog.test.tsx new file mode 100644 index 0000000..d7225d6 --- /dev/null +++ b/src/__tests__/components/request-form-dialog.test.tsx @@ -0,0 +1,155 @@ +/** + * Tests for RequestFormDialog — P2-T9: shade code/name split. + * Verifies: + * - Submitting with only shade code (no shade name) succeeds. + * - The all-or-nothing spec validation group no longer includes shade fields + * (product description + tube type only) — leaving those two blank while + * filling only shade code does not trigger the "fill in all spec fields" error. + */ +import { describe, it, expect, vi } from "vitest" +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +// jsdom doesn't implement these — Radix's Select relies on them for pointer +// interactions during open/close. Without the polyfill, clicking the tube +// type Select throws "hasPointerCapture is not a function". +if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false +} +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {} +} + +// ─── Module mocks ───────────────────────────────────────────────────────────── + +const mutateAsync = vi.fn().mockResolvedValue({ requestId: 1, requestNo: "REQ-0001" }) + +vi.mock("@/hooks/finance/use-cost-product-request", () => ({ + useCreateCostProductRequest: () => ({ mutateAsync, isPending: false }), + useUpdateCostProductRequest: () => ({ mutateAsync: vi.fn(), isPending: false }), +})) + +vi.mock("@/components/finance/comboboxes", () => ({ + RequestTypeCombobox: ({ onChange }: { onChange: (typeId: number, code: string, variant: string) => void }) => ( + + ), + ProductMasterCombobox: ({ + onChange, + }: { + onChange: (productSysId: number, productCode: string, productName: string) => void + }) => ( + + ), +})) + +// ─── Import under test (after all mocks are registered) ────────────────────── + +import { RequestFormDialog } from "@/components/finance/cost-product-request/request-form-dialog" + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function renderDialog() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + {}} request={null} /> + , + ) +} + +async function fillRequiredBaseFields() { + await userEvent.click(screen.getByRole("button", { name: "pick-request-type" })) + await userEvent.type(screen.getByLabelText(/title \*/i), "New product request") + await userEvent.type(screen.getByLabelText(/customer name \*/i), "Acme Corp") +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("RequestFormDialog — shade code/name split (P2-T9)", () => { + it("submits successfully with only shade code filled (no shade name)", async () => { + mutateAsync.mockClear() + renderDialog() + + await fillRequiredBaseFields() + await userEvent.type(screen.getByLabelText(/shade code/i), "NL") + + await userEvent.click(screen.getByRole("button", { name: /create request/i })) + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)) + + const payload = mutateAsync.mock.calls[0][0] + expect(payload.spec).toBeUndefined() + expect(screen.queryByText(/fill in all spec fields/i)).not.toBeInTheDocument() + }) + + it("does not require shade fields as part of the all-or-nothing spec group", async () => { + mutateAsync.mockClear() + renderDialog() + + await fillRequiredBaseFields() + // Fill only the all-or-nothing pair (description + tube type), leave shade blank. + await userEvent.type(screen.getByLabelText(/product description/i), "PET bottle grade resin") + await userEvent.click(screen.getByRole("combobox", { name: /tube/i })) + await userEvent.click(await screen.findByRole("option", { name: "Paper" })) + + await userEvent.click(screen.getByRole("button", { name: /create request/i })) + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)) + + const payload = mutateAsync.mock.calls[0][0] + expect(payload.spec).toBeDefined() + expect(payload.spec.shadeCode).toBe("") + expect(payload.spec.shadeName).toBe("") + expect(screen.queryByText(/fill in all spec fields/i)).not.toBeInTheDocument() + }) + + it("rejects a half-filled all-or-nothing group (description without tube type) while shade is irrelevant to that check", async () => { + mutateAsync.mockClear() + renderDialog() + + await fillRequiredBaseFields() + await userEvent.type(screen.getByLabelText(/product description/i), "PET bottle grade resin") + await userEvent.type(screen.getByLabelText(/shade code/i), "NL") + await userEvent.type(screen.getByLabelText(/shade name/i), "Natural") + + await userEvent.click(screen.getByRole("button", { name: /create request/i })) + + expect(await screen.findAllByText(/fill in all spec fields/i)).not.toHaveLength(0) + expect(mutateAsync).not.toHaveBeenCalled() + }) +}) + +describe("RequestFormDialog — reference product field (P2-T18)", () => { + it("submits successfully with the reference product left unset", async () => { + mutateAsync.mockClear() + renderDialog() + + await fillRequiredBaseFields() + await userEvent.click(screen.getByRole("button", { name: /create request/i })) + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)) + + const payload = mutateAsync.mock.calls[0][0] + expect(payload.referenceProductSysId).toBeUndefined() + expect(screen.queryByText(/reference product/i)).toBeInTheDocument() // label still rendered, just optional + }) + + it("submits the picked reference product sys id when one is selected", async () => { + mutateAsync.mockClear() + renderDialog() + + await fillRequiredBaseFields() + await userEvent.click(screen.getByRole("button", { name: "pick-reference-product" })) + await userEvent.click(screen.getByRole("button", { name: /create request/i })) + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)) + + const payload = mutateAsync.mock.calls[0][0] + expect(payload.referenceProductSysId).toBe(42) + }) +}) diff --git a/src/__tests__/components/route-lock-card.test.tsx b/src/__tests__/components/route-lock-card.test.tsx index ff62676..092240a 100644 --- a/src/__tests__/components/route-lock-card.test.tsx +++ b/src/__tests__/components/route-lock-card.test.tsx @@ -43,6 +43,8 @@ function baseHead(overrides: Partial = {}): CostRouteHead { lockedAt: "", unlockedBy: "", unlockedAt: "", + levelCount: 0, + rmCount: 0, ...overrides, } } diff --git a/src/__tests__/components/routing-resolver.test.tsx b/src/__tests__/components/routing-resolver.test.tsx new file mode 100644 index 0000000..0cc58e6 --- /dev/null +++ b/src/__tests__/components/routing-resolver.test.tsx @@ -0,0 +1,156 @@ +/** + * Tests for RoutingResolver — P3-T1: unified link-vs-create routing entry + * point (design.md §3, Area B / B1+B2). Covers all 3 branches: + * - existing route found → useLinkExistingRoute is invoked + * - no route found (has product)→ useCreateRouteFromProduct is invoked directly + * - brand-new-product toggle → useCreateCostProductMaster then + * useCreateRouteFromProduct are invoked, in order + */ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import type { CostRouteHead } from "@/types/finance/cost-route" + +// ─── Module mocks ───────────────────────────────────────────────────────────── + +const linkMutateAsync = vi.fn() +const createFromProductMutateAsync = vi.fn() +const createProductMutateAsync = vi.fn() + +let routeByProductData: CostRouteHead | null = null + +vi.mock("@/hooks/finance/use-cost-route", () => ({ + useRouteByProduct: vi.fn(() => ({ data: routeByProductData, isLoading: false })), + useCreateRouteFromProduct: () => ({ mutateAsync: createFromProductMutateAsync, isPending: false }), +})) + +vi.mock("@/hooks/finance/use-link-route", () => ({ + useLinkExistingRoute: () => ({ mutateAsync: linkMutateAsync, isPending: false }), +})) + +vi.mock("@/hooks/finance/use-cost-product-master", () => ({ + useCreateCostProductMaster: () => ({ mutateAsync: createProductMutateAsync, isPending: false }), +})) + +vi.mock("@/components/finance/comboboxes/product-master-combobox", () => ({ + ProductMasterCombobox: ({ + onChange, + }: { + onChange: (productSysId: number, productCode: string, productName: string) => void + }) => ( + + ), +})) + +vi.mock("@/components/finance/comboboxes/product-type-combobox", () => ({ + ProductTypeCombobox: ({ onChange }: { onChange: (typeId: number, code: string, name: string) => void }) => ( + + ), +})) + +// ─── Import under test (after all mocks are registered) ────────────────────── + +import { RoutingResolver } from "@/components/finance/cost-product-request/routing-resolver" + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function baseHead(overrides: Partial = {}): CostRouteHead { + return { + headId: 100, + productSysId: 42, + routingStatus: "DRAFT", + version: 1, + lockedBy: "", + lockedAt: "", + unlockedBy: "", + unlockedAt: "", + levelCount: 0, + rmCount: 0, + ...overrides, + } +} + +function renderResolver(onResolved: (headId: number) => void) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + , + ) +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("RoutingResolver", () => { + beforeEach(() => { + linkMutateAsync.mockReset() + createFromProductMutateAsync.mockReset() + createProductMutateAsync.mockReset() + routeByProductData = null + }) + + it("links the existing route when useRouteByProduct finds one", async () => { + routeByProductData = baseHead({ headId: 555 }) + linkMutateAsync.mockResolvedValue({}) + const onResolved = vi.fn() + + renderResolver(onResolved) + + await userEvent.click(screen.getByRole("button", { name: "pick-product" })) + await userEvent.click(screen.getByRole("button", { name: /resolve routing/i })) + + expect(linkMutateAsync).toHaveBeenCalledTimes(1) + expect(linkMutateAsync).toHaveBeenCalledWith({ requestId: 7, routeHeadId: 555 }) + expect(createFromProductMutateAsync).not.toHaveBeenCalled() + expect(createProductMutateAsync).not.toHaveBeenCalled() + expect(onResolved).toHaveBeenCalledWith(555) + }) + + it("creates a route from the product when no existing route is found", async () => { + routeByProductData = null + createFromProductMutateAsync.mockResolvedValue(777) + const onResolved = vi.fn() + + renderResolver(onResolved) + + await userEvent.click(screen.getByRole("button", { name: "pick-product" })) + await userEvent.click(screen.getByRole("button", { name: /resolve routing/i })) + + expect(createFromProductMutateAsync).toHaveBeenCalledTimes(1) + expect(createFromProductMutateAsync).toHaveBeenCalledWith({ productSysId: 42, linkedRequestId: 7 }) + expect(linkMutateAsync).not.toHaveBeenCalled() + expect(onResolved).toHaveBeenCalledWith(777) + }) + + it("creates the product master then the route when the brand-new-product toggle is used", async () => { + createProductMutateAsync.mockResolvedValue({ productSysId: 900 }) + createFromProductMutateAsync.mockResolvedValue(901) + const onResolved = vi.fn() + + renderResolver(onResolved) + + await userEvent.click(screen.getByLabelText(/brand-new product/i)) + await userEvent.type(screen.getByPlaceholderText(/PTY 75\/72/i), "New Product X") + await userEvent.click(screen.getByRole("button", { name: "pick-product-type" })) + await userEvent.click(screen.getByRole("button", { name: /resolve routing/i })) + + expect(createProductMutateAsync).toHaveBeenCalledTimes(1) + expect(createProductMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ productTypeId: 9, productName: "New Product X" }), + ) + expect(createFromProductMutateAsync).toHaveBeenCalledTimes(1) + expect(createFromProductMutateAsync).toHaveBeenCalledWith({ productSysId: 900, linkedRequestId: 7 }) + expect(linkMutateAsync).not.toHaveBeenCalled() + + const productCallOrder = createProductMutateAsync.mock.invocationCallOrder[0] + const routeCallOrder = createFromProductMutateAsync.mock.invocationCallOrder[0] + expect(productCallOrder).toBeLessThan(routeCallOrder) + + expect(onResolved).toHaveBeenCalledWith(901) + }) +}) diff --git a/src/app/(dashboard)/finance/costing/fill-tasks/page.tsx b/src/app/(dashboard)/finance/costing/fill-tasks/page.tsx index d9673df..98576cc 100644 --- a/src/app/(dashboard)/finance/costing/fill-tasks/page.tsx +++ b/src/app/(dashboard)/finance/costing/fill-tasks/page.tsx @@ -5,7 +5,6 @@ import { useSearchParams, useRouter } from "next/navigation"; import { useFillTasks, useClaimFillTask, - useApproveFillTask, useRejectFillTask, } from "@/hooks/finance/use-fill-assignment"; import { useCostProductRequests } from "@/hooks/finance/use-cost-product-request"; @@ -128,7 +127,6 @@ function FillTasksContent({ requestId, onRequestSelect }: FillTasksContentProps) const { data: tasks = [], isLoading } = useFillTasks(requestId); const claim = useClaimFillTask(requestId); - const approve = useApproveFillTask(requestId); const reject = useRejectFillTask(requestId); const myBlockerTask = tasks.find( @@ -185,7 +183,6 @@ function FillTasksContent({ requestId, onRequestSelect }: FillTasksContentProps) }, }); }} - onApprove={(taskId) => approve.mutate({ taskId })} onReject={(taskId) => reject.mutate({ taskId, reason: "Rejected" }) } diff --git a/src/app/(dashboard)/finance/product-requests/product-requests-page-client.tsx b/src/app/(dashboard)/finance/product-requests/product-requests-page-client.tsx index fa68707..8940e11 100644 --- a/src/app/(dashboard)/finance/product-requests/product-requests-page-client.tsx +++ b/src/app/(dashboard)/finance/product-requests/product-requests-page-client.tsx @@ -1,8 +1,7 @@ "use client" import { useState } from "react" -import { useRouter } from "next/navigation" -import { Ban, FileText, Inbox, Plus, XCircle } from "lucide-react" +import { Ban, Download, FileText, Inbox, Loader2, Plus, Upload, XCircle } from "lucide-react" import { PageHeader } from "@/components/common/page-header" import { DebouncedSearchInput } from "@/components/common/debounced-search-input" @@ -18,9 +17,16 @@ import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import { ColumnVisibilityMenu } from "@/components/shared/data-table/column-visibility-menu" import { DataTablePagination } from "@/components/shared" import { + CostProductRequestImportDialog, RequestFormDialog, RequestTable, buildColumns, @@ -28,7 +34,11 @@ import { useRequestTableColumns, } from "@/components/finance/cost-product-request" import { FillTrackingDrawer } from "@/components/finance/fill-assignment" -import { useCostProductRequestCounts, useCostProductRequests } from "@/hooks/finance/use-cost-product-request" +import { + useCostProductRequestCounts, + useCostProductRequests, + useExportCostProductRequests, +} from "@/hooks/finance/use-cost-product-request" import { useUrlState } from "@/lib/hooks" import { getStatusDisplay } from "@/lib/ui/status-colors" import { usePermissionContext } from "@/providers/permission-provider" @@ -46,17 +56,25 @@ const defaultFilters: ListCostProductRequestsParams = { status: "", page: 1, pageSize: 25, + // sortBy/sortOrder must be listed here — useUrlState only tracks keys present in defaultValues. + // sortOrder must default to a string ("asc"), not undefined — useUrlState's defaultDeserialize + // branches on typeof defaultValue, and "undefined" falls through to JSON.parse(urlValue), which + // throws for a plain string like "asc" and silently resets to the default on every read. That + // made sortOrder permanently stick at undefined regardless of the URL (found during manual + // verification of this batch — same latent bug in product-master-page-client.tsx, out of scope here). + sortBy: "", + sortOrder: "asc", } const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] export default function ProductRequestsPageClient() { - const router = useRouter() const { hasPermission } = usePermissionContext() const canCreate = hasPermission(PERMISSIONS.ProductRequests.requestCreate) const [filters, setFilters] = useUrlState({ defaultValues: defaultFilters }) const [formOpen, setFormOpen] = useState(false) + const [importOpen, setImportOpen] = useState(false) const [trackingRequest, setTrackingRequest] = useState(null) // Column visibility managed at page level so the toggle lives in CardHeader @@ -64,19 +82,62 @@ export default function ProductRequestsPageClient() { const { data: list, isLoading } = useCostProductRequests(filters) const { data: counts, isLoading: countsLoading } = useCostProductRequestCounts() + const exportMutation = useExportCostProductRequests() const items = list?.items ?? [] const pagination = list?.pagination const totalItems = Number(pagination?.totalItems ?? 0) + function handleSort(sortKey: string) { + const nextOrder = filters.sortBy === sortKey && filters.sortOrder === "asc" ? "desc" : "asc" + setFilters({ ...filters, sortBy: sortKey as ListCostProductRequestsParams["sortBy"], sortOrder: nextOrder, page: 1 }) + } + + async function handleExport() { + await exportMutation.mutateAsync({ + search: filters.search, + status: filters.status, + requestTypeId: filters.requestTypeId, + }) + } + return (
- {canCreate && ( - - )} +
+ {/* Export/Import Dropdown */} + + + + + + + + Export to Excel + + setImportOpen(true)}> + + Import from Excel + + + + + {canCreate && ( + + )} +
@@ -137,8 +198,10 @@ export default function ProductRequestsPageClient() { items={items} isLoading={isLoading} visibility={visibility} - onOpen={(r) => router.push(`/finance/product-requests/${r.requestId}`)} onTrack={(r) => setTrackingRequest(r)} + sortBy={filters.sortBy} + sortOrder={filters.sortOrder} + onSort={handleSort} /> {totalItems > 0 && ( @@ -157,6 +220,8 @@ export default function ProductRequestsPageClient() { + + { if (!open) setTrackingRequest(null); }} diff --git a/src/app/(dashboard)/finance/routes/[headId]/loading.tsx b/src/app/(dashboard)/finance/routes/[headId]/loading.tsx new file mode 100644 index 0000000..98d2142 --- /dev/null +++ b/src/app/(dashboard)/finance/routes/[headId]/loading.tsx @@ -0,0 +1,19 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export default function RouteDetailLoading() { + return ( +
+
+ + + +
+
+ + + +
+ +
+ ) +} diff --git a/src/app/(dashboard)/finance/routes/loading.tsx b/src/app/(dashboard)/finance/routes/loading.tsx new file mode 100644 index 0000000..3543c39 --- /dev/null +++ b/src/app/(dashboard)/finance/routes/loading.tsx @@ -0,0 +1,20 @@ +import { TableSkeleton } from "@/components/loading" +import { Skeleton } from "@/components/ui/skeleton" + +export default function RoutesLoading() { + return ( +
+
+ + + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ +
+ ) +} diff --git a/src/app/(dashboard)/finance/routes/page.tsx b/src/app/(dashboard)/finance/routes/page.tsx index 7528172..ba60ddd 100644 --- a/src/app/(dashboard)/finance/routes/page.tsx +++ b/src/app/(dashboard)/finance/routes/page.tsx @@ -1,171 +1,8 @@ -"use client" +import { generateMetadata as genMeta } from "@/config/site" +import RoutesPageClient from "./routes-page-client" -import Link from "next/link" -import { useState } from "react" -import { CheckCircle2, FileStack, Loader2, Lock, PencilRuler } from "lucide-react" +export const metadata = genMeta("Product Routes") -import { DebouncedSearchInput, KpiCard, KpiGrid, PageHeader } from "@/components/common" -import { StatusBadge } from "@/components/common/status-badge" -import { CreateRoutingWizard } from "@/components/finance/cost-product-request/create-routing-wizard" -import { Button } from "@/components/ui/button" -import { Card } from "@/components/ui/card" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { useRouteCounts, useRoutes } from "@/hooks/finance/use-cost-route" -import type { RouteStatus } from "@/types/finance/cost-route" - -export default function RoutesListPage() { - const [search, setSearch] = useState("") - const [status, setStatus] = useState("") - const [page, setPage] = useState(1) - const [wizardOpen, setWizardOpen] = useState(false) - const pageSize = 20 - - const { data, isLoading } = useRoutes({ search, status, page, pageSize }) - const { data: counts, isLoading: countsLoading } = useRouteCounts() - - return ( -
- - - - - - - setWizardOpen(true)}> - From product (wizard) - - {/* "From template (duplicate)" deferred — user opens an existing route then Fork. */} - - - - - - - - - - - - setWizardOpen(false)} - requestId={0} - /> - - -
-
- { - setSearch(v) - setPage(1) - }} - placeholder="Search by product code or name…" - debounceMs={300} - /> -
- -
-
- - - - - - Head # - Product - Status - Version - From draft - Actions - - - - {isLoading && ( - - - Loading… - - - )} - {!isLoading && data?.items.length === 0 && ( - - - No routes yet. Promote a routing draft to create one. - - - )} - {data?.items.map((h) => ( - - #{h.headId} - -
{h.productCode || "—"}
-
{h.productName || ""}
-
- - - - v{h.version} - {h.promotedFromDraftId ? `#${h.promotedFromDraftId}` : "—"} - - - -
- ))} -
-
-
- - {data && data.totalPages > 1 && ( -
-
- Showing page {data.page} of {data.totalPages} ({data.total} total) -
-
- - -
-
- )} -
- ) +export default function RoutesPage() { + return } - diff --git a/src/app/(dashboard)/finance/routes/routes-page-client.tsx b/src/app/(dashboard)/finance/routes/routes-page-client.tsx new file mode 100644 index 0000000..74d8312 --- /dev/null +++ b/src/app/(dashboard)/finance/routes/routes-page-client.tsx @@ -0,0 +1,275 @@ +"use client" + +import { useMemo, useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { + CheckCircle2, + FileStack, + GitFork, + Lock, + PencilRuler, + Plus, + X, +} from "lucide-react" + +import { DebouncedSearchInput, EmptyState, KpiCard, KpiGrid, PageHeader } from "@/components/common" +import { StatusBadge } from "@/components/common/status-badge" +import { RoutingResolver } from "@/components/finance/cost-product-request/routing-resolver" +import { ColumnVisibilityMenu, DataTablePagination, SortableHeader, useColumnVisibility } from "@/components/shared" +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table" +import { useRouteCounts, useRoutes } from "@/hooks/finance/use-cost-route" +import { useUrlState } from "@/lib/hooks" +import { cn } from "@/lib/utils" +import type { ListRoutesParams, RouteStatus } from "@/types/finance/cost-route" + +// Column definitions — id doubles as the sort key where sortable +const ROUTE_COLUMNS = [ + { id: "head_id", header: "Head #", canHide: false }, + { id: "product_code", header: "Product" }, + { id: "status", header: "Status" }, + { id: "version", header: "Version", defaultHidden: false }, + { id: "level_count", header: "# levels" }, + { id: "rm_count", header: "# RM" }, +] as const + +type ColId = (typeof ROUTE_COLUMNS)[number]["id"] + +const TABLE_ID = "finance-routes" + +const defaultFilters: ListRoutesParams = { + page: 1, + pageSize: 20, + search: "", + status: "", + sortBy: "", + sortOrder: "asc", +} + +export default function RoutesPageClient() { + const router = useRouter() + const [filters, setFilters] = useUrlState({ defaultValues: defaultFilters }) + const [resolverOpen, setResolverOpen] = useState(false) + + const { data, isLoading } = useRoutes(filters) + const { data: counts, isLoading: countsLoading } = useRouteCounts() + + const columns = useMemo(() => [...ROUTE_COLUMNS], []) + const { visibility, toggle, setAll, reset } = useColumnVisibility(TABLE_ID, columns) + const show = (id: ColId) => visibility[id] !== false + // Right edge padding goes on whichever column renders last (routes has no trailing actions column). + const lastVisibleId = [...ROUTE_COLUMNS].reverse().find((c) => show(c.id))?.id + const edgeRight = (id: ColId) => (id === lastVisibleId ? "pr-4" : "") + + function handleSort(sortKey: string) { + const nextOrder = + filters.sortBy === sortKey && filters.sortOrder === "asc" ? "desc" : "asc" + setFilters({ ...filters, sortBy: sortKey as ListRoutesParams["sortBy"], sortOrder: nextOrder, page: 1 }) + } + + const sortProps = { + currentSortBy: filters.sortBy, + currentSortOrder: filters.sortOrder as "asc" | "desc" | undefined, + onSort: handleSort, + } + + const hasActiveFilters = !!filters.search || !!filters.status || !!filters.sortBy + + function clearFilters() { + setFilters({ + ...filters, + search: "", + status: "", + sortBy: "", + sortOrder: "asc", + page: 1, + }) + } + + const items = data?.items ?? [] + const totalItems = data?.total ?? 0 + const totalPages = data?.totalPages ?? 0 + + return ( +
+ + + + + + + + + + + + {/* Filter bar — matches product-master grid pattern */} +
+ setFilters({ ...filters, search, page: 1 })} + placeholder="Search by product code or name…" + className="h-9" + containerClassName="min-w-0 sm:col-span-2 lg:col-span-1" + /> + +
+ {hasActiveFilters && ( + + )} + +
+
+ + {/* Table */} +
+
+ + + + {show("head_id") && ( + + )} + {show("product_code") && ( + + )} + {show("status") && ( + + )} + {show("version") && ( + + )} + {show("level_count") && ( + + )} + {show("rm_count") && ( + + )} + + + + {isLoading && + Array.from({ length: 5 }).map((_, i) => ( + + {show("head_id") && } + {show("product_code") && } + {show("status") && } + {show("version") && } + {show("level_count") && } + {show("rm_count") && } + + ))} + {!isLoading && items.length === 0 && ( + + show(c.id)).length} className="p-0"> + + + + )} + {items.map((h) => ( + + {show("head_id") && ( + + + Open route #{h.headId} + + #{h.headId} + + )} + {show("product_code") && ( + +
{h.productCode || "—"}
+ {h.productName && ( +
{h.productName}
+ )} +
+ )} + {show("status") && ( + + + + )} + {show("version") && ( + v{h.version} + )} + {show("level_count") && ( + {h.levelCount} + )} + {show("rm_count") && ( + {h.rmCount} + )} +
+ ))} +
+
+
+
+ + {totalItems > 0 && ( + setFilters({ ...filters, page })} + onPageSizeChange={(pageSize) => setFilters({ ...filters, pageSize, page: 1 })} + /> + )} + + + + + New route + + { + setResolverOpen(false) + router.push(`/finance/routes/${headId}`) + }} + /> + + +
+ ) +} diff --git a/src/app/api/v1/finance/cost-product-requests/[requestId]/route.ts b/src/app/api/v1/finance/cost-product-requests/[requestId]/route.ts index efd906a..b138655 100644 --- a/src/app/api/v1/finance/cost-product-requests/[requestId]/route.ts +++ b/src/app/api/v1/finance/cost-product-requests/[requestId]/route.ts @@ -6,8 +6,10 @@ type SpecBody = { rawMaterialType?: string productDescription?: string shadeId?: number | string - shadeCustomText?: string + shadeCode?: string + shadeName?: string paperTubeTypeId?: number | string + tubeType?: number weightPerBobbinKg?: string boxType?: string } @@ -18,8 +20,10 @@ function normalizeSpec(spec: SpecBody | undefined | null) { rawMaterialType: spec.rawMaterialType ?? "", productDescription: spec.productDescription ?? "", shadeId: Number(spec.shadeId ?? 0) || 0, - shadeCustomText: spec.shadeCustomText ?? "", + shadeCode: spec.shadeCode ?? "", + shadeName: spec.shadeName ?? "", paperTubeTypeId: Number(spec.paperTubeTypeId ?? 0) || 0, + tubeType: Number(spec.tubeType ?? 0) || 0, weightPerBobbinKg: spec.weightPerBobbinKg ?? "", boxType: spec.boxType ?? "", } @@ -57,6 +61,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ urgencyLevel: body.urgencyLevel ?? body.urgency_level ?? "", neededByDate: body.neededByDate ?? body.needed_by_date ?? "", spec: normalizeSpec(body.spec), + referenceProductSysId: Number(body.referenceProductSysId ?? body.reference_product_sys_id ?? 0) || 0, }, metadata, ) diff --git a/src/app/api/v1/finance/cost-product-requests/[requestId]/submit-and-decide/route.ts b/src/app/api/v1/finance/cost-product-requests/[requestId]/submit-and-decide/route.ts new file mode 100644 index 0000000..47a8171 --- /dev/null +++ b/src/app/api/v1/finance/cost-product-requests/[requestId]/submit-and-decide/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server" +import { getCostProductRequestClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +export async function POST(request: NextRequest, { params }: { params: Promise<{ requestId: string }> }) { + try { + const { requestId } = await params + const body = await request.json() + const metadata = createMetadataFromRequest(request) + const client = getCostProductRequestClient() + const response = await client.submitAndDecideCostProductRequest({ + requestId: Number(requestId), + verifiedClassification: body.verifiedClassification || body.verified_classification, + overrideReason: body.overrideReason || body.override_reason || "", + decision: body.decision, + note: body.note || "", + referenceProductHeadId: body.referenceProductHeadId || body.reference_product_head_id || 0, + }, metadata) + return NextResponse.json({ base: response.base, data: response.data }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + return NextResponse.json({ base: { isSuccess: false, statusCode: "500", message: "Failed to submit and decide", validationErrors: [] } }, { status: 500 }) + } +} diff --git a/src/app/api/v1/finance/cost-product-requests/export/route.ts b/src/app/api/v1/finance/cost-product-requests/export/route.ts new file mode 100644 index 0000000..b0f541e --- /dev/null +++ b/src/app/api/v1/finance/cost-product-requests/export/route.ts @@ -0,0 +1,45 @@ +// GET /api/v1/finance/cost-product-requests/export - Export cost product requests to Excel + +import { NextRequest, NextResponse } from "next/server" +import { getCostProductRequestClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const metadata = createMetadataFromRequest(request) + const client = getCostProductRequestClient() + + const response = await client.exportCostProductRequests( + { + search: searchParams.get("search") || "", + status: searchParams.get("status") || "", + requestTypeId: Number(searchParams.get("requestTypeId") || searchParams.get("request_type_id")) || 0, + }, + metadata + ) + + // Convert Uint8Array to base64 string for JSON serialization + // Proto parser (bytesFromBase64) expects base64 string, not Uint8Array object + const fileContentBase64 = Buffer.from(response.fileContent).toString('base64') + + return NextResponse.json({ + base: response.base, + fileContent: fileContentBase64, + fileName: response.fileName, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error exporting cost product requests:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to export product requests", + validationErrors: [], + }, + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/finance/cost-product-requests/import/route.ts b/src/app/api/v1/finance/cost-product-requests/import/route.ts new file mode 100644 index 0000000..10ee6a0 --- /dev/null +++ b/src/app/api/v1/finance/cost-product-requests/import/route.ts @@ -0,0 +1,49 @@ +// POST /api/v1/finance/cost-product-requests/import - Import cost product requests from Excel + +import { NextRequest, NextResponse } from "next/server" +import { getCostProductRequestClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const metadata = createMetadataFromRequest(request) + const client = getCostProductRequestClient() + + // Convert fileContent array to Uint8Array for gRPC + const fileContentBytes = new Uint8Array(body.fileContent) + + const response = await client.importCostProductRequests({ + fileContent: fileContentBytes, + fileName: body.fileName, + duplicateAction: body.duplicateAction, + }, metadata) + + return NextResponse.json({ + base: response.base, + successCount: response.successCount, + skippedCount: response.skippedCount, + updatedCount: response.updatedCount, + failedCount: response.failedCount, + errors: response.errors, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error importing cost product requests:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to import product requests", + validationErrors: [], + }, + successCount: 0, + skippedCount: 0, + updatedCount: 0, + failedCount: 0, + errors: [], + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/finance/cost-product-requests/route.ts b/src/app/api/v1/finance/cost-product-requests/route.ts index 66dd4b7..f5861e7 100644 --- a/src/app/api/v1/finance/cost-product-requests/route.ts +++ b/src/app/api/v1/finance/cost-product-requests/route.ts @@ -8,8 +8,10 @@ type SpecBody = { rawMaterialType?: string productDescription?: string shadeId?: number | string - shadeCustomText?: string + shadeCode?: string + shadeName?: string paperTubeTypeId?: number | string + tubeType?: number weightPerBobbinKg?: string boxType?: string } @@ -20,8 +22,10 @@ function normalizeSpec(spec: SpecBody | undefined | null) { rawMaterialType: spec.rawMaterialType ?? "", productDescription: spec.productDescription ?? "", shadeId: Number(spec.shadeId ?? 0) || 0, - shadeCustomText: spec.shadeCustomText ?? "", + shadeCode: spec.shadeCode ?? "", + shadeName: spec.shadeName ?? "", paperTubeTypeId: Number(spec.paperTubeTypeId ?? 0) || 0, + tubeType: Number(spec.tubeType ?? 0) || 0, weightPerBobbinKg: spec.weightPerBobbinKg ?? "", boxType: spec.boxType ?? "", } @@ -73,6 +77,7 @@ export async function POST(request: NextRequest) { urgencyLevel: body.urgencyLevel ?? body.urgency_level ?? "", neededByDate: body.neededByDate ?? body.needed_by_date ?? "", spec: normalizeSpec(body.spec), + referenceProductSysId: Number(body.referenceProductSysId ?? body.reference_product_sys_id ?? 0) || 0, }, metadata, ) diff --git a/src/app/api/v1/finance/cost-product-requests/template/route.ts b/src/app/api/v1/finance/cost-product-requests/template/route.ts new file mode 100644 index 0000000..baa0d54 --- /dev/null +++ b/src/app/api/v1/finance/cost-product-requests/template/route.ts @@ -0,0 +1,35 @@ +// GET /api/v1/finance/cost-product-requests/template - Download import template + +import { NextRequest, NextResponse } from "next/server" +import { getCostProductRequestClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +export async function GET(request: NextRequest) { + try { + const metadata = createMetadataFromRequest(request) + const client = getCostProductRequestClient() + const response = await client.getCostProductRequestImportTemplate({}, metadata) + + // Convert Uint8Array to base64 string for JSON serialization + const fileContentBase64 = Buffer.from(response.fileContent).toString('base64') + + return NextResponse.json({ + base: response.base, + fileContent: fileContentBase64, + fileName: response.fileName, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error downloading cost product request template:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to download template", + validationErrors: [], + }, + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/finance/routes/[headId]/graph/route.ts b/src/app/api/v1/finance/routes/[headId]/graph/route.ts index f44a343..7001186 100644 --- a/src/app/api/v1/finance/routes/[headId]/graph/route.ts +++ b/src/app/api/v1/finance/routes/[headId]/graph/route.ts @@ -49,6 +49,9 @@ function normalizeGraphForSave(graph: unknown): unknown { rmProductSysId: n0(r.rmProductSysId), uomId: n0(r.uomId), routeRmRatio: n0(r.routeRmRatio) || 1, + // double position fields — undefined throws in ts-proto BinaryWriter. + positionX: n0(r.positionX), + positionY: n0(r.positionY), } }), } diff --git a/src/components/common/paper-tube-name.tsx b/src/components/common/paper-tube-name.tsx index f518d6c..561a342 100644 --- a/src/components/common/paper-tube-name.tsx +++ b/src/components/common/paper-tube-name.tsx @@ -1,18 +1,35 @@ "use client" -// PaperTubeName — resolves a cost_paper_tube_type ID to its code + display name -// via the existing TanStack-cached useCostPaperTubeTypes hook. Avoids the -// "Paper tube: #3" UUID-style display in spec / detail sections. +// PaperTubeName — displays a product spec's tube classification. +// Prefers the fixed Paper/Plastic `tubeType` enum (product-request-workflow- +// revamp D3) when set — a static label, no lookup needed. Falls back to +// resolving the legacy `cost_paper_tube_type` master-data ID (via the +// TanStack-cached useCostPaperTubeTypes hook) for historical rows that only +// have `paperTubeTypeId` and no `tubeType`. import { useCostPaperTubeTypes } from "@/hooks/finance/use-cost-paper-tube-type" +import { TubeType } from "@/types/generated/finance/v1/cost_product_request" interface Props { id: number | undefined | null + tubeType?: TubeType className?: string } -export function PaperTubeName({ id, className }: Props) { +const TUBE_TYPE_LABELS: Partial> = { + [TubeType.TUBE_TYPE_PAPER]: "Paper", + [TubeType.TUBE_TYPE_PLASTIC]: "Plastic", +} + +export function PaperTubeName({ id, tubeType, className }: Props) { + const legacyLookupEnabled = !tubeType && !!id const { data, isLoading } = useCostPaperTubeTypes() - if (!id) return + + if (tubeType) { + const label = TUBE_TYPE_LABELS[tubeType] + if (label) return {label} + } + + if (!legacyLookupEnabled) return if (isLoading) return Loading… const match = (data ?? []).find((p) => p.paperTubeTypeId === id) if (!match) { diff --git a/src/components/common/user-avatar.tsx b/src/components/common/user-avatar.tsx new file mode 100644 index 0000000..ef8a0fd --- /dev/null +++ b/src/components/common/user-avatar.tsx @@ -0,0 +1,83 @@ +"use client" + +// UserAvatar — shared avatar component. Renders the user's uploaded photo +// (mst_user.profile_picture_url, surfaced as `profilePictureUrl` on UserDetail) +// when available, falling back to initials. Consolidates three previously +// duplicated implementations: +// - cost-request-comment/comments-panel.tsx's `UserInitials` (deterministic +// per-userId color hash fallback — pass `colorHash` to reproduce that look) +// - nav/nav-user.tsx (plain shadcn fallback, already-resolved avatar/name) +// - profile/profile-header.tsx (plain shadcn fallback, already-resolved avatarUrl/name) +// +// Callers that already have `avatarUrl`/`fullName` (nav-user, profile-header) +// pass them directly and no lookup happens. Callers that only have a `userId` +// (comment threads) let this component resolve both via useUser(). +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { useUser as useIamUser } from "@/hooks/iam/use-users" + +const AVATAR_COLORS = [ + "bg-blue-100 text-blue-700", + "bg-emerald-100 text-emerald-700", + "bg-violet-100 text-violet-700", + "bg-orange-100 text-orange-700", + "bg-pink-100 text-pink-700", + "bg-cyan-100 text-cyan-700", +] + +/** Deterministic per-userId color hash — same user always gets the same color. */ +function avatarColor(userId: string): string { + const hash = (userId || "").split("").reduce((a, c) => a + c.charCodeAt(0), 0) + return AVATAR_COLORS[hash % AVATAR_COLORS.length] +} + +export interface UserAvatarProps { + /** IAM user id. When `avatarUrl`/`fullName` aren't passed directly, both are resolved via useUser(). */ + userId?: string | null + /** Pre-resolved full name — skips the name portion of the lookup when provided. */ + fullName?: string + /** Pre-resolved avatar URL — skips the avatar portion of the lookup when provided. */ + avatarUrl?: string | null + /** Applied to the outer Avatar element — controls size/shape, e.g. "h-8 w-8 rounded-lg". */ + className?: string + /** Applied to AvatarFallback — controls fallback text styling. */ + fallbackClassName?: string + /** + * When true, the fallback background uses the deterministic per-userId color hash + * (the look originally used in comment threads' `UserInitials`). Defaults to false, + * matching the plain shadcn muted fallback already used by nav-user/profile-header. + */ + colorHash?: boolean +} + +export function UserAvatar({ + userId, + fullName: fullNameProp, + avatarUrl: avatarUrlProp, + className, + fallbackClassName, + colorHash = false, +}: UserAvatarProps) { + // Only hit the network when the caller hasn't already resolved both fields + // (mirrors the enabled: !!id guard inside useUser/createCrudHooks). + const needsLookup = fullNameProp === undefined || avatarUrlProp === undefined + const { data: resp } = useIamUser(needsLookup ? userId || "" : "") + const detail = resp?.data?.detail + + const fullName = fullNameProp ?? detail?.fullName ?? "" + const avatarUrl = avatarUrlProp ?? detail?.profilePictureUrl ?? undefined + + const initials = fullName + ? fullName.trim().split(/\s+/).map((w) => w[0]).join("").toUpperCase().slice(0, 2) + : (userId || "?").charAt(0).toUpperCase() + + const hashClass = colorHash && userId ? avatarColor(userId) : "" + + return ( + + {avatarUrl && } + + {initials} + + + ) +} diff --git a/src/components/finance/cost-product-master/product-master-table.tsx b/src/components/finance/cost-product-master/product-master-table.tsx index 92309ee..490cabf 100644 --- a/src/components/finance/cost-product-master/product-master-table.tsx +++ b/src/components/finance/cost-product-master/product-master-table.tsx @@ -1,7 +1,7 @@ "use client" import { useMemo } from "react" -import { useRouter } from "next/navigation" +import Link from "next/link" import { Edit, Eye, Package, Power } from "lucide-react" import { ProductTypeName } from "@/components/common/product-type-name" @@ -62,7 +62,6 @@ export function ProductMasterTable({ onSort, visibility, }: Props) { - const router = useRouter() const show = (id: string) => visibility[id] !== false const visibleCount = PRODUCT_MASTER_COLUMNS.filter((c) => show(c.id)).length + 1 // +1 actions @@ -133,13 +132,14 @@ export function ProductMasterTable({ )} {items.map((p) => ( - router.push(`/finance/product-master/${p.productSysId}`)} - > + {show("product_code") && ( - {p.productCode} + + + View {p.productCode} + + {p.productCode} + )} {show("product_name") && {p.productName}} {show("product_type_code") && ( @@ -166,7 +166,7 @@ export function ProductMasterTable({ )} e.stopPropagation()} > +
+ + {/* File Upload */} +
+ +
fileInputRef.current?.click()} + > + + {selectedFile ? ( +
+ + {selectedFile.name} + + ({(selectedFile.size / 1024).toFixed(1)} KB) + +
+ ) : ( + <> + +

+ Click to select or drag and drop an Excel file +

+

+ Supported: .xlsx, .xls +

+ + )} +
+
+ + {/* Import Result */} + {importResult && ( +
+
+ {importResult.failedCount === 0 ? ( + + ) : ( + + )} + Import Complete +
+ +
+
+ Created: + + {importResult.successCount} + +
+
+ Failed: + + {importResult.failedCount} + +
+
+ + {importResult.errors.length > 0 && ( +
+

+ Errors: +

+ +
    + {importResult.errors.map((error, index) => ( +
  • + Row {error.rowNumber}: {error.field} - {error.message} +
  • + ))} +
+
+
+ )} +
+ )} + + + + + {!importResult && ( + + )} + + + + ) +} diff --git a/src/components/finance/cost-product-request/create-routing-wizard.tsx b/src/components/finance/cost-product-request/create-routing-wizard.tsx deleted file mode 100644 index bf76660..0000000 --- a/src/components/finance/cost-product-request/create-routing-wizard.tsx +++ /dev/null @@ -1,208 +0,0 @@ -"use client" - -import { useRouter } from "next/navigation" -import { useState } from "react" -import { toast } from "sonner" - -import { ProductMasterCombobox } from "@/components/finance/comboboxes/product-master-combobox" -import { ProductTypeCombobox } from "@/components/finance/comboboxes/product-type-combobox" -import { Button } from "@/components/ui/button" -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" -import { useCreateCostProductMaster } from "@/hooks/finance/use-cost-product-master" -import { useCreateRouteFromProduct } from "@/hooks/finance/use-cost-route" - -interface Props { - open: boolean - onClose: () => void - /** Pass 0 when launched from Routes list (no auto-link to a request). */ - requestId: number -} - -type Mode = "existing" | "new" - -export function CreateRoutingWizard({ open, onClose, requestId }: Props) { - const router = useRouter() - const [step, setStep] = useState<1 | 2>(1) - const [mode, setMode] = useState("existing") - const [existingProductSysId, setExistingProductSysId] = useState() - const [newProduct, setNewProduct] = useState({ - name: "", - typeId: 0, - shade: "", - grade: "AX", - description: "", - }) - - const createProductM = useCreateCostProductMaster() - const createRouteM = useCreateRouteFromProduct() - - const reset = () => { - setStep(1) - setMode("existing") - setExistingProductSysId(undefined) - setNewProduct({ name: "", typeId: 0, shade: "", grade: "AX", description: "" }) - } - - const handleClose = () => { - reset() - onClose() - } - - const handleSubmit = async () => { - try { - let productSysId = existingProductSysId - if (mode === "new") { - const created = await createProductM.mutateAsync({ - productTypeId: newProduct.typeId, - productName: newProduct.name, - shadeCode: newProduct.shade, - gradeCode: newProduct.grade || "AX", - description: newProduct.description, - }) - productSysId = created.productSysId - if (!productSysId) throw new Error("Product master create returned no sys id") - } - if (!productSysId) { - toast.error("Pick or create a product first") - return - } - const newHeadId = await createRouteM.mutateAsync({ - productSysId, - linkedRequestId: requestId, - }) - handleClose() - router.push(`/finance/routes/${newHeadId}`) - } catch { - // Mutation hooks already surface a toast; avoid double-toasting. - } - } - - const canSubmitStep1 = mode === "existing" && !!existingProductSysId - const canSubmitStep2 = - mode === "new" && newProduct.name.trim().length > 0 && newProduct.typeId > 0 - - return ( - !o && handleClose()}> - - - Create new routing {mode === "new" ? `(step ${step} of 2)` : ""} - - - {step === 1 && ( -
- - setMode(v as Mode)}> -
- - -
-
- - -
-
- {mode === "existing" && ( -
- - setExistingProductSysId(id)} - placeholder="Search product by code or name…" - /> -

- {requestId > 0 - ? "The new route will be linked to this request." - : "Standalone route (no request link)."} -

-
- )} - {mode === "new" && ( -

- Next: provide the new product master fields (code is auto-generated). -

- )} -
- )} - - {step === 2 && ( -
-
- - setNewProduct({ ...newProduct, name: e.target.value })} - placeholder="e.g. PTY 75/72 SD BRIGHT" - /> -
-
-
- - setNewProduct({ ...newProduct, typeId: id })} - /> -
-
- - setNewProduct({ ...newProduct, grade: e.target.value })} - /> -
-
-
- - setNewProduct({ ...newProduct, shade: e.target.value })} - /> -
-
- - setNewProduct({ ...newProduct, description: e.target.value })} - /> -
-

- Product code is auto-generated. Required parameters (CAPP) are empty initially — open - the new product master after creation to fill values. -

-
- )} - - - {step === 2 && ( - - )} - - {step === 1 && mode === "existing" && ( - - )} - {step === 1 && mode === "new" && } - {step === 2 && ( - - )} - -
-
- ) -} diff --git a/src/components/finance/cost-product-request/index.ts b/src/components/finance/cost-product-request/index.ts index 341c953..72bac6a 100644 --- a/src/components/finance/cost-product-request/index.ts +++ b/src/components/finance/cost-product-request/index.ts @@ -2,6 +2,7 @@ export { RequestFormDialog } from "./request-form-dialog" export { RequestTable, buildColumns, useRequestTableColumns, TABLE_ID } from "./request-table" export { RequestDetailPanel } from "./request-detail-panel" export { StatusBadge } from "./status-badge" -export { CloseDialog, ConfirmActionDialog, FeasibilityDialog, ReasonDialog, UseExistingCostingDialog, VerifyClassificationDialog } from "./transition-dialogs" +export { ClassificationAndFeasibilityDialog, CloseDialog, ConfirmActionDialog, ReasonDialog, UseExistingCostingDialog } from "./transition-dialogs" export { UnlockPasswordDialog } from "./unlock-password-dialog" export { ParamSummaryPanel } from "./param-summary-panel" +export { CostProductRequestImportDialog } from "./cost-product-request-import-dialog" diff --git a/src/components/finance/cost-product-request/override-params-drawer.tsx b/src/components/finance/cost-product-request/override-params-drawer.tsx index 6f50663..869664b 100644 --- a/src/components/finance/cost-product-request/override-params-drawer.tsx +++ b/src/components/finance/cost-product-request/override-params-drawer.tsx @@ -1,13 +1,12 @@ "use client" import { useState } from "react" -import { ArrowLeft, Loader2, X } from "lucide-react" +import { ArrowLeft, Loader2 } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Switch } from "@/components/ui/switch" import { Sheet, - SheetClose, SheetContent, SheetDescription, SheetTitle, @@ -118,12 +117,6 @@ export function OverrideParamsDrawer({ - - - diff --git a/src/components/finance/cost-product-request/param-detail-drawer.tsx b/src/components/finance/cost-product-request/param-detail-drawer.tsx index 8dc497b..529f0da 100644 --- a/src/components/finance/cost-product-request/param-detail-drawer.tsx +++ b/src/components/finance/cost-product-request/param-detail-drawer.tsx @@ -1,9 +1,9 @@ "use client" -import { useState } from "react" -import { ArrowLeft, Clock, Pencil, X } from "lucide-react" +import { useMemo, useState } from "react" +import { ArrowLeft, Clock, Pencil } from "lucide-react" import { Button } from "@/components/ui/button" -import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet" +import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet" import { Badge } from "@/components/ui/badge" import { UserName } from "@/components/common/user-name" import { OverrideParamsDrawer } from "./override-params-drawer" @@ -29,6 +29,73 @@ interface Props { routeLocked: boolean } +function ParamGroupedList({ params }: { params: ParamValueEntry[] }) { + const groups = useMemo(() => { + const groupMinOrder: Record = {} + for (const p of params) { + const g = p.displayGroup ?? "" + const cur = groupMinOrder[g] + if (cur === undefined || p.displayOrder < cur) { + groupMinOrder[g] = p.displayOrder + } + } + const sorted = Object.keys(groupMinOrder).sort((a, b) => { + if (a === "" && b !== "") return 1 + if (b === "" && a !== "") return -1 + return (groupMinOrder[a] ?? 0) - (groupMinOrder[b] ?? 0) + }) + return sorted.map((g) => ({ + name: g, + params: params + .filter((p) => (p.displayGroup ?? "") === g) + .sort((a, b) => a.displayOrder - b.displayOrder || a.paramCode.localeCompare(b.paramCode)), + })) + }, [params]) + + return ( +
+ {groups.map((group, i) => ( +
+ {group.name && ( +
0 ? "mt-1" : ""}`}> + + {group.name} + +
+
+ )} +
+ {group.params.map((p) => ( +
+ + {p.paramCode} + + {p.hasValue ? ( + + {p.dataType === "NUMBER" + ? p.valueNumeric + : p.dataType === "BOOLEAN" + ? p.valueFlag + ? "true" + : "false" + : p.valueText} + {p.uomCode ? ` ${p.uomCode}` : ""} + + ) : ( + —— + )} +
+ ))} +
+
+ ))} +
+ ) +} + function taskBadge(status: string) { switch (status) { case "APPROVED": @@ -106,32 +173,7 @@ function LevelSection({
-
- {level.params.map((p) => ( -
- - {p.paramCode} - - {p.hasValue ? ( - - {p.dataType === "NUMBER" - ? p.valueNumeric - : p.dataType === "BOOLEAN" - ? p.valueFlag - ? "true" - : "false" - : p.valueText} - {p.uomCode ? ` ${p.uomCode}` : ""} - - ) : ( - —— - )} -
- ))} -
+ {level.filledByUserId && (

@@ -185,12 +227,6 @@ export function ParamDetailDrawer({ open, onClose, requestId, product, canEdit, - - - diff --git a/src/components/finance/cost-product-request/param-edit-log-drawer.tsx b/src/components/finance/cost-product-request/param-edit-log-drawer.tsx index d5c9d66..8b5a071 100644 --- a/src/components/finance/cost-product-request/param-edit-log-drawer.tsx +++ b/src/components/finance/cost-product-request/param-edit-log-drawer.tsx @@ -1,8 +1,8 @@ "use client" -import { ArrowLeft, X } from "lucide-react" +import { ArrowLeft } from "lucide-react" import { Button } from "@/components/ui/button" -import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet" +import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet" import { Skeleton } from "@/components/ui/skeleton" import { useParamEditLog } from "@/hooks/finance/use-param-summary" @@ -53,12 +53,6 @@ export function ParamEditLogDrawer({ open, onClose, requestId, routeLevel, produ - - - diff --git a/src/components/finance/cost-product-request/param-summary-panel.tsx b/src/components/finance/cost-product-request/param-summary-panel.tsx index 1cb694c..9341bde 100644 --- a/src/components/finance/cost-product-request/param-summary-panel.tsx +++ b/src/components/finance/cost-product-request/param-summary-panel.tsx @@ -97,8 +97,9 @@ export function ParamSummaryPanel({ requestId, routeLocked = false }: Props) { > {product.productCode} + {product.productName && {product.productName}} - {product.levels.length} level{product.levels.length !== 1 ? "s" : ""} · {productFilled}/{productTotal} params + Level {product.levels.map(l => l.routeLevel).sort((a,b)=>a-b).join(", ")} · {productFilled}/{productTotal} params {fillStatusBadge(productFilled, productTotal, hasRejected)} diff --git a/src/components/finance/cost-product-request/pick-existing-route-dialog.tsx b/src/components/finance/cost-product-request/pick-existing-route-dialog.tsx deleted file mode 100644 index 80b2f22..0000000 --- a/src/components/finance/cost-product-request/pick-existing-route-dialog.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client" - -import { useState } from "react" - -import { ProductMasterCombobox } from "@/components/finance/comboboxes/product-master-combobox" -import { Button } from "@/components/ui/button" -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { Label } from "@/components/ui/label" -import { useRouteByProduct } from "@/hooks/finance/use-cost-route" - -interface Props { - open: boolean - onClose: () => void - onPick: (headId: number) => void -} - -export function PickExistingRouteDialog({ open, onClose, onPick }: Props) { - const [productSysId, setProductSysId] = useState() - const [productCode, setProductCode] = useState("") - const [productName, setProductName] = useState("") - const { data: head } = useRouteByProduct(productSysId) - - return ( -

!o && onClose()}> - - - Pick existing product with route - -
-
- - { - setProductSysId(id) - setProductCode(code) - setProductName(name) - }} - placeholder="Search product by code or name…" - /> -
- {productSysId && ( -
- {head ? ( - <> -
- Route #{head.headId} · {head.routingStatus} -
-
- {productCode} — {productName} -
- - ) : ( - This product does not have a route yet. - )} -
- )} -
- - - - -
-
- ) -} diff --git a/src/components/finance/cost-product-request/request-detail-panel.tsx b/src/components/finance/cost-product-request/request-detail-panel.tsx index 559acdc..2c1adcf 100644 --- a/src/components/finance/cost-product-request/request-detail-panel.tsx +++ b/src/components/finance/cost-product-request/request-detail-panel.tsx @@ -32,18 +32,14 @@ import { RoutingPanel } from "./routing-panel" import { StatusBadge } from "./status-badge" import { ParamSummaryPanel } from "./param-summary-panel" import { + ClassificationAndFeasibilityDialog, CloseDialog, ConfirmActionDialog, - FeasibilityDialog, ReasonDialog, - UseExistingCostingDialog, - VerifyClassificationDialog, } from "./transition-dialogs" import { useApproveRequest, - useCancelRequest, useConfirmRequest, - useDecideFeasibility, useMarkParameterComplete, useMarkParameterPending, useReleaseRequest, @@ -51,9 +47,6 @@ import { useReopenRequest, useRejectRequest, useStartReview, - useSubmitRequest, - useUseExistingCosting, - useVerifyClassification, useCloseRequest, } from "@/hooks/finance/use-cost-product-request" import type { CostProductRequest } from "@/types/finance/cost-product-request" @@ -72,7 +65,11 @@ interface Props { hasFillTracking?: boolean } -type DialogKind = "reject" | "cancel" | "verify" | "feasibility" | "close" | "useExisting" | "confirmAction" | null +// "submitDecide" — B3 merge (design.md §3 B3): the DRAFT "Submit" button now opens +// ClassificationAndFeasibilityDialog in mode="submit" instead of firing a bare +// useSubmitRequest() mutation. "reviewDecide" (mode="review", the UNDER_REVIEW flow) +// is unchanged and stays a separate dialog kind since its retry semantics differ. +type DialogKind = "reject" | "reviewDecide" | "submitDecide" | "close" | "confirmAction" | null export function RequestDetailPanel({ request, onEdit, allFillsApproved = false, hasFillTracking = false }: Props) { useCPRRealtimeSync(request.requestId) @@ -80,15 +77,10 @@ export function RequestDetailPanel({ request, onEdit, allFillsApproved = false, const [dialog, setDialog] = useState(null) const [confirmActionType, setConfirmActionType] = useState<"confirm" | "approve" | "release">("confirm") - const submitM = useSubmitRequest() const startM = useStartReview() const reviseM = useReviseRequest() const reopenM = useReopenRequest() - const useExistingM = useUseExistingCosting() - const verifyM = useVerifyClassification() - const feasibilityM = useDecideFeasibility() const rejectM = useRejectRequest() - const cancelM = useCancelRequest() const closeM = useCloseRequest() const markPendingM = useMarkParameterPending() const markCompleteM = useMarkParameterComplete() @@ -176,7 +168,7 @@ export function RequestDetailPanel({ request, onEdit, allFillsApproved = false, )} {isDraft && canSubmit && ( - )} @@ -193,25 +185,9 @@ export function RequestDetailPanel({ request, onEdit, allFillsApproved = false, Promote route )} - {isUnderReview && canResolve && !request.verifiedClassification && ( - - )} {isUnderReview && canResolve && ( - - )} - {isUnderReview && canResolve && - (request.verifiedClassification === "existing" || - (!request.verifiedClassification && request.productClassification === "existing")) && ( - )} {(isSubmitted || isUnderReview) && canReject && ( @@ -273,9 +249,6 @@ export function RequestDetailPanel({ request, onEdit, allFillsApproved = false, - )} @@ -317,38 +290,30 @@ export function RequestDetailPanel({ request, onEdit, allFillsApproved = false, {request.targetPriceRange || "—"} - {request.description && ( -
-
Description
-

{request.description}

-
- )} -
- -
- {request.spec && ( - - - Product specification - - - {request.spec.rawMaterialType} - - {request.spec.weightPerBobbinKg} kg - {request.spec.boxType} - {request.spec.shadeCustomText || `master #${request.spec.shadeId ?? "—"}`} -
- - -

{request.spec.productDescription}

+ + + Product specification + + + {request.spec && ( +
+ + + {request.spec.shadeCode || `master #${request.spec.shadeId ?? "—"}`} + {request.spec.shadeName || "—"}
-
-
- )} + )} + +

{request.spec?.productDescription || request.description || "—"}

+
+ + + + {(request.classificationOverrideReason || request.feasibilityDecision) && ( @@ -497,47 +462,22 @@ export function RequestDetailPanel({ request, onEdit, allFillsApproved = false, rejectM.mutate({ requestId, body: { reason } }, { onSuccess: () => setDialog(null) }) }} /> - setDialog(o ? "cancel" : null)} - title="Cancel request" - description="Cancelling closes the request with sub-status = cancelled. Provide a reason." - confirmLabel="Cancel request" - pending={cancelM.isPending} - onConfirm={(reason) => { - cancelM.mutate({ requestId, body: { reason } }, { onSuccess: () => setDialog(null) }) - }} - /> - setDialog(o ? "verify" : null)} + setDialog(o ? "reviewDecide" : null)} + requestId={requestId} currentClassification={request.productClassification} - pending={verifyM.isPending} - onConfirm={(verified, overrideReason) => { - verifyM.mutate( - { requestId, verifiedClassification: verified, overrideReason }, - { onSuccess: () => setDialog(null) }, - ) - }} + initialVerifiedClassification={request.verifiedClassification} + referenceProductSysId={request.referenceProductSysId} /> - setDialog(o ? "feasibility" : null)} - pending={feasibilityM.isPending} - onConfirm={(decision, note) => { - feasibilityM.mutate({ requestId, decision, note }, { onSuccess: () => setDialog(null) }) - }} - /> - setDialog(o ? "useExisting" : null)} - pending={useExistingM.isPending} - onConfirm={(existingProductSysId) => { - useExistingM.mutate( - { requestId, body: { existingProductSysId } }, - { onSuccess: () => setDialog(null) }, - ) - }} + setDialog(o ? "submitDecide" : null)} + requestId={requestId} + currentClassification={request.productClassification} + initialVerifiedClassification={request.verifiedClassification} + referenceProductSysId={request.referenceProductSysId} + mode="submit" /> @@ -60,67 +62,46 @@ interface Props { const DEFAULTS: FormValues = { requestTypeId: 0, title: "", - description: "", customerName: "", customerCode: "", - productClassification: "existing", urgencyLevel: "medium", neededByDate: "", targetVolume: "", targetPriceRange: "", - specRawMaterial: "", + referenceProductSysId: undefined, specProductDescription: "", - specShadeCustomText: "", - specPaperTubeTypeId: 0, - specWeightPerBobbinKg: "", - specBoxType: "", + specShadeCode: "", + specShadeName: "", + specTubeType: TubeType.TUBE_TYPE_UNSPECIFIED, } export function RequestFormDialog({ open, onOpenChange, request }: Props) { const isEditing = !!request && request.status === "DRAFT" const createMutation = useCreateCostProductRequest() const updateMutation = useUpdateCostProductRequest() - const { data: requestTypes } = useCostRequestTypes() const form = useForm({ resolver: zodResolver(schema) as never, defaultValues: DEFAULTS, }) - const productClassification = form.watch("productClassification") - const requestTypeId = form.watch("requestTypeId") - const selectedType = useMemo( - () => (requestTypes ?? []).find((t) => t.typeId === requestTypeId), - [requestTypes, requestTypeId], - ) - - // DEVELOPMENT always implies new classification (FR-1). - useEffect(() => { - if (selectedType?.code === "DEVELOPMENT" && form.getValues("productClassification") !== "new") { - form.setValue("productClassification", "new") - } - }, [selectedType, form]) - useEffect(() => { if (!open) return if (request) { form.reset({ requestTypeId: request.requestTypeId, title: request.title, - description: request.description || "", customerName: request.customerName, customerCode: request.customerCode || "", - productClassification: request.productClassification, urgencyLevel: request.urgencyLevel, neededByDate: request.neededByDate || "", targetVolume: request.targetVolume || "", targetPriceRange: request.targetPriceRange || "", - specRawMaterial: (request.spec?.rawMaterialType as RawMaterialType) || "", + referenceProductSysId: request.referenceProductSysId || undefined, specProductDescription: request.spec?.productDescription || "", - specShadeCustomText: request.spec?.shadeCustomText || "", - specPaperTubeTypeId: request.spec?.paperTubeTypeId || 0, - specWeightPerBobbinKg: request.spec?.weightPerBobbinKg || "", - specBoxType: (request.spec?.boxType as "JUMBO" | "NORMAL" | "PALLET") || "", + specShadeCode: request.spec?.shadeCode || "", + specShadeName: request.spec?.shadeName || "", + specTubeType: request.spec?.tubeType || TubeType.TUBE_TYPE_UNSPECIFIED, }) } else { form.reset(DEFAULTS) @@ -128,56 +109,53 @@ export function RequestFormDialog({ open, onOpenChange, request }: Props) { }, [open, request, form]) async function onSubmit(values: FormValues) { - // Cross-field rule: spec required iff classification = new. - if (values.productClassification === "new") { - if (!values.specRawMaterial) { - form.setError("specRawMaterial", { message: "Required for new products" }) - return - } - if (!values.specProductDescription) { - form.setError("specProductDescription", { message: "Required for new products" }) - return - } - if (!values.specPaperTubeTypeId) { - form.setError("specPaperTubeTypeId", { message: "Required for new products" }) - return - } - if (!values.specWeightPerBobbinKg) { - form.setError("specWeightPerBobbinKg", { message: "Required for new products" }) - return - } - if (!values.specBoxType) { - form.setError("specBoxType", { message: "Required for new products" }) - return - } - if (!values.specShadeCustomText) { - form.setError("specShadeCustomText", { message: "Required (use 'natural' if unpigmented)" }) - return + // Cross-field rule: spec is all-or-nothing — either every field is filled, or none are. + // Shade code/name are excluded from this group — both are independently optional. + const specFieldEntries: Array<[keyof FormValues, string]> = [ + ["specProductDescription", "Fill in all spec fields, or leave all blank"], + ["specTubeType", "Fill in all spec fields, or leave all blank"], + ] + const isEmpty = (v: unknown) => + v === undefined || v === null || v === "" || v === 0 || v === TubeType.TUBE_TYPE_UNSPECIFIED + const filledCount = specFieldEntries.filter(([key]) => !isEmpty(values[key])).length + if (filledCount > 0 && filledCount < specFieldEntries.length) { + for (const [key, message] of specFieldEntries) { + if (isEmpty(values[key])) { + form.setError(key, { message }) + } } + return } + // Shade code/name never gate whether a spec is sent — they're independently + // optional extras included alongside the description+tube all-or-nothing group. + const hasSpec = filledCount === specFieldEntries.length const payload = { requestTypeId: values.requestTypeId, title: values.title, - description: values.description, + description: values.specProductDescription || "", customerName: values.customerName, customerCode: values.customerCode, - productClassification: values.productClassification as ProductClassification, + productClassification: isEditing && request ? request.productClassification : "pending", urgencyLevel: values.urgencyLevel as UrgencyLevel, neededByDate: values.neededByDate, targetVolume: values.targetVolume, targetPriceRange: values.targetPriceRange, - spec: - values.productClassification === "new" - ? { - rawMaterialType: values.specRawMaterial as RawMaterialType, - productDescription: values.specProductDescription!, - shadeId: 0, // master shade not picked — using free-text fallback - shadeCustomText: values.specShadeCustomText, - paperTubeTypeId: values.specPaperTubeTypeId!, - weightPerBobbinKg: values.specWeightPerBobbinKg!, - boxType: values.specBoxType as "JUMBO" | "NORMAL" | "PALLET", - } - : undefined, + referenceProductSysId: values.referenceProductSysId, + spec: hasSpec + ? { + // rawMaterialType/weightPerBobbinKg/boxType removed from the form (D1) — send + // empty so historical rows' columns stay unpopulated on new writes. + rawMaterialType: "" as SpecInput["rawMaterialType"], + productDescription: values.specProductDescription || "", + shadeId: 0, // master shade not picked — using free-text fallback + shadeCode: values.specShadeCode, + shadeName: values.specShadeName, + paperTubeTypeId: 0, + tubeType: values.specTubeType ?? TubeType.TUBE_TYPE_UNSPECIFIED, + weightPerBobbinKg: "", + boxType: "" as SpecInput["boxType"], + } + : undefined, } try { if (isEditing && request) { @@ -199,8 +177,8 @@ export function RequestFormDialog({ open, onOpenChange, request }: Props) { {isEditing ? `Edit ${request?.requestNo}` : "New product request"} - Section 2 (Product specification) becomes required when classification = new. DEVELOPMENT - requests are forced to new. + Product specification is optional — leave it blank, or fill in every field if you have the + details.
@@ -292,37 +270,6 @@ export function RequestFormDialog({ open, onOpenChange, request }: Props) { )} />
- ( - - Product classification * - - - - - - - {selectedType?.code === "DEVELOPMENT" && ( - - DEVELOPMENT request type forces classification = new. - - )} - - - )} - />
+ + + {/* SECTION 2 — Product Specification & Pricing (always visible; description+tube + all-or-nothing, everything else independently optional) */} +
+

+ Section 2 — Product specification & pricing +

( - Description + Product description -