From 907bd563e7fe9abea187cf97c01c36af3cbe8618 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:46:36 +0700 Subject: [PATCH 01/99] feat(sales): deal page offers the next party to quote after an acceptance, and opens new-engine quotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner rulings 2026-10-22. After a designer or owner accepts a quotation the deal page used to offer only ยืนยันคำสั่งซื้อ, which confirmed the customer and jumped the deal from ขั้น 4 to ขั้น 10 (then 11 on the deposit). Now: - A. After a non-last-party acceptance the sticky primary is เสนอราคา with a menu of the remaining on-route parties (เสนอราคาเจ้าของโครงการ / ข้ามไปเสนอราคาผู้ซื้อ), an info line in the action bar, and the pipeline card's ถัดไป line naming both choices. Picking one opens the price-request form with ผู้รับ pre-filled; the deal moves when the quotation is created, as before. ยืนยันคำสั่งซื้อ only when no on-route party remains. A later party's live request means neither is offered. - B. When the CEO has settled the price, เสนอใบราคา creates the quotation draft in the new engine and opens /quotations/:id (UAT ruling R2 of 2026-10-18). The deal page's old-engine one-click create+issue path is removed. - C. Quotation rows in the deal's เอกสาร tab link to their quotation page. - บันทึกผลใบเสนอราคา opens the price-request page, where the outcome is recorded for both engines, instead of a panel that knew only the old one. Frontend only. Tests revised first and shown red; 5 tests pinning the removed old-engine path deleted. Co-Authored-By: Claude Fable 5.1 --- .../src/components/common/OverflowMenu.jsx | 6 +- .../components/common/OverflowMenu.test.jsx | 13 + .../features/dashboard/SalesOverview.test.jsx | 4 +- .../PricingRequestCreateModal.jsx | 9 +- .../PricingRequestCreateModal.test.jsx | 61 +++ .../pricingRequests/PricingRequestPanel.jsx | 12 +- .../features/tickets/DealDocumentRegister.jsx | 18 +- .../tickets/DealDocumentRegister.test.jsx | 23 + .../features/tickets/DealQuotationPanel.jsx | 219 +--------- .../src/features/tickets/DealStagePanel.jsx | 10 + .../features/tickets/DealStagePanel.test.jsx | 20 + .../src/features/tickets/TicketDetailPage.jsx | 74 +++- .../tickets/TicketDetailPage.test.jsx | 403 +++++------------- frontend/src/features/tickets/salesActions.js | 68 ++- .../src/features/tickets/salesActions.test.js | 115 ++++- frontend/src/features/tickets/workState.js | 22 +- .../src/features/tickets/workState.test.js | 20 + 17 files changed, 554 insertions(+), 543 deletions(-) diff --git a/frontend/src/components/common/OverflowMenu.jsx b/frontend/src/components/common/OverflowMenu.jsx index d1cb43277..fbf142f12 100644 --- a/frontend/src/components/common/OverflowMenu.jsx +++ b/frontend/src/components/common/OverflowMenu.jsx @@ -65,6 +65,8 @@ export function OverflowMenu({ triggerLabel, triggerIcon = 'moreHorizontal', triggerClassName, + // Extra attributes for the trigger button (e.g. a data-testid / data-action e2e hook). + triggerProps, }) { const [open, setOpen] = useState(false); const triggerRef = useRef(null); @@ -181,8 +183,9 @@ export function OverflowMenu({ aria-label={label} title={label} onClick={() => setOpen((v) => !v)} + {...triggerProps} > - + {triggerIcon ? : null} {triggerLabel} ) : ( @@ -196,6 +199,7 @@ export function OverflowMenu({ aria-label={label} title={label} onClick={() => setOpen((v) => !v)} + {...triggerProps} > diff --git a/frontend/src/components/common/OverflowMenu.test.jsx b/frontend/src/components/common/OverflowMenu.test.jsx index 637766372..754a24954 100644 --- a/frontend/src/components/common/OverflowMenu.test.jsx +++ b/frontend/src/components/common/OverflowMenu.test.jsx @@ -59,6 +59,19 @@ describe('OverflowMenu', () => { expect(trigger.textContent).toContain('เอกสาร'); }); + it('passes triggerProps (e.g. data-testid / data-action) onto the trigger, labelled or icon-only', () => { + const props = { 'data-testid': 'ticket-primary-action', 'data-action': 'quote_next_party' }; + const { unmount } = render(); + let trigger = screen.getByTestId('ticket-primary-action'); + expect(trigger).toBe(screen.getByRole('button', { name: 'เสนอราคา' })); + expect(trigger.getAttribute('data-action')).toBe('quote_next_party'); + unmount(); + + render(); + trigger = screen.getByTestId('ticket-primary-action'); + expect(trigger).toBe(screen.getByRole('button', { name: 'การดำเนินการเพิ่มเติม' })); + }); + it('keeps menu items at the mobile 44px touch target floor without changing desktop density', () => { render(); fireEvent.click(screen.getByRole('button', { name: 'การดำเนินการเพิ่มเติม' })); diff --git a/frontend/src/features/dashboard/SalesOverview.test.jsx b/frontend/src/features/dashboard/SalesOverview.test.jsx index 338811446..62045ebc1 100644 --- a/frontend/src/features/dashboard/SalesOverview.test.jsx +++ b/frontend/src/features/dashboard/SalesOverview.test.jsx @@ -66,7 +66,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 +166,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 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..94aa60209 100644 --- a/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestCreateModal.test.jsx @@ -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/PricingRequestPanel.jsx b/frontend/src/features/pricingRequests/PricingRequestPanel.jsx index 174d57b67..095822ef3 100644 --- a/frontend/src/features/pricingRequests/PricingRequestPanel.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestPanel.jsx @@ -68,6 +68,7 @@ const EVENT_LABEL = { export const PricingRequestPanel = forwardRef(function PricingRequestPanel({ ticketId, deal, ticketItems = [], user, showToast }, ref) { const queryClient = useQueryClient(); const [createOpen, setCreateOpen] = useState(false); + const [createRecipientType, setCreateRecipientType] = useState(null); const [expandedId, setExpandedId] = useState(null); const [editingId, setEditingId] = useState(null); // pricing request id being edited (Fix 2), or null const [cancelDraft, setCancelDraft] = useState(null); // { id, reason } @@ -123,7 +124,11 @@ export const PricingRequestPanel = forwardRef(function PricingRequestPanel({ tic // ref-exposed openers): a stale or over-eager caller cannot force the // create modal open past the real gate. useImperativeHandle(ref, () => ({ - openCreate: () => { if (canCreate) setCreateOpen(true); }, + openCreate: (opts) => { + if (!canCreate) return; + setCreateRecipientType(opts?.recipientType ?? null); + setCreateOpen(true); + }, })); return ( @@ -261,8 +266,9 @@ export const PricingRequestPanel = forwardRef(function PricingRequestPanel({ tic ticketItems={ticketItems} deal={deal} showToast={showToast} - onClose={() => setCreateOpen(false)} - onCreated={() => { setCreateOpen(false); invalidate(); }} + initialRecipientType={createRecipientType} + onClose={() => { setCreateOpen(false); setCreateRecipientType(null); }} + onCreated={() => { setCreateOpen(false); setCreateRecipientType(null); invalidate(); }} createFn={(payload) => api.pricingRequests.create(ticketId, payload)} submitFn={(id) => api.pricingRequests.submit(id)} // Wired even in create mode so the modal's duplicate-draft retry diff --git a/frontend/src/features/tickets/DealDocumentRegister.jsx b/frontend/src/features/tickets/DealDocumentRegister.jsx index 96e8a45ed..18ef1871f 100644 --- a/frontend/src/features/tickets/DealDocumentRegister.jsx +++ b/frontend/src/features/tickets/DealDocumentRegister.jsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react'; import { useQueries, useQuery } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; import { api } from '../../api/index.js'; import { queryKeys } from '../../api/queryKeys.js'; import { Button } from '../../components/common/Button.jsx'; @@ -316,6 +317,8 @@ export function DealDocumentRegister({ meta: `ครั้งที่ ${q.revisionNo ?? 1}`, // Mirrors DealDirectQuotationPanel: PDF + Excel for every status. actions: pdfXlsx('direct', downloadDirectQuotation, q), + // The whole row opens the quotation (the query that lists these is gated on canViewDealQuotation). + to: `/quotations/${q.id}`, }); const depositItem = (doc) => ({ id: doc.id, @@ -507,16 +510,23 @@ function DocumentGroup({ items }) { // 8px gap between them is kept by `gap-2`. Desktop stays compact. const TOUCH_TARGET = 'nav-drawer:min-h-11 nav-drawer:min-w-11'; -function DocumentRow({ testId, icon, title, meta, status, date, amount, recipient, actions = [], muted = false }) { +function DocumentRow({ testId, icon, title, meta, status, date, amount, recipient, actions = [], muted = false, to }) { const hasAmount = amount !== undefined && amount !== null; const metaText = [date ? formatThaiDate(date) : null, meta].filter(Boolean).join(' · '); return ( // Line 1: number + status + recipient (wraps). Line 2: date/meta. Line 3 (mobile) / right side // of line 2 (desktop): amount on the left, actions on the right. -
+ // `to`: the title is a link stretched over the whole row (keyboard-reachable as one link); the + // action buttons sit above that overlay so PDF/Excel never navigate. `isolate` keeps their z-10 + // inside the row, so they never paint over the page's sticky header (also z-10, earlier in the DOM). +
{!muted && icon ? : null} - {title} + {to ? ( + {title} + ) : ( + {title} + )} {status ? {status.label} : null} {/* Recipient of the whole document: shown on the current (top) row only. */} {recipient && !muted ? ( @@ -533,7 +543,7 @@ function DocumentRow({ testId, icon, title, meta, status, date, amount, recipien {formatMoney(amount)} ) : null} {actions.length > 0 ? ( -
+
{actions.map((action) => (action.href ? ( // Not a -
+

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

) : (

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

) @@ -398,22 +222,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. -
+
บันทึกผลจากลูกค้า