diff --git a/frontend/e2e/server-app-ordering.spec.ts b/frontend/e2e/server-app-ordering.spec.ts index 3f9d931b5..5f72ce335 100644 --- a/frontend/e2e/server-app-ordering.spec.ts +++ b/frontend/e2e/server-app-ordering.spec.ts @@ -124,4 +124,410 @@ test.describe('Server App tableside ordering', () => { await header.getByRole('button', { name: 'Logout' }).click(); await expect(page.getByRole('button', { name: 'Sign In' })).toBeVisible(); }); + + test('freezes draft controls, pre-opened customizer, and table switching during a delayed send without blocking on printing', async ({ + page, + }) => { + const managerToken = getE2eToken('e2e-manager', 'manager@flo.local', 'manager'); + const serverToken = getE2eToken('e2e-server', 'server@flo.local', 'server'); + const headers = { Authorization: `Bearer ${managerToken}` }; + + const tableAName = `Freeze A ${Date.now()}`; + const tableBName = `Freeze B ${Date.now()}`; + const [tableARes, tableBRes] = await Promise.all([ + page.request.post(`${E2E_BASE_URL}/api/tables`, { + headers, + data: { number: tableAName, capacity: 2 }, + }), + page.request.post(`${E2E_BASE_URL}/api/tables`, { + headers, + data: { number: tableBName, capacity: 2 }, + }), + ]); + expect(tableARes.ok()).toBeTruthy(); + expect(tableBRes.ok()).toBeTruthy(); + const { table: tableA } = await tableARes.json(); + const { table: tableB } = await tableBRes.json(); + + await openServerApp(page, serverToken); + await page.getByRole('button', { name: new RegExp(tableAName) }).click(); + + const grid = page.getByTestId('pos-product-grid'); + const search = grid.getByPlaceholder('Search menu'); + await search.fill('E2E Coffee'); + await search.press('Enter'); + + const notesInput = page.getByPlaceholder('Order notes (optional)').first(); + await notesInput.fill('Initial note'); + + // Open the customizer before Send is clicked so its callback is live when Send starts. + await page.getByRole('button', { name: 'Edit', exact: true }).first().click(); + await expect(page.getByPlaceholder('e.g., no onions, extra spicy...')).toBeVisible(); + await page.getByRole('button', { name: '3', exact: true }).click(); + + let markOrderPostInFlight!: () => void; + const orderPostInFlight = new Promise((resolve) => { + markOrderPostInFlight = resolve; + }); + let releaseOrderPost!: () => void; + const orderPostGate = new Promise((resolve) => { + releaseOrderPost = resolve; + }); + + await page.route('**/api/orders', async (route) => { + if (route.request().method() !== 'POST') { + await route.continue(); + return; + } + markOrderPostInFlight(); + await orderPostGate; + await route.continue(); + }); + + const kotPrintPayloads: Array<{ + orderId: number; + items: Array<{ id: number; quantity: number }>; + }> = []; + let releaseKotPrint!: () => void; + const kotPrintGate = new Promise((resolve) => { + releaseKotPrint = resolve; + }); + let firstKotGateUsed = false; + await page.route('**/api/printers/print-kot', async (route) => { + kotPrintPayloads.push(route.request().postDataJSON()); + if (!firstKotGateUsed) { + firstKotGateUsed = true; + await kotPrintGate; + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + }); + + // Trigger Send while the pre-opened customizer modal is still mounted. + const sendButton = page.getByRole('button', { name: 'Send to kitchen' }).first(); + await sendButton.dispatchEvent('click'); + await orderPostInFlight; + + await expect(page.getByRole('button', { name: 'Sending...' }).first()).toBeDisabled(); + + // Submitting the pre-opened customizer while Send is in flight must not mutate the draft. + await page.getByRole('button', { name: /Save changes/ }).click(); + + const productCard = grid.getByTestId('pos-product-card').first(); + const removeButton = page.getByRole('button', { name: 'Remove item' }).first(); + const editButton = page.getByRole('button', { name: 'Edit', exact: true }).first(); + const tableBButton = page.getByRole('button', { name: new RegExp(tableBName) }); + const refreshButton = page.locator('header').getByRole('button', { name: 'Refresh' }); + const phoneInput = page.getByPlaceholder('Phone').first(); + const nameInput = page.getByPlaceholder('Customer name').first(); + + await expect(productCard).toBeDisabled(); + await expect(removeButton).toBeDisabled(); + await expect(editButton).toBeDisabled(); + await expect(tableBButton).toBeDisabled(); + await expect(refreshButton).toBeDisabled(); + await expect(phoneInput).toBeDisabled(); + await expect(nameInput).toBeDisabled(); + await expect(notesInput).toBeDisabled(); + + // Bypassing disabled UI via synthetic clicks or search Enter must also be blocked by the in-flight ref. + await productCard.dispatchEvent('click'); + await removeButton.dispatchEvent('click'); + await editButton.dispatchEvent('click'); + await tableBButton.dispatchEvent('click'); + await refreshButton.dispatchEvent('click'); + await search.fill('E2E Coffee'); + await search.press('Enter'); + + await expect(page.locator('header')).toContainText(tableAName); + await expect(notesInput).toHaveValue('Initial note'); + + // Release the order POST while print-kot is still blocked; Send must complete without waiting on printing. + releaseOrderPost(); + await expect(page.getByText('Order sent').first()).toBeVisible(); + await expect(page.getByText('Tap menu items to add them here.').first()).toBeVisible(); + await expect(page.getByText('1 x E2E Coffee').first()).toBeVisible(); + releaseKotPrint(); + + // Now that the first send finished and unlocked the UI, adding and sending a second item must succeed. + await search.fill('E2E Coffee'); + await search.press('Enter'); + await page.getByRole('button', { name: 'Add to order' }).first().click(); + await expect(page.locator('main').getByText('1 x E2E Coffee')).toHaveCount(2); + await expect.poll(() => kotPrintPayloads.length).toBe(2); + expect(kotPrintPayloads[0]?.items).toHaveLength(1); + expect(kotPrintPayloads[1]?.items).toHaveLength(1); + expect(kotPrintPayloads[1]?.items[0]?.id).not.toBe(kotPrintPayloads[0]?.items[0]?.id); + + const [ordersARes, ordersBRes] = await Promise.all([ + page.request.get(`${E2E_BASE_URL}/api/orders`, { + headers, + params: { table_id: tableA.id, type: 'dine_in', per_page: 5 }, + }), + page.request.get(`${E2E_BASE_URL}/api/orders`, { + headers, + params: { table_id: tableB.id, type: 'dine_in', per_page: 5 }, + }), + ]); + const { orders: ordersA } = await ordersARes.json(); + const { orders: ordersB } = await ordersBRes.json(); + const totalCoffeeQty = (ordersA[0]?.items || []) + .filter((item: { product_name: string }) => item.product_name === 'E2E Coffee') + .reduce((sum: number, item: { quantity: number }) => sum + item.quantity, 0); + expect(totalCoffeeQty).toBe(2); + expect(ordersB).toHaveLength(0); + }); + + test('double Send in the same tick creates only one order mutation', async ({ page }) => { + const managerToken = getE2eToken('e2e-manager', 'manager@flo.local', 'manager'); + const serverToken = getE2eToken('e2e-server', 'server@flo.local', 'server'); + const headers = { Authorization: `Bearer ${managerToken}` }; + + const tableName = `DoubleSend ${Date.now()}`; + const tableRes = await page.request.post(`${E2E_BASE_URL}/api/tables`, { + headers, + data: { number: tableName, capacity: 2 }, + }); + expect(tableRes.ok()).toBeTruthy(); + const { table } = await tableRes.json(); + + await openServerApp(page, serverToken); + await page.getByRole('button', { name: new RegExp(tableName) }).click(); + + const search = page.getByTestId('pos-product-grid').getByPlaceholder('Search menu'); + await search.fill('E2E Coffee'); + await search.press('Enter'); + + let postCalls = 0; + await page.route('**/api/orders', async (route) => { + if (route.request().method() === 'POST') { + postCalls += 1; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + await route.continue(); + }); + + const sendButton = page.getByRole('button', { name: 'Send to kitchen' }).first(); + await sendButton.evaluate((btn: HTMLButtonElement) => { + btn.click(); + btn.click(); + }); + + await expect(page.getByText('Order sent').first()).toBeVisible(); + expect(postCalls).toBe(1); + + const ordersRes = await page.request.get(`${E2E_BASE_URL}/api/orders`, { + headers, + params: { table_id: table.id, type: 'dine_in', per_page: 5 }, + }); + const { orders } = await ordersRes.json(); + expect(orders).toHaveLength(1); + expect(orders[0]?.items).toHaveLength(1); + expect(orders[0]?.items[0]?.quantity).toBe(1); + }); + + test('retains draft and idempotency retry key after a failed send, and ignores stale table order responses', async ({ + page, + }) => { + const managerToken = getE2eToken('e2e-manager', 'manager@flo.local', 'manager'); + const serverToken = getE2eToken('e2e-server', 'server@flo.local', 'server'); + const headers = { Authorization: `Bearer ${managerToken}` }; + + const tableAName = `Retry A ${Date.now()}`; + const tableBName = `Retry B ${Date.now()}`; + const [tableARes, tableBRes] = await Promise.all([ + page.request.post(`${E2E_BASE_URL}/api/tables`, { + headers, + data: { number: tableAName, capacity: 2 }, + }), + page.request.post(`${E2E_BASE_URL}/api/tables`, { + headers, + data: { number: tableBName, capacity: 2 }, + }), + ]); + expect(tableARes.ok()).toBeTruthy(); + expect(tableBRes.ok()).toBeTruthy(); + const { table: tableA } = await tableARes.json(); + const { table: tableB } = await tableBRes.json(); + + await openServerApp(page, serverToken); + await page.getByRole('button', { name: new RegExp(tableAName) }).click(); + + const search = page.getByTestId('pos-product-grid').getByPlaceholder('Search menu'); + await search.fill('2*E2E Coffee'); + await search.press('Enter'); + + const notesInput = page.getByPlaceholder('Order notes (optional)').first(); + await notesInput.fill('Retry note'); + + const idempotencyKeys: string[] = []; + const postPaths: string[] = []; + let simulateLostCommitResponse = true; + await page.route('**/api/orders*', async (route) => { + if (route.request().method() !== 'POST') { + await route.continue(); + return; + } + const url = new URL(route.request().url()); + postPaths.push(url.pathname); + const key = route.request().headers()['idempotency-key'] || ''; + idempotencyKeys.push(key); + if (simulateLostCommitResponse) { + simulateLostCommitResponse = false; + const committed = await route.fetch(); + expect(committed.ok()).toBeTruthy(); + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: 'Temporary failure after commit' }), + }); + return; + } + await route.continue(); + }); + + // First send commits on backend, but response is lost: draft and notes must remain intact. + await page.getByRole('button', { name: 'Send to kitchen' }).first().click(); + await expect(page.getByRole('button', { name: 'Send to kitchen' }).first()).toBeEnabled(); + await expect(notesInput).toHaveValue('Retry note'); + await expect(page.getByRole('button', { name: 'Remove item' }).first()).toBeEnabled(); + + // Switch away to Table B and back to Table A so the newly created order loads while the draft remains. + const reloadTableAOrder = page.waitForResponse( + (res) => + res.request().method() === 'GET' && + res.url().includes('/api/orders') && + res.url().includes(`table_id=${tableA.id}`), + ); + await page.getByRole('button', { name: new RegExp(tableBName) }).click(); + await page.getByRole('button', { name: new RegExp(tableAName) }).click(); + await reloadTableAOrder; + await expect(page.getByText('2 x E2E Coffee').first()).toBeVisible(); + + // Retrying the unmodified draft must replay the original POST /api/orders with the same key instead of appending a second time. + await page.getByRole('button', { name: 'Add to order' }).first().click(); + await expect(page.getByText('Order sent').first()).toBeVisible(); + expect(postPaths).toEqual(['/api/orders', '/api/orders']); + expect(idempotencyKeys).toHaveLength(2); + expect(idempotencyKeys[0]).toBeTruthy(); + expect(idempotencyKeys[1]).toBe(idempotencyKeys[0]); + + const ordersAAfterRetryRes = await page.request.get(`${E2E_BASE_URL}/api/orders`, { + headers, + params: { table_id: tableA.id, type: 'dine_in', per_page: 5 }, + }); + const { orders: ordersAAfterRetry } = await ordersAAfterRetryRes.json(); + expect(ordersAAfterRetry).toHaveLength(1); + expect(ordersAAfterRetry[0]?.items).toHaveLength(1); + expect(ordersAAfterRetry[0]?.items[0]?.quantity).toBe(2); + + // Delay a subsequent GET /api/orders for Table A, wait for interception before switching back to Table B, + // and wait for its released response to finish before asserting Table A's order does not render on Table B. + let markTableAOrderGetIntercepted!: () => void; + const tableAOrderGetIntercepted = new Promise((resolve) => { + markTableAOrderGetIntercepted = resolve; + }); + let releaseTableAOrderGet!: () => void; + const tableAOrderGetGate = new Promise((resolve) => { + releaseTableAOrderGet = resolve; + }); + let failNextTableALookup = false; + let delayedOnce = false; + await page.route('**/api/orders?*', async (route) => { + const url = route.request().url(); + if (failNextTableALookup && url.includes(`table_id=${tableA.id}`)) { + failNextTableALookup = false; + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: 'Lookup failed' }), + }); + return; + } + if (!delayedOnce && url.includes(`table_id=${tableA.id}`)) { + delayedOnce = true; + markTableAOrderGetIntercepted(); + await tableAOrderGetGate; + } + await route.continue(); + }); + + const firstTableBOrderGet = page.waitForResponse( + (res) => + res.request().method() === 'GET' && + res.url().includes('/api/orders') && + res.url().includes(`table_id=${tableB.id}`), + ); + await page.getByRole('button', { name: new RegExp(tableBName) }).click(); + await firstTableBOrderGet; + + const delayedTableAOrderGet = page.waitForResponse( + (res) => + res.request().method() === 'GET' && + res.url().includes('/api/orders') && + res.url().includes(`table_id=${tableA.id}`), + ); + await page.getByRole('button', { name: new RegExp(tableAName) }).click(); + await tableAOrderGetIntercepted; + + const secondTableBOrderGet = page.waitForResponse( + (res) => + res.request().method() === 'GET' && + res.url().includes('/api/orders') && + res.url().includes(`table_id=${tableB.id}`), + ); + await page.getByRole('button', { name: new RegExp(tableBName) }).click(); + await secondTableBOrderGet; + + releaseTableAOrderGet(); + const staleTableAResponse = await delayedTableAOrderGet; + expect(staleTableAResponse.ok()).toBeTruthy(); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve(null)))); + + await expect(page.locator('header')).toContainText(tableBName); + await expect(page.getByText('2 x E2E Coffee')).toHaveCount(0); + + // Prepare a new draft on Table B, then return to occupied Table A while its lookup fails: + // Send must remain blocked until Table A's lookup succeeds so it cannot create a separate order. + await search.fill('E2E Coffee'); + await search.press('Enter'); + await expect(page.getByRole('button', { name: 'Send to kitchen' }).first()).toBeEnabled(); + + failNextTableALookup = true; + const failedTableALookup = page.waitForResponse( + (res) => + res.request().method() === 'GET' && + res.url().includes('/api/orders') && + res.url().includes(`table_id=${tableA.id}`), + ); + await page.getByRole('button', { name: new RegExp(tableAName) }).click(); + await failedTableALookup; + + const sendOnTableAButton = page.getByRole('button', { name: 'Send to kitchen' }).first(); + await expect(sendOnTableAButton).toBeDisabled(); + await sendOnTableAButton.dispatchEvent('click'); + expect(postPaths).toHaveLength(2); + + // Refreshing recovers Table A's order lookup and switches the action to append ("Add to order"). + const recoveredTableALookup = page.waitForResponse( + (res) => + res.request().method() === 'GET' && + res.url().includes('/api/orders') && + res.url().includes(`table_id=${tableA.id}`) && + res.ok(), + ); + await page.locator('header').getByRole('button', { name: 'Refresh' }).click(); + await recoveredTableALookup; + await expect(page.getByRole('button', { name: 'Add to order' }).first()).toBeEnabled(); + + const ordersBRes = await page.request.get(`${E2E_BASE_URL}/api/orders`, { + headers, + params: { table_id: tableB.id, type: 'dine_in', per_page: 5 }, + }); + const { orders: ordersB } = await ordersBRes.json(); + expect(ordersB).toHaveLength(0); + }); }); diff --git a/frontend/src/app/server-standalone/page.tsx b/frontend/src/app/server-standalone/page.tsx index b0fd01c45..b4f3b55ed 100644 --- a/frontend/src/app/server-standalone/page.tsx +++ b/frontend/src/app/server-standalone/page.tsx @@ -94,7 +94,11 @@ function createApi(): AxiosInstance { api.interceptors.response.use( (response) => response, (error) => { - if (error.response?.status === 401) localStorage.removeItem(TOKEN_KEY); + const sentAuth = error.config?.headers?.Authorization; + const currentToken = localStorage.getItem(TOKEN_KEY); + if (error.response?.status === 401 && currentToken && sentAuth === `Bearer ${currentToken}`) { + localStorage.removeItem(TOKEN_KEY); + } return Promise.reject(error); }, ); @@ -143,15 +147,27 @@ function money(value: number | string, regional: ServerAppInfo | null) { ); } +type SendSnapshot = { + tableId: string; + tableName: string | undefined; + orderId: number | null; + orderCustomer: Order['customer'] | null; + existingItemIds: Set; + draftLines: DraftLine[]; + customerName: string; + customerPhone: string; + orderNotes: string; +}; + function sendAttemptSignature( - scopeId: string, + tableId: string, draft: DraftLine[], customerName: string, customerPhone: string, orderNotes: string, ): string { const items = draft.map((line) => `${line.id}:${line.quantity}`).join('|'); - return `${scopeId}|${items}|${customerName.trim()}|${customerPhone.trim()}|${orderNotes.trim()}`; + return `${tableId}|${items}|${customerName.trim()}|${customerPhone.trim()}|${orderNotes.trim()}`; } export default function ServerStandalonePage() { @@ -183,6 +199,7 @@ export default function ServerStandalonePage() { const [tables, setTables] = useState([]); const [tablesLoaded, setTablesLoaded] = useState(false); const [selectedTableId, setSelectedTableId] = useState(''); + const [orderLoadedTableId, setOrderLoadedTableId] = useState(''); const [selectedCategoryId, setSelectedCategoryId] = useState(null); const [query, setQuery] = useState(''); const [draft, setDraft] = useState([]); @@ -205,23 +222,39 @@ export default function ServerStandalonePage() { const [sending, setSending] = useState(false); // Synchronous re-entry guard: `sending` state updates too late to stop a second click fired before the first render. const sendInFlightRef = useRef(false); - // Nonce for the in-flight send attempt, paired with a signature of what defines it (draft/customer/order). - // Reused only while retrying that exact same attempt; a content change or a success both rotate it. - const sendAttemptRef = useRef<{ signature: string; nonce: string } | null>(null); + // Nonce and frozen target snapshot for the pending send attempt, paired with a signature of what defines it. + // Reused while retrying that exact same attempt so a lost create response cannot switch to append on retry. + const sendAttemptRef = useRef<{ + signature: string; + nonce: string; + snapshot: SendSnapshot; + } | null>(null); + const sendSeqRef = useRef(0); + const addonModalSendSeqRef = useRef(0); + const editingDraftSendSeqRef = useRef(0); + const selectedTableIdRef = useRef(''); + const orderLoadedTableIdRef = useRef(''); + const orderLookupFailedRef = useRef(false); + const orderLoadSeqRef = useRef(0); - async function loadAll() { + async function loadAll(options?: { allowWhileSending?: boolean }) { if (!api) return; + if (sendInFlightRef.current && !options?.allowWhileSending) return; const [categoriesRes, productsRes, tablesRes] = await Promise.all([ api.get('/api/categories', { params: { active: 'true' } }), api.get('/api/products', { params: { active: 'true' } }), api.get('/api/tables', { params: { active: 'true' } }), ]); + if (sendInFlightRef.current && !options?.allowWhileSending) return; setCategories(categoriesRes.data.categories || []); setProducts(productsRes.data.products || []); const loadedTables = tablesRes.data.tables || []; setTables(loadedTables); setTablesLoaded(true); - if (!selectedTableId && loadedTables[0]) setSelectedTableId(loadedTables[0].id); + if (!selectedTableIdRef.current && loadedTables[0]) { + selectedTableIdRef.current = loadedTables[0].id; + setSelectedTableId(loadedTables[0].id); + } } function cancelPendingCustomerLookup() { @@ -229,28 +262,66 @@ export default function ServerStandalonePage() { phoneAbortRef.current?.abort(); } - async function loadOrder(tableId: string) { + async function loadOrder(tableId: string, options?: { allowWhileSending?: boolean }) { if (!api || !tableId) return; + if (sendInFlightRef.current && !options?.allowWhileSending) return; + const loadSeq = ++orderLoadSeqRef.current; + orderLookupFailedRef.current = false; + if (!options?.allowWhileSending) { + orderLoadedTableIdRef.current = ''; + setOrderLoadedTableId(''); + } cancelPendingCustomerLookup(); - const res = await api.get('/api/orders', { - params: { - table_id: tableId, - type: 'dine_in', - status: 'pending,preparing,ready', - per_page: 1, - }, - }); - const order = res.data.orders?.[0] || null; - setCurrentOrder(order); - if (order?.customer) { - setCustomerName(order.customer.name || ''); - setCustomerPhone(order.customer.phone || ''); - } else { - setCustomerName(''); - setCustomerPhone(''); + try { + const res = await api.get('/api/orders', { + params: { + table_id: tableId, + type: 'dine_in', + status: 'pending,preparing,ready', + per_page: 1, + }, + }); + if ( + (sendInFlightRef.current && !options?.allowWhileSending) || + loadSeq !== orderLoadSeqRef.current || + selectedTableIdRef.current !== tableId + ) { + return; + } + const order = res.data.orders?.[0] || null; + setCurrentOrder(order); + const pendingAttempt = + sendAttemptRef.current?.snapshot.tableId === tableId + ? sendAttemptRef.current.snapshot + : null; + if (pendingAttempt) { + setCustomerName(pendingAttempt.customerName); + setCustomerPhone(pendingAttempt.customerPhone); + } else if (order?.customer) { + setCustomerName(order.customer.name || ''); + setCustomerPhone(order.customer.phone || ''); + } else { + setCustomerName(''); + setCustomerPhone(''); + } + setCustomerMatch(null); + setCustomerSearched(false); + orderLoadedTableIdRef.current = tableId; + setOrderLoadedTableId(tableId); + } catch (error) { + if ( + !options?.allowWhileSending && + !sendInFlightRef.current && + loadSeq === orderLoadSeqRef.current && + selectedTableIdRef.current === tableId + ) { + setCurrentOrder(null); + orderLoadedTableIdRef.current = ''; + setOrderLoadedTableId(''); + orderLookupFailedRef.current = true; + } + throw error; } - setCustomerMatch(null); - setCustomerSearched(false); } useEffect(() => { @@ -285,13 +356,16 @@ export default function ServerStandalonePage() { api.get('/api/tables', { params: { active: 'true' } }), ]) .then(([categoriesRes, productsRes, tablesRes]) => { - if (cancelled) return; + if (cancelled || sendInFlightRef.current) return; setCategories(categoriesRes.data.categories || []); setProducts(productsRes.data.products || []); const loadedTables = tablesRes.data.tables || []; setTables(loadedTables); setTablesLoaded(true); - if (!selectedTableId && loadedTables[0]) setSelectedTableId(loadedTables[0].id); + if (!selectedTableIdRef.current && loadedTables[0]) { + selectedTableIdRef.current = loadedTables[0].id; + setSelectedTableId(loadedTables[0].id); + } }) .catch(() => toast.error(t('couldNotLoadData'))); return () => { @@ -302,21 +376,39 @@ export default function ServerStandalonePage() { useEffect(() => { if (!selectedTableId || !user || !api) return; let cancelled = false; + const loadSeq = ++orderLoadSeqRef.current; + const targetTableId = selectedTableId; + orderLookupFailedRef.current = false; + orderLoadedTableIdRef.current = ''; cancelPendingCustomerLookup(); api .get('/api/orders', { params: { - table_id: selectedTableId, + table_id: targetTableId, type: 'dine_in', status: 'pending,preparing,ready', per_page: 1, }, }) .then((res) => { - if (cancelled) return; + if ( + cancelled || + sendInFlightRef.current || + loadSeq !== orderLoadSeqRef.current || + selectedTableIdRef.current !== targetTableId + ) { + return; + } const order = res.data.orders?.[0] || null; setCurrentOrder(order); - if (order?.customer) { + const pendingAttempt = + sendAttemptRef.current?.snapshot.tableId === targetTableId + ? sendAttemptRef.current.snapshot + : null; + if (pendingAttempt) { + setCustomerName(pendingAttempt.customerName); + setCustomerPhone(pendingAttempt.customerPhone); + } else if (order?.customer) { setCustomerName(order.customer.name || ''); setCustomerPhone(order.customer.phone || ''); } else { @@ -325,9 +417,21 @@ export default function ServerStandalonePage() { } setCustomerMatch(null); setCustomerSearched(false); + orderLoadedTableIdRef.current = targetTableId; + setOrderLoadedTableId(targetTableId); }) .catch(() => { - if (!cancelled) setCurrentOrder(null); + if ( + !cancelled && + !sendInFlightRef.current && + loadSeq === orderLoadSeqRef.current && + selectedTableIdRef.current === targetTableId + ) { + setCurrentOrder(null); + orderLoadedTableIdRef.current = ''; + setOrderLoadedTableId(''); + orderLookupFailedRef.current = true; + } }); return () => { cancelled = true; @@ -342,6 +446,7 @@ export default function ServerStandalonePage() { }, []); function searchCustomerByPhone(rawPhone: string) { + if (sendInFlightRef.current) return; clearTimeout(phoneDebounceRef.current); phoneAbortRef.current?.abort(); const digits = rawPhone.replace(/\D/g, ''); @@ -351,7 +456,7 @@ export default function ServerStandalonePage() { return; } phoneDebounceRef.current = setTimeout(async () => { - if (!api) return; + if (!api || sendInFlightRef.current) return; const controller = new AbortController(); phoneAbortRef.current = controller; try { @@ -361,6 +466,7 @@ export default function ServerStandalonePage() { params: { phone: lookupPhone }, signal: controller.signal, }); + if (controller.signal.aborted || sendInFlightRef.current) return; if (data.found && data.customer) { setCustomerMatch({ id: data.customer.id, name: data.customer.name }); setCustomerName(data.customer.name || ''); @@ -369,7 +475,7 @@ export default function ServerStandalonePage() { } setCustomerSearched(true); } catch { - if (controller.signal.aborted) return; + if (controller.signal.aborted || sendInFlightRef.current) return; setCustomerMatch(null); setCustomerSearched(true); } @@ -377,6 +483,7 @@ export default function ServerStandalonePage() { } function handleCustomerPhoneChange(value: string) { + if (sendInFlightRef.current) return; setCustomerPhone(value); setCustomerMatch(null); setCustomerSearched(false); @@ -384,6 +491,66 @@ export default function ServerStandalonePage() { searchCustomerByPhone(value); } + function handleCustomerNameChange(value: string) { + if (sendInFlightRef.current || customerMatch) return; + setCustomerName(value); + } + + function handleOrderNotesChange(value: string) { + if (sendInFlightRef.current) return; + setOrderNotes(value.slice(0, 200)); + } + + function selectTable(tableId: string) { + if (sendInFlightRef.current) return; + if (tableId === selectedTableIdRef.current) { + if (orderLookupFailedRef.current) { + loadOrder(tableId).catch(() => toast.error(t('couldNotLoadData'))); + } + return; + } + selectedTableIdRef.current = tableId; + orderLookupFailedRef.current = false; + orderLoadedTableIdRef.current = ''; + setOrderLoadedTableId(''); + orderLoadSeqRef.current += 1; + cancelPendingCustomerLookup(); + setCurrentOrder(null); + const pendingAttempt = + sendAttemptRef.current?.snapshot.tableId === tableId ? sendAttemptRef.current.snapshot : null; + setCustomerName(pendingAttempt?.customerName ?? ''); + setCustomerPhone(pendingAttempt?.customerPhone ?? ''); + setCustomerMatch(null); + setCustomerSearched(false); + setSelectedTableId(tableId); + } + + function handleRefresh() { + if (sendInFlightRef.current) return; + const tableId = selectedTableIdRef.current; + Promise.all([loadAll(), tableId ? loadOrder(tableId) : Promise.resolve()]).catch(() => + toast.error(t('refreshFailed')), + ); + } + + function openAddonModal( + product: Product, + quantity: number, + variant: ProductVariant | null = null, + ) { + if (sendInFlightRef.current) return; + addonModalSendSeqRef.current = sendSeqRef.current; + setAddonModalQuantity(quantity); + setAddonModalVariant(variant); + setAddonModalProduct(product); + } + + function openEditDraftModal(line: DraftLine) { + if (sendInFlightRef.current) return; + editingDraftSendSeqRef.current = sendSeqRef.current; + setEditingDraftLine(line); + } + async function handleLogin(event: FormEvent) { event.preventDefault(); if (!api) return; @@ -418,6 +585,7 @@ export default function ServerStandalonePage() { specialInstructions: string, variant: ProductVariant | null = null, ) { + if (sendInFlightRef.current) return; setDraft((lines) => { const lineId = generateCartItemId( product.id, @@ -452,6 +620,7 @@ export default function ServerStandalonePage() { specialInstructions: string, variant: ProductVariant | null = null, ) { + if (sendInFlightRef.current) return; setDraft((lines) => { const target = lines.find((line) => line.id === lineId); if (!target) return lines; @@ -501,10 +670,12 @@ export default function ServerStandalonePage() { } function removeDraftLine(lineId: string) { + if (sendInFlightRef.current) return; setDraft((lines) => lines.filter((line) => line.id !== lineId)); } function changeQty(lineId: string, delta: number) { + if (sendInFlightRef.current) return; setDraft((lines) => lines .map((line) => (line.id === lineId ? { ...line, quantity: line.quantity + delta } : line)) @@ -512,10 +683,13 @@ export default function ServerStandalonePage() { ); } - async function ensureCustomer(): Promise { + async function ensureCustomer( + submittedName: string, + submittedPhone: string, + ): Promise { if (!api) return null; - const name = customerName.trim(); - const rawPhone = customerPhone.trim(); + const name = submittedName.trim(); + const rawPhone = submittedPhone.trim(); if (!name && !rawPhone) return null; let normalizedPhone: string | undefined = undefined; if (rawPhone) { @@ -609,35 +783,63 @@ export default function ServerStandalonePage() { } async function sendDraft() { - if (!api || !selectedTableId || draft.length === 0 || sendInFlightRef.current) return; + if ( + !api || + !selectedTableId || + orderLoadedTableIdRef.current !== selectedTableId || + draft.length === 0 || + sendInFlightRef.current + ) { + return; + } + const snapshotTable = tables.find((table) => table.id === selectedTableId) || null; + const candidateSnapshot: SendSnapshot = { + tableId: selectedTableId, + tableName: snapshotTable ? snapshotTable.name || snapshotTable.number : undefined, + orderId: currentOrder?.id ?? null, + orderCustomer: currentOrder?.customer ?? null, + existingItemIds: new Set((currentOrder?.items || []).map((item) => item.id)), + draftLines: draft.map((line) => ({ + ...line, + addons: line.addons.map((addon) => ({ ...addon })), + })), + customerName: customerName.trim(), + customerPhone: customerPhone.trim(), + orderNotes, + }; // Nonce first via the LAN-safe helper: anything thrown after the // sending flag is set sticks the UI on Sending... forever. const signature = sendAttemptSignature( - selectedTableId, - draft, - customerName, - customerPhone, - orderNotes, + candidateSnapshot.tableId, + candidateSnapshot.draftLines, + candidateSnapshot.customerName, + candidateSnapshot.customerPhone, + candidateSnapshot.orderNotes, ); if (sendAttemptRef.current?.signature !== signature) { sendAttemptRef.current = { signature, nonce: createPaymentIdempotencyKey(), + snapshot: candidateSnapshot, }; } - const idempotencyKey = `server-app-${selectedTableId}-${sendAttemptRef.current.nonce}`; + const { nonce, snapshot } = sendAttemptRef.current; + const idempotencyKey = `server-app-${snapshot.tableId}-${nonce}`; + sendSeqRef.current += 1; + orderLoadSeqRef.current += 1; + cancelPendingCustomerLookup(); sendInFlightRef.current = true; setSending(true); try { - const customerId = await ensureCustomer(); - const items = draft.map((line) => cartItemToOrderItem(line)); - const specialInstructions = orderNotes.trim() || undefined; + const customerId = await ensureCustomer(snapshot.customerName, snapshot.customerPhone); + const items = snapshot.draftLines.map((line) => cartItemToOrderItem(line)); + const specialInstructions = snapshot.orderNotes.trim() || undefined; let orderId: number; let rawOrder: Record; let newItems: OrderItem[]; - if (currentOrder?.id) { + if (snapshot.orderId) { const { data } = await api.post( - `/api/orders/${currentOrder.id}/items`, + `/api/orders/${snapshot.orderId}/items`, { items, special_instructions: specialInstructions }, { headers: { 'Idempotency-Key': idempotencyKey }, @@ -646,13 +848,14 @@ export default function ServerStandalonePage() { orderId = data.order.id; rawOrder = data.order; // Print only what this call added — omitting items reprints every pending item on the order. - const existingIds = new Set((currentOrder.items || []).map((item) => item.id)); - newItems = (data.order.items || []).filter((item: OrderItem) => !existingIds.has(item.id)); + newItems = (data.order.items || []).filter( + (item: OrderItem) => !snapshot.existingItemIds.has(item.id), + ); } else { const { data } = await api.post( '/api/orders', { - table_id: selectedTableId, + table_id: snapshot.tableId, customer_id: customerId, type: 'dine_in', special_instructions: specialInstructions, @@ -664,20 +867,37 @@ export default function ServerStandalonePage() { rawOrder = data.order; newItems = data.order.items || []; } - sendAttemptRef.current = null; - setDraft([]); - setOrderNotes(''); + if (sendAttemptRef.current?.signature === signature) { + sendAttemptRef.current = null; + } + const submittedQtyById = new Map(snapshot.draftLines.map((line) => [line.id, line.quantity])); + setDraft((currentLines) => + currentLines.flatMap((line) => { + const ackedQty = submittedQtyById.get(line.id); + if (ackedQty === undefined) return [line]; + const remainingQuantity = line.quantity - ackedQty; + return remainingQuantity > 0 ? [{ ...line, quantity: remainingQuantity }] : []; + }), + ); + setOrderNotes((currentNotes) => (currentNotes === snapshot.orderNotes ? '' : currentNotes)); + if (selectedTableIdRef.current === snapshot.tableId) { + setCurrentOrder(rawOrder as unknown as Order); + orderLoadedTableIdRef.current = snapshot.tableId; + setOrderLoadedTableId(snapshot.tableId); + } setMobileCartOpen(false); - await Promise.all([loadAll(), loadOrder(selectedTableId)]); + await Promise.all([ + loadAll({ allowWhileSending: true }), + loadOrder(snapshot.tableId, { allowWhileSending: true }), + ]).catch(() => {}); toast.success(t('orderSent')); // rawOrder only has table_id/customer_id; the KOT/slip renderers need the nested table/customer for display. - const trimmedCustomerName = customerName.trim(); const enrichedOrder = { ...rawOrder, - table: activeTable ? { name: activeTable.name || activeTable.number } : undefined, + table: snapshot.tableName ? { name: snapshot.tableName } : undefined, customer: - currentOrder?.customer || - (trimmedCustomerName ? { name: trimmedCustomerName } : undefined), + snapshot.orderCustomer || + (snapshot.customerName ? { name: snapshot.customerName } : undefined), }; // Backgrounded so an unreachable printer can't block Send; sequenced (not concurrent) // since KOT and bill can share a default printer and race its socket connection. @@ -788,14 +1008,18 @@ export default function ServerStandalonePage() {
handleCustomerPhoneChange(event.target.value)} dir="ltr" placeholder={t('phonePlaceholder')} - className="h-10 rounded-lg border border-border bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-brand focus:outline-none" + className="h-10 rounded-lg border border-border bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-brand focus:outline-none disabled:opacity-50" /> setCustomerName(event.target.value)} + disabled={sending} + onChange={ + customerMatch ? undefined : (event) => handleCustomerNameChange(event.target.value) + } readOnly={!!customerMatch} placeholder={ customerSearched @@ -804,7 +1028,7 @@ export default function ServerStandalonePage() { : t('customerNamePlaceholder') : t('customerNamePlaceholder') } - className={`h-10 rounded-lg border px-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-brand focus:outline-none ${customerMatch ? 'border-border bg-muted' : 'border-border bg-background'}`} + className={`h-10 rounded-lg border px-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-brand focus:outline-none disabled:opacity-50 ${customerMatch ? 'border-border bg-muted' : 'border-border bg-background'}`} />
{customerSearched && ( @@ -868,9 +1092,10 @@ export default function ServerStandalonePage() { )} @@ -878,8 +1103,9 @@ export default function ServerStandalonePage() {
@@ -887,15 +1113,17 @@ export default function ServerStandalonePage() { {line.quantity}