diff --git a/frontend/src/api/mockApi.dealQuotationOutcome.test.js b/frontend/src/api/mockApi.dealQuotationOutcome.test.js index 869eb929e..43c33fd9a 100644 --- a/frontend/src/api/mockApi.dealQuotationOutcome.test.js +++ b/frontend/src/api/mockApi.dealQuotationOutcome.test.js @@ -366,3 +366,32 @@ describe('mockApi confirmOrder from a NEW-engine accepted quotation (GLA-123 sli expect(result.ticket.summary.salesStage).toBe('ORDER_RECEIVED'); }, 20000); }); + +// Owner ruling 2026-10-27 (redo): the deal page's "เสนอใบราคา" re-quotes a request whose +// quotation the customer rejected or sent back for changes. DealQuotationService +// #createFromPricingRequest allows that from QUOTATION_ISSUED, except when the request's latest +// quotation was superseded by a newer one on the deal. Mock parity only. +describe('mockApi dealQuotations.createFromPricingRequest after an outcome — the re-quote (mock parity)', () => { + it.each(['REJECTED', 'REVISION_REQUESTED'])('after %s: a fresh DRAFT from the same request', async (outcome) => { + const { ticketId, sourceItemId } = await freshTicket(); + const { prId, quotationId } = await issuedNewOriginQuotation(ticketId, sourceItemId, 'BUYER'); + await api.auth.login({ role: 'sales' }); + await api.dealQuotations.recordOutcome(quotationId, { outcome, clientRequestId: nextClientRequestId() }); + + const { quotation } = await api.dealQuotations.createFromPricingRequest(prId); + expect(quotation).toMatchObject({ docStatus: 'DRAFT', pricingRequestId: prId }); + expect(quotation.id).not.toBe(quotationId); + }, 20000); + + it('its quotation superseded by a later request\'s issue: 409, no re-quote from the older price', async () => { + const { ticketId, sourceItemId } = await freshTicket(); + const designer = await issuedNewOriginQuotation(ticketId, sourceItemId, 'DESIGNER'); + await issuedNewOriginQuotation(ticketId, sourceItemId, 'OWNER'); + await api.auth.login({ role: 'sales' }); + + await expect(api.dealQuotations.createFromPricingRequest(designer.prId)).rejects.toMatchObject({ + status: 409, + message: 'คำขอราคานี้ถูกแทนที่ด้วยใบเสนอราคาฉบับใหม่ของดีลแล้ว — ออกใบเสนอราคาจากคำขอราคาล่าสุด หรือสร้างคำขอราคาใหม่', + }); + }, 20000); +}); diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js index e9f1f358c..fe31c7939 100644 --- a/frontend/src/api/mockApi.js +++ b/frontend/src/api/mockApi.js @@ -16766,18 +16766,38 @@ export const api = { const pr = findPricingRequestRaw(pricingRequestId); const ticket = db.tickets.find((t) => t.id === pr.ticketId); if (ticket?.createdById !== user.id) fail('ไม่มีสิทธิ์เข้าถึงรายการนี้', 403); - if (pr.status !== 'APPROVED_FOR_QUOTATION') { + if ((ticket.lifecycle ?? 'ACTIVE') !== 'ACTIVE') fail('ดีลต้นทางต้องอยู่ในสถานะ ACTIVE', 409); + // The service's re-quote: a request already QUOTATION_ISSUED may be quoted again (its quotation was + // rejected, expired or sent back for changes; the live-quotation guard below refuses the rest), + // but never once a newer quotation on the deal superseded its latest one. + if (pr.status !== 'APPROVED_FOR_QUOTATION' && pr.status !== 'QUOTATION_ISSUED') { fail(`คำขอราคาต้องอยู่ในสถานะ 'อนุมัติราคาขายแล้ว' ก่อนจึงจะออกใบเสนอราคาลูกค้าได้ (ปัจจุบัน: ${pr.status})`, 409); } + const latest = mockDealQuotations + .filter((q) => q.pricingRequestId === pr.id && q.origin === 'PRICING_REQUEST') + .reduce((newest, q) => (newest == null || q.id > newest.id ? q : newest), null); + if (pr.status === 'QUOTATION_ISSUED' && latest?.docStatus === 'SUPERSEDED') { + fail('คำขอราคานี้ถูกแทนที่ด้วยใบเสนอราคาฉบับใหม่ของดีลแล้ว — ออกใบเสนอราคาจากคำขอราคาล่าสุด หรือสร้างคำขอราคาใหม่', 409); + } const decision = mockPricingDecisions.find((d) => d.pricingRequestId === pr.id && d.status === 'APPROVED'); if (!decision) fail('ยังไม่มีราคาขายที่ CEO อนุมัติสำหรับคำขอราคานี้', 409); if (!decision.priceMode) { fail('คำขอราคานี้ยังใช้รูปแบบราคาเดิม (ก่อน CEO เลือกวิธีกรอกราคา) กรุณาสร้างใบเสนอราคาแบบเดิมจากหน้านี้', 409); } + // Mutual exclusivity — CustomerQuotationRepository#hasLiveQuotation's set. + if (mockCustomerQuotations.some((q) => q.pricingRequestId === pr.id + && !['CANCELLED', 'SUPERSEDED', 'EXPIRED', 'REJECTED'].includes(q.docStatus))) { + fail('คำขอราคานี้มีใบเสนอราคาลูกค้า (แบบเดิม) อยู่แล้ว ไม่สามารถสร้างใบเสนอราคาแบบใหม่ซ้ำได้', 409); + } // Idempotent replay guard — same rule as DealQuotationRepository#findOpenDraftForPricingRequest. const existingDraft = mockDealQuotations.find( (q) => q.pricingRequestId === pr.id && q.origin === 'PRICING_REQUEST' && q.docStatus === 'DRAFT'); if (existingDraft) return delay({ quotation: buildDealQuotationDto(existingDraft) }); + // One live quotation per request — DealQuotationRepository#hasLivePricingRequestQuotation's set. + if (mockDealQuotations.some((q) => q.pricingRequestId === pr.id && q.origin === 'PRICING_REQUEST' + && !['CANCELLED', 'SUPERSEDED', 'EXPIRED', 'REJECTED', 'REVISION_REQUESTED'].includes(q.docStatus))) { + fail('คำขอราคานี้มีใบเสนอราคาที่ยังไม่หมดอายุอยู่แล้ว ไม่สามารถสร้างใบเสนอราคาใหม่ซ้ำได้', 409); + } const now = new Date().toISOString(); const contactSnapshot = resolveDealQuotationContact(ticket, { contactId: pr.recipientContactId }); diff --git a/frontend/src/api/mockApi.pricingRequestQuotation.test.js b/frontend/src/api/mockApi.pricingRequestQuotation.test.js index 7a46e123b..33ae8a6ec 100644 --- a/frontend/src/api/mockApi.pricingRequestQuotation.test.js +++ b/frontend/src/api/mockApi.pricingRequestQuotation.test.js @@ -248,6 +248,41 @@ describe('mockApi.dealQuotations.createFromPricingRequest', () => { }); }); +// Spec L1 (2026-10-27): the deal page's sticky "เสนอใบราคา" calls this endpoint, so the mock +// refuses what DealQuotationService#createFromPricingRequest refuses (same 409s, same Thai messages) +// instead of quietly writing a second quotation. Mock plumbing only — the real-DB tests are the +// authority on the service itself. +describe('mockApi.dealQuotations.createFromPricingRequest — refuses what the service refuses (mock parity)', () => { + it('a deal that is no longer ACTIVE: 409 "ดีลต้นทางต้องอยู่ในสถานะ ACTIVE"', async () => { + const { ticketId, prId } = await approvedNetPricingRequest(); + await api.tickets.hold(ticketId, { note: 'ลูกค้าขอพักโครงการ' }); + + await expect(api.dealQuotations.createFromPricingRequest(prId)) + .rejects.toMatchObject({ status: 409, message: 'ดีลต้นทางต้องอยู่ในสถานะ ACTIVE' }); + }); + + it('the request\'s quotation already past DRAFT (awaiting approval): 409, and no second quotation is written', async () => { + const { ticketId, prId } = await approvedNetPricingRequest(); + const { quotation } = await api.dealQuotations.createFromPricingRequest(prId); + await api.dealQuotations.submit(quotation.id); + + await expect(api.dealQuotations.createFromPricingRequest(prId)).rejects.toMatchObject({ + status: 409, message: 'คำขอราคานี้มีใบเสนอราคาที่ยังไม่หมดอายุอยู่แล้ว ไม่สามารถสร้างใบเสนอราคาใหม่ซ้ำได้', + }); + const { items } = await api.dealQuotations.listForTicket(ticketId); + expect(items.filter((q) => q.pricingRequestId === prId).map((q) => q.id)).toEqual([quotation.id]); + }); + + it('a live old-engine customer quotation on the request: 409', async () => { + const { prId } = await approvedNetPricingRequest(); + await api.pricingRequests.createCustomerQuotation(prId, { clientRequestId: nextClientRequestId() }); + + await expect(api.dealQuotations.createFromPricingRequest(prId)).rejects.toMatchObject({ + status: 409, message: 'คำขอราคานี้มีใบเสนอราคาลูกค้า (แบบเดิม) อยู่แล้ว ไม่สามารถสร้างใบเสนอราคาแบบใหม่ซ้ำได้', + }); + }); +}); + describe('mockApi.dealQuotations.update — origin=PRICING_REQUEST extra-row refusal', () => { it('refuses a payload that adds a new row, and writes nothing', async () => { const { prId } = await approvedNetPricingRequest(); diff --git a/frontend/src/components/common/OverflowMenu.jsx b/frontend/src/components/common/OverflowMenu.jsx index d1cb43277..9367f18bc 100644 --- a/frontend/src/components/common/OverflowMenu.jsx +++ b/frontend/src/components/common/OverflowMenu.jsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { cn } from '../../utils/cn.js'; import { useIsMobile } from '../../hooks/useIsMobile.js'; @@ -65,8 +65,19 @@ export function OverflowMenu({ triggerLabel, triggerIcon = 'moreHorizontal', triggerClassName, + // Extra attributes for the trigger button (e.g. a data-testid / data-action e2e hook); spread first, + // so they can never override the menu's own handlers or ARIA. + triggerProps, + // Told when the menu opens and closes (also closed on unmount) — the deal page holds its sticky + // header still while one is open, since condensing it remounts the trigger and closes the menu. + onOpenChange, }) { const [open, setOpen] = useState(false); + useEffect(() => { + if (!open) return undefined; + onOpenChange?.(true); + return () => onOpenChange?.(false); + }, [open, onOpenChange]); const triggerRef = useRef(null); const menuRef = useRef(null); const isMobile = useIsMobile(); @@ -119,7 +130,7 @@ export function OverflowMenu({ if (event.key === 'Escape') { event.stopPropagation(); setOpen(false); - triggerRef.current?.focus(); + triggerRef.current?.focus({ preventScroll: true }); return; } // Tab/Shift+Tab: close the menu and let the browser's own default Tab @@ -170,6 +181,7 @@ export function OverflowMenu({ class, so it's left untouched rather than guessed into a variant. */} {triggerLabel ? ( ) : ( : null} + + ); + } + render(); + const trigger = screen.getByRole('button', { name: 'การดำเนินการเพิ่มเติม' }); + fireEvent.click(trigger); + const focus = vi.spyOn(trigger, 'focus'); + + fireEvent.click(screen.getByRole('menuitem', { name: 'เปิดหน้าต่างยืนยัน' })); + expect(focusedWhenSelected).toBe(trigger); + fireEvent.click(screen.getByRole('button', { name: 'เสร็จ' })); + + expect(focus.mock.calls).toEqual([[{ preventScroll: true }], [{ preventScroll: true }]]); + }); + + it('Escape closes the menu and returns focus to the trigger, without scrolling the page', () => { render(); const trigger = screen.getByRole('button', { name: 'การดำเนินการเพิ่มเติม' }); fireEvent.click(trigger); expect(screen.getByRole('menu')).not.toBeNull(); + const focus = vi.spyOn(trigger, 'focus'); fireEvent.keyDown(document, { key: 'Escape' }); expect(screen.queryByRole('menu')).toBeNull(); expect(document.activeElement).toBe(trigger); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); }); it('a click outside the menu closes it', () => { diff --git a/frontend/src/features/dashboard/SalesOverview.jsx b/frontend/src/features/dashboard/SalesOverview.jsx index fdd341055..51bb0fb48 100644 --- a/frontend/src/features/dashboard/SalesOverview.jsx +++ b/frontend/src/features/dashboard/SalesOverview.jsx @@ -1,6 +1,6 @@ /* Hallmark · genre: modern-minimal · macrostructure: Workbench · design-system: design.md · designed-as-app · pre-emit critique: P4 H4 E4 S4 R5 V5 */ import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQueries, useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { api } from '../../api/index.js'; import { queryKeys } from '../../api/queryKeys.js'; @@ -11,7 +11,13 @@ import { PageStack, Panel, StatGrid } from '../../components/common/Layout.jsx'; import { StatCard } from '../../components/common/StatCard.jsx'; import { StatusBadge } from '../../components/common/StatusBadge.jsx'; import { bangkokMonthStartIso, bangkokTodayIso, formatMoney, formatThaiDate, greetingName } from '../../utils/format.js'; +import { newestQuotationByRequest } from '../quotations/quotationMeta.js'; import { followUpStatus, nextSalesAction, sortWorklist, SALES_ACTION } from '../tickets/salesActions.js'; +import { offRoutePartiesFor, useStageCatalog } from '../tickets/stageCatalog.js'; + +// useQueries' `combine`: each query's rows. Module-level so react-query keeps the combined array +// stable while the data is unchanged, and the worklist memo below holds. +const dataOfQueries = (results) => results.map((result) => result.data); const FOLLOW_UP_LIST_LIMIT = 6; @@ -89,13 +95,39 @@ export function SalesOverview({ user, employee }) { const loading = ticketsQuery.isLoading || pricingRequestsQuery.isLoading; const activeDeals = useMemo(() => deals.filter((deal) => deal.lifecycle === 'ACTIVE'), [deals]); + // Each deal's route (the served catalog.routes for its entryChannel), for the route choice. + const { catalog: stageCatalog } = useStageCatalog(); + + // Review V11: each quoted deal's quotation list (the deal page's own query, shared through the + // cache), so the worklist reads approval waits and redos the way the deal page does. Only deals + // with a request the list decides for; until a deal's list is read, it gets no route choice. + const quotedDealIds = useMemo(() => [...new Set(pricingRequests + .filter((pr) => pr.status === 'APPROVED_FOR_QUOTATION' || pr.status === 'QUOTATION_ISSUED') + .map((pr) => Number(pr.ticketId)))], [pricingRequests]); + const quotationRows = useQueries({ + queries: quotedDealIds.map((id) => ({ + queryKey: queryKeys.dealQuotationsByTicket(id), + queryFn: () => api.dealQuotations.listForTicket(id).then((response) => response.items ?? []), + })), + combine: dataOfQueries, + }); const worklist = useMemo(() => { + const rowsByDeal = new Map(quotedDealIds.map((id, index) => [id, quotationRows[index]])); const items = activeDeals - .map((deal) => ({ deal, action: nextSalesAction(deal, pricingRequests) })) + .map((deal) => { + const rows = rowsByDeal.get(Number(deal.id)); + return { + deal, + action: nextSalesAction(deal, pricingRequests, { + offRouteParties: rows ? offRoutePartiesFor(stageCatalog, deal.entryChannel) : null, + quotationByRequest: rows ? newestQuotationByRequest(rows) : undefined, + }), + }; + }) .filter((item) => item.action != null); return sortWorklist(items); - }, [activeDeals, pricingRequests]); + }, [activeDeals, pricingRequests, stageCatalog, quotedDealIds, quotationRows]); const pulse = useMemo(() => { let overdueFollowUp = 0; diff --git a/frontend/src/features/dashboard/SalesOverview.test.jsx b/frontend/src/features/dashboard/SalesOverview.test.jsx index 338811446..5d996c62d 100644 --- a/frontend/src/features/dashboard/SalesOverview.test.jsx +++ b/frontend/src/features/dashboard/SalesOverview.test.jsx @@ -11,12 +11,20 @@ globalThis.React = React; vi.mock('../../api/index.js', async (importOriginal) => { const actual = await importOriginal(); + // Imported inside the factory: vi.mock is hoisted above every import. + const { DEAL_STAGE_CATALOG } = await import('../../data/dealStageCatalog.js'); return { ...actual, api: { tickets: { list: vi.fn() }, pricingRequests: { queue: vi.fn() }, commissions: { monthlySummary: vi.fn() }, + // Each quoted deal's quotation list (the same call the deal page makes) — none by default. + dealQuotations: { listForTicket: vi.fn().mockResolvedValue({ items: [] }) }, + // GET /api/meta/deal-stages: each deal's route (catalog.routes by entryChannel) for the + // worklist's route choice. The same canned payload mockApi serves, pinned to DealRoute.java by + // stageCatalog.test.js. + meta: { dealStages: vi.fn().mockResolvedValue(DEAL_STAGE_CATALOG) }, }, }; }); @@ -66,7 +74,7 @@ const deals = [ ]; const pricingRequests = [ - { id: 1, ticketId: 602, status: 'APPROVED_FOR_QUOTATION', orderConfirmedAt: null }, + { id: 1, ticketId: 602, status: 'APPROVED_FOR_QUOTATION', recipientType: 'BUYER', orderConfirmedAt: null }, { id: 2, ticketId: 603, status: 'QUOTATION_ACCEPTED', orderConfirmedAt: null }, { id: 3, ticketId: 604, status: 'SUBMITTED', orderConfirmedAt: null }, { id: 4, ticketId: 605, status: 'IMPORT_REVIEWING', orderConfirmedAt: null }, @@ -166,7 +174,7 @@ describe('SalesOverview', () => { } expect(within(ctaFor('บริษัท เอ จำกัด')).getByText('สร้างคำขอราคา')).not.toBeNull(); // no PR at all - expect(within(ctaFor('บริษัท บี จำกัด')).getByText('ออกใบเสนอราคา')).not.toBeNull(); // APPROVED_FOR_QUOTATION + expect(within(ctaFor('บริษัท บี จำกัด')).getByText('เสนอใบราคาผู้ซื้อ')).not.toBeNull(); // APPROVED_FOR_QUOTATION, named for its recipient expect(within(ctaFor('บริษัท ซี จำกัด')).getByText('ยืนยันคำสั่งซื้อ')).not.toBeNull(); // QUOTATION_ACCEPTED, not confirmed expect(within(ctaFor('บริษัท ดี จำกัด')).getByText('ติดตามลูกค้า')).not.toBeNull(); // follow-up overdue expect(within(ctaFor('บริษัท อี จำกัด')).getByText('ติดตามลูกค้า')).not.toBeNull(); // follow-up due today @@ -333,3 +341,51 @@ describe('SalesOverview — live direct quotation worklist tones (slice 2)', () expect(badgeFor('บริษัท รอ จำกัด').className).toContain('status-neutral'); }); }); + +// Owner ruling 2026-10-27 (spec A7/H1): the worklist runs the deal page's resolver, so a designer +// quotation that went out offers the next party — named from THAT deal's route (the served +// catalog.routes for its entryChannel), not from a route every deal is assumed to share. There is +// no quotation list here, so a request at QUOTATION_ISSUED counts as sent on its status alone. +describe('SalesOverview — the next party after a designer quotation is sent follows each deal\'s route', () => { + const at = '2026-07-01T00:00:00.000Z'; + const routeDeals = [ + { id: 811, code: 'DL-2026-0811', customerName: 'บริษัท ผู้ออกแบบนำ จำกัด', title: 'ผู้ออกแบบนำ', lifecycle: 'ACTIVE', entryChannel: 'DESIGNER_LED', salesStage: 'QUOTE_DESIGN_SIDE', amountPayable: 0, stale: false, nextFollowUpAt: null, stageUpdatedAt: at }, + // A designer quotation on a buyer-direct deal is off its route (allowed); the owner is off it too. + { id: 812, code: 'DL-2026-0812', customerName: 'บริษัท ผู้ซื้อติดต่อตรง จำกัด', title: 'ผู้ซื้อติดต่อตรง', lifecycle: 'ACTIVE', entryChannel: 'BUYER_DIRECT', salesStage: 'SPEC_APPROVED', amountPayable: 0, stale: false, nextFollowUpAt: null, stageUpdatedAt: at }, + { id: 813, code: 'DL-2026-0813', customerName: 'บริษัท ผู้ออกแบบขอแก้ จำกัด', title: 'ผู้ออกแบบขอแก้', lifecycle: 'ACTIVE', entryChannel: 'DESIGNER_LED', salesStage: 'QUOTE_DESIGN_SIDE', amountPayable: 0, stale: false, nextFollowUpAt: null, stageUpdatedAt: at }, + ]; + const sentRequests = [ + { id: 81, ticketId: 811, status: 'QUOTATION_ISSUED', recipientType: 'DESIGNER', orderConfirmedAt: null }, + { id: 82, ticketId: 812, status: 'QUOTATION_ISSUED', recipientType: 'DESIGNER', orderConfirmedAt: null }, + { id: 83, ticketId: 813, status: 'QUOTATION_ISSUED', recipientType: 'DESIGNER', orderConfirmedAt: null }, + ]; + // Each request's newest quotation, read from its deal's quotation list as the deal page reads it + // (review V11): the designer on 813 asked for changes. + const docStatusByRequest = { 81: 'ISSUED', 82: 'ISSUED', 83: 'REVISION_REQUESTED' }; + + beforeEach(() => { + vi.clearAllMocks(); + api.tickets.list.mockResolvedValue({ tickets: routeDeals }); + api.pricingRequests.queue.mockResolvedValue({ items: sentRequests }); + api.commissions.monthlySummary.mockResolvedValue({ summary: defaultCommissionSummary }); + api.dealQuotations.listForTicket.mockImplementation((ticketId) => Promise.resolve({ + items: sentRequests.filter((pr) => pr.ticketId === ticketId).map((pr) => ({ + id: 9000 + pr.id, origin: 'PRICING_REQUEST', pricingRequestId: pr.id, docStatus: docStatusByRequest[pr.id], + })), + })); + }); + + it('designer-led -> เสนอราคาเจ้าของโครงการ; buyer-direct, whose route skips the owner -> เสนอราคาผู้ซื้อ; a designer quotation being redone -> เสนอใบราคาผู้ออกแบบ', async () => { + renderOverview(); + const worklist = within(worklistSection()); + await worklist.findByText('บริษัท ผู้ออกแบบนำ จำกัด'); + const badgeOf = (customerName) => worklist.getByText(customerName).closest('button').querySelector('.status-badge'); + + // waitFor: the label follows the stage catalog, which may land after the deal list. + await waitFor(() => { + expect(badgeOf('บริษัท ผู้ออกแบบนำ จำกัด').textContent).toBe('เสนอราคาเจ้าของโครงการ'); + expect(badgeOf('บริษัท ผู้ซื้อติดต่อตรง จำกัด').textContent).toBe('เสนอราคาผู้ซื้อ'); + expect(badgeOf('บริษัท ผู้ออกแบบขอแก้ จำกัด').textContent).toBe('เสนอใบราคาผู้ออกแบบ'); + }); + }); +}); diff --git a/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx b/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx index 66bf1fe2c..72599b76f 100644 --- a/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestCreateModal.jsx @@ -452,6 +452,8 @@ export function PricingRequestCreateModal({ // (a failed contact create/edit-in-place) — optional, so a caller with no toast plumbing of // its own (none currently omit it, but the picker itself tolerates an absent one) still works. showToast, + // Create mode only: ผู้รับ pre-selected from the deal page's เสนอราคา menu. + initialRecipientType = null, }) { const isEdit = mode === 'edit'; const isRevision = mode === 'revision'; @@ -460,11 +462,14 @@ export function PricingRequestCreateModal({ // ticket_item rows instead. const seedsFromExisting = isEdit || isRevision; const initialSummary = initialValue?.summary ?? null; - const [recipientType, setRecipientType] = useState(() => initialSummary?.recipientType ?? 'DESIGNER'); + const seededRecipientType = seedsFromExisting + ? (initialSummary?.recipientType ?? 'DESIGNER') + : (initialRecipientType ?? initialSummary?.recipientType ?? 'DESIGNER'); + const [recipientType, setRecipientType] = useState(seededRecipientType); const [recipientLabel, setRecipientLabel] = useState(() => ( seedsFromExisting ? (initialSummary?.recipientLabel ?? '') - : recipientLabelForDeal(deal, initialSummary?.recipientType ?? 'DESIGNER') + : recipientLabelForDeal(deal, seededRecipientType) )); // Tracks whether the user has hand-edited ผู้รับ's free-text label — once true, switching the // ผู้รับ chip must never overwrite what they typed (see the effect below). Stays false forever diff --git a/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx b/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx index 0c1acfb44..9ed5cd262 100644 --- a/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx @@ -504,7 +504,7 @@ describe('PricingRequestCreateModal เพิ่มรายการ (UAT item fireEvent.click(screen.getAllByRole('button', { name: 'ลบรายการ' })[1]); // remove the clean copy: marks move back up expect(rowsErrored()).toEqual([false, true, true, false]); - }); + }, 20000); // three saves + per-row waits: 5–17 s on a loaded CI runner, not a hang }); describe('PricingRequestCreateModal typed โรงงาน → สกุลเงิน/หน่วยราคา default (UAT item 3, owner ruling 2026-10-18)', () => { @@ -1309,3 +1309,64 @@ describe('PricingRequestCreateModal — สกุลเงิน + หน่ว expect(updateFn).not.toHaveBeenCalled(); }); }); + +// Owner ruling 2026-10-22: the deal page's เสนอราคา menu opens this modal with ผู้รับ +// already set to the next party on the route. `initialRecipientType` seeds the chip AND the ผู้รับ +// label (from the deal's per-type name) — CREATE MODE ONLY; edit/revision keep the persisted recipient. +describe('PricingRequestCreateModal initialRecipientType (owner ruling 2026-10-22)', () => { + const deal = { + designerName: 'สมชาย ผู้ออกแบบ', + ownerName: 'บริษัท เจ้าของ จำกัด', + buyerName: 'ผู้ซื้อ ข.', + customerName: 'ลูกค้า ง.', + }; + const chip = (name) => screen.getByRole('radio', { name }); + + it('create mode: pre-selects เจ้าของโครงการ and pre-fills the ผู้รับ label from deal.ownerName', () => { + renderModal({ deal, initialRecipientType: 'OWNER' }); + expect(chip(/เจ้าของโครงการ/).getAttribute('aria-checked')).toBe('true'); + expect(chip(/ผู้ออกแบบ/).getAttribute('aria-checked')).toBe('false'); + expect(screen.getByPlaceholderText('เช่น ชื่อผู้ออกแบบ หรือชื่อบริษัทผู้ซื้อ').value).toBe('บริษัท เจ้าของ จำกัด'); + }); + + it('create mode: BUYER pre-selects ผู้ซื้อ and fills deal.buyerName', () => { + renderModal({ deal, initialRecipientType: 'BUYER' }); + expect(chip(/ผู้ซื้อ/).getAttribute('aria-checked')).toBe('true'); + expect(screen.getByPlaceholderText('เช่น ชื่อผู้ออกแบบ หรือชื่อบริษัทผู้ซื้อ').value).toBe('ผู้ซื้อ ข.'); + }); + + it('create mode: the seeded recipient is what gets sent', async () => { + const { createFn } = renderModal({ deal, initialRecipientType: 'OWNER' }); + fillRequiredFields(); + fireEvent.click(screen.getByRole('button', { name: /ส่งให้ฝ่ายนำเข้า/ })); + await waitFor(() => expect(createFn).toHaveBeenCalledWith(expect.objectContaining({ + recipientType: 'OWNER', recipientLabel: 'บริษัท เจ้าของ จำกัด', + }))); + }); + + it('without the prop, create mode still defaults to ผู้ออกแบบ (unchanged)', () => { + renderModal({ deal }); + expect(chip(/ผู้ออกแบบ/).getAttribute('aria-checked')).toBe('true'); + expect(screen.getByPlaceholderText('เช่น ชื่อผู้ออกแบบ หรือชื่อบริษัทผู้ซื้อ').value).toBe('สมชาย ผู้ออกแบบ'); + }); + + it('edit mode ignores it: the persisted recipient and label win', () => { + render( + , + ); + expect(chip(/เจ้าของโครงการ/).getAttribute('aria-checked')).toBe('true'); + expect(chip(/ผู้ซื้อ/).getAttribute('aria-checked')).toBe('false'); + expect(screen.getByPlaceholderText('เช่น ชื่อผู้ออกแบบ หรือชื่อบริษัทผู้ซื้อ').value).toBe('เจ้าของโครงการ ข.'); + }); +}); diff --git a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx index bba0382d7..4b52c69cb 100644 --- a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx @@ -72,7 +72,7 @@ import { import { useUnitBasisCatalog } from './unitBasisCatalog.js'; import { buttonVariants } from '../../components/common/Button.jsx'; import { cn } from '../../utils/cn.js'; -import { PRICE_MODE_OPTIONS } from '../quotations/quotationMeta.js'; +import { acceptOutcomeConfirmCopy, newestQuotationByRequest, PRICE_MODE_OPTIONS } from '../quotations/quotationMeta.js'; import { SearchableCombobox } from '../../components/common/SearchableCombobox.jsx'; // B6 (GLA-135): reuses the catalog page's own add-factory dialog rather than a second, drifting // copy of the same five fields + validation — see ImportFactoryPicker's own comment below. @@ -1009,7 +1009,19 @@ export function PricingRequestDetailPage({ user, showToast }) { // doomed request and surface a raw error where the panel should simply not query at all. const dealQuotationForPrQuery = useQuery({ queryKey: queryKeys.dealQuotationForPricingRequest(pricingRequestId), - queryFn: () => api.dealQuotations.findForPricingRequest(pricingRequestId).then((r) => r.quotation ?? null), + // Review V4: the server's newest row may be a draft cancelled after แก้ใบเสนอราคา — then the + // quotation it revised is still this request's, read from the deal's list the way the deal page + // reads it (newest row that is not CANCELLED). If that list is not readable, the server's row. + queryFn: async () => { + const quotation = (await api.dealQuotations.findForPricingRequest(pricingRequestId)).quotation ?? null; + if (quotation?.docStatus !== 'CANCELLED') return quotation; + try { + const rows = (await api.dealQuotations.listForTicket(Number(detailQuery.data?.summary?.ticketId))).items ?? []; + return newestQuotationByRequest(rows).get(pricingRequestId) ?? null; + } catch { + return quotation; + } + }, enabled: Number.isFinite(pricingRequestId) && canViewCustomerQuotation(user, detailQuery.data?.summary) && !isImport(user), }); @@ -1082,6 +1094,10 @@ export function PricingRequestDetailPage({ user, showToast }) { // superseded/replaced it). queryClient.invalidateQueries({ queryKey: ['customerQuotations', 'discountApprovals'] }); queryClient.invalidateQueries({ queryKey: ['pricingRequests', 'queue'] }); + // The deal page's sticky bar reads its pricing requests and quotations. By prefix: that page + // keys them by its own route param. + queryClient.invalidateQueries({ queryKey: ['pricingRequests', 'byTicket'] }); + queryClient.invalidateQueries({ queryKey: ['dealQuotations', 'byTicket'] }); return refetched; // resolves once the detail + factory quotes have been re-read } @@ -1336,6 +1352,8 @@ export function PricingRequestDetailPage({ user, showToast }) { // Step 5: Customer Decision and Commercial Revisions. const [outcomeClientRequestId, setOutcomeClientRequestId] = useState(() => generateClientRequestId()); const [outcomeNote, setOutcomeNote] = useState(''); + // B2: the ACCEPTED outcome a DESIGNER/OWNER request asks about first — { engine, quotation }. + const [acceptToConfirm, setAcceptToConfirm] = useState(null); // Step 6: Deposit, Payment, and Order Confirmation. const [confirmOrderClientRequestId, setConfirmOrderClientRequestId] = useState(() => generateClientRequestId()); const startCeoReview = useActionMutation( @@ -1694,6 +1712,15 @@ export function PricingRequestDetailPage({ user, showToast }) { const request = detailQuery.data; const summary = request?.summary; const status = pricingRequestStatusLabel(summary?.status); + // B2 (owner ruling 2026-10-27): ลูกค้ายอมรับ on a DESIGNER/OWNER request asks first, both engines. + const acceptConfirm = acceptOutcomeConfirmCopy(summary?.recipientType); + function recordAccepted(engine, quotation) { + (engine === 'deal' ? recordDealQuotationOutcome : recordQuotationOutcome).mutate({ quotation, outcome: 'ACCEPTED' }); + } + function acceptOutcome(engine, quotation) { + if (acceptConfirm) setAcceptToConfirm({ engine, quotation }); + else recordAccepted(engine, quotation); + } // itemId -> the factoryId Import has picked (as a string, matching SearchableCombobox's own // `code` convention) for that line. B6 (GLA-135): this used to hold the free-typed factory // NAME; it holds an id now, resolved server-side to the canonical master name on save. Same @@ -3926,7 +3953,7 @@ export function PricingRequestDetailPage({ user, showToast }) { />
-
+

+ CEO อนุมัติราคาขายแล้ว — กดปุ่ม “เสนอใบราคา” บนแถบด้านบนเพื่อเขียนใบเสนอราคา +

) : (

ยังไม่มีใบเสนอราคาลูกค้าสำหรับคำขอราคานี้

) @@ -398,22 +245,8 @@ export const DealQuotationPanel = forwardRef(function DealQuotationPanel({ ticke ) : null} - {isCustomerQuotationEditable(current) && canManageCustomerQuotation(user, pr) ? ( - // The "ออกใบเสนอราคา" button itself lives solely on the sticky - // header now (FIX 2, see this component's own doc comment) — - // this explains the section instead of duplicating the CTA. -

- พร้อมออกใบเสนอราคาแล้ว — กดปุ่ม “ออกใบเสนอราคา” บนแถบด้านบนของหน้า - หรือแก้ไขรายละเอียด/ส่วนลดก่อนได้ที่ลิงก์ด้านบน -

- ) : null} - {canRecordCustomerQuotationOutcome(user, pr, current) ? ( - // ref + tabIndex: openRecordOutcome's scroll-and-focus target (the sticky header's - // "บันทึกผลใบเสนอราคา" CTA, salesActions.js's RECORD_QUOTATION_OUTCOME bucket) — see - // this component's useImperativeHandle above. tabIndex={-1} makes the section - // programmatically focusable without adding it to the page's normal tab order. -
+
บันทึกผลจากลูกค้า