diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da7b48f2b..d0aae7b67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -312,9 +312,13 @@ jobs: - name: Run native Electron tests under Xvfb # Playwright supplies --no-sandbox for Electron on Linux. Xvfb is the # required visible display; no packaged or modified app is launched. + # The harness hides windows by default so local runs do not pop up on a + # developer's desktop; CI has no desktop, and the native window-state + # and OS-theme tests need a shown window, so opt back in here. run: xvfb-run -a --server-args='-screen 0 1280x800x24' npm run test:e2e:electron env: CI: true + FLO_E2E_SHOW_WINDOW: '1' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() 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/architecture/runtime-and-lifecycle.md b/docs/architecture/runtime-and-lifecycle.md index 11fd727d7..0ddc1cbef 100644 --- a/docs/architecture/runtime-and-lifecycle.md +++ b/docs/architecture/runtime-and-lifecycle.md @@ -134,6 +134,13 @@ Closing the main window does not quit the app. The window `close` handler calls and hides the window unless quitting, so a hidden POS keeps serving. `window-all-closed` quits on every platform except macOS, and even then only when no window recovery is in progress. +The native Playwright harness launches the real app with the POS window held hidden +(`FLO_E2E_HIDDEN_WINDOW=1`, which the harness sets unless `FLO_E2E_SHOW_WINDOW=1`), so a local +`npm run test:e2e:electron` does not take over the developer's desktop. The renderer still runs +unthrottled. macOS only delivers native window-state changes and `prefers-color-scheme` updates to +a shown window, so those tests are skipped in hidden mode; CI sets `FLO_E2E_SHOW_WINDOW=1` because +its virtual display has no desktop to disturb. + ## `node dev-server.js` diverges from the packaged app `dev-server.js` is the supported way to run the backend without Electron, and it is useful for API 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/docs/reference/api.md b/docs/reference/api.md index 25c3a4c55..7f8d50715 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -213,7 +213,7 @@ Router: `main/routes/supplies.ts`. Full path: `/api/supplies`. | --- | --- | --- | --- | --- | | `GET` | `/` | `ROLE_ACCESS.ownerManager` | query: `?include_inactive`, `?low_stock`, `?search` | Supply list. `?low_stock` and `?include_inactive` narrow it. | | `POST` | `/` | `ROLE_ACCESS.ownerManager` | body: `name`, `base_unit`, `stock_quantity`, `low_stock_threshold`, `is_active` | - | -| `GET` | `/movements` | `ROLE_ACCESS.ownerManager` | query: `?supply_id`, `?movement_type`, `?before_id`, `?per_page` | Supply movement ledger, newest first; `?before_id` pages backwards. | +| `GET` | `/movements` | `ROLE_ACCESS.ownerManager` | query: `?supply_id`, `?movement_type`, `?search`, `?before_id`, `?per_page` | Supply movement ledger, newest first; `?search` matches the supply name, reason, or actor across the whole ledger; `?before_id` pages backwards. | | `GET` | `/:id` | `ROLE_ACCESS.ownerManager` | path: `id` | - | | `PUT` | `/:id` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `name`, `is_active`, `low_stock_threshold` | - | | `DELETE` | `/:id` | `ROLE_ACCESS.ownerManager` | path: `id` | - | @@ -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/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/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/theme.electron.spec.ts b/frontend/e2e/desktop/theme.electron.spec.ts index 60a44e624..8625aa9ed 100644 --- a/frontend/e2e/desktop/theme.electron.spec.ts +++ b/frontend/e2e/desktop/theme.electron.spec.ts @@ -145,6 +145,12 @@ test('real Electron flips the renderer palette when the owner toggles Dark in Se }); test('real System mode follows nativeTheme.themeSource through the renderer matchMedia listener', async () => { + // macOS only delivers prefers-color-scheme updates to a window that has been + // shown; measured with the harness's hidden default, the main process sees + // themeSource/shouldUseDarkColors change while the renderer's media query + // never leaves light. System mode therefore needs a real window. + test.skip(harness.hiddenWindow, 'Run with FLO_E2E_SHOW_WINDOW=1 for OS theme propagation'); + // The Settings page is still mounted from test 1 — switch back to System. await clickThemeRadio('System'); 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/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 bbb9d9449..d1d7c84af 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(); @@ -36,6 +42,7 @@ test('menu modal suspends barcode scans and browser fallback includes selected d const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); await dialog.getByRole('button', { name: 'Cancel', exact: true }).focus(); + await dialog.getByRole('radio', { name: 'Paper (A4 / Letter)' }).click(); await page.evaluate(() => { for (const key of '9780123456789') window.dispatchEvent(new KeyboardEvent('keydown', { key })); window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); @@ -53,5 +60,81 @@ 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, request }) => { + await context.addInitScript(() => { + window.print = () => {}; + // 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: 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 }; + await page.goto(`${BASE}/auth/login`); + await page.getByLabel('Email').fill('owner@flo.local'); + await page.getByLabel('Password').fill(E2E_PASSWORD); + await page.getByRole('button', { name: 'Sign In' }).click(); + await page.waitForURL(/\/(pos|orders)/); + await expect.poll(() => page.evaluate(() => localStorage.getItem('token'))).not.toBeNull(); + await page.goto(`${BASE}/pos`); + await expect(page.getByRole('button', { name: /E2E Coffee/ })).toBeVisible(); + await page.getByRole('button', { name: 'Print Menu', exact: true }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + // Every destination the operator can reach is offered up front; A4/Letter is + // a detail of the paper choice rather than the only way to print. + const destinations = dialog.getByRole('radiogroup', { name: 'Print destination' }); + await expect(destinations.getByRole('radio')).toHaveCount(3); + await expect(destinations.getByRole('radio', { name: 'Receipt printer' })).toBeVisible(); + const paperDestination = destinations.getByRole('radio', { name: 'Paper (A4 / Letter)' }); + await expect(paperDestination).toHaveAttribute('aria-checked', 'false'); + await expect(destinations.getByRole('radio', { name: 'Save as PDF' })).toBeVisible(); + const destinationBox = await destinations.boundingBox(); + const filterBox = await dialog.getByRole('checkbox').first().boundingBox(); + expect(destinationBox).not.toBeNull(); + expect(filterBox).not.toBeNull(); + expect(destinationBox!.y).toBeLessThan(filterBox!.y); + const printButton = dialog.getByRole('button', { name: 'Print Menu', exact: true }); + await expect(printButton).toBeDisabled(); + await paperDestination.click(); + await expect(printButton).toBeEnabled(); + + // The receipt destination swaps the sheet size for the thermal roll width, + // and a missing picker never dead-ends the operator. + 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.getByRole('option', { name: printerName, exact: true })).toHaveCount(1); + await printerPicker.selectOption(printer.id); + await expect(printerPicker).toHaveValue(printer.id); + 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. + await destinations.getByRole('radio', { name: 'Save as PDF' }).click(); + const popupPromise = page.waitForEvent('popup'); + await dialog.getByRole('button', { name: 'Save as PDF' }).click(); + const popup = await popupPromise; + 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 ddc812295..edd2761da 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,8 +85,11 @@ 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(''); + const [movementSearch, setMovementSearch] = useState(''); const [lowStockOnly, setLowStockOnly] = useState(false); const [includeInactive, setIncludeInactive] = useState(false); const [refreshKey, setRefreshKey] = useState(0); @@ -142,32 +145,39 @@ 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]); + }, [refreshKey, movementSearch]); useEffect(() => { if (!canManage || tab !== 'movements') return; const controller = new AbortController(); - api.get('/supplies/movements', { params: { per_page: 50 }, signal: controller.signal }) + 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 (!(err instanceof Error && (err.name === 'CanceledError' || err.name === 'AbortError'))) toast.error(t('loadFailed')); + 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, refreshKey, canManage]); + }, [tab, canManage, refreshKey, movementSearch]); const openAddSupply = () => { setEditingSupply(null); @@ -229,7 +239,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 @@ -303,6 +312,16 @@ export default function InventoryPage() { } }; + // Recipes come from one unpaginated list, so this filter always sees every + // recipe; movements are searched on the server (see the movements request). + const normalizedRecipeSearch = recipeSearch.trim().toLowerCase(); + const visibleRecipes = normalizedRecipeSearch === '' + ? recipes + : recipes.filter((recipe) => [ + recipe.product_name || recipe.product_id, + ...recipe.items.map((item) => item.supply_name), + ].some((field) => field.toLowerCase().includes(normalizedRecipeSearch))); + if (!canManage) { return (
@@ -405,6 +424,14 @@ export default function InventoryPage() { +
+ + setRecipeSearch(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" + /> +
@@ -416,7 +443,7 @@ export default function InventoryPage() { - {recipes.map((r) => ( + {visibleRecipes.map((r) => ( @@ -443,11 +470,24 @@ 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')}

}
+
+ + { + 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" + /> +
@@ -488,7 +528,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)/kds/page.tsx b/frontend/src/app/(dashboard)/kds/page.tsx index 39278bd53..a63bd69a6 100644 --- a/frontend/src/app/(dashboard)/kds/page.tsx +++ b/frontend/src/app/(dashboard)/kds/page.tsx @@ -11,14 +11,18 @@ import { useSyncServerLanguage } from '@/lib/i18n'; import { useTranslations } from 'use-intl'; import type { KdsViewMode } from '@/hooks/useKdsView'; -// Checks live kds_enabled setting directly from API; defaults to enabled on fetch errors. +// Keeps KDS idle until its setting is confirmed enabled. function useKdsEnabledCheck(): boolean | null { const [enabled, setEnabled] = useState(null); useEffect(() => { let cancelled = false; api.get('/settings/kds_enabled') - .then((res) => { if (!cancelled) setEnabled(res.data?.setting?.value !== 'false'); }) - .catch(() => { if (!cancelled) setEnabled(true); }); + .then((res) => { + if (cancelled) return; + const value = res.data?.setting?.value; + setEnabled(value === 'true' ? true : value === 'false' ? false : null); + }) + .catch(() => { if (!cancelled) setEnabled(null); }); return () => { cancelled = true; }; }, []); return enabled; @@ -46,9 +50,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..875f3c435 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(); @@ -408,56 +411,25 @@ export default function OrdersPage() { useEffect(() => { api.get('/settings/kds_enabled') - .then((res) => setKdsEnabled(res.data?.setting?.value !== 'false')) - .catch(() => setKdsEnabled(true)); + .then((res) => { + const value = res.data?.setting?.value; + setKdsEnabled(value === 'true' ? true : value === 'false' ? false : null); + }) + .catch(() => setKdsEnabled(null)); }, []); + // 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 active = true; 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); @@ -479,8 +451,21 @@ export default function OrdersPage() { } }; - ws.onclose = () => { - reconnectTimeout = setTimeout(connectWS, 3000); + ws.onclose = (event) => { + if (/kds is disabled/i.test(event.reason)) { + setKdsEnabled(false); + return; + } + reconnectTimeout = setTimeout(() => { + api.get('/settings/kds_enabled') + .then(({ data }) => { + if (!active) return; + const value = data?.setting?.value; + if (value === 'true') connectWS(); + else setKdsEnabled(value === 'false' ? false : null); + }) + .catch(() => { if (active) setKdsEnabled(null); }); + }, 3000); }; ws.onerror = () => { @@ -494,17 +479,62 @@ export default function OrdersPage() { connectWS(); return () => { - clearInterval(interval); + active = false; 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 +613,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 === true && (order.items || []).some((item) => !['served', 'cancelled'].includes(item.status)); } return true; }; @@ -631,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); @@ -1191,8 +1234,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)} @@ -1223,6 +1266,7 @@ export default function OrdersPage() { linkCustomerResults={linkCustomerResults} linkingCustomer={linkingCustomer} onCheckout={handleCheckout} + onPayBill={handlePayBill} onAddItems={openAddItemsModal} onRefund={(ord, bills) => setRefundModal({ order: ord, bills })} onConvertToTakeaway={handleConvertToTakeaway} @@ -1337,6 +1381,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/app/(dashboard)/products/page.tsx b/frontend/src/app/(dashboard)/products/page.tsx index 4b5a89a6a..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 && (
{/* 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 +1071,13 @@ export default function ProductsPage() { {t('variantToggle')} {form.has_variants && ( -
-
+
+
+ {t('variantsSection')}
- +
@@ -1282,19 +1333,20 @@ export default function ProductsPage() { )} - + - )} - - )} + - {activeTab === 'categories' && ( - <> +
- {categories.map((cat) => { + {visibleCategories.map((cat) => { const colorObj = CATEGORY_COLORS.find((c) => c.key === cat.color); return ( @@ -1352,7 +1404,7 @@ export default function ProductsPage() { })}
{t('nameLabel')}
- {categories.length === 0 &&

{t('categoryEmpty')}

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

{normalizedSearch === '' ? t('categoryEmpty') : tCommon('noResults')}

}
{showForm && ( @@ -1415,11 +1467,9 @@ export default function ProductsPage() {
)} - - )} + - {activeTab === 'addons' && isRestaurant && ( - <> +
{showAddonModal && ( @@ -1550,8 +1600,8 @@ export default function ProductsPage() {
)} - - )} + + {showBulkTaxModal && (
diff --git a/frontend/src/app/kds-standalone/page.tsx b/frontend/src/app/kds-standalone/page.tsx index df20a0ed2..705755372 100644 --- a/frontend/src/app/kds-standalone/page.tsx +++ b/frontend/src/app/kds-standalone/page.tsx @@ -9,18 +9,21 @@ import { useSyncServerLanguage } from '@/lib/i18n'; import { useTranslations } from 'use-intl'; import { useEffect, useMemo, useState } from 'react'; -// Check whether KDS is disabled (endpoint returns 404) without locking out network errors. -function useKdsDisabledCheck(baseUrl: string): boolean { - const [disabled, setDisabled] = useState(false); +// The standalone server hides this endpoint with 404 while KDS is disabled. +function useKdsEnabledCheck(baseUrl: string): boolean | null { + const [enabled, setEnabled] = useState(null); useEffect(() => { if (typeof window === 'undefined') return; let cancelled = false; fetch(`${baseUrl}/api/kds/info`, { cache: 'no-store' }) - .then((res) => { if (!cancelled && res.status === 404) setDisabled(true); }) - .catch(() => {}); + .then((res) => { + if (cancelled) return; + setEnabled(res.status === 404 ? false : res.ok ? true : null); + }) + .catch(() => { if (!cancelled) setEnabled(null); }); return () => { cancelled = true; }; }, [baseUrl]); - return disabled; + return enabled; } // Standalone axios client — points at this KDS server's origin (e.g. :3002), @@ -61,12 +64,22 @@ export default function KdsStandalonePage() { orders: '/api/kds/orders', itemStatus: '/api/kds/items/:itemId/status', }; - const conn = useKdsConnection(api ? { api, endpoints: standaloneEndpoints } : { api: axios.create() }); const origin = typeof window !== 'undefined' ? window.location.origin : ''; const { kdsDefaultView } = useServerKdsInfo(origin); - const kdsDisabled = useKdsDisabledCheck(origin); + const kdsEnabled = useKdsEnabledCheck(origin); + const conn = useKdsConnection(api + ? { api, endpoints: standaloneEndpoints, enabled: kdsEnabled === true } + : { api: axios.create(), enabled: false }); - if (kdsDisabled) { + if (kdsEnabled === null) { + return ( +
+
+
+ ); + } + + if (kdsEnabled === false) { return (

{t('disabledTitle')}

@@ -86,4 +99,4 @@ export default function KdsStandalonePage() { } if (!conn.user) return ; return ; -} \ No newline at end of file +} diff --git a/frontend/src/components/orders/OrderDetailPanel.tsx b/frontend/src/components/orders/OrderDetailPanel.tsx index 3c1632546..edeb6fd5f 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,9 @@ 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 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; @@ -617,6 +622,36 @@ function OrderDetailContent({ {tOrders('noFurtherActions')}
+ ) : hasUnpaidSplitBills ? ( +
+ {splitBills.map((splitBill) => ( +
+
+

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

+

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

+
+ {canPaySplitBill(splitBill) && onPayBill && ( + + )} +
+ ))} + {canRefund && hasEligibleRefund && ( + + )} +
) : isPaid || order.status === 'completed' ? (
{bill && ( @@ -728,4 +763,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 d043c7f21..6c0ede7da 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,20 @@ export default function PaymentModal({ bill, initialOverridePin, onClose, onPaid && Number(bill.paid_amount || 0) === 0 && bill.payment_status !== 'refunded' && bill.payment_status !== 'partially_refunded'; + const splitCheckItems = (bill.order?.items || []).filter( + (item) => !['cancelled', 'voided', 'void_adjustment'].includes(item.status), + ); + const hasDivisibleSplitCheckItems = splitCheckItems.length > 0 + && splitCheckItems.every((item) => Number.isSafeInteger(Number(item.quantity)) && Number(item.quantity) > 0) + && splitCheckItems.reduce((total, item) => total + Number(item.quantity), 0) >= 2; + // 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 + && hasDivisibleSplitCheckItems + && 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 +237,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 +402,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 +444,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) { @@ -442,10 +494,14 @@ 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); - toast.error(t('paymentIncomplete', { - amount: currencyFmt(Number(updatedBill?.balance) || 0), - })); + if (updatedBill && onBillUpdate) onBillUpdate({ ...bill, ...updatedBill, order: bill.order }); + 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; @@ -925,12 +981,34 @@ export default function PaymentModal({ bill, initialOverridePin, onClose, onPaid ) : ( - + <> + {canSplitCheck && ( + + )} + + )}
+ {splitCheckOrder && ( + setSplitCheckOrder(null)} + onSplit={handleSplitComplete} + /> + )} {ConfirmDialog}
); diff --git a/frontend/src/components/pos/PosTopbar.tsx b/frontend/src/components/pos/PosTopbar.tsx index 5ec37bb3e..0dc40f518 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 end-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..fa8555ad9 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(null); 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) => { @@ -70,13 +103,16 @@ export default function PrintMenuModal({ open, onOpenChange }: Props) { setIncludeHidden(false); setIncludeDescriptions(false); setIncludeModifiers(false); + setDestination(null); } onOpenChange(nextOpen); }; 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 +148,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 +162,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 +171,92 @@ 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, targetWindow?: Window | null): Promise => { + const html = await buildMenuHtml(selectedFilters); + if (html === null) return false; + const saveHtmlAsPdf = window.electronAPI?.saveHtmlAsPdf; + if (!saveHtmlAsPdf) { + printMenuInBrowser(html, targetWindow); + 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; + if (printing || !destination) 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 (destination === 'paper' || destination === 'pdf') { + const reservedWindow = window.electronAPI ? undefined : reservePrintGesture(); + try { + const printed = destination === 'paper' + ? 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(); } - if (!printerService.isConnected) await printerService.tryReconnect(); - if (!printerService.isConnected) throw new Error('WebUSB printer is not connected'); - await printerService.print(new Uint8Array(bytes)); + } catch (error) { + if (reservedWindow && !reservedWindow.closed) reservedWindow.close(); + 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 = window.electronAPI ? undefined : reservePrintGesture(); + try { + await printToReceipt(filters, reservedWindow); setDialogOpen(false); } catch (error) { const failure = error as { @@ -169,16 +267,13 @@ 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' || status === 502 || (status !== undefined && status >= 500) || status === undefined) { try { - if (await printBrowserFallback(filters, reservedWindow)) { + if (await printOnPaper(filters, reservedWindow)) { + if (reservedWindow && !reservedWindow.closed) reservedWindow.close(); setDialogOpen(false); } else if (reservedWindow && !reservedWindow.closed) { reservedWindow.close(); @@ -187,10 +282,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 +299,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' + ?