Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions frontend/src/api/mockApi.dealQuotationOutcome.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 "เสนอใบราคา<party>" 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);
});
22 changes: 21 additions & 1 deletion frontend/src/api/mockApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/api/mockApi.pricingRequestQuotation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,41 @@ describe('mockApi.dealQuotations.createFromPricingRequest', () => {
});
});

// Spec L1 (2026-10-27): the deal page's sticky "เสนอใบราคา<party>" 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();
Expand Down
24 changes: 20 additions & 4 deletions frontend/src/components/common/OverflowMenu.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -170,6 +181,7 @@ export function OverflowMenu({
class, so it's left untouched rather than guessed into a variant. */}
{triggerLabel ? (
<button
{...triggerProps}
type="button"
ref={triggerRef}
className={cn(
Expand All @@ -182,11 +194,12 @@ export function OverflowMenu({
title={label}
onClick={() => setOpen((v) => !v)}
>
<Icon name={triggerIcon} size={18} />
<span>{triggerLabel}</span>
{triggerIcon ? <Icon name={triggerIcon} size={18} /> : null}
<span className="inline-flex items-center gap-[7px]">{triggerLabel}</span>
</button>
) : (
<Button
{...triggerProps}
type="button"
ref={triggerRef}
variant="icon"
Expand Down Expand Up @@ -242,6 +255,9 @@ export function OverflowMenu({
onClick={() => {
if (item.disabled) return;
setOpen(false);
// Focus back on the trigger first, so a dialog the item opens returns focus there. It
// was just clicked, so it is on screen: no scroll (a padded scroller would jump).
triggerRef.current?.focus({ preventScroll: true });
item.onSelect();
}}
>
Expand Down
54 changes: 53 additions & 1 deletion frontend/src/components/common/OverflowMenu.test.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Modal } from './Modal.jsx';
import { OverflowMenu } from './OverflowMenu.jsx';

globalThis.React = React;
Expand Down Expand Up @@ -59,6 +60,25 @@ describe('OverflowMenu', () => {
expect(trigger.textContent).toContain('เอกสาร');
});

// The deal page's เสนอราคา trigger: its chevron rides inside triggerLabel and triggerIcon={null}
// drops the leading icon, so the label lays out in a row with a gap (review V9, V15).
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(
<OverflowMenu items={items()} label="เสนอราคา" triggerLabel={<>เสนอราคา<svg /></>} triggerIcon={null} triggerProps={props} />,
);
let trigger = screen.getByTestId('ticket-primary-action');
expect(trigger).toBe(screen.getByRole('button', { name: 'เสนอราคา' }));
expect(trigger.getAttribute('data-action')).toBe('quote_next_party');
expect(trigger.querySelectorAll('svg')).toHaveLength(1);
expect(trigger.firstElementChild.className).toEqual(expect.stringContaining('inline-flex items-center gap-[7px]'));
unmount();

render(<OverflowMenu items={items()} triggerProps={props} />);
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(<OverflowMenu items={items()} />);
fireEvent.click(screen.getByRole('button', { name: 'การดำเนินการเพิ่มเติม' }));
Expand Down Expand Up @@ -107,16 +127,48 @@ describe('OverflowMenu', () => {
expect(screen.queryByRole('menu')).toBeNull();
});

it('Escape closes the menu and returns focus to the trigger', () => {
// Spec J1 (2026-10-27): a dialog an item opens (a confirm, a form) hands focus back, when it
// closes, to whatever held focus when it opened — that has to be this trigger, not the menu item
// the click is about to unmount.
// The trigger is on screen (it was just clicked); a scroller that pads its top (the deal page's
// sticky header) would jump to "reveal" it — on the way into the dialog and, via useDialogFocus,
// on the way out (review V7).
it('an item that opens a dialog: focus goes to the trigger before it runs and back to it on close, without scrolling the page', () => {
let focusedWhenSelected = null;
function Harness() {
const [open, setOpen] = React.useState(false);
const onSelect = () => { focusedWhenSelected = document.activeElement; setOpen(true); };
return (
<>
<OverflowMenu items={[{ key: 'a', label: 'เปิดหน้าต่างยืนยัน', onSelect }]} />
{open ? <Modal title="หน้าต่าง" onClose={() => setOpen(false)}><button type="button" onClick={() => setOpen(false)}>เสร็จ</button></Modal> : null}
</>
);
}
render(<Harness />);
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(<OverflowMenu items={items()} />);
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', () => {
Expand Down
38 changes: 35 additions & 3 deletions frontend/src/features/dashboard/SalesOverview.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading