From 32fa82f69dea3dd15f4c043eda8c41c89d812a8c Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 6 Oct 2026 14:50:24 -0400 Subject: [PATCH 01/20] fix(shifts): return null from cash-sessions/current when no shift is open The renderer logged a failed request on every POS mount because the endpoint answered 404 for the normal no-open-shift state, contradicting the documented contract in docs/reference/api.md. --- frontend/src/hooks/useCashSession.ts | 8 +++----- main/routes/cash-sessions.ts | 4 +++- tests/cash-sessions.test.ts | 3 ++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/frontend/src/hooks/useCashSession.ts b/frontend/src/hooks/useCashSession.ts index d7653c920..4096dd012 100644 --- a/frontend/src/hooks/useCashSession.ts +++ b/frontend/src/hooks/useCashSession.ts @@ -12,7 +12,8 @@ import { printerService } from '@/lib/printer/PrinterService'; import { displayAmountToCents } from '@/lib/money'; /** Open cash session row plus the live expected figure, from - * GET /api/cash-sessions/current. Money fields are integer cents. */ + * GET /api/cash-sessions/current. The endpoint answers 200 with `null` when + * no shift is open. Money fields are integer cents. */ export interface CashSession { id: number; opened_by: string; @@ -69,11 +70,8 @@ export function useCashSession() { const fetchState = async (): Promise<{ data: CashSession | null; error: string | null }> => { try { const res = await api.get('/cash-sessions/current'); - return { data: res.data as CashSession, error: null }; + return { data: (res.data ?? null) as CashSession | null, error: null }; } catch (err: unknown) { - if (axios.isAxiosError(err) && err.response?.status === 404) { - return { data: null, error: null }; - } return { data: null, error: axios.isAxiosError(err) ? err.response?.data?.error || err.message : 'Failed to load shift', diff --git a/main/routes/cash-sessions.ts b/main/routes/cash-sessions.ts index c956bc0da..eb7ce4a70 100644 --- a/main/routes/cash-sessions.ts +++ b/main/routes/cash-sessions.ts @@ -226,7 +226,9 @@ router.get('/current', requirePermission('cash.shifts.view'), (req: Request, res try { const db = getDatabase(); const session = getOpenSession(db); - if (!session) return res.status(404).json({ error: 'No open shift' }); + // Documented contract (docs/reference/api.md): 200 with `null` when no + // shift is open. A 404 here logged a failed request on every POS mount. + if (!session) return res.json(null); // Single timestamp for the whole snapshot: now() has second granularity, // so two evaluations could straddle a second boundary and disagree. // Lightweight path: the snapshot only needs expected cash, none of the diff --git a/tests/cash-sessions.test.ts b/tests/cash-sessions.test.ts index 25f372e66..67a164994 100644 --- a/tests/cash-sessions.test.ts +++ b/tests/cash-sessions.test.ts @@ -180,7 +180,8 @@ async function main() { assert(typeof closure?.z_number === 'number', 'session closure carries a Z number'); const after = await request(app).get('/api/cash-sessions/current') .set('Authorization', `Bearer ${cashierToken}`); - assert(after.status === 404, 'no current session after close (404)'); + assert(after.status === 200, 'no current session after close (200)'); + assert(after.body === null, 'current returns null when no shift is open'); } // ── Section 3: enforcement (default off) ────────────────────────────── From 6786c6897b59391e9f5371b378ef9e9ad8d52038 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 6 Oct 2026 14:50:25 -0400 Subject: [PATCH 02/20] fix(kds): keep client sockets closed while the KDS feature is off A disabled KDS refuses the /kds upgrade with 404, so the orders page reconnect loop and the KDS page logged repeated handshake failures. Both now wait for kds_enabled before connecting; REST polling remains the fallback. --- frontend/src/app/(dashboard)/kds/page.tsx | 6 +- frontend/src/app/(dashboard)/orders/page.tsx | 104 +++++++++++-------- frontend/src/hooks/useKdsConnection.ts | 16 ++- 3 files changed, 74 insertions(+), 52 deletions(-) diff --git a/frontend/src/app/(dashboard)/kds/page.tsx b/frontend/src/app/(dashboard)/kds/page.tsx index 39278bd53..1f57a7b5d 100644 --- a/frontend/src/app/(dashboard)/kds/page.tsx +++ b/frontend/src/app/(dashboard)/kds/page.tsx @@ -46,9 +46,11 @@ function useDashboardKdsDefault(): KdsViewMode | null { export default function KdsPage() { useSyncServerLanguage(); const t = useTranslations('kds'); - const conn = useKdsConnection({ api }); - const kdsDefaultView = useDashboardKdsDefault(); const kdsEnabled = useKdsEnabledCheck(); + // Stay idle until the feature is confirmed on: a disabled KDS refuses the + // /kds upgrade with 404, which only logs handshake failures. + const conn = useKdsConnection({ api, enabled: kdsEnabled === true }); + const kdsDefaultView = useDashboardKdsDefault(); if (kdsEnabled === null) { return ( diff --git a/frontend/src/app/(dashboard)/orders/page.tsx b/frontend/src/app/(dashboard)/orders/page.tsx index d6ae7cc83..22ed5ce7d 100644 --- a/frontend/src/app/(dashboard)/orders/page.tsx +++ b/frontend/src/app/(dashboard)/orders/page.tsx @@ -132,7 +132,10 @@ export default function OrdersPage() { const [paymentBill, setPaymentBill] = useState(null); const [refundModal, setRefundModal] = useState<{ order: Order; bills: Bill[] } | null>(null); const [tables, setTables] = useState([]); - const [kdsEnabled, setKdsEnabled] = useState(true); + // null until /settings/kds_enabled resolves; the WebSocket stays closed + // until the feature is confirmed on, because a disabled KDS refuses the + // upgrade with 404. + const [kdsEnabled, setKdsEnabled] = useState(null); const { confirm, ConfirmDialog } = useConfirm(); const isWhatsAppReady = useWhatsAppReady(); @@ -412,52 +415,17 @@ export default function OrdersPage() { .catch(() => setKdsEnabled(true)); }, []); + // Live KDS push while the feature is on. The 10-second polling interval in + // the effect below is the fallback when the socket is unavailable. useEffect(() => { - const interval = setInterval(() => setNow(Date.now()), 30000); - return () => clearInterval(interval); - }, []); - - useEffect(() => { - const initPage = async () => { - let isTablesRequired = true; - try { - const { data } = await api.get('/settings/business'); - isTablesRequired = typeof data.tables_required === 'boolean' ? data.tables_required : true; - setTablesRequired(isTablesRequired); - } catch { - // Ignore and fallback to default (true) - } - - fetchOrders(); - - if (isTablesRequired) { - heldOrdersStore.fetchHeldOrders(); - api.get('/tables') - .then((res) => setTables(res.data.tables || [])) - .catch(() => {}); - } - - api.get('/settings/discount') - .then((res) => { - setDiscountMode(normalizeDiscountMode(res.data.discount_mode)); - setDiscountRequiresApproval(!!res.data.discount_requires_approval); - }) - .catch(() => {}); - }; - - initPage(); - - // 10-second backup polling interval (WebSocket handles real-time updates) - const interval = setInterval(() => fetchOrders(undefined, { rateLimitedRefresh: true }), 10000); - - // Live WebSocket connection to trigger immediate updates + if (kdsEnabled !== true) return; let ws: globalThis.WebSocket | null = null; let reconnectTimeout: NodeJS.Timeout | null = null; const connectWS = () => { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/kds`; - + try { ws = new WebSocket(wsUrl); @@ -494,17 +462,61 @@ export default function OrdersPage() { connectWS(); return () => { - clearInterval(interval); if (reconnectTimeout) clearTimeout(reconnectTimeout); - if (ordersRefreshTimerRef.current !== null) clearTimeout(ordersRefreshTimerRef.current); - ordersRefreshTimerRef.current = null; - ordersRefreshPendingRef.current = false; - ordersRefreshLoadedPagesPendingRef.current = false; if (ws) { ws.onclose = null; ws.close(); } }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [kdsEnabled]); + + useEffect(() => { + const interval = setInterval(() => setNow(Date.now()), 30000); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + const initPage = async () => { + let isTablesRequired = true; + try { + const { data } = await api.get('/settings/business'); + isTablesRequired = typeof data.tables_required === 'boolean' ? data.tables_required : true; + setTablesRequired(isTablesRequired); + } catch { + // Ignore and fallback to default (true) + } + + fetchOrders(); + + if (isTablesRequired) { + heldOrdersStore.fetchHeldOrders(); + api.get('/tables') + .then((res) => setTables(res.data.tables || [])) + .catch(() => {}); + } + + api.get('/settings/discount') + .then((res) => { + setDiscountMode(normalizeDiscountMode(res.data.discount_mode)); + setDiscountRequiresApproval(!!res.data.discount_requires_approval); + }) + .catch(() => {}); + }; + + initPage(); + + // 10-second backup polling interval (WebSocket handles real-time updates) + const interval = setInterval(() => fetchOrders(undefined, { rateLimitedRefresh: true }), 10000); + + return () => { + clearInterval(interval); + if (ordersRefreshTimerRef.current !== null) clearTimeout(ordersRefreshTimerRef.current); + ordersRefreshTimerRef.current = null; + ordersRefreshPendingRef.current = false; + ordersRefreshLoadedPagesPendingRef.current = false; + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [setTablesRequired]); @@ -583,7 +595,7 @@ export default function OrdersPage() { const isOrderActive = (order: Order) => { if (order.status === 'cancelled') return false; if (order.status === 'completed') { - return kdsEnabled && (order.items || []).some((item) => !['served', 'cancelled'].includes(item.status)); + return kdsEnabled !== false && (order.items || []).some((item) => !['served', 'cancelled'].includes(item.status)); } return true; }; diff --git a/frontend/src/hooks/useKdsConnection.ts b/frontend/src/hooks/useKdsConnection.ts index ab0c32745..66c5f1df5 100644 --- a/frontend/src/hooks/useKdsConnection.ts +++ b/frontend/src/hooks/useKdsConnection.ts @@ -149,6 +149,10 @@ export interface UseKdsConnectionOptions { api: AxiosInstance; /** Overrides endpoint paths for standalone KDS device page (:3002). */ endpoints?: UseKdsConnectionEndpoints; + /** When false, session restore, WebSocket, and REST polling stay idle. The + * host page owns the disabled state; the server refuses /kds upgrades with + * 404 while the feature is off. Defaults to true. */ + enabled?: boolean; } export interface UseKdsConnectionResult { @@ -192,7 +196,7 @@ function markKdsAuthBlocked(): void { } export function useKdsConnection(options: UseKdsConnectionOptions): UseKdsConnectionResult { - const { api, endpoints } = options; + const { api, endpoints, enabled = true } = options; const loginPath = endpoints?.login ?? LOGIN_ENDPOINT; const mePath = endpoints?.me ?? ME_ENDPOINT; const logoutPath = endpoints?.logout ?? '/auth/logout'; @@ -647,6 +651,10 @@ export function useKdsConnection(options: UseKdsConnectionOptions): UseKdsConnec useEffect(() => { if (typeof window === 'undefined') return; + if (!enabled) { + const disabledTimer = window.setTimeout(() => setLoading(false), 0); + return () => window.clearTimeout(disabledTimer); + } const savedToken = window.localStorage.getItem('token'); if (!savedToken || isKdsAuthBlocked()) { const resetTimer = window.setTimeout(() => setLoading(false), 0); @@ -713,14 +721,14 @@ export function useKdsConnection(options: UseKdsConnectionOptions): UseKdsConnec } stopRestPolling(); }; - }, [api, mePath, tryWebSocket, stopRestPolling, t]); + }, [api, mePath, tryWebSocket, stopRestPolling, t, enabled]); useEffect(() => { - if (connectionMode === 'rest' && user) { + if (enabled && connectionMode === 'rest' && user) { startRestPolling(); } return () => stopRestPolling(); - }, [connectionMode, user, startRestPolling, stopRestPolling]); + }, [connectionMode, user, startRestPolling, stopRestPolling, enabled]); return { user, From c972a9001b6a662852e765b3a19f7cc0a4b2e555 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 6 Oct 2026 14:50:25 -0400 Subject: [PATCH 03/20] test(e2e): run native Electron suites with hidden windows by default FLO_E2E_HIDDEN_WINDOW keeps the real app's window off the developer's desktop while leaving the renderer unthrottled; FLO_E2E_SHOW_WINDOW=1 opts back into visible-window coverage for native focus and window state assertions. --- frontend/e2e/desktop/native-harness.ts | 50 ++++++++++++------- .../e2e/desktop/title-bar.electron.spec.ts | 8 ++- main/index.ts | 8 +++ 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/frontend/e2e/desktop/native-harness.ts b/frontend/e2e/desktop/native-harness.ts index efe4bb94b..1f1f9cb8e 100644 --- a/frontend/e2e/desktop/native-harness.ts +++ b/frontend/e2e/desktop/native-harness.ts @@ -27,6 +27,9 @@ export interface NativeElectronHarness { page: Page; ports: NativeServicePorts; profileDir: string; + /** True when the app runs with its window hidden (default). Native window + * state and renderer focus cannot be exercised in this mode. */ + hiddenWindow: boolean; setActivePage: (page: Page) => void; authenticateDashboard: () => Promise; simulateTerminalRuntimeLoss: () => Promise; @@ -326,6 +329,10 @@ async function boundedRelaunchClose( } export async function createNativeElectronHarness(): Promise { + // Headless by default so a local run never pops windows over the developer's + // desktop. FLO_E2E_SHOW_WINDOW=1 restores the visible-window variant for + // native focus/window-state coverage. + const hiddenWindow = process.env.FLO_E2E_SHOW_WINDOW !== '1'; const profileDir = mkdtempSync(path.join(tmpdir(), 'flo-native-e2e-')); const ports = await findServicePorts(); const pidFile = path.join(profileDir, 'electron.pid'); @@ -343,6 +350,7 @@ export async function createNativeElectronHarness(): Promise url.pathname.replace(/\/+$/, '') === '/pos', { timeout: 30_000 }); } - const activeOrigin = new URL(nextPage.url()).origin; - await nextPage.bringToFront().catch(() => {}); - await nextApp.evaluate(({ app: electronApp, BrowserWindow }, origin) => { - electronApp.focus({ steal: true }); - const target = BrowserWindow.getAllWindows().find((window: { webContents: { getURL: () => string }; isDestroyed: () => boolean }) => { - try { return new URL(window.webContents.getURL()).origin === origin; } catch { return false; } - }); - if (!target || target.isDestroyed()) return; - target.show(); - target.focus(); - target.webContents.focus(); - // Xvfb runs without a window manager in CI, so briefly toggling - // always-on-top is the reliable way to deliver native focus there. - if (process.platform === 'linux') { - target.setAlwaysOnTop(true); - target.setAlwaysOnTop(false); + if (!hiddenWindow) { + const activeOrigin = new URL(nextPage.url()).origin; + await nextPage.bringToFront().catch(() => {}); + await nextApp.evaluate(({ app: electronApp, BrowserWindow }, origin) => { electronApp.focus({ steal: true }); - } - }, activeOrigin); - await nextPage.waitForFunction(() => document.hasFocus() && document.documentElement.dataset.floWindowFocused === 'true'); + const target = BrowserWindow.getAllWindows().find((window: { webContents: { getURL: () => string }; isDestroyed: () => boolean }) => { + try { return new URL(window.webContents.getURL()).origin === origin; } catch { return false; } + }); + if (!target || target.isDestroyed()) return; + target.show(); + target.focus(); + target.webContents.focus(); + // Xvfb runs without a window manager in CI, so briefly toggling + // always-on-top is the reliable way to deliver native focus there. + if (process.platform === 'linux') { + target.setAlwaysOnTop(true); + target.setAlwaysOnTop(false); + electronApp.focus({ steal: true }); + } + }, activeOrigin); + await nextPage.waitForFunction(() => document.hasFocus() && document.documentElement.dataset.floWindowFocused === 'true'); + } await nextPage.waitForFunction(() => document.documentElement.dataset.floDesktopTitlebar === 'true'); }; @@ -436,6 +446,7 @@ export async function createNativeElectronHarness(): Promise { activePage = page; }, authenticateDashboard: buildAuthenticate(() => activePage, app), simulateTerminalRuntimeLoss: async () => { @@ -500,6 +511,7 @@ export async function createNativeElectronHarness(): Promise { newActivePage = page; }, authenticateDashboard: buildAuthenticate(() => newActivePage, newApp), simulateTerminalRuntimeLoss: async () => { diff --git a/frontend/e2e/desktop/title-bar.electron.spec.ts b/frontend/e2e/desktop/title-bar.electron.spec.ts index db09b0dd4..2a648c771 100644 --- a/frontend/e2e/desktop/title-bar.electron.spec.ts +++ b/frontend/e2e/desktop/title-bar.electron.spec.ts @@ -61,7 +61,9 @@ test('real preload, renderer, and main boundaries reach an authenticated dashboa expect(runtime.titleBarMode).toBe('native-overlay'); expect(runtime.titleBarEpoch).toBeGreaterThan(0); expect(runtime.titleBarDocumentNonce).toMatch(/^[0-9a-f-]{36}$/i); - expect(runtime.focusedAttribute).toBe('true'); + if (!harness.hiddenWindow) { + expect(runtime.focusedAttribute).toBe('true'); + } expect(runtime.desktopAttribute).toBe('true'); expect(runtime.appInfo).toMatchObject({ name: 'flo-desktop', platform: process.platform }); expect(runtime.updateStatus.status).toBeTruthy(); @@ -71,6 +73,9 @@ test('real preload, renderer, and main boundaries reach an authenticated dashboa test('POS topbar fullscreen toggle stays synchronized with native window state', async () => { test.skip(process.platform === 'linux', 'Linux CI uses Xvfb without a window manager, so native maximize state is not observable'); + // Maximizing a hidden window surfaces it, so this native-state test only + // runs in the opt-in visible-window mode. + test.skip(harness.hiddenWindow, 'Run with FLO_E2E_SHOW_WINDOW=1 for native maximize coverage'); await harness.authenticateDashboard(); const readNativeWindowState = async () => harness.app.evaluate(({ BrowserWindow }) => { @@ -141,6 +146,7 @@ test('POS topbar fullscreen toggle stays synchronized with native window state', test('native window lifecycle is observable through the Electron boundary', async () => { test.skip(!['darwin', 'win32', 'linux'].includes(process.platform), 'FloCafe native window lifecycle is unsupported on this platform'); test.skip(process.platform === 'linux', 'Linux CI uses Xvfb without a window manager, so native minimize/restore is not observable'); + test.skip(harness.hiddenWindow, 'Run with FLO_E2E_SHOW_WINDOW=1 for native minimize/restore coverage'); await harness.app.evaluate(({ app, BrowserWindow }) => { app.focus({ steal: true }); const window = BrowserWindow.getAllWindows()[0]; diff --git a/main/index.ts b/main/index.ts index 86a0f64f4..641683d8f 100644 --- a/main/index.ts +++ b/main/index.ts @@ -390,6 +390,9 @@ function showMainWindow(expectedWindow?: BrowserWindow): boolean { if ( (!isWindowRendererReady() && !isRendererReadinessFailSafeShown()) ) return false; + // Native E2E drives the real app without popping a window on the developer's + // desktop; the renderer keeps running unthrottled (see createWindow). + if (process.env.FLO_E2E_HIDDEN_WINDOW === '1') return true; if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); mainWindow.focus(); @@ -644,6 +647,11 @@ function createWindow(): void { process.platform, initialIsDark, resolvedTitleBarMode, + // Hidden windows are throttled by Chromium; the native E2E harness still + // needs live timers and WebSocket traffic from a window it never shows. + process.env.FLO_E2E_HIDDEN_WINDOW === '1' + ? { webPreferences: { backgroundThrottling: false } } + : undefined, ); mainWindow = createdWindow; From af952b9d0690e3a93e48142999d34017f714ffc9 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 6 Oct 2026 15:52:35 -0400 Subject: [PATCH 04/20] feat(pos): widen the product dialog, unclutter the top bar, and add print destinations The Add/Edit Product dialog was a single narrow column, so the variant editor's table (min-w-[640px]) overflowed a max-w-2xl popup. It is now a two-column form in a max-w-5xl dialog, with the variant editor as a full-width framed section under its own short heading and Save/Cancel pinned to a footer outside the scrolling body. The POS top bar put the customer name/phone fields on the same row as every action button, so narrowing the window crushed and then overlapped them. The actions now form one right-aligned group that takes its own full-width row until xl, where it rejoins the search field. The Print Menu dialog pointed A4/Letter output at whichever printer happened to be the system default, which cannot produce a sheet and never let a merchant choose among configured printers or hand the menu to someone without printing. The dialog now asks for the destination first: a receipt printer (with a picker over the configured printers and the 58/80mm roll width), paper through the system dialog, or a PDF saved through the desktop bridge with a browser print fallback. /print-menu accepts the chosen printerId and refuses one that matches no printer instead of silently printing elsewhere. Filter rows put the label at the start and its check at the end. --- docs/reference/api.md | 2 +- .../src/app/(dashboard)/products/page.tsx | 20 +- frontend/src/components/pos/PosTopbar.tsx | 112 ++++---- .../components/products/PrintMenuModal.tsx | 267 ++++++++++++++---- frontend/src/lib/i18n/messages/ar.json | 5 + frontend/src/lib/i18n/messages/bn.json | 5 + frontend/src/lib/i18n/messages/de.json | 5 + frontend/src/lib/i18n/messages/en.json | 5 + frontend/src/lib/i18n/messages/es.json | 5 + frontend/src/lib/i18n/messages/fa.json | 5 + frontend/src/lib/i18n/messages/fil.json | 5 + frontend/src/lib/i18n/messages/fr.json | 5 + frontend/src/lib/i18n/messages/hi.json | 5 + frontend/src/lib/i18n/messages/id.json | 5 + frontend/src/lib/i18n/messages/it.json | 5 + frontend/src/lib/i18n/messages/ja.json | 5 + frontend/src/lib/i18n/messages/ko.json | 5 + frontend/src/lib/i18n/messages/ne.json | 5 + frontend/src/lib/i18n/messages/nl.json | 5 + frontend/src/lib/i18n/messages/pt.json | 5 + frontend/src/lib/i18n/messages/ru.json | 5 + frontend/src/lib/i18n/messages/sq.json | 5 + frontend/src/lib/i18n/messages/th.json | 5 + frontend/src/lib/i18n/messages/tr.json | 5 + frontend/src/lib/i18n/messages/ur.json | 5 + frontend/src/lib/i18n/messages/vi.json | 5 + frontend/src/lib/i18n/messages/zh-tw.json | 5 + frontend/src/lib/i18n/messages/zh.json | 5 + frontend/src/types/electron.d.ts | 7 + main/ipc.ts | 45 +++ main/preload.ts | 1 + main/routes/printers.ts | 48 ++-- 32 files changed, 489 insertions(+), 133 deletions(-) diff --git a/docs/reference/api.md b/docs/reference/api.md index eadbb4500..dc4c8c8fb 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -424,7 +424,7 @@ Router: `main/routes/printers.ts`. Full path: `/api/printers`. | `DELETE` | `/:id` | `ROLE_ACCESS.ownerManager` | path: `id` | - | | `POST` | `/:id/set-default` | `ROLE_ACCESS.ownerManager` | path: `id` | - | | `POST` | `/:id/test` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `rasterProbe` | - | -| `POST` | `/print-menu` | `catalog.view` and `printing.execute` | body: boolean `includeInactive`, `includeOutOfStock`, `includeHidden`, `includeDescriptions`, `includeModifiers`; optional `paperWidth` (`58` or `80`) | `200` physical-print success, or `webusb: true` with byte array for browser delivery; `400` invalid input or `printer_not_configured`, `409` missing regional configuration, `422` empty selection, `502` print failure. Uses the configured default printer across connection types. | +| `POST` | `/print-menu` | `catalog.view` and `printing.execute` | body: boolean `includeInactive`, `includeOutOfStock`, `includeHidden`, `includeDescriptions`, `includeModifiers`; optional `paperWidth` (`58` or `80`) and `printerId` | `200` physical-print success, or `webusb: true` with byte array for browser delivery; `400` invalid input or `printer_not_configured`, `404` unknown `printerId` (`printer_not_found`), `409` missing regional configuration, `422` empty selection, `502` print failure. Prints to `printerId` when given, otherwise to the configured default, across connection types. | | `POST` | `/print-bill` | `ROLE_ACCESS.sales` | body: `billId`, `orderId`, `isReprint`, `preview`, `useUnicode`, `arabicShaping` | Body `billId` or `orderId`, plus `isReprint`, `preview`, `useUnicode`, `arabicShaping`. `preview` returns the rendered payload without sending it to the device. | | `POST` | `/print-kot` | `ROLE_ACCESS.sales` | body: `orderId`, `stationName`, `items`, `useUnicode`, `arabicShaping` | Body `orderId`, optional `stationName` and `items`, plus `useUnicode` and `arabicShaping`. | | `GET` | `/delivery-slip-payment/:orderId` | `printing.execute` | path: `orderId` | `200` with optional `payment` fields `status`, `amount`, `amountDue`, `formattedAmount`, `formattedAmountDue`, `method`, `methods`, and `expectedMethod` (the order's expected collection method, absent when unknown); `404` if the order does not exist. Uses the order total when no bill exists, otherwise the latest bill or all same-order bills in that bill's split group and their stored balances. | diff --git a/frontend/src/app/(dashboard)/products/page.tsx b/frontend/src/app/(dashboard)/products/page.tsx index 4b5a89a6a..a2423590b 100644 --- a/frontend/src/app/(dashboard)/products/page.tsx +++ b/frontend/src/app/(dashboard)/products/page.tsx @@ -868,13 +868,15 @@ export default function ProductsPage() { {/* Product Form Modal */} {showForm && (
-
+

{editingProduct ? t('editProductTitle') : t('addProductTitle')}

-
+ {/* Wide two-column layout: the full-width variant editor and the + grid rows keep every control inside the popup. */} +
setForm({ ...form, name: e.target.value })} @@ -1021,12 +1023,13 @@ export default function ProductsPage() { {t('variantToggle')} {form.has_variants && ( -
-
+
+
+ {t('variantsSection')}
- +
@@ -1282,10 +1285,13 @@ export default function ProductsPage() { )} - + - diff --git a/frontend/src/components/pos/PosTopbar.tsx b/frontend/src/components/pos/PosTopbar.tsx index 5ec37bb3e..655e3a11a 100644 --- a/frontend/src/components/pos/PosTopbar.tsx +++ b/frontend/src/components/pos/PosTopbar.tsx @@ -34,74 +34,78 @@ export default function PosTopbar({ tables, onShowTablePicker, onShowCashMovemen const showTableBtn = isRestaurant && cart.orderType === 'dine_in' && tablesRequired; return ( -
+ // Two rows below xl so the action buttons never crowd the customer + // fields: lookup on the first row, actions right-aligned on the second. +
- {/* Select Table — between customer search and printer */} - {showTableBtn && ( +
+ {/* Select Table — between customer search and printer */} + {showTableBtn && ( + + )} + - )} - + {canUseShift && ( + + )} - {canUseShift && ( +
+ +
+ - )} - -
-
- -
); } diff --git a/frontend/src/components/products/PrintMenuModal.tsx b/frontend/src/components/products/PrintMenuModal.tsx index 401336a07..382fdb7f9 100644 --- a/frontend/src/components/products/PrintMenuModal.tsx +++ b/frontend/src/components/products/PrintMenuModal.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Check, LoaderCircle, Printer } from 'lucide-react'; +import { Check, FileDown, FileText, LoaderCircle, Printer, Receipt } from 'lucide-react'; import { useTranslations } from 'use-intl'; import toast from 'react-hot-toast'; import api from '@/lib/api'; @@ -15,7 +15,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { useAuthStore } from '@/store/auth'; -import { usePrinterStore } from '@/hooks/usePrinter'; +import { usePrinterStore, type HardwarePrinter } from '@/hooks/usePrinter'; import { printerService } from '@/lib/printer/PrinterService'; import { useFormatCurrency } from '@/hooks/useFormatCurrency'; import { formatDateForTenant } from '@/lib/countries'; @@ -35,6 +35,15 @@ interface PrintFilters { includeModifiers: boolean; } +/** Where the rendered menu goes: thermal roll, system print dialog, or a PDF file. */ +type PrintDestination = 'receipt' | 'paper' | 'pdf'; + +const DESTINATIONS: Array<{ id: PrintDestination; labelKey: 'printToReceipt' | 'printToPaper' | 'printToPdf'; Icon: typeof Printer }> = [ + { id: 'receipt', labelKey: 'printToReceipt', Icon: Receipt }, + { id: 'paper', labelKey: 'printToPaper', Icon: FileText }, + { id: 'pdf', labelKey: 'printToPdf', Icon: FileDown }, +]; + function isOutOfStock(product: Product): boolean { return product.track_inventory && Number(product.stock_quantity) <= 0; } @@ -44,23 +53,47 @@ export default function PrintMenuModal({ open, onOpenChange }: Props) { const tCommon = useTranslations('common'); const tPrint = useTranslations('print.menu'); const tSettings = useTranslations('settings'); + const tPos = useTranslations('pos'); const tenant = useAuthStore((state) => state.currentTenant); const formatCurrency = useFormatCurrency(); - const hardwarePrinter = usePrinterStore((state) => state.hardwarePrinter); - const webusbPrinter = usePrinterStore((state) => state.webusbPrinter); const refreshHardwarePrinter = usePrinterStore((state) => state.refreshHardwarePrinter); const [includeInactive, setIncludeInactive] = useState(false); const [includeOutOfStock, setIncludeOutOfStock] = useState(false); const [includeHidden, setIncludeHidden] = useState(false); const [includeDescriptions, setIncludeDescriptions] = useState(false); const [includeModifiers, setIncludeModifiers] = useState(false); + const [destination, setDestination] = useState('paper'); const [pageSize, setPageSize] = useState<'A4' | 'Letter'>('A4'); + const [paperWidth, setPaperWidth] = useState<58 | 80>(58); + const [printers, setPrinters] = useState([]); + const [selectedPrinterId, setSelectedPrinterId] = useState(''); const [printing, setPrinting] = useState(false); - const printer = hardwarePrinter ?? webusbPrinter; + // Loads the configured printers when the dialog opens; the request is + // inlined so state only settles in async continuations. useEffect(() => { if (!open) return; + let cancelled = false; void refreshHardwarePrinter(); + api.get('/printers') + .then((res) => { + if (cancelled) return; + const list = (res.data?.printers || []) as HardwarePrinter[]; + setPrinters(list); + setSelectedPrinterId((current) => { + if (current && list.some((printer) => printer.id === current)) return current; + const preferred = list.find((printer) => printer.is_default === 1) + ?? list.find((printer) => printer.connection_type !== 'webusb') + ?? list[0]; + return preferred ? preferred.id : ''; + }); + }) + .catch(() => { + if (cancelled) return; + setPrinters([]); + setSelectedPrinterId(''); + }); + return () => { cancelled = true; }; }, [open, refreshHardwarePrinter]); const setDialogOpen = (nextOpen: boolean) => { @@ -76,7 +109,9 @@ export default function PrintMenuModal({ open, onOpenChange }: Props) { const filters: PrintFilters = { includeInactive, includeOutOfStock, includeHidden, includeDescriptions, includeModifiers }; - const printBrowserFallback = async (selectedFilters: PrintFilters, targetWindow?: Window | null) => { + /** Renders the selected catalog into the standalone print HTML both paper + * printing and PDF export share. */ + const buildMenuHtml = async (selectedFilters: PrintFilters): Promise => { const [productResponse, categoryResponse] = await Promise.all([ api.get('/products'), api.get('/categories'), @@ -112,7 +147,7 @@ export default function PrintMenuModal({ open, onOpenChange }: Props) { const itemCount = sections.reduce((total, section) => total + section.products.length, 0); if (itemCount === 0) { toast.error(t('noProductsToPrint')); - return false; + return null; } const printedAt = formatDateForTenant( @@ -126,7 +161,7 @@ export default function PrintMenuModal({ open, onOpenChange }: Props) { }, { dateStyle: 'medium', timeStyle: 'short' }, ); - const html = buildMenuWebPrintHtml({ + return buildMenuWebPrintHtml({ businessName: tenant?.business_name || 'Store', printedAt, sections, @@ -135,30 +170,85 @@ export default function PrintMenuModal({ open, onOpenChange }: Props) { totalItemsLabel: tPrint('totalItems'), pageSize, }); + }; + + /** System print dialog: the browser path used by paper output and by the + * browser fallback when no thermal transport is available. */ + const printOnPaper = async (selectedFilters: PrintFilters, targetWindow?: Window | null): Promise => { + const html = await buildMenuHtml(selectedFilters); + if (html === null) return false; printMenuInBrowser(html, targetWindow); toast.success(t('menuPrintedSuccess')); return true; }; + /** PDF export. Electron renders the HTML offscreen and asks where to save; + * a plain browser falls back to the print dialog's "Save as PDF". */ + const savePdf = async (selectedFilters: PrintFilters): Promise => { + const html = await buildMenuHtml(selectedFilters); + if (html === null) return false; + const saveHtmlAsPdf = window.electronAPI?.saveHtmlAsPdf; + if (!saveHtmlAsPdf) { + printMenuInBrowser(html); + toast.success(tCommon('done')); + return true; + } + const result = await saveHtmlAsPdf({ + html, + defaultFileName: `${tenant?.business_name || 'menu'}-menu.pdf`, + pageSize, + }); + if (result?.canceled) return false; + if (!result?.success) throw new Error(result?.error || t('menuPrintFailed')); + toast.success(tCommon('done')); + return true; + }; + + /** Sends ESC/POS to the chosen receipt printer; WebUSB printers receive the + * encoded bytes over the browser transport instead. */ + const printToReceipt = async (selectedFilters: PrintFilters, targetWindow?: Window | null): Promise => { + const response = await api.post('/printers/print-menu', { + ...selectedFilters, + ...(selectedPrinterId ? { printerId: selectedPrinterId } : {}), + paperWidth, + }); + if (response.data.webusb === true) { + const bytes = response.data.bytes; + if (!Array.isArray(bytes) || !bytes.every((byte: unknown) => Number.isInteger(byte) && Number(byte) >= 0 && Number(byte) <= 255)) { + throw new Error('Invalid printer data'); + } + if (!printerService.isConnected) await printerService.tryReconnect(); + if (!printerService.isConnected) throw new Error('WebUSB printer is not connected'); + await printerService.print(new Uint8Array(bytes)); + } + if (targetWindow && !targetWindow.closed) targetWindow.close(); + toast.success(t('menuPrintedSuccess')); + return true; + }; + const handlePrint = async () => { if (printing) return; setPrinting(true); - // Preserve the user gesture for browsers that block asynchronous popups. - const reservedWindow = reservePrintGesture(); - try { - const response = await api.post('/printers/print-menu', filters); - if (response.data.webusb === true) { - const bytes = response.data.bytes; - if (!Array.isArray(bytes) || !bytes.every((byte: unknown) => Number.isInteger(byte) && Number(byte) >= 0 && Number(byte) <= 255)) { - throw new Error('Invalid printer data'); - } - if (!printerService.isConnected) await printerService.tryReconnect(); - if (!printerService.isConnected) throw new Error('WebUSB printer is not connected'); - await printerService.print(new Uint8Array(bytes)); + if (destination === 'paper' || destination === 'pdf') { + try { + const printed = destination === 'paper' + ? await printOnPaper(filters) + : await savePdf(filters); + if (printed) setDialogOpen(false); + } catch (error) { + toast.error(error instanceof MenuPopupBlockedError ? t('menuPopupBlocked') : t('menuPrintFailed')); + } finally { + setPrinting(false); } - if (reservedWindow && !reservedWindow.closed) reservedWindow.close(); - toast.success(t('menuPrintedSuccess')); + return; + } + + // Preserve the user gesture for browsers that block asynchronous popups: + // a failed thermal print falls back to the system print dialog. + const reservedWindow = reservePrintGesture(); + try { + await printToReceipt(filters, reservedWindow); setDialogOpen(false); } catch (error) { const failure = error as { @@ -169,16 +259,12 @@ export default function PrintMenuModal({ open, onOpenChange }: Props) { if (code === 'no_products_to_print') { if (reservedWindow && !reservedWindow.closed) reservedWindow.close(); toast.error(t('noProductsToPrint')); - return; - } - if (status === 401 || status === 403) { + } else if (status === 401 || status === 403) { if (reservedWindow && !reservedWindow.closed) reservedWindow.close(); toast.error(t('menuPrintFailed')); - return; - } - if (code === 'printer_not_configured' || status === 502 || (status !== undefined && status >= 500) || status === undefined) { + } else if (code === 'printer_not_configured' || code === 'printer_not_found' || status === 502 || (status !== undefined && status >= 500) || status === undefined) { try { - if (await printBrowserFallback(filters, reservedWindow)) { + if (await printOnPaper(filters, reservedWindow)) { setDialogOpen(false); } else if (reservedWindow && !reservedWindow.closed) { reservedWindow.close(); @@ -187,10 +273,10 @@ export default function PrintMenuModal({ open, onOpenChange }: Props) { if (reservedWindow && !reservedWindow.closed) reservedWindow.close(); toast.error(popupError instanceof MenuPopupBlockedError ? t('menuPopupBlocked') : t('menuPrintFailed')); } - return; + } else { + if (reservedWindow && !reservedWindow.closed) reservedWindow.close(); + toast.error(t('menuPrintFailed')); } - if (reservedWindow && !reservedWindow.closed) reservedWindow.close(); - toast.error(t('menuPrintFailed')); } finally { setPrinting(false); } @@ -204,18 +290,27 @@ export default function PrintMenuModal({ open, onOpenChange }: Props) { aria-checked={checked} disabled={printing} onClick={() => onChange(!checked)} - className="touch-target flex min-h-14 w-full items-center justify-between rounded-lg border border-border px-4 text-start text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50" + className="touch-target flex min-h-12 w-full items-center justify-between gap-6 px-4 text-start text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50" > - {label} + {label} {checked && ); + const usesPaper = destination === 'paper' || destination === 'pdf'; + // A missing picker is not a dead end: the receipt path still asks the + // backend, which falls back to the configured default printer and, when + // there is none, into the system print dialog. + const noListedPrinter = destination === 'receipt' && printers.length === 0; + const primaryIcon = destination === 'pdf' + ?
{t('nameLabel')}
@@ -416,7 +439,7 @@ export default function InventoryPage() { - {recipes.map((r) => ( + {visibleRecipes.map((r) => ( @@ -443,11 +466,19 @@ export default function InventoryPage() { ))}
{r.product_name || r.product_id} {fmtNum(Number(r.yield_quantity))}
- {recipes.length === 0 &&

{t('emptyRecipes')}

} + {visibleRecipes.length === 0 &&

{normalizedRecipeSearch === '' ? t('emptyRecipes') : tCommon('noResults')}

}
+
+ + setMovementSearch(e.target.value)} + placeholder={tCommon('search')} + className="w-full ps-10 pe-4 py-2.5 bg-card border border-border rounded-lg focus:ring-2 focus:ring-brand outline-none" + /> +
@@ -488,7 +519,7 @@ export default function InventoryPage() { })}
- {movements.length === 0 &&

{t('emptyMovements')}

} + {movements.length === 0 &&

{movementSearch.trim() === '' ? t('emptyMovements') : tCommon('noResults')}

} {nextCursor !== null && (
diff --git a/frontend/src/app/(dashboard)/products/page.tsx b/frontend/src/app/(dashboard)/products/page.tsx index a2423590b..303967677 100644 --- a/frontend/src/app/(dashboard)/products/page.tsx +++ b/frontend/src/app/(dashboard)/products/page.tsx @@ -4,8 +4,9 @@ import { useState, useEffect, useRef } from 'react'; import api from '@/lib/api'; import { useAuthStore } from '@/store/auth'; import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import toast from 'react-hot-toast'; -import { Plus, Pencil, Trash2, X, Package, Folder, Puzzle, FileSpreadsheet, Download, Upload, CheckCircle, AlertCircle, AlertTriangle, Printer, ChevronUp, ChevronDown } from 'lucide-react'; +import { Plus, Pencil, Trash2, X, Package, Folder, Puzzle, FileSpreadsheet, Download, Upload, CheckCircle, AlertCircle, AlertTriangle, Printer, ChevronUp, ChevronDown, Search } from 'lucide-react'; import type { Product, Category, AddonGroup } from '@/lib/types'; import { buildVariantsPayload, @@ -146,6 +147,7 @@ export default function ProductsPage() { image_url: null as string | null, }); const [imageTouched, setImageTouched] = useState(false); + const [search, setSearch] = useState(''); const [showCsvModal, setShowCsvModal] = useState(false); const [showPrintMenuModal, setShowPrintMenuModal] = useState(false); @@ -673,6 +675,31 @@ export default function ProductsPage() { setAddonList((prev) => prev.map((a, i) => (i === idx ? { ...a, stock_quantity: value, stock_edited: true } : a))); const removeAddonItem = (idx: number) => setAddonList((prev) => prev.filter((_, i) => i !== idx)); + // One search box filters whichever list the active tab shows; every list is + // already fully loaded, so the filter never stops at a first page. + const normalizedSearch = search.trim().toLowerCase(); + const matchesSearch = (fields: Array) => + fields.some((field) => typeof field === 'string' && field.toLowerCase().includes(normalizedSearch)); + const visibleProducts = normalizedSearch === '' + ? products + : products.filter((product) => matchesSearch([ + product.name, + product.sku, + product.barcode, + product.category?.name, + categories.find((c) => String(c.id) === String(product.category_id || product.category?.id))?.name, + ])); + const visibleCategories = normalizedSearch === '' + ? categories + : categories.filter((category) => matchesSearch([category.name, category.description])); + const visibleAddonGroups = normalizedSearch === '' + ? addonGroups + : addonGroups.filter((group) => matchesSearch([ + group.name, + group.description, + ...(group.addons || []).map((addon) => addon.name), + ])); + if (loading) { return (
@@ -683,26 +710,47 @@ export default function ProductsPage() { return (
-
+

{t('title')}

+
+ + setSearch(e.target.value)} + placeholder={tCommon('search')} + aria-label={tCommon('search')} + className="w-full rounded-lg border border-border bg-card py-2 ps-9 pe-9 text-sm outline-none focus:ring-2 focus:ring-brand" + /> + {search !== '' && ( + + )} +
-
- - - {isRestaurant && ( - - )} -
+ setActiveTab(value as TabType)}> + + + {t('tabProducts')} + + + {t('tabCategories')} + + {isRestaurant && ( + + {t('tabAddonGroups')} + + )} + - {activeTab === 'products' && ( - <> +
{isOwnerOrManager && taxCategories.length > 0 && (
@@ -1296,11 +1344,9 @@ export default function ProductsPage() {
)} - - )} + - {activeTab === 'categories' && ( - <> +
{showForm && ( @@ -1421,11 +1467,9 @@ export default function ProductsPage() {
)} - - )} + - {activeTab === 'addons' && isRestaurant && ( - <> +
{showAddonModal && ( @@ -1556,8 +1600,8 @@ export default function ProductsPage() {
)} - - )} + + {showBulkTaxModal && (
diff --git a/main/routes/supplies.ts b/main/routes/supplies.ts index 9c6c393f4..a46dccb5b 100644 --- a/main/routes/supplies.ts +++ b/main/routes/supplies.ts @@ -70,6 +70,7 @@ router.get('/movements', requirePermission('supplies.manage'), (req: Request, re if (movementType && !ALL_MOVEMENT_TYPES.includes(movementType)) { return res.status(400).json({ error: 'movement_type is invalid' }); } + const search = queryString(req.query.search, 'search'); const rawBeforeId = req.query.before_id; const beforeId = rawBeforeId === undefined ? undefined @@ -83,7 +84,7 @@ router.get('/movements', requirePermission('supplies.manage'), (req: Request, re ? Math.min(requestedPerPage, 500) : 50; - const page = listSupplyMovements(getDatabase(), { supplyId, movementType, beforeId, perPage }); + const page = listSupplyMovements(getDatabase(), { supplyId, movementType, search, beforeId, perPage }); res.json({ movements: page.movements, ...(page.nextCursor !== null && { nextCursor: page.nextCursor }), diff --git a/main/services/supplies.ts b/main/services/supplies.ts index 74517a143..1d6a962e1 100644 --- a/main/services/supplies.ts +++ b/main/services/supplies.ts @@ -335,6 +335,7 @@ export function listSupplyMovements( filters: { supplyId?: string; movementType?: SupplyMovementType; + search?: string; beforeId?: number; perPage?: number; } = {}, @@ -349,6 +350,11 @@ export function listSupplyMovements( conditions.push('m.movement_type = ?'); params.push(filters.movementType); } + if (filters.search) { + conditions.push('(s.name LIKE ? OR m.reason LIKE ? OR u.name LIKE ?)'); + const searchTerm = `%${filters.search}%`; + params.push(searchTerm, searchTerm, searchTerm); + } if (filters.beforeId !== undefined) { const cursor = db.prepare('SELECT created_at FROM supply_movements WHERE id = ?').get(filters.beforeId) as | { created_at: string } diff --git a/tests/supplies-service.test.ts b/tests/supplies-service.test.ts index 75701a8aa..18ceba453 100644 --- a/tests/supplies-service.test.ts +++ b/tests/supplies-service.test.ts @@ -179,6 +179,25 @@ async function main() { assert(filtered.movements.every((m: any) => m.movement_type === 'receive'), 'type filter works'); assertEqual(filtered.movements.length, 2, 'two receive movements recorded'); + // search filters server-side across supply name, reason, and actor + const bySupplyName = listSupplyMovements(db, { search: 'coffee' }); + assertEqual(bySupplyName.movements.length, 7, 'search matches the supply name case-insensitively'); + assert(bySupplyName.movements.every((m: any) => m.supply_id === coffee.id), 'search by supply name excludes other supplies'); + const byReason = listSupplyMovements(db, { search: 'Delivery' }); + assertEqual(byReason.movements.length, 1, 'search matches the reason'); + assertEqual(byReason.movements[0].reason, 'Delivery', 'search returns the reason-matched movement'); + assertEqual(listSupplyMovements(db, { search: 'spilled' }).movements.length, 1, 'search is case-insensitive'); + const allMovements = listSupplyMovements(db, { perPage: 100 }).movements; + assertEqual(allMovements.length, 11, 'all recorded movements are listed'); + const byActor = listSupplyMovements(db, { search: 'test owner', perPage: 100 }); + assertEqual(byActor.movements.length, allMovements.length, 'search matches the actor name across all supplies'); + assertEqual(listSupplyMovements(db, { search: 'nothing-here' }).movements.length, 0, 'search with no match returns an empty page'); + const searchedPage = listSupplyMovements(db, { search: 'coffee', perPage: 3 }); + assertEqual(searchedPage.movements.length, 3, 'search respects the page size'); + assert(searchedPage.nextCursor !== null, 'search keeps the cursor for the next page'); + const searchedOlder = listSupplyMovements(db, { search: 'coffee', beforeId: searchedPage.nextCursor, perPage: 100 }); + assert(searchedOlder.movements.every((m: any) => m.supply_id === coffee.id), 'search cursor page stays on the filtered set'); + // ── Soft delete ── softDeleteSupply(db, cups.id); expectServiceError(() => getSupply(db, cups.id), 404, 'soft-deleted supply is not found'); From 8de2fe457e2f84d373ca3c805495f6ca60f1cb8b Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 6 Oct 2026 16:33:16 -0400 Subject: [PATCH 09/20] feat(pos): surface split checks and partial payments at checkout Enabling Split checks in Settings had no effect on the Orders page: the split action only existed in the POS table checkout, while Orders opened PaymentModal directly. The payment dialog now offers Split check for an untouched dine-in bill, opens the existing allocation dialog, and hands control back so the refreshed order shows every check with its own Pay button. A short tender is no longer refused outright - it confirms as a partial payment, which the backend already records. The Orders detail pane also fills the row next to the fixed-width master list instead of stopping at a share of it. --- frontend/src/app/(dashboard)/orders/page.tsx | 13 ++- frontend/src/app/(dashboard)/pos/page.tsx | 1 + .../components/orders/OrderDetailPanel.tsx | 24 ++++++ frontend/src/components/pos/PaymentModal.tsx | 83 +++++++++++++++++-- 4 files changed, 109 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/(dashboard)/orders/page.tsx b/frontend/src/app/(dashboard)/orders/page.tsx index 22ed5ce7d..89565dbfb 100644 --- a/frontend/src/app/(dashboard)/orders/page.tsx +++ b/frontend/src/app/(dashboard)/orders/page.tsx @@ -1203,8 +1203,8 @@ export default function OrdersPage() {
) : ordersLayout === 'split' ? (
- {/* Master pane — 40% on desktop, full width below md. */} -
+ {/* Master pane — a fixed reading column on desktop, full width below md. */} +
- {/* Detail pane — 60% on desktop, full width with back nav below md. */} -
+ {/* Detail pane — fills the remaining row width on desktop, full width with back nav below md. */} +
setSelectedOrderId(null)} @@ -1235,6 +1235,7 @@ export default function OrdersPage() { linkCustomerResults={linkCustomerResults} linkingCustomer={linkingCustomer} onCheckout={handleCheckout} + onPayBill={(bill) => setPaymentBill(bill)} onAddItems={openAddItemsModal} onRefund={(ord, bills) => setRefundModal({ order: ord, bills })} onConvertToTakeaway={handleConvertToTakeaway} @@ -1349,6 +1350,10 @@ export default function OrdersPage() { onClose={() => setPaymentBill(null)} onPaid={handlePaymentComplete} onBillUpdate={(updated) => setPaymentBill(updated)} + onSplit={() => { + setPaymentBill(null); + fetchOrders(undefined, { refreshLoadedPages: true }); + }} /> )} diff --git a/frontend/src/app/(dashboard)/pos/page.tsx b/frontend/src/app/(dashboard)/pos/page.tsx index 6bc148c80..cce730e10 100644 --- a/frontend/src/app/(dashboard)/pos/page.tsx +++ b/frontend/src/app/(dashboard)/pos/page.tsx @@ -1311,6 +1311,7 @@ export default function POSPage() { onClose={() => { setPaymentBill(null); setCheckoutOverridePin(undefined); }} onPaid={handlePaymentComplete} onBillUpdate={(updated) => setPaymentBill(updated)} + onSplit={() => { setPaymentBill(null); setCheckoutOverridePin(undefined); refreshTables(); }} /> )} diff --git a/frontend/src/components/orders/OrderDetailPanel.tsx b/frontend/src/components/orders/OrderDetailPanel.tsx index 3c1632546..b19963ef5 100644 --- a/frontend/src/components/orders/OrderDetailPanel.tsx +++ b/frontend/src/components/orders/OrderDetailPanel.tsx @@ -52,6 +52,7 @@ export interface OrderDetailPanelProps { linkingCustomer?: boolean; onBack?: () => void; onCheckout: (orderId: number) => void; + onPayBill?: (bill: Bill) => void; onAddItems: (order: Order) => void; onRefund: (order: Order, bills: Bill[]) => void; onConvertToTakeaway: (order: Order) => void; @@ -91,6 +92,7 @@ function OrderDetailContent({ linkCustomerResults, linkingCustomer, onCheckout, + onPayBill, onAddItems, onRefund, onConvertToTakeaway, @@ -138,6 +140,8 @@ function OrderDetailContent({ const payBadge = payStatus ? paymentStatusBadge[payStatus] : null; const orderBills = order.bills && order.bills.length > 0 ? order.bills : bill ? [bill] : []; + const splitBills = orderBills.filter((candidate) => Boolean(candidate.split_group_id)); + const hasUnpaidSplitBills = splitBills.some((candidate) => candidate.payment_status !== 'paid'); const paidBills = orderBills.filter((b) => Number(b.paid_amount) > 0 && b.payment_status !== 'refunded'); const hasEligibleRefund = paidBills.length > 0; @@ -617,6 +621,26 @@ function OrderDetailContent({ {tOrders('noFurtherActions')}
+ ) : hasUnpaidSplitBills ? ( +
+ {splitBills.map((splitBill) => ( +
+
+

+ {splitBill.split_label || `#${splitBill.bill_number}`} +

+

+ {fmt(Number(splitBill.total))} · {splitBill.payment_status === 'paid' ? tOrders('paid') : tOrders('balance')} +

+
+ {splitBill.payment_status !== 'paid' && onPayBill && ( + + )} +
+ ))} +
) : isPaid || order.status === 'completed' ? (
{bill && ( diff --git a/frontend/src/components/pos/PaymentModal.tsx b/frontend/src/components/pos/PaymentModal.tsx index d043c7f21..b00c775bd 100644 --- a/frontend/src/components/pos/PaymentModal.tsx +++ b/frontend/src/components/pos/PaymentModal.tsx @@ -1,12 +1,13 @@ 'use client'; import { useState, useEffect, useRef } from 'react'; -import { X, Wallet, ArrowLeftRight, CheckCircle2, Sparkles, User, Percent, Send, ChevronDown } from 'lucide-react'; +import { X, Wallet, ArrowLeftRight, CheckCircle2, Sparkles, User, Percent, Send, ChevronDown, Users } from 'lucide-react'; import { Button } from '@/components/ui/button'; import api from '@/lib/api'; import toast from 'react-hot-toast'; -import type { Bill } from '@/lib/types'; +import type { Bill, Order } from '@/lib/types'; import TaxBreakdown from '@/components/pos/TaxBreakdown'; +import { SplitCheckModal } from '@/components/pos/SplitCheckModal'; import { resolveTaxComponents } from '@/lib/printer/tax-components'; import { useCartStore } from '@/store/cart'; import { useConfirm } from '@/hooks/use-confirm'; @@ -40,6 +41,8 @@ interface Props { onClose: () => void; onPaid: () => void; onBillUpdate?: (bill: Bill) => void; + /** Runs after the check is split; the bill being paid no longer exists on its own. */ + onSplit?: () => void; } interface Payment { @@ -61,7 +64,7 @@ const BUILT_IN_PAYMENT_KEYS = { card: 'methodCard', } as const satisfies Record<'cash' | 'card', PosKey>; -export default function PaymentModal({ bill, initialOverridePin, onClose, onPaid, onBillUpdate }: Props) { +export default function PaymentModal({ bill, initialOverridePin, onClose, onPaid, onBillUpdate, onSplit }: Props) { const remaining = Number(bill.balance); const cartCustomerId = useCartStore((s) => s.customerId); const cartCustomer = useCartStore((s) => s.customer); @@ -136,6 +139,9 @@ export default function PaymentModal({ bill, initialOverridePin, onClose, onPaid const charges = useChargesStore((s) => s.charges); const loadCharges = useChargesStore((s) => s.load); const [updatingChargeId, setUpdatingChargeId] = useState(null); + const [splitChecksEnabled, setSplitChecksEnabled] = useState(false); + const [splitCheckOrder, setSplitCheckOrder] = useState(null); + const [openingSplitCheck, setOpeningSplitCheck] = useState(false); const [chargeStateUncertain, setChargeStateUncertain] = useState(false); useEffect(() => { void loadCharges(); @@ -150,6 +156,13 @@ export default function PaymentModal({ bill, initialOverridePin, onClose, onPaid && Number(bill.paid_amount || 0) === 0 && bill.payment_status !== 'refunded' && bill.payment_status !== 'partially_refunded'; + // Split checks divide an untouched dine-in bill into separately payable + // checks; the backend refuses anything else (POST /bills/:id/split-check). + const canSplitCheck = splitChecksEnabled + && bill.order?.type === 'dine_in' + && bill.payment_status === 'unpaid' + && Number(bill.paid_amount || 0) === 0 + && !bill.split_group_id; const canEditCharges = tenantCan(currentTenant, 'bills.discount.apply') && canToggleCharges && !processing && !chargeStateUncertain; const addableCharges = applicableCharges.filter( (charge) => !charge.is_default_active && !appliedCharges.some((applied) => applied.id === charge.id), @@ -217,6 +230,12 @@ export default function PaymentModal({ bill, initialOverridePin, onClose, onPaid } } + useEffect(() => { + api.get('/settings/split_checks_enabled') + .then((res) => setSplitChecksEnabled(res.data?.setting?.value === 'true')) + .catch(() => setSplitChecksEnabled(false)); + }, []); + useEffect(() => { const custId = bill.customer_id || cartCustomerId; if (custId) { @@ -376,6 +395,25 @@ export default function PaymentModal({ bill, initialOverridePin, onClose, onPaid } }; + const handleOpenSplitCheck = async () => { + if (openingSplitCheck) return; + setOpeningSplitCheck(true); + try { + const { data } = await api.get(`/orders/${bill.order_id}`); + setSplitCheckOrder(data.order as Order); + } catch { + toast.error(t('splitCheckFailed')); + } finally { + setOpeningSplitCheck(false); + } + }; + + const handleSplitComplete = () => { + setSplitCheckOrder(null); + if (onSplit) onSplit(); + else onClose(); + }; + const handlePay = async () => { if (processing || updatingChargeId || chargeStateUncertain) return; const decimalPart = unitAdapter.maxDecimals > 0 ? `(?:\\.\\d{1,${unitAdapter.maxDecimals}})?` : ''; @@ -399,9 +437,16 @@ export default function PaymentModal({ bill, initialOverridePin, onClose, onPaid toast.error(t('paymentAboveBalance')); return; } + // A short tender is a deliberate choice: the backend records what was + // collected and leaves the rest as the bill's outstanding balance. if (totalPaymentMinor < remainingMinor) { - toast.error(t('paymentBelowBalance')); - return; + const collected = totalPaymentMinor / minorFactor; + const stillDue = (remainingMinor - totalPaymentMinor) / minorFactor; + const proceed = await confirm( + t('partialPaymentConfirm', { amount: currencyFmt(collected), remaining: currencyFmt(stillDue) }), + { confirmLabel: t('pay') }, + ); + if (!proceed) return; } // Validate wallet amount against available balance (convert currency to points for comparison) if (walletAmt > 0 && walletBalance !== null) { @@ -925,12 +970,34 @@ export default function PaymentModal({ bill, initialOverridePin, onClose, onPaid ) : ( - + <> + {canSplitCheck && ( + + )} + + )}
+ {splitCheckOrder && ( + setSplitCheckOrder(null)} + onSplit={handleSplitComplete} + /> + )} {ConfirmDialog}
); From 0455541e3ef7beda2b6af02649657b1668f99d8a Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 6 Oct 2026 16:33:25 -0400 Subject: [PATCH 10/20] test: guard catalog search, checkout options, and orders layout Add a source-level contract suite for the shared tab strip, whole-list catalog and inventory search, split checks from payment, intentional partial payments, and the full-width orders detail pane. The payment-modal render harness learns the @print alias because the shared Tabs component now loads through the product page, and the POS top-bar comment no longer trips the RTL direction scan. --- frontend/src/components/pos/PosTopbar.tsx | 2 +- package.json | 3 +- tests/payment-modal-currency-adapter.test.ts | 9 +- tests/pos-checkout-search-guards.test.ts | 187 +++++++++++++++++++ 4 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 tests/pos-checkout-search-guards.test.ts diff --git a/frontend/src/components/pos/PosTopbar.tsx b/frontend/src/components/pos/PosTopbar.tsx index 655e3a11a..0dc40f518 100644 --- a/frontend/src/components/pos/PosTopbar.tsx +++ b/frontend/src/components/pos/PosTopbar.tsx @@ -35,7 +35,7 @@ export default function PosTopbar({ tables, onShowTablePicker, onShowCashMovemen return ( // Two rows below xl so the action buttons never crowd the customer - // fields: lookup on the first row, actions right-aligned on the second. + // fields: lookup on the first row, actions end-aligned on the second.
diff --git a/package.json b/package.json index 130682cb3..7ebbd24a0 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "pretest": "npm run test:script-coverage && bash tests/run-test.sh npm run test:payment-methods-split && bash tests/run-test.sh npm run test:release-regressions && bash tests/run-test.sh npm run test:diagnostics && bash tests/run-test.sh npm run test:reports-export && bash tests/run-test.sh npm run test:category-addon-groups && bash tests/run-test.sh npm run test:product-variants-db && bash tests/run-test.sh npm run test:addon-inventory-db && bash tests/run-test.sh npm run test:addon-inventory-api && bash tests/run-test.sh npm run test:product-variants-api", "start": "electron .", "rebuild": "HOME=~/.electron-gyp node-gyp rebuild --target=$(node -p \"require('./node_modules/electron/package.json').version\") --arch=$(node -p \"process.arch\") --dist-url=https://electronjs.org/headers --runtime=electron --directory node_modules/better-sqlite3", -"test": "bash tests/run-test.sh npm run test:smoke && bash tests/run-test.sh npm run test:server-port-collision && bash tests/run-test.sh npm run test:kds-integration && bash tests/run-test.sh npm run test:kds-contract && bash tests/run-test.sh npm run test:kds-frontend-conflict && bash tests/run-test.sh npm run test:kds-window-hardening && bash tests/run-test.sh npm run test:electron-api-contract && bash tests/run-test.sh npm run test:titlebar-window-options && bash tests/run-test.sh npm run test:menu-surface && bash tests/run-test.sh npm run test:window-readiness && bash tests/run-test.sh npm run test:window-load-retry && bash tests/run-test.sh npm run test:cors && bash tests/run-test.sh npm run test:csp-lan && bash tests/run-test.sh npm run test:release-config && bash tests/run-test.sh npm run test:update-state && bash tests/run-test.sh npm run test:update-ui && bash tests/run-test.sh npm run test:update-channel && bash tests/run-test.sh npm run test:telemetry && bash tests/run-test.sh npm run test:telemetry-install-provenance && bash tests/run-test.sh npm run test:country-provenance && bash tests/run-test.sh npm run test:first-run && bash tests/run-test.sh npm run test:phase7-setup-i18n && bash tests/run-test.sh npm run test:security && bash tests/run-test.sh npm run test:jwt-secret-cache-identity && bash tests/run-test.sh npm run test:jwt-secret-persist-before-cache && bash tests/run-test.sh npm run test:staff-authz && bash tests/run-test.sh npm run test:orders-authz && bash tests/run-test.sh npm run test:order-item-cancel-restore && bash tests/run-test.sh npm run test:product-variants-orders && bash tests/run-test.sh npm run test:addon-inventory-pos-surface && bash tests/run-test.sh npm run test:addon-inventory-orders && bash tests/run-test.sh npm run test:addon-inventory-lifecycle && bash tests/run-test.sh npm run test:order-totals-recompute && bash tests/run-test.sh npm run test:issue-718-whatsapp-order-status && bash tests/run-test.sh npm run test:order-audit-log && bash tests/run-test.sh npm run test:authz-phase3 && bash tests/run-test.sh npm run test:authorization-permissions && bash tests/run-test.sh npm run test:auth-ui-deterministic && bash tests/run-test.sh npm run test:customer-auth && bash tests/run-test.sh npm run test:customer-soft-reactivate-bindings && bash tests/run-test.sh npm run test:customer-pagination && bash tests/run-test.sh npm run test:server-app-server-role && bash tests/run-test.sh npm run test:backup && bash tests/run-test.sh npm run test:issue-278-fail-closed-db && bash tests/run-test.sh npm run test:recovery-cloud && bash tests/run-test.sh npm run test:cloud-account-status && bash tests/run-test.sh npm run test:printer && bash tests/run-test.sh npm run test:printing-settings && bash tests/run-test.sh npm run test:printer-width-refresh && bash tests/run-test.sh npm run test:printer-migrations && bash tests/run-test.sh npm run test:print-parity && bash tests/run-test.sh npm run test:delivery-slip && bash tests/run-test.sh npm run test:delivery-address && bash tests/run-test.sh npm run test:thermal-capabilities && bash tests/run-test.sh npm run test:raster && bash tests/run-test.sh npm run test:merchant-print-templates && bash tests/run-test.sh npm run test:merchant-template-transfer && bash tests/run-test.sh npm run test:print-document && bash tests/run-test.sh npm run test:menu-printing && bash tests/run-test.sh npm run test:print-kernel && bash tests/run-test.sh npm run test:translations && bash tests/run-test.sh npm run test:print-labels && bash tests/run-test.sh npm run test:locale-chunks && bash tests/run-test.sh npm run test:rtl-foundation && bash tests/run-test.sh npm run test:rtl-setup-auth-settings && bash tests/run-test.sh npm run test:rtl-dashboard-pos-common && bash tests/run-test.sh npm run test:rtl-kds-server-whatsapp && bash tests/run-test.sh npm run test:phone && bash tests/run-test.sh npm run test:country-localization && bash tests/run-test.sh npm run test:e2e-argentina-flow && bash tests/run-test.sh npm run test:charges && bash tests/run-test.sh npm run test:charges-engine && bash tests/run-test.sh npm run test:currency && bash tests/run-test.sh npm run test:tax-engine && bash tests/run-test.sh npm run test:tax-components && bash tests/run-test.sh npm run test:tax-pack-catalog && bash tests/run-test.sh npm run test:tax-pack-management && bash tests/run-test.sh npm run test:manual-tax-config && bash tests/run-test.sh npm run test:legacy-tax-pack-digest && bash tests/run-test.sh npm run test:community-tax-packs && bash tests/run-test.sh npm run test:support-ticket && bash tests/run-test.sh npm run test:customer-phone-search && bash tests/run-test.sh npm run test:phone-search-integration && bash tests/run-test.sh npm run test:orders-search && bash tests/run-test.sh npm run test:orders-layout-settings && bash tests/run-test.sh npm run test:receipt-column-width && bash tests/run-test.sh npm run test:receipt-column-oracle && bash tests/run-test.sh npm run test:notes-validation && bash tests/run-test.sh npm run test:receipt-printing && bash tests/run-test.sh npm run test:cancel-override && bash tests/run-test.sh npm run test:refunds && bash tests/run-test.sh npm run test:refund-completed-orders && bash tests/run-test.sh npm run test:cash-closures && bash tests/run-test.sh npm run test:cash-sessions && bash tests/run-test.sh npm run test:kitchen-addons && bash tests/run-test.sh npm run test:order-item-addons && bash tests/run-test.sh npm run test:issue-125-addon-reads && bash tests/run-test.sh npm run test:windows-country-code-crash && bash tests/run-test.sh npm run test:reports-insights && bash tests/run-test.sh npm run test:reports-daily-stats-table-turn && bash tests/run-test.sh npm run test:timezone-report-boundaries && bash tests/run-test.sh npm run test:business-date && bash tests/run-test.sh npm run test:dashboard-business-date && bash tests/run-test.sh npm run test:sequence && bash tests/run-test.sh npm run test:order-numbering-settings && bash tests/run-test.sh npm run test:integration-happy && bash tests/run-test.sh npm run test:integration-tax && bash tests/run-test.sh npm run test:integration-payments && bash tests/run-test.sh npm run test:kitchen-delivery-enforcement && bash tests/run-test.sh npm run test:issue-214 && bash tests/run-test.sh npm run test:issue-214-auth && bash tests/run-test.sh npm run test:issue-214-migration && bash tests/run-test.sh npm run test:issue-255 && bash tests/run-test.sh npm run test:cart-variant-identity && bash tests/run-test.sh npm run test:product-variants-screen && bash tests/run-test.sh npm run test:variant-pos-surface && bash tests/run-test.sh npm run test:issue-788-order-attempt-storage && bash tests/run-test.sh npm run test:integration-lifecycle && bash tests/run-test.sh npm run test:integration-reconciliation && bash tests/run-test.sh npm run test:integration-loyalty && bash tests/run-test.sh npm run test:integration-discount && bash tests/run-test.sh npm run test:loyalty-toggle && bash tests/run-test.sh npm run test:discount-system && bash tests/run-test.sh npm run test:integration-discount-settings && bash tests/run-test.sh npm run test:integration-loyalty-global && bash tests/run-test.sh npm run test:issue-248-csv && bash tests/run-test.sh npm run test:integration-loyalty-redemption && bash tests/run-test.sh npm run test:bills-print-api && bash tests/run-test.sh npm run test:issue-24 && bash tests/run-test.sh npm run test:issue-134-routing && bash tests/run-test.sh npm run test:issue-134-mgmt && bash tests/run-test.sh npm run test:issue-137-barcode && bash tests/run-test.sh npm run test:issue-244-product-addon-links && bash tests/run-test.sh npm run test:issue-245-246-addon-groups && bash tests/run-test.sh npm run test:issue-247-catalog-invariants && bash tests/run-test.sh npm run test:issue-250-catalog-perf && bash tests/run-test.sh npm run test:issue-252 && bash tests/run-test.sh npm run test:inventory-ledger && bash tests/run-test.sh npm run test:issue-printing-and-kds-resilience && bash tests/run-test.sh npm run test:issue-258-bill-pagination && bash tests/run-test.sh npm run test:issue-265-morocco-profile && bash tests/run-test.sh npm run test:issue-266-currency-symbol-print && bash tests/run-test.sh npm run test:tables-string-ids && bash tests/run-test.sh npm run test:issue-646-floor-management && bash tests/run-test.sh npm run test:held-orders && bash tests/run-test.sh npm run test:product-images && bash tests/run-test.sh npm run test:schema-health && bash tests/run-test.sh npm run test:migration-registry && bash tests/run-test.sh npm run test:upgrade-path && bash tests/run-test.sh npm run test:upgrade-matrix-harness && bash tests/run-test.sh npm run test:migration-v56-v57 && bash tests/run-test.sh npm run test:migration-v71-repair && bash tests/run-test.sh npm run test:migration-v80-cash-drawer-pulse && bash tests/run-test.sh npm run test:migration-v81-cash-closures && bash tests/run-test.sh npm run test:master-pin && bash tests/run-test.sh npm run test:google-drive && bash tests/run-test.sh npm run test:database-tools-api && bash tests/run-test.sh npm run test:phone-validation && bash tests/run-test.sh npm run test:phone-migration && bash tests/run-test.sh npm run test:issue-133-kds-kot-toggles && bash tests/run-test.sh npm run test:whatsapp-schema && bash tests/run-test.sh npm run test:whatsapp-service && bash tests/run-test.sh npm run test:whatsapp-middleware && bash tests/run-test.sh npm run test:issue-127-password-recovery && bash tests/run-test.sh npm run test:dev-tooling && bash tests/run-test.sh npm run test:windows-uninstaller && bash tests/run-test.sh npm run test:shutdown-lifecycle && bash tests/run-test.sh npm run test:redos-hardening && bash tests/run-test.sh npm run test:startup-cache && bash tests/run-test.sh npm run test:service-worker && bash tests/run-test.sh npm run test:issue-389-timezone-override && bash tests/run-test.sh npm run test:issue-390-locale-preference-invariants && bash tests/run-test.sh npm run test:issue-475-picker-highlight && bash tests/run-test.sh npm run test:theme-mode-settings && bash tests/run-test.sh npm run test:theme-fouc-script && bash tests/run-test.sh npm run test:ui-regressions-621-623-626 && bash tests/run-test.sh npm run test:pos-ux-layout-guards && bash tests/run-test.sh npm run test:issue-zero-balance-settlement && bash tests/run-test.sh npm run test:daily-sales-export && bash tests/run-test.sh npm run test:issue-terminal-state-guards && bash tests/run-test.sh npm run test:issue-payload-and-discount-bounds && bash tests/run-test.sh npm run test:units && bash tests/run-test.sh npm run test:supplies-service && bash tests/run-test.sh npm run test:recipe-order-lifecycle", +"test": "bash tests/run-test.sh npm run test:smoke && bash tests/run-test.sh npm run test:server-port-collision && bash tests/run-test.sh npm run test:kds-integration && bash tests/run-test.sh npm run test:kds-contract && bash tests/run-test.sh npm run test:kds-frontend-conflict && bash tests/run-test.sh npm run test:kds-window-hardening && bash tests/run-test.sh npm run test:electron-api-contract && bash tests/run-test.sh npm run test:titlebar-window-options && bash tests/run-test.sh npm run test:menu-surface && bash tests/run-test.sh npm run test:window-readiness && bash tests/run-test.sh npm run test:window-load-retry && bash tests/run-test.sh npm run test:cors && bash tests/run-test.sh npm run test:csp-lan && bash tests/run-test.sh npm run test:release-config && bash tests/run-test.sh npm run test:update-state && bash tests/run-test.sh npm run test:update-ui && bash tests/run-test.sh npm run test:update-channel && bash tests/run-test.sh npm run test:telemetry && bash tests/run-test.sh npm run test:telemetry-install-provenance && bash tests/run-test.sh npm run test:country-provenance && bash tests/run-test.sh npm run test:first-run && bash tests/run-test.sh npm run test:phase7-setup-i18n && bash tests/run-test.sh npm run test:security && bash tests/run-test.sh npm run test:jwt-secret-cache-identity && bash tests/run-test.sh npm run test:jwt-secret-persist-before-cache && bash tests/run-test.sh npm run test:staff-authz && bash tests/run-test.sh npm run test:orders-authz && bash tests/run-test.sh npm run test:order-item-cancel-restore && bash tests/run-test.sh npm run test:product-variants-orders && bash tests/run-test.sh npm run test:addon-inventory-pos-surface && bash tests/run-test.sh npm run test:addon-inventory-orders && bash tests/run-test.sh npm run test:addon-inventory-lifecycle && bash tests/run-test.sh npm run test:order-totals-recompute && bash tests/run-test.sh npm run test:issue-718-whatsapp-order-status && bash tests/run-test.sh npm run test:order-audit-log && bash tests/run-test.sh npm run test:authz-phase3 && bash tests/run-test.sh npm run test:authorization-permissions && bash tests/run-test.sh npm run test:auth-ui-deterministic && bash tests/run-test.sh npm run test:customer-auth && bash tests/run-test.sh npm run test:customer-soft-reactivate-bindings && bash tests/run-test.sh npm run test:customer-pagination && bash tests/run-test.sh npm run test:server-app-server-role && bash tests/run-test.sh npm run test:backup && bash tests/run-test.sh npm run test:issue-278-fail-closed-db && bash tests/run-test.sh npm run test:recovery-cloud && bash tests/run-test.sh npm run test:cloud-account-status && bash tests/run-test.sh npm run test:printer && bash tests/run-test.sh npm run test:printing-settings && bash tests/run-test.sh npm run test:printer-width-refresh && bash tests/run-test.sh npm run test:printer-migrations && bash tests/run-test.sh npm run test:print-parity && bash tests/run-test.sh npm run test:delivery-slip && bash tests/run-test.sh npm run test:delivery-address && bash tests/run-test.sh npm run test:thermal-capabilities && bash tests/run-test.sh npm run test:raster && bash tests/run-test.sh npm run test:merchant-print-templates && bash tests/run-test.sh npm run test:merchant-template-transfer && bash tests/run-test.sh npm run test:print-document && bash tests/run-test.sh npm run test:menu-printing && bash tests/run-test.sh npm run test:print-kernel && bash tests/run-test.sh npm run test:translations && bash tests/run-test.sh npm run test:print-labels && bash tests/run-test.sh npm run test:locale-chunks && bash tests/run-test.sh npm run test:rtl-foundation && bash tests/run-test.sh npm run test:rtl-setup-auth-settings && bash tests/run-test.sh npm run test:rtl-dashboard-pos-common && bash tests/run-test.sh npm run test:rtl-kds-server-whatsapp && bash tests/run-test.sh npm run test:phone && bash tests/run-test.sh npm run test:country-localization && bash tests/run-test.sh npm run test:e2e-argentina-flow && bash tests/run-test.sh npm run test:charges && bash tests/run-test.sh npm run test:charges-engine && bash tests/run-test.sh npm run test:currency && bash tests/run-test.sh npm run test:tax-engine && bash tests/run-test.sh npm run test:tax-components && bash tests/run-test.sh npm run test:tax-pack-catalog && bash tests/run-test.sh npm run test:tax-pack-management && bash tests/run-test.sh npm run test:manual-tax-config && bash tests/run-test.sh npm run test:legacy-tax-pack-digest && bash tests/run-test.sh npm run test:community-tax-packs && bash tests/run-test.sh npm run test:support-ticket && bash tests/run-test.sh npm run test:customer-phone-search && bash tests/run-test.sh npm run test:phone-search-integration && bash tests/run-test.sh npm run test:orders-search && bash tests/run-test.sh npm run test:orders-layout-settings && bash tests/run-test.sh npm run test:receipt-column-width && bash tests/run-test.sh npm run test:receipt-column-oracle && bash tests/run-test.sh npm run test:notes-validation && bash tests/run-test.sh npm run test:receipt-printing && bash tests/run-test.sh npm run test:cancel-override && bash tests/run-test.sh npm run test:refunds && bash tests/run-test.sh npm run test:refund-completed-orders && bash tests/run-test.sh npm run test:cash-closures && bash tests/run-test.sh npm run test:cash-sessions && bash tests/run-test.sh npm run test:kitchen-addons && bash tests/run-test.sh npm run test:order-item-addons && bash tests/run-test.sh npm run test:issue-125-addon-reads && bash tests/run-test.sh npm run test:windows-country-code-crash && bash tests/run-test.sh npm run test:reports-insights && bash tests/run-test.sh npm run test:reports-daily-stats-table-turn && bash tests/run-test.sh npm run test:timezone-report-boundaries && bash tests/run-test.sh npm run test:business-date && bash tests/run-test.sh npm run test:dashboard-business-date && bash tests/run-test.sh npm run test:sequence && bash tests/run-test.sh npm run test:order-numbering-settings && bash tests/run-test.sh npm run test:integration-happy && bash tests/run-test.sh npm run test:integration-tax && bash tests/run-test.sh npm run test:integration-payments && bash tests/run-test.sh npm run test:kitchen-delivery-enforcement && bash tests/run-test.sh npm run test:issue-214 && bash tests/run-test.sh npm run test:issue-214-auth && bash tests/run-test.sh npm run test:issue-214-migration && bash tests/run-test.sh npm run test:issue-255 && bash tests/run-test.sh npm run test:cart-variant-identity && bash tests/run-test.sh npm run test:product-variants-screen && bash tests/run-test.sh npm run test:variant-pos-surface && bash tests/run-test.sh npm run test:issue-788-order-attempt-storage && bash tests/run-test.sh npm run test:integration-lifecycle && bash tests/run-test.sh npm run test:integration-reconciliation && bash tests/run-test.sh npm run test:integration-loyalty && bash tests/run-test.sh npm run test:integration-discount && bash tests/run-test.sh npm run test:loyalty-toggle && bash tests/run-test.sh npm run test:discount-system && bash tests/run-test.sh npm run test:integration-discount-settings && bash tests/run-test.sh npm run test:integration-loyalty-global && bash tests/run-test.sh npm run test:issue-248-csv && bash tests/run-test.sh npm run test:integration-loyalty-redemption && bash tests/run-test.sh npm run test:bills-print-api && bash tests/run-test.sh npm run test:issue-24 && bash tests/run-test.sh npm run test:issue-134-routing && bash tests/run-test.sh npm run test:issue-134-mgmt && bash tests/run-test.sh npm run test:issue-137-barcode && bash tests/run-test.sh npm run test:issue-244-product-addon-links && bash tests/run-test.sh npm run test:issue-245-246-addon-groups && bash tests/run-test.sh npm run test:issue-247-catalog-invariants && bash tests/run-test.sh npm run test:issue-250-catalog-perf && bash tests/run-test.sh npm run test:issue-252 && bash tests/run-test.sh npm run test:inventory-ledger && bash tests/run-test.sh npm run test:issue-printing-and-kds-resilience && bash tests/run-test.sh npm run test:issue-258-bill-pagination && bash tests/run-test.sh npm run test:issue-265-morocco-profile && bash tests/run-test.sh npm run test:issue-266-currency-symbol-print && bash tests/run-test.sh npm run test:tables-string-ids && bash tests/run-test.sh npm run test:issue-646-floor-management && bash tests/run-test.sh npm run test:held-orders && bash tests/run-test.sh npm run test:product-images && bash tests/run-test.sh npm run test:schema-health && bash tests/run-test.sh npm run test:migration-registry && bash tests/run-test.sh npm run test:upgrade-path && bash tests/run-test.sh npm run test:upgrade-matrix-harness && bash tests/run-test.sh npm run test:migration-v56-v57 && bash tests/run-test.sh npm run test:migration-v71-repair && bash tests/run-test.sh npm run test:migration-v80-cash-drawer-pulse && bash tests/run-test.sh npm run test:migration-v81-cash-closures && bash tests/run-test.sh npm run test:master-pin && bash tests/run-test.sh npm run test:google-drive && bash tests/run-test.sh npm run test:database-tools-api && bash tests/run-test.sh npm run test:phone-validation && bash tests/run-test.sh npm run test:phone-migration && bash tests/run-test.sh npm run test:issue-133-kds-kot-toggles && bash tests/run-test.sh npm run test:whatsapp-schema && bash tests/run-test.sh npm run test:whatsapp-service && bash tests/run-test.sh npm run test:whatsapp-middleware && bash tests/run-test.sh npm run test:issue-127-password-recovery && bash tests/run-test.sh npm run test:dev-tooling && bash tests/run-test.sh npm run test:windows-uninstaller && bash tests/run-test.sh npm run test:shutdown-lifecycle && bash tests/run-test.sh npm run test:redos-hardening && bash tests/run-test.sh npm run test:startup-cache && bash tests/run-test.sh npm run test:service-worker && bash tests/run-test.sh npm run test:issue-389-timezone-override && bash tests/run-test.sh npm run test:issue-390-locale-preference-invariants && bash tests/run-test.sh npm run test:issue-475-picker-highlight && bash tests/run-test.sh npm run test:theme-mode-settings && bash tests/run-test.sh npm run test:theme-fouc-script && bash tests/run-test.sh npm run test:ui-regressions-621-623-626 && bash tests/run-test.sh npm run test:pos-ux-layout-guards && bash tests/run-test.sh npm run test:pos-checkout-search-guards && bash tests/run-test.sh npm run test:issue-zero-balance-settlement && bash tests/run-test.sh npm run test:daily-sales-export && bash tests/run-test.sh npm run test:issue-terminal-state-guards && bash tests/run-test.sh npm run test:issue-payload-and-discount-bounds && bash tests/run-test.sh npm run test:units && bash tests/run-test.sh npm run test:supplies-service && bash tests/run-test.sh npm run test:recipe-order-lifecycle", "test:dev-tooling": "ts-node --transpile-only -P tests/tsconfig.json tests/dev-tooling-scripts.test.ts && npm run test:phase2 && npm run test:runtime-recovery && npm run test:backend-health && npm run test:url-allowlist && npm run test:static-routes", "test:script-coverage": "node scripts/ci/validate-test-script-coverage.cjs && node tests/test-script-coverage.test.cjs && ts-node --transpile-only -P tests/tsconfig.json tests/test-harness-assertions.test.ts", "test:shutdown-lifecycle": "npm run build && node tests/run-electron-node-test.cjs tests/shutdown-lifecycle.test.ts", @@ -70,6 +70,7 @@ "test:theme-fouc-script": "ts-node --transpile-only -P tests/tsconfig.json tests/theme-fouc-script.test.ts", "test:ui-regressions-621-623-626": "ts-node --transpile-only -P tests/tsconfig.json tests/ui-regressions-621-623-626.test.ts", "test:pos-ux-layout-guards": "ts-node --transpile-only -P tests/tsconfig.json tests/pos-ux-layout-guards.test.ts", + "test:pos-checkout-search-guards": "ts-node --transpile-only -P tests/tsconfig.json tests/pos-checkout-search-guards.test.ts", "test:window-readiness": "ts-node --transpile-only -P tests/tsconfig.json tests/window-readiness.test.ts", "test:window-load-retry": "ts-node --transpile-only -P tests/tsconfig.json tests/window-load-retry.test.ts", "test:runtime-recovery": "ts-node --transpile-only -P tests/tsconfig.json tests/runtime-recovery.test.ts", diff --git a/tests/payment-modal-currency-adapter.test.ts b/tests/payment-modal-currency-adapter.test.ts index b37113664..da06497f8 100644 --- a/tests/payment-modal-currency-adapter.test.ts +++ b/tests/payment-modal-currency-adapter.test.ts @@ -35,6 +35,8 @@ const originalResolveFilename = moduleApi._resolveFilename; moduleApi._resolveFilename = function (request: string, parent: any, isMain: boolean, options?: any) { const resolvedRequest = request === '@countries' ? path.resolve(ROOT, 'main/countries.ts') + : request.startsWith('@print/') + ? path.resolve(ROOT, 'shared/print', request.slice('@print/'.length)) : request.startsWith('@shared/') ? path.resolve(ROOT, 'shared', request.slice('@shared/'.length)) : request.startsWith('@/') @@ -480,8 +482,11 @@ async function runCatalogSaveBoundaryTests() { }; const productTree = renderProductsPage('products'); const submitForms = collectElements(productTree, (element) => typeof element.props?.onSubmit === 'function'); - assert.equal(submitForms.length, 1, 'ProductsPage exposes the product save form'); - await submitForms[0].props.onSubmit({ preventDefault: () => undefined }); + // The shared Tabs markup keeps every tab's form in the element tree, so the + // product form is picked by its id rather than by being the only form. + const productForm = submitForms.find((form) => form.props?.id === 'product-form'); + assert(productForm, 'ProductsPage exposes the product save form'); + await productForm.props.onSubmit({ preventDefault: () => undefined }); const productSave = calls.find((call) => call.method === 'post' && call.path === '/products'); assert.equal(productSave?.payload?.price, 2, 'product price is rounded at the save handler'); assert.equal(productSave?.payload?.cost_price, 3, 'product cost price is rounded at the save handler'); diff --git a/tests/pos-checkout-search-guards.test.ts b/tests/pos-checkout-search-guards.test.ts new file mode 100644 index 000000000..0829f38f2 --- /dev/null +++ b/tests/pos-checkout-search-guards.test.ts @@ -0,0 +1,187 @@ +/** + * Source-level guards for the catalog/inventory search and checkout surfaces: + * + * - Products (products / categories / add-ons) and Inventory (recipes / + * movements) must offer search, and every search must run over the whole + * collection rather than the first loaded page; + * - the Products tab strip must use the shared Tabs component that + * Inventory already uses; + * - a split-check-enabled store must be able to split a dine-in check from + * the payment dialog, and pay each resulting check; + * - a short tender must be collectable as a deliberate partial payment + * instead of being blocked in the UI; + * - the Orders detail pane must fill the space next to the master list. + * + * These are UI contracts asserted against the rendered markup's own class and + * call contract, in the same shape as the other guards in this directory. + * + * Run: ts-node --transpile-only -P tests/tsconfig.json tests/pos-checkout-search-guards.test.ts + */ +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const source = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); + +// ── Products: shared tabs + one search over the full catalog ──────────────── +{ + const products = source('frontend/src/app/(dashboard)/products/page.tsx'); + const inventory = source('frontend/src/app/(dashboard)/inventory/page.tsx'); + + assert.match( + products, + /import \{ Tabs, TabsContent, TabsList, TabsTrigger \} from '@\/components\/ui\/tabs';/, + 'the products page uses the same Tabs component as the inventory page', + ); + assert.match( + inventory, + /from '@\/components\/ui\/tabs';/, + 'the inventory page still uses the shared Tabs component', + ); + for (const tab of ['products', 'categories', 'addons'] as const) { + assert.match( + products, + new RegExp(``), + `the ${tab} tab is a shared TabsTrigger`, + ); + assert.match( + products, + new RegExp(``), + `the ${tab} tab body is a shared TabsContent`, + ); + } + + // Every list is filtered client-side from the complete API response, so a + // search can never stop at a paginated subset. + assert.match( + products, + /const visibleProducts = normalizedSearch === ''\n \? products\n : products\.filter/, + 'product search filters the complete product list', + ); + assert.match( + products, + /const visibleCategories = normalizedSearch === ''\n \? categories\n : categories\.filter/, + 'category search filters the complete category list', + ); + assert.match( + products, + /const visibleAddonGroups = normalizedSearch === ''\n \? addonGroups\n : addonGroups\.filter/, + 'add-on search filters the complete add-on group list', + ); + for (const list of ['visibleProducts', 'visibleCategories', 'visibleAddonGroups'] as const) { + assert.match(products, new RegExp(`${list}\\.map`), `${list} renders the filtered rows`); + } + assert.match( + products, + /placeholder=\{tCommon\('search'\)\}/, + 'the catalog search box is labelled by the shared search translation', + ); +} + +// ── Inventory: recipe search client-side, movement search server-side ─────── +{ + const inventory = source('frontend/src/app/(dashboard)/inventory/page.tsx'); + + assert.match( + inventory, + /const visibleRecipes = normalizedRecipeSearch === ''\n \? recipes\n : recipes\.filter/, + 'recipe search filters the complete, unpaginated recipe list', + ); + assert.match(inventory, /visibleRecipes\.map/, 'the recipe table renders the filtered rows'); + assert.match( + inventory, + /if \(movementSearch\.trim\(\)\) params\.search = movementSearch\.trim\(\);/, + 'movement search is sent to the API, so it spans every page, not just the loaded rows', + ); + assert.match( + inventory, + /value=\{movementSearch\} onChange=\{\(e\) => setMovementSearch\(e\.target\.value\)\}/, + 'the movements tab shows its own search box', + ); + assert.match( + inventory, + /value=\{recipeSearch\} onChange=\{\(e\) => setRecipeSearch\(e\.target\.value\)\}/, + 'the recipes tab shows its own search box', + ); + assert.match(inventory, /tCommon\('noResults'\)/, 'filtered-to-empty lists say so instead of showing the empty-store copy'); +} + +// ── Payment dialog: split checks and deliberate partial payments ──────────── +{ + const modal = source('frontend/src/components/pos/PaymentModal.tsx'); + + assert.match( + modal, + /api\.get\('\/settings\/split_checks_enabled'\)/, + 'the payment dialog reads the split-checks setting instead of assuming it', + ); + assert.match( + modal, + /const canSplitCheck = splitChecksEnabled[\s\S]{0,220}bill\.order\?\.type === 'dine_in'/, + 'split check is offered only for an untouched dine-in bill', + ); + assert.match(modal, / 0\)\}/, + 'the pay button stays disabled when nothing has been entered on a bill with a balance', + ); +} + +// ── Orders: split checks are payable, and the detail pane fills the row ───── +{ + const panel = source('frontend/src/components/orders/OrderDetailPanel.tsx'); + const orders = source('frontend/src/app/(dashboard)/orders/page.tsx'); + + assert.match( + panel, + /const splitBills = orderBills\.filter\(\(candidate\) => Boolean\(candidate\.split_group_id\)\);/, + 'the detail panel lists the order split checks', + ); + assert.match(panel, /onPayBill\?: \(bill: Bill\) => void;/, 'the detail panel can hand a single split check to payment'); + assert.match( + panel, + /onPayBill\(\{ \.\.\.splitBill, order \}\)/, + 'each unpaid split check carries its order context into payment', + ); + assert.match( + orders, + /onPayBill=\{\(bill\) => setPaymentBill\(bill\)\}/, + 'the Orders page opens payment for the picked split check', + ); + + assert.match( + orders, + /'w-full md:flex-1 min-w-0'/, + 'the detail pane fills the space next to the master list instead of stopping at a share of the row', + ); + assert.match( + orders, + /'w-full md:w-\[360px\] lg:w-\[400px\] xl:w-\[440px\] min-w-0 flex-col/, + 'the master list stays a fixed reading column so the detail pane keeps the rest', + ); + assert.match( + orders, + /onSplit=\{\(\) => \{\n setPaymentBill\(null\);/, + 'a completed split closes payment and refreshes the order list', + ); +} + +console.log('pos-checkout-search-guards: catalog search, split checks, partial payments, and orders width contracts hold'); From 745a16219d57a9905ea5a6006c4f37d52725ee2a Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 6 Oct 2026 16:51:31 -0400 Subject: [PATCH 11/20] no-mistakes(review): Fix review findings; supplies test blocked by missing Electron --- .../src/app/(dashboard)/inventory/page.tsx | 31 ++- .../components/orders/OrderDetailPanel.tsx | 7 +- frontend/src/components/pos/PaymentModal.tsx | 10 +- .../components/products/PrintMenuModal.tsx | 20 +- main/services/supplies.ts | 4 +- package.json | 4 +- tests/pos-checkout-search-guards.test.ts | 187 ------------------ tests/pos-ux-layout-guards.test.ts | 146 -------------- tests/supplies-service.test.ts | 12 ++ 9 files changed, 53 insertions(+), 368 deletions(-) delete mode 100644 tests/pos-checkout-search-guards.test.ts delete mode 100644 tests/pos-ux-layout-guards.test.ts diff --git a/frontend/src/app/(dashboard)/inventory/page.tsx b/frontend/src/app/(dashboard)/inventory/page.tsx index 934fefe74..386f5cc89 100644 --- a/frontend/src/app/(dashboard)/inventory/page.tsx +++ b/frontend/src/app/(dashboard)/inventory/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import api from '@/lib/api'; import { useAuthStore } from '@/store/auth'; import { Button } from '@/components/ui/button'; @@ -85,6 +85,7 @@ export default function InventoryPage() { const [recipes, setRecipes] = useState([]); const [movements, setMovements] = useState([]); const [nextCursor, setNextCursor] = useState(null); + const movementRequestSequence = useRef(0); const [products, setProducts] = useState([]); const [search, setSearch] = useState(''); const [recipeSearch, setRecipeSearch] = useState(''); @@ -144,35 +145,25 @@ export default function InventoryPage() { }, [tab, refreshKey, canManage]); const loadMovements = useCallback(async (cursor?: number | null) => { + const requestSequence = ++movementRequestSequence.current; try { const params: Record = { per_page: 50 }; if (movementSearch.trim()) params.search = movementSearch.trim(); if (cursor) params.before_id = cursor; const { data } = await api.get('/supplies/movements', { params }); + if (requestSequence !== movementRequestSequence.current) return; setMovements((prev) => (cursor ? [...prev, ...(data.movements || [])] : data.movements || [])); setNextCursor(data.nextCursor ?? null); } catch { - toast.error(t('loadFailed')); + if (requestSequence === movementRequestSequence.current) toast.error(t('loadFailed')); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [refreshKey, movementSearch]); useEffect(() => { if (!canManage || tab !== 'movements') return; - const controller = new AbortController(); - const params: Record = { per_page: 50 }; - if (movementSearch.trim()) params.search = movementSearch.trim(); - api.get('/supplies/movements', { params, signal: controller.signal }) - .then(({ data }) => { - setMovements(data.movements || []); - setNextCursor(data.nextCursor ?? null); - }) - .catch((err: unknown) => { - if (!(err instanceof Error && (err.name === 'CanceledError' || err.name === 'AbortError'))) toast.error(t('loadFailed')); - }); - return () => controller.abort(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tab, refreshKey, canManage, movementSearch]); + void loadMovements(null); + }, [tab, canManage, loadMovements]); const openAddSupply = () => { setEditingSupply(null); @@ -234,7 +225,6 @@ export default function InventoryPage() { setMovementSupply(null); setMovementForm({ movement_type: 'receive', quantity: '', unit: 'each', reason: '' }); refresh(); - if (tab === 'movements') loadMovements(null); } catch (err: unknown) { const message = err instanceof Error && 'response' in err ? (err as { response?: { data?: { error?: string } } }).response?.data?.error @@ -474,7 +464,12 @@ export default function InventoryPage() {
setMovementSearch(e.target.value)} + type="text" value={movementSearch} onChange={(e) => { + movementRequestSequence.current++; + setMovements([]); + setNextCursor(null); + setMovementSearch(e.target.value); + }} placeholder={tCommon('search')} className="w-full ps-10 pe-4 py-2.5 bg-card border border-border rounded-lg focus:ring-2 focus:ring-brand outline-none" /> diff --git a/frontend/src/components/orders/OrderDetailPanel.tsx b/frontend/src/components/orders/OrderDetailPanel.tsx index b19963ef5..6ae2edf13 100644 --- a/frontend/src/components/orders/OrderDetailPanel.tsx +++ b/frontend/src/components/orders/OrderDetailPanel.tsx @@ -141,7 +141,8 @@ function OrderDetailContent({ const orderBills = order.bills && order.bills.length > 0 ? order.bills : bill ? [bill] : []; const splitBills = orderBills.filter((candidate) => Boolean(candidate.split_group_id)); - const hasUnpaidSplitBills = splitBills.some((candidate) => candidate.payment_status !== 'paid'); + const canPaySplitBill = (candidate: Bill) => candidate.payment_status === 'unpaid' || candidate.payment_status === 'partial'; + const hasUnpaidSplitBills = splitBills.some(canPaySplitBill); const paidBills = orderBills.filter((b) => Number(b.paid_amount) > 0 && b.payment_status !== 'refunded'); const hasEligibleRefund = paidBills.length > 0; @@ -633,7 +634,7 @@ function OrderDetailContent({ {fmt(Number(splitBill.total))} · {splitBill.payment_status === 'paid' ? tOrders('paid') : tOrders('balance')}

- {splitBill.payment_status !== 'paid' && onPayBill && ( + {canPaySplitBill(splitBill) && onPayBill && ( @@ -752,4 +753,4 @@ export function OrderDetailPanel({ onBack, ...props }: OrderDetailPanelProps) { )}
); -} \ No newline at end of file +} diff --git a/frontend/src/components/pos/PaymentModal.tsx b/frontend/src/components/pos/PaymentModal.tsx index b00c775bd..4f7f78281 100644 --- a/frontend/src/components/pos/PaymentModal.tsx +++ b/frontend/src/components/pos/PaymentModal.tsx @@ -488,9 +488,13 @@ export default function PaymentModal({ bill, initialOverridePin, onClose, onPaid // new request and must not reuse the completed request's hash. if (updatedBill) idempotencyKeyRef.current = null; if (updatedBill && onBillUpdate) onBillUpdate(updatedBill); - toast.error(t('paymentIncomplete', { - amount: currencyFmt(Number(updatedBill?.balance) || 0), - })); + if (updatedBill?.payment_status === 'partial') { + toast.success(t('paymentRecorded')); + } else { + toast.error(t('paymentIncomplete', { + amount: currencyFmt(Number(updatedBill?.balance) || 0), + })); + } return; } const earned = res.data?.loyaltyPointsEarned > 0 ? res.data.loyaltyPointsEarned : 0; diff --git a/frontend/src/components/products/PrintMenuModal.tsx b/frontend/src/components/products/PrintMenuModal.tsx index 382fdb7f9..5a56d56e6 100644 --- a/frontend/src/components/products/PrintMenuModal.tsx +++ b/frontend/src/components/products/PrintMenuModal.tsx @@ -184,12 +184,12 @@ export default function PrintMenuModal({ open, onOpenChange }: Props) { /** PDF export. Electron renders the HTML offscreen and asks where to save; * a plain browser falls back to the print dialog's "Save as PDF". */ - const savePdf = async (selectedFilters: PrintFilters): Promise => { + const savePdf = async (selectedFilters: PrintFilters, targetWindow?: Window | null): Promise => { const html = await buildMenuHtml(selectedFilters); if (html === null) return false; const saveHtmlAsPdf = window.electronAPI?.saveHtmlAsPdf; if (!saveHtmlAsPdf) { - printMenuInBrowser(html); + printMenuInBrowser(html, targetWindow); toast.success(tCommon('done')); return true; } @@ -231,12 +231,19 @@ export default function PrintMenuModal({ open, onOpenChange }: Props) { setPrinting(true); if (destination === 'paper' || destination === 'pdf') { + const reservedWindow = window.electronAPI ? undefined : reservePrintGesture(); try { const printed = destination === 'paper' - ? await printOnPaper(filters) - : await savePdf(filters); - if (printed) setDialogOpen(false); + ? await printOnPaper(filters, reservedWindow) + : await savePdf(filters, reservedWindow); + if (printed) { + if (reservedWindow && !reservedWindow.closed) reservedWindow.close(); + setDialogOpen(false); + } else if (reservedWindow && !reservedWindow.closed) { + reservedWindow.close(); + } } catch (error) { + if (reservedWindow && !reservedWindow.closed) reservedWindow.close(); toast.error(error instanceof MenuPopupBlockedError ? t('menuPopupBlocked') : t('menuPrintFailed')); } finally { setPrinting(false); @@ -246,7 +253,7 @@ export default function PrintMenuModal({ open, onOpenChange }: Props) { // Preserve the user gesture for browsers that block asynchronous popups: // a failed thermal print falls back to the system print dialog. - const reservedWindow = reservePrintGesture(); + const reservedWindow = window.electronAPI ? undefined : reservePrintGesture(); try { await printToReceipt(filters, reservedWindow); setDialogOpen(false); @@ -265,6 +272,7 @@ export default function PrintMenuModal({ open, onOpenChange }: Props) { } else if (code === 'printer_not_configured' || code === 'printer_not_found' || status === 502 || (status !== undefined && status >= 500) || status === undefined) { try { if (await printOnPaper(filters, reservedWindow)) { + if (reservedWindow && !reservedWindow.closed) reservedWindow.close(); setDialogOpen(false); } else if (reservedWindow && !reservedWindow.closed) { reservedWindow.close(); diff --git a/main/services/supplies.ts b/main/services/supplies.ts index 1d6a962e1..bdcc86182 100644 --- a/main/services/supplies.ts +++ b/main/services/supplies.ts @@ -351,8 +351,8 @@ export function listSupplyMovements( params.push(filters.movementType); } if (filters.search) { - conditions.push('(s.name LIKE ? OR m.reason LIKE ? OR u.name LIKE ?)'); - const searchTerm = `%${filters.search}%`; + conditions.push("(s.name LIKE ? ESCAPE '!' OR m.reason LIKE ? ESCAPE '!' OR u.name LIKE ? ESCAPE '!')"); + const searchTerm = `%${filters.search.replace(/[!%_]/g, '!$&')}%`; params.push(searchTerm, searchTerm, searchTerm); } if (filters.beforeId !== undefined) { diff --git a/package.json b/package.json index 7ebbd24a0..77d7b91e6 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "pretest": "npm run test:script-coverage && bash tests/run-test.sh npm run test:payment-methods-split && bash tests/run-test.sh npm run test:release-regressions && bash tests/run-test.sh npm run test:diagnostics && bash tests/run-test.sh npm run test:reports-export && bash tests/run-test.sh npm run test:category-addon-groups && bash tests/run-test.sh npm run test:product-variants-db && bash tests/run-test.sh npm run test:addon-inventory-db && bash tests/run-test.sh npm run test:addon-inventory-api && bash tests/run-test.sh npm run test:product-variants-api", "start": "electron .", "rebuild": "HOME=~/.electron-gyp node-gyp rebuild --target=$(node -p \"require('./node_modules/electron/package.json').version\") --arch=$(node -p \"process.arch\") --dist-url=https://electronjs.org/headers --runtime=electron --directory node_modules/better-sqlite3", -"test": "bash tests/run-test.sh npm run test:smoke && bash tests/run-test.sh npm run test:server-port-collision && bash tests/run-test.sh npm run test:kds-integration && bash tests/run-test.sh npm run test:kds-contract && bash tests/run-test.sh npm run test:kds-frontend-conflict && bash tests/run-test.sh npm run test:kds-window-hardening && bash tests/run-test.sh npm run test:electron-api-contract && bash tests/run-test.sh npm run test:titlebar-window-options && bash tests/run-test.sh npm run test:menu-surface && bash tests/run-test.sh npm run test:window-readiness && bash tests/run-test.sh npm run test:window-load-retry && bash tests/run-test.sh npm run test:cors && bash tests/run-test.sh npm run test:csp-lan && bash tests/run-test.sh npm run test:release-config && bash tests/run-test.sh npm run test:update-state && bash tests/run-test.sh npm run test:update-ui && bash tests/run-test.sh npm run test:update-channel && bash tests/run-test.sh npm run test:telemetry && bash tests/run-test.sh npm run test:telemetry-install-provenance && bash tests/run-test.sh npm run test:country-provenance && bash tests/run-test.sh npm run test:first-run && bash tests/run-test.sh npm run test:phase7-setup-i18n && bash tests/run-test.sh npm run test:security && bash tests/run-test.sh npm run test:jwt-secret-cache-identity && bash tests/run-test.sh npm run test:jwt-secret-persist-before-cache && bash tests/run-test.sh npm run test:staff-authz && bash tests/run-test.sh npm run test:orders-authz && bash tests/run-test.sh npm run test:order-item-cancel-restore && bash tests/run-test.sh npm run test:product-variants-orders && bash tests/run-test.sh npm run test:addon-inventory-pos-surface && bash tests/run-test.sh npm run test:addon-inventory-orders && bash tests/run-test.sh npm run test:addon-inventory-lifecycle && bash tests/run-test.sh npm run test:order-totals-recompute && bash tests/run-test.sh npm run test:issue-718-whatsapp-order-status && bash tests/run-test.sh npm run test:order-audit-log && bash tests/run-test.sh npm run test:authz-phase3 && bash tests/run-test.sh npm run test:authorization-permissions && bash tests/run-test.sh npm run test:auth-ui-deterministic && bash tests/run-test.sh npm run test:customer-auth && bash tests/run-test.sh npm run test:customer-soft-reactivate-bindings && bash tests/run-test.sh npm run test:customer-pagination && bash tests/run-test.sh npm run test:server-app-server-role && bash tests/run-test.sh npm run test:backup && bash tests/run-test.sh npm run test:issue-278-fail-closed-db && bash tests/run-test.sh npm run test:recovery-cloud && bash tests/run-test.sh npm run test:cloud-account-status && bash tests/run-test.sh npm run test:printer && bash tests/run-test.sh npm run test:printing-settings && bash tests/run-test.sh npm run test:printer-width-refresh && bash tests/run-test.sh npm run test:printer-migrations && bash tests/run-test.sh npm run test:print-parity && bash tests/run-test.sh npm run test:delivery-slip && bash tests/run-test.sh npm run test:delivery-address && bash tests/run-test.sh npm run test:thermal-capabilities && bash tests/run-test.sh npm run test:raster && bash tests/run-test.sh npm run test:merchant-print-templates && bash tests/run-test.sh npm run test:merchant-template-transfer && bash tests/run-test.sh npm run test:print-document && bash tests/run-test.sh npm run test:menu-printing && bash tests/run-test.sh npm run test:print-kernel && bash tests/run-test.sh npm run test:translations && bash tests/run-test.sh npm run test:print-labels && bash tests/run-test.sh npm run test:locale-chunks && bash tests/run-test.sh npm run test:rtl-foundation && bash tests/run-test.sh npm run test:rtl-setup-auth-settings && bash tests/run-test.sh npm run test:rtl-dashboard-pos-common && bash tests/run-test.sh npm run test:rtl-kds-server-whatsapp && bash tests/run-test.sh npm run test:phone && bash tests/run-test.sh npm run test:country-localization && bash tests/run-test.sh npm run test:e2e-argentina-flow && bash tests/run-test.sh npm run test:charges && bash tests/run-test.sh npm run test:charges-engine && bash tests/run-test.sh npm run test:currency && bash tests/run-test.sh npm run test:tax-engine && bash tests/run-test.sh npm run test:tax-components && bash tests/run-test.sh npm run test:tax-pack-catalog && bash tests/run-test.sh npm run test:tax-pack-management && bash tests/run-test.sh npm run test:manual-tax-config && bash tests/run-test.sh npm run test:legacy-tax-pack-digest && bash tests/run-test.sh npm run test:community-tax-packs && bash tests/run-test.sh npm run test:support-ticket && bash tests/run-test.sh npm run test:customer-phone-search && bash tests/run-test.sh npm run test:phone-search-integration && bash tests/run-test.sh npm run test:orders-search && bash tests/run-test.sh npm run test:orders-layout-settings && bash tests/run-test.sh npm run test:receipt-column-width && bash tests/run-test.sh npm run test:receipt-column-oracle && bash tests/run-test.sh npm run test:notes-validation && bash tests/run-test.sh npm run test:receipt-printing && bash tests/run-test.sh npm run test:cancel-override && bash tests/run-test.sh npm run test:refunds && bash tests/run-test.sh npm run test:refund-completed-orders && bash tests/run-test.sh npm run test:cash-closures && bash tests/run-test.sh npm run test:cash-sessions && bash tests/run-test.sh npm run test:kitchen-addons && bash tests/run-test.sh npm run test:order-item-addons && bash tests/run-test.sh npm run test:issue-125-addon-reads && bash tests/run-test.sh npm run test:windows-country-code-crash && bash tests/run-test.sh npm run test:reports-insights && bash tests/run-test.sh npm run test:reports-daily-stats-table-turn && bash tests/run-test.sh npm run test:timezone-report-boundaries && bash tests/run-test.sh npm run test:business-date && bash tests/run-test.sh npm run test:dashboard-business-date && bash tests/run-test.sh npm run test:sequence && bash tests/run-test.sh npm run test:order-numbering-settings && bash tests/run-test.sh npm run test:integration-happy && bash tests/run-test.sh npm run test:integration-tax && bash tests/run-test.sh npm run test:integration-payments && bash tests/run-test.sh npm run test:kitchen-delivery-enforcement && bash tests/run-test.sh npm run test:issue-214 && bash tests/run-test.sh npm run test:issue-214-auth && bash tests/run-test.sh npm run test:issue-214-migration && bash tests/run-test.sh npm run test:issue-255 && bash tests/run-test.sh npm run test:cart-variant-identity && bash tests/run-test.sh npm run test:product-variants-screen && bash tests/run-test.sh npm run test:variant-pos-surface && bash tests/run-test.sh npm run test:issue-788-order-attempt-storage && bash tests/run-test.sh npm run test:integration-lifecycle && bash tests/run-test.sh npm run test:integration-reconciliation && bash tests/run-test.sh npm run test:integration-loyalty && bash tests/run-test.sh npm run test:integration-discount && bash tests/run-test.sh npm run test:loyalty-toggle && bash tests/run-test.sh npm run test:discount-system && bash tests/run-test.sh npm run test:integration-discount-settings && bash tests/run-test.sh npm run test:integration-loyalty-global && bash tests/run-test.sh npm run test:issue-248-csv && bash tests/run-test.sh npm run test:integration-loyalty-redemption && bash tests/run-test.sh npm run test:bills-print-api && bash tests/run-test.sh npm run test:issue-24 && bash tests/run-test.sh npm run test:issue-134-routing && bash tests/run-test.sh npm run test:issue-134-mgmt && bash tests/run-test.sh npm run test:issue-137-barcode && bash tests/run-test.sh npm run test:issue-244-product-addon-links && bash tests/run-test.sh npm run test:issue-245-246-addon-groups && bash tests/run-test.sh npm run test:issue-247-catalog-invariants && bash tests/run-test.sh npm run test:issue-250-catalog-perf && bash tests/run-test.sh npm run test:issue-252 && bash tests/run-test.sh npm run test:inventory-ledger && bash tests/run-test.sh npm run test:issue-printing-and-kds-resilience && bash tests/run-test.sh npm run test:issue-258-bill-pagination && bash tests/run-test.sh npm run test:issue-265-morocco-profile && bash tests/run-test.sh npm run test:issue-266-currency-symbol-print && bash tests/run-test.sh npm run test:tables-string-ids && bash tests/run-test.sh npm run test:issue-646-floor-management && bash tests/run-test.sh npm run test:held-orders && bash tests/run-test.sh npm run test:product-images && bash tests/run-test.sh npm run test:schema-health && bash tests/run-test.sh npm run test:migration-registry && bash tests/run-test.sh npm run test:upgrade-path && bash tests/run-test.sh npm run test:upgrade-matrix-harness && bash tests/run-test.sh npm run test:migration-v56-v57 && bash tests/run-test.sh npm run test:migration-v71-repair && bash tests/run-test.sh npm run test:migration-v80-cash-drawer-pulse && bash tests/run-test.sh npm run test:migration-v81-cash-closures && bash tests/run-test.sh npm run test:master-pin && bash tests/run-test.sh npm run test:google-drive && bash tests/run-test.sh npm run test:database-tools-api && bash tests/run-test.sh npm run test:phone-validation && bash tests/run-test.sh npm run test:phone-migration && bash tests/run-test.sh npm run test:issue-133-kds-kot-toggles && bash tests/run-test.sh npm run test:whatsapp-schema && bash tests/run-test.sh npm run test:whatsapp-service && bash tests/run-test.sh npm run test:whatsapp-middleware && bash tests/run-test.sh npm run test:issue-127-password-recovery && bash tests/run-test.sh npm run test:dev-tooling && bash tests/run-test.sh npm run test:windows-uninstaller && bash tests/run-test.sh npm run test:shutdown-lifecycle && bash tests/run-test.sh npm run test:redos-hardening && bash tests/run-test.sh npm run test:startup-cache && bash tests/run-test.sh npm run test:service-worker && bash tests/run-test.sh npm run test:issue-389-timezone-override && bash tests/run-test.sh npm run test:issue-390-locale-preference-invariants && bash tests/run-test.sh npm run test:issue-475-picker-highlight && bash tests/run-test.sh npm run test:theme-mode-settings && bash tests/run-test.sh npm run test:theme-fouc-script && bash tests/run-test.sh npm run test:ui-regressions-621-623-626 && bash tests/run-test.sh npm run test:pos-ux-layout-guards && bash tests/run-test.sh npm run test:pos-checkout-search-guards && bash tests/run-test.sh npm run test:issue-zero-balance-settlement && bash tests/run-test.sh npm run test:daily-sales-export && bash tests/run-test.sh npm run test:issue-terminal-state-guards && bash tests/run-test.sh npm run test:issue-payload-and-discount-bounds && bash tests/run-test.sh npm run test:units && bash tests/run-test.sh npm run test:supplies-service && bash tests/run-test.sh npm run test:recipe-order-lifecycle", +"test": "bash tests/run-test.sh npm run test:smoke && bash tests/run-test.sh npm run test:server-port-collision && bash tests/run-test.sh npm run test:kds-integration && bash tests/run-test.sh npm run test:kds-contract && bash tests/run-test.sh npm run test:kds-frontend-conflict && bash tests/run-test.sh npm run test:kds-window-hardening && bash tests/run-test.sh npm run test:electron-api-contract && bash tests/run-test.sh npm run test:titlebar-window-options && bash tests/run-test.sh npm run test:menu-surface && bash tests/run-test.sh npm run test:window-readiness && bash tests/run-test.sh npm run test:window-load-retry && bash tests/run-test.sh npm run test:cors && bash tests/run-test.sh npm run test:csp-lan && bash tests/run-test.sh npm run test:release-config && bash tests/run-test.sh npm run test:update-state && bash tests/run-test.sh npm run test:update-ui && bash tests/run-test.sh npm run test:update-channel && bash tests/run-test.sh npm run test:telemetry && bash tests/run-test.sh npm run test:telemetry-install-provenance && bash tests/run-test.sh npm run test:country-provenance && bash tests/run-test.sh npm run test:first-run && bash tests/run-test.sh npm run test:phase7-setup-i18n && bash tests/run-test.sh npm run test:security && bash tests/run-test.sh npm run test:jwt-secret-cache-identity && bash tests/run-test.sh npm run test:jwt-secret-persist-before-cache && bash tests/run-test.sh npm run test:staff-authz && bash tests/run-test.sh npm run test:orders-authz && bash tests/run-test.sh npm run test:order-item-cancel-restore && bash tests/run-test.sh npm run test:product-variants-orders && bash tests/run-test.sh npm run test:addon-inventory-pos-surface && bash tests/run-test.sh npm run test:addon-inventory-orders && bash tests/run-test.sh npm run test:addon-inventory-lifecycle && bash tests/run-test.sh npm run test:order-totals-recompute && bash tests/run-test.sh npm run test:issue-718-whatsapp-order-status && bash tests/run-test.sh npm run test:order-audit-log && bash tests/run-test.sh npm run test:authz-phase3 && bash tests/run-test.sh npm run test:authorization-permissions && bash tests/run-test.sh npm run test:auth-ui-deterministic && bash tests/run-test.sh npm run test:customer-auth && bash tests/run-test.sh npm run test:customer-soft-reactivate-bindings && bash tests/run-test.sh npm run test:customer-pagination && bash tests/run-test.sh npm run test:server-app-server-role && bash tests/run-test.sh npm run test:backup && bash tests/run-test.sh npm run test:issue-278-fail-closed-db && bash tests/run-test.sh npm run test:recovery-cloud && bash tests/run-test.sh npm run test:cloud-account-status && bash tests/run-test.sh npm run test:printer && bash tests/run-test.sh npm run test:printing-settings && bash tests/run-test.sh npm run test:printer-width-refresh && bash tests/run-test.sh npm run test:printer-migrations && bash tests/run-test.sh npm run test:print-parity && bash tests/run-test.sh npm run test:delivery-slip && bash tests/run-test.sh npm run test:delivery-address && bash tests/run-test.sh npm run test:thermal-capabilities && bash tests/run-test.sh npm run test:raster && bash tests/run-test.sh npm run test:merchant-print-templates && bash tests/run-test.sh npm run test:merchant-template-transfer && bash tests/run-test.sh npm run test:print-document && bash tests/run-test.sh npm run test:menu-printing && bash tests/run-test.sh npm run test:print-kernel && bash tests/run-test.sh npm run test:translations && bash tests/run-test.sh npm run test:print-labels && bash tests/run-test.sh npm run test:locale-chunks && bash tests/run-test.sh npm run test:rtl-foundation && bash tests/run-test.sh npm run test:rtl-setup-auth-settings && bash tests/run-test.sh npm run test:rtl-dashboard-pos-common && bash tests/run-test.sh npm run test:rtl-kds-server-whatsapp && bash tests/run-test.sh npm run test:phone && bash tests/run-test.sh npm run test:country-localization && bash tests/run-test.sh npm run test:e2e-argentina-flow && bash tests/run-test.sh npm run test:charges && bash tests/run-test.sh npm run test:charges-engine && bash tests/run-test.sh npm run test:currency && bash tests/run-test.sh npm run test:tax-engine && bash tests/run-test.sh npm run test:tax-components && bash tests/run-test.sh npm run test:tax-pack-catalog && bash tests/run-test.sh npm run test:tax-pack-management && bash tests/run-test.sh npm run test:manual-tax-config && bash tests/run-test.sh npm run test:legacy-tax-pack-digest && bash tests/run-test.sh npm run test:community-tax-packs && bash tests/run-test.sh npm run test:support-ticket && bash tests/run-test.sh npm run test:customer-phone-search && bash tests/run-test.sh npm run test:phone-search-integration && bash tests/run-test.sh npm run test:orders-search && bash tests/run-test.sh npm run test:orders-layout-settings && bash tests/run-test.sh npm run test:receipt-column-width && bash tests/run-test.sh npm run test:receipt-column-oracle && bash tests/run-test.sh npm run test:notes-validation && bash tests/run-test.sh npm run test:receipt-printing && bash tests/run-test.sh npm run test:cancel-override && bash tests/run-test.sh npm run test:refunds && bash tests/run-test.sh npm run test:refund-completed-orders && bash tests/run-test.sh npm run test:cash-closures && bash tests/run-test.sh npm run test:cash-sessions && bash tests/run-test.sh npm run test:kitchen-addons && bash tests/run-test.sh npm run test:order-item-addons && bash tests/run-test.sh npm run test:issue-125-addon-reads && bash tests/run-test.sh npm run test:windows-country-code-crash && bash tests/run-test.sh npm run test:reports-insights && bash tests/run-test.sh npm run test:reports-daily-stats-table-turn && bash tests/run-test.sh npm run test:timezone-report-boundaries && bash tests/run-test.sh npm run test:business-date && bash tests/run-test.sh npm run test:dashboard-business-date && bash tests/run-test.sh npm run test:sequence && bash tests/run-test.sh npm run test:order-numbering-settings && bash tests/run-test.sh npm run test:integration-happy && bash tests/run-test.sh npm run test:integration-tax && bash tests/run-test.sh npm run test:integration-payments && bash tests/run-test.sh npm run test:kitchen-delivery-enforcement && bash tests/run-test.sh npm run test:issue-214 && bash tests/run-test.sh npm run test:issue-214-auth && bash tests/run-test.sh npm run test:issue-214-migration && bash tests/run-test.sh npm run test:issue-255 && bash tests/run-test.sh npm run test:cart-variant-identity && bash tests/run-test.sh npm run test:product-variants-screen && bash tests/run-test.sh npm run test:variant-pos-surface && bash tests/run-test.sh npm run test:issue-788-order-attempt-storage && bash tests/run-test.sh npm run test:integration-lifecycle && bash tests/run-test.sh npm run test:integration-reconciliation && bash tests/run-test.sh npm run test:integration-loyalty && bash tests/run-test.sh npm run test:integration-discount && bash tests/run-test.sh npm run test:loyalty-toggle && bash tests/run-test.sh npm run test:discount-system && bash tests/run-test.sh npm run test:integration-discount-settings && bash tests/run-test.sh npm run test:integration-loyalty-global && bash tests/run-test.sh npm run test:issue-248-csv && bash tests/run-test.sh npm run test:integration-loyalty-redemption && bash tests/run-test.sh npm run test:bills-print-api && bash tests/run-test.sh npm run test:issue-24 && bash tests/run-test.sh npm run test:issue-134-routing && bash tests/run-test.sh npm run test:issue-134-mgmt && bash tests/run-test.sh npm run test:issue-137-barcode && bash tests/run-test.sh npm run test:issue-244-product-addon-links && bash tests/run-test.sh npm run test:issue-245-246-addon-groups && bash tests/run-test.sh npm run test:issue-247-catalog-invariants && bash tests/run-test.sh npm run test:issue-250-catalog-perf && bash tests/run-test.sh npm run test:issue-252 && bash tests/run-test.sh npm run test:inventory-ledger && bash tests/run-test.sh npm run test:issue-printing-and-kds-resilience && bash tests/run-test.sh npm run test:issue-258-bill-pagination && bash tests/run-test.sh npm run test:issue-265-morocco-profile && bash tests/run-test.sh npm run test:issue-266-currency-symbol-print && bash tests/run-test.sh npm run test:tables-string-ids && bash tests/run-test.sh npm run test:issue-646-floor-management && bash tests/run-test.sh npm run test:held-orders && bash tests/run-test.sh npm run test:product-images && bash tests/run-test.sh npm run test:schema-health && bash tests/run-test.sh npm run test:migration-registry && bash tests/run-test.sh npm run test:upgrade-path && bash tests/run-test.sh npm run test:upgrade-matrix-harness && bash tests/run-test.sh npm run test:migration-v56-v57 && bash tests/run-test.sh npm run test:migration-v71-repair && bash tests/run-test.sh npm run test:migration-v80-cash-drawer-pulse && bash tests/run-test.sh npm run test:migration-v81-cash-closures && bash tests/run-test.sh npm run test:master-pin && bash tests/run-test.sh npm run test:google-drive && bash tests/run-test.sh npm run test:database-tools-api && bash tests/run-test.sh npm run test:phone-validation && bash tests/run-test.sh npm run test:phone-migration && bash tests/run-test.sh npm run test:issue-133-kds-kot-toggles && bash tests/run-test.sh npm run test:whatsapp-schema && bash tests/run-test.sh npm run test:whatsapp-service && bash tests/run-test.sh npm run test:whatsapp-middleware && bash tests/run-test.sh npm run test:issue-127-password-recovery && bash tests/run-test.sh npm run test:dev-tooling && bash tests/run-test.sh npm run test:windows-uninstaller && bash tests/run-test.sh npm run test:shutdown-lifecycle && bash tests/run-test.sh npm run test:redos-hardening && bash tests/run-test.sh npm run test:startup-cache && bash tests/run-test.sh npm run test:service-worker && bash tests/run-test.sh npm run test:issue-389-timezone-override && bash tests/run-test.sh npm run test:issue-390-locale-preference-invariants && bash tests/run-test.sh npm run test:issue-475-picker-highlight && bash tests/run-test.sh npm run test:theme-mode-settings && bash tests/run-test.sh npm run test:theme-fouc-script && bash tests/run-test.sh npm run test:ui-regressions-621-623-626 && bash tests/run-test.sh npm run test:issue-zero-balance-settlement && bash tests/run-test.sh npm run test:daily-sales-export && bash tests/run-test.sh npm run test:issue-terminal-state-guards && bash tests/run-test.sh npm run test:issue-payload-and-discount-bounds && bash tests/run-test.sh npm run test:units && bash tests/run-test.sh npm run test:supplies-service && bash tests/run-test.sh npm run test:recipe-order-lifecycle", "test:dev-tooling": "ts-node --transpile-only -P tests/tsconfig.json tests/dev-tooling-scripts.test.ts && npm run test:phase2 && npm run test:runtime-recovery && npm run test:backend-health && npm run test:url-allowlist && npm run test:static-routes", "test:script-coverage": "node scripts/ci/validate-test-script-coverage.cjs && node tests/test-script-coverage.test.cjs && ts-node --transpile-only -P tests/tsconfig.json tests/test-harness-assertions.test.ts", "test:shutdown-lifecycle": "npm run build && node tests/run-electron-node-test.cjs tests/shutdown-lifecycle.test.ts", @@ -69,8 +69,6 @@ "test:menu-surface": "ts-node --transpile-only -P tests/tsconfig.json tests/menu-surface.test.ts", "test:theme-fouc-script": "ts-node --transpile-only -P tests/tsconfig.json tests/theme-fouc-script.test.ts", "test:ui-regressions-621-623-626": "ts-node --transpile-only -P tests/tsconfig.json tests/ui-regressions-621-623-626.test.ts", - "test:pos-ux-layout-guards": "ts-node --transpile-only -P tests/tsconfig.json tests/pos-ux-layout-guards.test.ts", - "test:pos-checkout-search-guards": "ts-node --transpile-only -P tests/tsconfig.json tests/pos-checkout-search-guards.test.ts", "test:window-readiness": "ts-node --transpile-only -P tests/tsconfig.json tests/window-readiness.test.ts", "test:window-load-retry": "ts-node --transpile-only -P tests/tsconfig.json tests/window-load-retry.test.ts", "test:runtime-recovery": "ts-node --transpile-only -P tests/tsconfig.json tests/runtime-recovery.test.ts", diff --git a/tests/pos-checkout-search-guards.test.ts b/tests/pos-checkout-search-guards.test.ts deleted file mode 100644 index 0829f38f2..000000000 --- a/tests/pos-checkout-search-guards.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Source-level guards for the catalog/inventory search and checkout surfaces: - * - * - Products (products / categories / add-ons) and Inventory (recipes / - * movements) must offer search, and every search must run over the whole - * collection rather than the first loaded page; - * - the Products tab strip must use the shared Tabs component that - * Inventory already uses; - * - a split-check-enabled store must be able to split a dine-in check from - * the payment dialog, and pay each resulting check; - * - a short tender must be collectable as a deliberate partial payment - * instead of being blocked in the UI; - * - the Orders detail pane must fill the space next to the master list. - * - * These are UI contracts asserted against the rendered markup's own class and - * call contract, in the same shape as the other guards in this directory. - * - * Run: ts-node --transpile-only -P tests/tsconfig.json tests/pos-checkout-search-guards.test.ts - */ -import { strict as assert } from 'node:assert'; -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -const source = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); - -// ── Products: shared tabs + one search over the full catalog ──────────────── -{ - const products = source('frontend/src/app/(dashboard)/products/page.tsx'); - const inventory = source('frontend/src/app/(dashboard)/inventory/page.tsx'); - - assert.match( - products, - /import \{ Tabs, TabsContent, TabsList, TabsTrigger \} from '@\/components\/ui\/tabs';/, - 'the products page uses the same Tabs component as the inventory page', - ); - assert.match( - inventory, - /from '@\/components\/ui\/tabs';/, - 'the inventory page still uses the shared Tabs component', - ); - for (const tab of ['products', 'categories', 'addons'] as const) { - assert.match( - products, - new RegExp(``), - `the ${tab} tab is a shared TabsTrigger`, - ); - assert.match( - products, - new RegExp(``), - `the ${tab} tab body is a shared TabsContent`, - ); - } - - // Every list is filtered client-side from the complete API response, so a - // search can never stop at a paginated subset. - assert.match( - products, - /const visibleProducts = normalizedSearch === ''\n \? products\n : products\.filter/, - 'product search filters the complete product list', - ); - assert.match( - products, - /const visibleCategories = normalizedSearch === ''\n \? categories\n : categories\.filter/, - 'category search filters the complete category list', - ); - assert.match( - products, - /const visibleAddonGroups = normalizedSearch === ''\n \? addonGroups\n : addonGroups\.filter/, - 'add-on search filters the complete add-on group list', - ); - for (const list of ['visibleProducts', 'visibleCategories', 'visibleAddonGroups'] as const) { - assert.match(products, new RegExp(`${list}\\.map`), `${list} renders the filtered rows`); - } - assert.match( - products, - /placeholder=\{tCommon\('search'\)\}/, - 'the catalog search box is labelled by the shared search translation', - ); -} - -// ── Inventory: recipe search client-side, movement search server-side ─────── -{ - const inventory = source('frontend/src/app/(dashboard)/inventory/page.tsx'); - - assert.match( - inventory, - /const visibleRecipes = normalizedRecipeSearch === ''\n \? recipes\n : recipes\.filter/, - 'recipe search filters the complete, unpaginated recipe list', - ); - assert.match(inventory, /visibleRecipes\.map/, 'the recipe table renders the filtered rows'); - assert.match( - inventory, - /if \(movementSearch\.trim\(\)\) params\.search = movementSearch\.trim\(\);/, - 'movement search is sent to the API, so it spans every page, not just the loaded rows', - ); - assert.match( - inventory, - /value=\{movementSearch\} onChange=\{\(e\) => setMovementSearch\(e\.target\.value\)\}/, - 'the movements tab shows its own search box', - ); - assert.match( - inventory, - /value=\{recipeSearch\} onChange=\{\(e\) => setRecipeSearch\(e\.target\.value\)\}/, - 'the recipes tab shows its own search box', - ); - assert.match(inventory, /tCommon\('noResults'\)/, 'filtered-to-empty lists say so instead of showing the empty-store copy'); -} - -// ── Payment dialog: split checks and deliberate partial payments ──────────── -{ - const modal = source('frontend/src/components/pos/PaymentModal.tsx'); - - assert.match( - modal, - /api\.get\('\/settings\/split_checks_enabled'\)/, - 'the payment dialog reads the split-checks setting instead of assuming it', - ); - assert.match( - modal, - /const canSplitCheck = splitChecksEnabled[\s\S]{0,220}bill\.order\?\.type === 'dine_in'/, - 'split check is offered only for an untouched dine-in bill', - ); - assert.match(modal, / 0\)\}/, - 'the pay button stays disabled when nothing has been entered on a bill with a balance', - ); -} - -// ── Orders: split checks are payable, and the detail pane fills the row ───── -{ - const panel = source('frontend/src/components/orders/OrderDetailPanel.tsx'); - const orders = source('frontend/src/app/(dashboard)/orders/page.tsx'); - - assert.match( - panel, - /const splitBills = orderBills\.filter\(\(candidate\) => Boolean\(candidate\.split_group_id\)\);/, - 'the detail panel lists the order split checks', - ); - assert.match(panel, /onPayBill\?: \(bill: Bill\) => void;/, 'the detail panel can hand a single split check to payment'); - assert.match( - panel, - /onPayBill\(\{ \.\.\.splitBill, order \}\)/, - 'each unpaid split check carries its order context into payment', - ); - assert.match( - orders, - /onPayBill=\{\(bill\) => setPaymentBill\(bill\)\}/, - 'the Orders page opens payment for the picked split check', - ); - - assert.match( - orders, - /'w-full md:flex-1 min-w-0'/, - 'the detail pane fills the space next to the master list instead of stopping at a share of the row', - ); - assert.match( - orders, - /'w-full md:w-\[360px\] lg:w-\[400px\] xl:w-\[440px\] min-w-0 flex-col/, - 'the master list stays a fixed reading column so the detail pane keeps the rest', - ); - assert.match( - orders, - /onSplit=\{\(\) => \{\n setPaymentBill\(null\);/, - 'a completed split closes payment and refreshes the order list', - ); -} - -console.log('pos-checkout-search-guards: catalog search, split checks, partial payments, and orders width contracts hold'); diff --git a/tests/pos-ux-layout-guards.test.ts b/tests/pos-ux-layout-guards.test.ts deleted file mode 100644 index c5d4d53eb..000000000 --- a/tests/pos-ux-layout-guards.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Source-level guards for the POS surfaces reported as unusable at narrow - * widths and at high control counts: - * - * - the POS top bar crushed the customer name/phone fields and then - * overlapped them once the action buttons ran out of room; - * - the Add/Edit Product dialog was too narrow for the variant editor, which - * spilled outside the popup; - * - the Print Menu dialog jammed each checkbox against its label, and offered - * only A4/Letter output on whichever printer happened to be selected - * system-wide. - * - * These are layout contracts, so they are asserted against the rendered - * markup's own class contract rather than through a DOM, in the same shape as - * the other UI-regression guards in this directory. - * - * Run: ts-node --transpile-only -P tests/tsconfig.json tests/pos-ux-layout-guards.test.ts - */ -import { strict as assert } from 'node:assert'; -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -const source = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); - -// ── POS top bar: actions move to their own row instead of overlapping ──────── -{ - const topbar = source('frontend/src/components/pos/PosTopbar.tsx'); - - assert.match( - topbar, - /className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b bg-card[^"]*"/, - 'the top bar wraps, so its rows stack instead of overlapping', - ); - assert.match( - topbar, - /className="flex-1 min-w-0">\s*/, - 'the printer control stays in the action group', - ); -} - -// ── Add/Edit Product dialog: wide enough for the variant editor ───────────── -{ - const products = source('frontend/src/app/(dashboard)/products/page.tsx'); - - assert.match( - products, - /bg-card rounded-2xl w-full max-w-5xl max-h-\[92vh\] flex flex-col overflow-hidden/, - 'the product dialog uses the wide layout and a bounded, scrollable height', - ); - assert.match( - products, - /\{t\('variantsSection'\)\}<\/span>/, - 'the variant section is labelled by its own short heading, not a repeat of the toggle sentence', - ); - assert.match( - products, - / - diff --git a/main/routes/printers.ts b/main/routes/printers.ts index eaa555e76..48659782e 100644 --- a/main/routes/printers.ts +++ b/main/routes/printers.ts @@ -369,7 +369,7 @@ router.post('/print-menu', requirePermission('catalog.view'), requirePermission( if (body.paperWidth !== undefined && body.paperWidth !== 58 && body.paperWidth !== 80) { return res.status(400).json({ error: 'paperWidth must be 58 or 80' }); } - if (body.printerId !== undefined && typeof body.printerId !== 'string' && typeof body.printerId !== 'number') { + if (body.printerId !== undefined && typeof body.printerId !== 'string') { return res.status(400).json({ error: 'printerId must be a string' }); } diff --git a/tests/print-menu-printer-selection.test.ts b/tests/print-menu-printer-selection.test.ts index 31aea2853..7e9b5ec70 100644 --- a/tests/print-menu-printer-selection.test.ts +++ b/tests/print-menu-printer-selection.test.ts @@ -123,8 +123,9 @@ async function main() { assert.equal(unknown.status, 404, `an unknown printerId is refused (${unknown.status})`); assert.equal(unknown.data.code, 'printer_not_found', 'an unknown printerId is refused as printer_not_found'); - const numericUnknown = await printMenu({ printerId: 999999 }); - assert.equal(numericUnknown.status, 404, `a numeric printerId is treated as an identifier and refused when unknown (${numericUnknown.status})`); + const numericId = await printMenu({ printerId: 999999 }); + assert.equal(numericId.status, 400, `a numeric printerId is rejected (${numericId.status})`); + assert.equal(numericId.data.error, 'printerId must be a string', 'a numeric printerId reports the expected message'); const selectedByDefault = await printMenu({ printerId: frontCounterId }); assert.equal(selectedByDefault.data.printerName, 'Front Counter Printer', 'picking the default printer explicitly is also honoured'); From a64b578fc78d11187a3682f0bde4a9a12eca45fc Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 6 Oct 2026 18:36:30 -0400 Subject: [PATCH 17/20] no-mistakes(document): Document print destinations and fix Inventory lint --- docs/architecture/desktop-build.md | 4 +- docs/architecture/printing.md | 10 +++-- docs/printers.md | 14 +++++++ frontend/e2e/menu-printing.spec.ts | 42 ++++++++++++++++--- .../src/app/(dashboard)/inventory/page.tsx | 18 +++++++- 5 files changed, 75 insertions(+), 13 deletions(-) diff --git a/docs/architecture/desktop-build.md b/docs/architecture/desktop-build.md index d841b0f6c..c6bad330f 100644 --- a/docs/architecture/desktop-build.md +++ b/docs/architecture/desktop-build.md @@ -66,8 +66,8 @@ does not preserve the nesting the export emits elsewhere. IPC is a narrow native surface, not a second data plane. The renderer reaches native capability through `main/preload.ts`, which exposes a `contextBridge` object. The window set is small: window -controls, window state, opening the KDS window, update status, database initialization, and the -theme handshake. +controls, window state, opening the KDS window, update status, database initialization, the theme +handshake, and native PDF export. The main window is created with `contextIsolation: true`, `nodeIntegration: false`, and `sandbox: false`. `sandbox: false` is a deliberate choice recorded in diff --git a/docs/architecture/printing.md b/docs/architecture/printing.md index 9c15d00ad..be552855d 100644 --- a/docs/architecture/printing.md +++ b/docs/architecture/printing.md @@ -266,9 +266,13 @@ KOT path applies it, so an item that has left the kitchen does not reappear on a ## Menu catalog printing Products and POS expose a filtered menu print dialog with optional descriptions and effective -add-on groups. `main/printers/document-menu.ts` builds the thermal menu; `printMenuDocument` uses -the profile-owned raster path for unsupported text. Physical-print failures can fall back to -`frontend/src/lib/printer/menu-web-print.ts` with the selected A4 or Letter page size. +add-on groups. The dialog can send ESC/POS to a selected receipt printer and roll width, open the +system print dialog for A4 or Letter paper, or save a structured PDF. In Electron, the PDF uses the +`save-html-as-pdf` IPC capability; in a browser it uses the system print dialog's Save as PDF +option. `main/printers/document-menu.ts` builds the thermal menu; `printMenuDocument` uses the +profile-owned raster path for unsupported text. A failed receipt print can fall back to +`frontend/src/lib/printer/menu-web-print.ts` with the selected A4 or Letter page size. Operator +steps are in the [printer setup guide](../printers.md#menu-printing). ## Raster printing diff --git a/docs/printers.md b/docs/printers.md index 1dd128112..1d3aecbbb 100644 --- a/docs/printers.md +++ b/docs/printers.md @@ -26,6 +26,20 @@ Enable **Open cash drawer on checkout** on a receipt printer only when a till is printer's drawer-kick port. When enabled, FloCafe appends the standard ESC/POS drawer pulse to printed receipt jobs for that printer, and only for payment methods on the drawer's allowlist. +## Menu printing + +From **Products** or **POS**, open **Print Menu** and choose a destination before printing: + +- **Receipt printer** sends the menu to a configured printer. Choose the printer and its 58 mm or + 80 mm roll width. +- **Paper** opens the system print dialog for A4 or Letter paper. +- **PDF** saves a structured PDF through the desktop save dialog. In a browser, use **Save as PDF** + in the system print dialog instead. + +The dialog also lets you filter which products and details appear. See the +[printing architecture](architecture/printing.md#menu-catalog-printing) for how each destination is +rendered. + ## Printer profiles and paper width FloCafe resolves a printer to a capability profile in three steps, in order: diff --git a/frontend/e2e/menu-printing.spec.ts b/frontend/e2e/menu-printing.spec.ts index 133ca1e37..fdeb9a2f9 100644 --- a/frontend/e2e/menu-printing.spec.ts +++ b/frontend/e2e/menu-printing.spec.ts @@ -1,9 +1,15 @@ import { test, expect } from '@playwright/test'; import { E2E_BASE_URL as BASE } from './helpers/urls'; -import { E2E_PASSWORD } from './helpers/test-auth'; +import { E2E_PASSWORD, getE2eToken } from './helpers/test-auth'; + +const evidence = process.env.FLO_E2E_EVIDENCE_DIR; test('menu modal suspends barcode scans and browser fallback includes selected details on Letter paper', async ({ page, context }) => { - await context.addInitScript(() => { window.print = () => {}; }); + await context.addInitScript(() => { + window.print = () => {}; + // Keep the generated document inspectable after the stubbed print dialog returns. + window.close = () => {}; + }); await page.route('**/api/products*', async (route) => { if (route.request().method() !== 'GET') return route.continue(); const response = await route.fetch(); @@ -54,11 +60,22 @@ test('menu modal suspends barcode scans and browser fallback includes selected d await expect.poll(() => popup.locator('style').textContent()).toContain('size: Letter portrait'); await expect(page.getByRole('dialog')).toHaveCount(0); await expect(page.getByRole('heading', { name: 'E2E Coffee', exact: true, level: 2 })).toHaveCount(0); + if (evidence) await popup.screenshot({ path: `${evidence}/print-menu-letter-browser-preview.png`, fullPage: true }); await popup.close(); }); -test('print menu offers receipt, paper, and PDF destinations', async ({ page, context }) => { - await context.addInitScript(() => { window.print = () => {}; }); +test('print menu offers receipt, paper, and PDF destinations', async ({ page, context, request }) => { + await context.addInitScript(() => { + window.print = () => {}; + // Keep the generated document inspectable after the stubbed print dialog returns. + window.close = () => {}; + }); + const printerResponse = await request.post(`${BASE}/api/printers`, { + headers: { Authorization: `Bearer ${getE2eToken()}` }, + data: { name: 'E2E Menu Printer', connection_type: 'network', ip_address: '127.0.0.1', port: 9100 }, + }); + expect(printerResponse.status()).toBe(201); + const printer = (await printerResponse.json()).printer as { id: string; name: string }; await page.goto(`${BASE}/auth/login`); await page.getByLabel('Email').fill('owner@flo.local'); await page.getByLabel('Password').fill(E2E_PASSWORD); @@ -91,9 +108,21 @@ test('print menu offers receipt, paper, and PDF destinations', async ({ page, co // The receipt destination swaps the sheet size for the thermal roll width, // and a missing picker never dead-ends the operator. - await destinations.getByRole('radio', { name: 'Receipt printer' }).click(); - await expect(dialog.getByRole('combobox', { name: 'Paper Size' })).toBeVisible(); + const receiptDestination = destinations.getByRole('radio', { name: 'Receipt printer' }); + await receiptDestination.click(); + await expect(receiptDestination).toHaveAttribute('aria-checked', 'true'); + await expect(receiptDestination).toHaveClass(/border-brand/); + await expect(paperDestination).toHaveAttribute('aria-checked', 'false'); + const printerPicker = dialog.getByRole('combobox', { name: 'Printer', exact: true }); + await expect(printerPicker).toHaveValue(printer.id); + await expect(printerPicker.locator('option')).toHaveText([printer.name]); + const rollWidth = dialog.getByRole('combobox', { name: 'Paper Size' }); + await expect(rollWidth.locator('option')).toHaveText(['2.5" (58mm)', '3.5" (80mm)']); + await rollWidth.selectOption('80'); + await expect(rollWidth).toHaveValue('80'); await expect(dialog.getByRole('button', { name: 'Print Menu', exact: true })).toBeEnabled(); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + if (evidence) await page.screenshot({ path: `${evidence}/print-menu-receipt-destination.png`, fullPage: true }); // Without the desktop bridge, saving as PDF still produces a printable // document through the browser print dialog. @@ -104,5 +133,6 @@ test('print menu offers receipt, paper, and PDF destinations', async ({ page, co await expect(popup.getByRole('heading', { name: 'Menu', level: 1 })).toBeVisible(); await expect.poll(() => popup.locator('style').textContent()).toContain('size: A4 portrait'); await expect(page.getByRole('dialog')).toHaveCount(0); + if (evidence) await popup.screenshot({ path: `${evidence}/print-menu-a4-pdf-preview.png`, fullPage: true }); await popup.close(); }); diff --git a/frontend/src/app/(dashboard)/inventory/page.tsx b/frontend/src/app/(dashboard)/inventory/page.tsx index 386f5cc89..edd2761da 100644 --- a/frontend/src/app/(dashboard)/inventory/page.tsx +++ b/frontend/src/app/(dashboard)/inventory/page.tsx @@ -162,8 +162,22 @@ export default function InventoryPage() { useEffect(() => { if (!canManage || tab !== 'movements') return; - void loadMovements(null); - }, [tab, canManage, loadMovements]); + const controller = new AbortController(); + const requestSequence = ++movementRequestSequence.current; + const params: Record = { per_page: 50 }; + if (movementSearch.trim()) params.search = movementSearch.trim(); + api.get('/supplies/movements', { params, signal: controller.signal }) + .then(({ data }) => { + if (requestSequence !== movementRequestSequence.current) return; + setMovements(data.movements || []); + setNextCursor(data.nextCursor ?? null); + }) + .catch((err: unknown) => { + if (requestSequence === movementRequestSequence.current && !(err instanceof Error && (err.name === 'CanceledError' || err.name === 'AbortError'))) toast.error(t('loadFailed')); + }); + return () => controller.abort(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tab, canManage, refreshKey, movementSearch]); const openAddSupply = () => { setEditingSupply(null); From 64638175a31b8c1505b4471e40e41de15f366f35 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 6 Oct 2026 18:56:55 -0400 Subject: [PATCH 18/20] no-mistakes(ci): Fixed the menu-printing, supplies-service, and Playwright failures: removed a source-text-only assertion, cleaned up wildcard-search fixtures, updated the Categories locator to use the tab role, and made the printer test select its uniquely named printer. The Orders refresh test now waits for the expected 503 response, avoiding a race with earlier 200 responses. Focused suites passed, including five consecutive runs of the Orders test; backend and frontend builds, lint, and git diff checks passed. Lint reported existing warnings but no errors. The Required Checks Gate will be evaluated by the outer pipeline --- .../e2e/category-inactive-addon-groups.spec.ts | 2 +- frontend/e2e/kot-append-only-print.spec.ts | 3 ++- frontend/e2e/menu-printing.spec.ts | 6 ++++-- tests/menu-printing.test.ts | 16 +--------------- tests/supplies-service.test.ts | 4 +++- 5 files changed, 11 insertions(+), 20 deletions(-) diff --git a/frontend/e2e/category-inactive-addon-groups.spec.ts b/frontend/e2e/category-inactive-addon-groups.spec.ts index a4767049b..de42b0aa9 100644 --- a/frontend/e2e/category-inactive-addon-groups.spec.ts +++ b/frontend/e2e/category-inactive-addon-groups.spec.ts @@ -36,7 +36,7 @@ test('category editor can remove an assigned inactive add-on group', async ({ pa await page.getByRole('button', { name: 'Sign In' }).click(); await page.waitForURL(/\/(pos|orders)/, { timeout: 20000 }); await page.goto(`${BASE}/products`); - await page.getByRole('button', { name: 'Categories', exact: true }).click(); + await page.getByRole('tab', { name: 'Categories', exact: true }).click(); const row = page.getByRole('row').filter({ hasText: categoryName }); await row.getByRole('button').first().click(); diff --git a/frontend/e2e/kot-append-only-print.spec.ts b/frontend/e2e/kot-append-only-print.spec.ts index 6387f56ed..561dac04c 100644 --- a/frontend/e2e/kot-append-only-print.spec.ts +++ b/frontend/e2e/kot-append-only-print.spec.ts @@ -823,7 +823,8 @@ test('Orders keeps its bill print confirmation when the post-generation list ref const generation = page.waitForResponse((response) => response.request().method() === 'POST' && new URL(response.url()).pathname === '/api/bills/generate'); const listRefresh = page.waitForResponse((response) => response.request().method() === 'GET' - && new URL(response.url()).pathname === '/api/orders'); + && new URL(response.url()).pathname === '/api/orders' + && response.status() === 503); await printButton.click(); const generationResponse = await generation; expect(generationResponse.ok()).toBeTruthy(); diff --git a/frontend/e2e/menu-printing.spec.ts b/frontend/e2e/menu-printing.spec.ts index fdeb9a2f9..d1d7c84af 100644 --- a/frontend/e2e/menu-printing.spec.ts +++ b/frontend/e2e/menu-printing.spec.ts @@ -70,9 +70,10 @@ test('print menu offers receipt, paper, and PDF destinations', async ({ page, co // Keep the generated document inspectable after the stubbed print dialog returns. window.close = () => {}; }); + const printerName = `E2E Menu Printer ${Math.random().toString(36).slice(2, 8)}`; const printerResponse = await request.post(`${BASE}/api/printers`, { headers: { Authorization: `Bearer ${getE2eToken()}` }, - data: { name: 'E2E Menu Printer', connection_type: 'network', ip_address: '127.0.0.1', port: 9100 }, + data: { name: printerName, connection_type: 'network', ip_address: '127.0.0.1', port: 9100 }, }); expect(printerResponse.status()).toBe(201); const printer = (await printerResponse.json()).printer as { id: string; name: string }; @@ -114,8 +115,9 @@ test('print menu offers receipt, paper, and PDF destinations', async ({ page, co await expect(receiptDestination).toHaveClass(/border-brand/); await expect(paperDestination).toHaveAttribute('aria-checked', 'false'); const printerPicker = dialog.getByRole('combobox', { name: 'Printer', exact: true }); + await expect(printerPicker.getByRole('option', { name: printerName, exact: true })).toHaveCount(1); + await printerPicker.selectOption(printer.id); await expect(printerPicker).toHaveValue(printer.id); - await expect(printerPicker.locator('option')).toHaveText([printer.name]); const rollWidth = dialog.getByRole('combobox', { name: 'Paper Size' }); await expect(rollWidth.locator('option')).toHaveText(['2.5" (58mm)', '3.5" (80mm)']); await rollWidth.selectOption('80'); diff --git a/tests/menu-printing.test.ts b/tests/menu-printing.test.ts index 819655f0a..6b15034fb 100644 --- a/tests/menu-printing.test.ts +++ b/tests/menu-printing.test.ts @@ -1,7 +1,5 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import fs from 'node:fs'; -import path from 'node:path'; import { displayCellWidth } from '../shared/print/width'; import { buildMenuDocument, renderMenuViaDocument } from '../main/printers/document-menu'; import { escPosToText } from '../main/printers/thermal'; @@ -174,7 +172,7 @@ test('the print gesture is reserved only where a popup blocker could take it', ( assert.equal(reservePrintGesture({ open: () => { throw new Error('blocked'); } } as never), null, 'a refused reservation is reported, not thrown'); }); -test('a deleted reservation would block the browser print it exists for', () => { +test('browser printing reports popup blocking without a reservation', () => { const { MenuPopupBlockedError, printMenuInBrowser } = require('../frontend/src/lib/printer/menu-web-print'); // What the reservation prevents: with nothing held back from the click, the @@ -184,18 +182,6 @@ test('a deleted reservation would block the browser print it exists for', () => (error: unknown) => error instanceof MenuPopupBlockedError, 'with no reservation in hand, a blocked window is reported rather than printed into nothing', ); - - // The modal has to actually make the reservation; a helper nobody calls is - // the same bug with extra indirection. - const modal = fs.readFileSync( - path.join(__dirname, '../frontend/src/components/products/PrintMenuModal.tsx'), - 'utf8', - ); - assert.match( - modal, - /const reservedWindow = reservePrintGesture\(\);/, - 'the print handler reserves the gesture before the print request goes out', - ); }); test('Unicode menu printing uses raster-capable WebUSB output and refuses raster failure', async () => { diff --git a/tests/supplies-service.test.ts b/tests/supplies-service.test.ts index 7749b2ae3..05c4e21f8 100644 --- a/tests/supplies-service.test.ts +++ b/tests/supplies-service.test.ts @@ -201,7 +201,7 @@ async function main() { const wildcardSupply = createSupply(db, { name: 'Mix 100%_salt!', baseUnit: 'kg', stockQuantity: 1, actorUserId: actor, }); - createSupply(db, { + const wildcardLookalikeSupply = createSupply(db, { name: 'Mix 1000Xsalt', baseUnit: 'kg', stockQuantity: 1, actorUserId: actor, }); for (const search of ['100%', '_salt', '!']) { @@ -209,6 +209,8 @@ async function main() { assertEqual(matches.movements.length, 1, `search treats ${search} as literal text`); assertEqual(matches.movements[0].supply_id, wildcardSupply.id, `search ${search} excludes wildcard matches`); } + softDeleteSupply(db, wildcardSupply.id); + softDeleteSupply(db, wildcardLookalikeSupply.id); // ── Soft delete ── softDeleteSupply(db, cups.id); From 7d50d5f66820ac72ff8d343f3118120b9f85f170 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 7 Oct 2026 00:15:51 -0400 Subject: [PATCH 19/20] fix(orders): allow refunding paid split bills while other splits remain unpaid --- frontend/src/components/orders/OrderDetailPanel.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/src/components/orders/OrderDetailPanel.tsx b/frontend/src/components/orders/OrderDetailPanel.tsx index 4daa98724..edeb6fd5f 100644 --- a/frontend/src/components/orders/OrderDetailPanel.tsx +++ b/frontend/src/components/orders/OrderDetailPanel.tsx @@ -641,6 +641,16 @@ function OrderDetailContent({ )}
))} + {canRefund && hasEligibleRefund && ( + + )}
) : isPaid || order.status === 'completed' ? (
From 5a4b2b62aacc05c51a245ccd730f73f3861bcd6f Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 7 Oct 2026 00:39:24 -0400 Subject: [PATCH 20/20] fix(pos): preserve order details on partial payments and load check items for split payments --- frontend/src/app/(dashboard)/orders/page.tsx | 15 ++++++++++++++- frontend/src/components/pos/PaymentModal.tsx | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/(dashboard)/orders/page.tsx b/frontend/src/app/(dashboard)/orders/page.tsx index d9e51ef86..875f3c435 100644 --- a/frontend/src/app/(dashboard)/orders/page.tsx +++ b/frontend/src/app/(dashboard)/orders/page.tsx @@ -661,6 +661,19 @@ export default function OrdersPage() { } }; + const handlePayBill = async (billToPay: Bill) => { + try { + const { data } = await api.get(`/bills/${billToPay.id}`); + if (data?.bill) { + setPaymentBill(preferChildScopedBill(data.bill as Bill, billToPay.order)); + return; + } + } catch { + // Fallback to existing bill if fetch fails + } + setPaymentBill(billToPay); + }; + const handlePaymentComplete = async () => { const bill = paymentBill; // capture before clearing state setPaymentBill(null); @@ -1253,7 +1266,7 @@ export default function OrdersPage() { linkCustomerResults={linkCustomerResults} linkingCustomer={linkingCustomer} onCheckout={handleCheckout} - onPayBill={(bill) => setPaymentBill(bill)} + onPayBill={handlePayBill} onAddItems={openAddItemsModal} onRefund={(ord, bills) => setRefundModal({ order: ord, bills })} onConvertToTakeaway={handleConvertToTakeaway} diff --git a/frontend/src/components/pos/PaymentModal.tsx b/frontend/src/components/pos/PaymentModal.tsx index 58dd19cf6..6c0ede7da 100644 --- a/frontend/src/components/pos/PaymentModal.tsx +++ b/frontend/src/components/pos/PaymentModal.tsx @@ -494,7 +494,7 @@ export default function PaymentModal({ bill, initialOverridePin, onClose, onPaid // This request committed a partial payment, so the next attempt is a // new request and must not reuse the completed request's hash. if (updatedBill) idempotencyKeyRef.current = null; - if (updatedBill && onBillUpdate) onBillUpdate(updatedBill); + if (updatedBill && onBillUpdate) onBillUpdate({ ...bill, ...updatedBill, order: bill.order }); if (updatedBill?.payment_status === 'partial') { toast.success(t('paymentRecorded')); } else {