Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
32fa82f
fix(shifts): return null from cash-sessions/current when no shift is …
khaira777 Oct 6, 2026
6786c68
fix(kds): keep client sockets closed while the KDS feature is off
khaira777 Oct 6, 2026
c972a90
test(e2e): run native Electron suites with hidden windows by default
khaira777 Oct 6, 2026
af952b9
feat(pos): widen the product dialog, unclutter the top bar, and add p…
khaira777 Oct 6, 2026
97f6aee
test(pos): cover the print destinations, printer picker, and dialog l…
khaira777 Oct 6, 2026
185e051
test(e2e): keep the hidden-window native run entirely off the desktop
khaira777 Oct 6, 2026
469a041
feat(i18n): add search, clear, and partial-payment strings
khaira777 Oct 6, 2026
df94fdc
feat(catalog): search every catalog and inventory list from the page …
khaira777 Oct 6, 2026
8de2fe4
feat(pos): surface split checks and partial payments at checkout
khaira777 Oct 6, 2026
0455541
test: guard catalog search, checkout options, and orders layout
khaira777 Oct 6, 2026
745a162
no-mistakes(review): Fix review findings; supplies test blocked by mi…
khaira777 Oct 6, 2026
77a083e
no-mistakes(review): Gate KDS and printer fallback; focused lint unav…
khaira777 Oct 6, 2026
eb0e184
no-mistakes(review): Stop KDS retries when disabled; build blocked: N…
khaira777 Oct 6, 2026
1d5e936
no-mistakes(review): Recheck KDS before Orders reconnects (build bloc…
khaira777 Oct 6, 2026
d9e2283
no-mistakes(review): Show split-check balances correctly; build block…
khaira777 Oct 6, 2026
a576db5
no-mistakes(review): Fix print and split contracts; Electron test una…
khaira777 Oct 6, 2026
a64b578
no-mistakes(document): Document print destinations and fix Inventory …
khaira777 Oct 6, 2026
6463817
no-mistakes(ci): Fixed the menu-printing, supplies-service, and Playw…
khaira777 Oct 6, 2026
a480542
Merge remote-tracking branch 'origin/main' into fix/pos-ux-kds-print-…
khaira777 Oct 7, 2026
7d50d5f
fix(orders): allow refunding paid split bills while other splits rema…
khaira777 Oct 7, 2026
5a4b2b6
fix(pos): preserve order details on partial payments and load check i…
khaira777 Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture/desktop-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions docs/architecture/printing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions docs/architecture/runtime-and-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/printers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | - |
Expand Down Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion frontend/e2e/category-inactive-addon-groups.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
50 changes: 31 additions & 19 deletions frontend/e2e/desktop/native-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
simulateTerminalRuntimeLoss: () => Promise<void>;
Expand Down Expand Up @@ -326,6 +329,10 @@ async function boundedRelaunchClose(
}

export async function createNativeElectronHarness(): Promise<NativeElectronHarness> {
// 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');
Expand All @@ -343,6 +350,7 @@ export async function createNativeElectronHarness(): Promise<NativeElectronHarne
FLO_E2E_USER_DATA_DIR: profileDir,
FLO_E2E_DB_PATH: path.join(profileDir, 'flo.db'),
FLO_E2E_PID_FILE: pidFile,
FLO_E2E_HIDDEN_WINDOW: hiddenWindow ? '1' : '0',
PORT: String(ports.main),
KDS_PORT: String(ports.kds),
SERVER_APP_PORT: String(ports.serverApp),
Expand Down Expand Up @@ -408,26 +416,28 @@ export async function createNativeElectronHarness(): Promise<NativeElectronHarne
await nextPage.goto(`http://localhost:${ports.main}/pos`, { waitUntil: 'domcontentloaded' });
await nextPage.waitForURL((url) => 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');
};

Expand All @@ -436,6 +446,7 @@ export async function createNativeElectronHarness(): Promise<NativeElectronHarne
get page() { return activePage; },
ports,
profileDir,
hiddenWindow,
setActivePage: (page) => { activePage = page; },
authenticateDashboard: buildAuthenticate(() => activePage, app),
simulateTerminalRuntimeLoss: async () => {
Expand Down Expand Up @@ -500,6 +511,7 @@ export async function createNativeElectronHarness(): Promise<NativeElectronHarne
get page() { return newActivePage; },
ports,
profileDir,
hiddenWindow,
setActivePage: (page) => { newActivePage = page; },
authenticateDashboard: buildAuthenticate(() => newActivePage, newApp),
simulateTerminalRuntimeLoss: async () => {
Expand Down
6 changes: 6 additions & 0 deletions frontend/e2e/desktop/theme.electron.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
8 changes: 7 additions & 1 deletion frontend/e2e/desktop/title-bar.electron.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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];
Expand Down
3 changes: 2 additions & 1 deletion frontend/e2e/kot-append-only-print.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading