From 1114e769b69db077eb08bbad494c8852f4f97804 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 11 Oct 2026 15:34:22 -0400 Subject: [PATCH 01/20] feat(pos): clean frontline cashier status bar and telemetry drawer --- frontend/e2e/cashier-status-bar.spec.ts | 93 ++++ frontend/src/components/layout/StatusBar.tsx | 442 ++++++++++++++++--- frontend/src/components/layout/TitleBar.tsx | 34 +- frontend/src/hooks/useCashSession.ts | 2 + main/routes/printers.ts | 119 ++++- tests/printer-api.test.ts | 22 + 6 files changed, 616 insertions(+), 96 deletions(-) create mode 100644 frontend/e2e/cashier-status-bar.spec.ts diff --git a/frontend/e2e/cashier-status-bar.spec.ts b/frontend/e2e/cashier-status-bar.spec.ts new file mode 100644 index 000000000..b79b04b42 --- /dev/null +++ b/frontend/e2e/cashier-status-bar.spec.ts @@ -0,0 +1,93 @@ +import { test, expect } from '@playwright/test'; +import { E2E_BASE_URL as BASE } from './helpers/urls'; +import { injectElectronFixture } from './helpers/electron-fixture'; + +test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { + test('frontline status bar displays cashier identity, printer, shift, and drawer actions', async ({ + page, + }) => { + // Inject Electron fixture so desktop capability and system telemetry are simulated + await injectElectronFixture(page, { api: true, platform: 'darwin' }); + + // Login as owner/cashier + await page.goto(`${BASE}/auth/login`); + await page.locator('#email').fill('owner@flo.local'); + await page.locator('#password').fill('E2ePass123!'); + await page.locator('button[type="submit"]').click(); + await page.waitForURL(/\/pos/, { timeout: 20000 }); + + // 1. Verify persistent frontline status bar is rendered + const statusBar = page.locator('footer[role="status"]'); + await expect(statusBar).toBeVisible(); + + // 2. Verify Cashier Name & Role badge are rendered on bottom-left + const cashierContainer = page.getByTestId('status-bar-cashier'); + await expect(cashierContainer).toBeVisible(); + + const cashierName = page.getByTestId('status-bar-cashier-name'); + await expect(cashierName).toBeVisible(); + await expect(cashierName).not.toBeEmpty(); + + const roleBadge = page.getByTestId('status-bar-role-badge'); + await expect(roleBadge).toBeVisible(); + + // 3. Verify desktop TitleBar does NOT render duplicate user name or role + const titleBar = page.getByTestId('desktop-title-bar'); + if (await titleBar.count() > 0) { + await expect(titleBar).toBeVisible(); + // Identity container in title bar must only have the business name + const titleBarIdentity = titleBar.locator('.flo-title-bar__identity'); + await expect(titleBarIdentity).toBeVisible(); + // Must not contain UserCircle icon or role badge + await expect(titleBarIdentity.locator('svg')).toHaveCount(0); + } + + // 4. Verify developer 'Heap' and 'Port' numbers are absent from persistent frontline bar + const statusBarText = await statusBar.innerText(); + expect(statusBarText).not.toMatch(/Heap:\s*\d+/i); + expect(statusBarText).not.toMatch(/Port:\s*\d+/i); + + // 5. Verify Thermal Printer and Shift status pills + const printerPill = page.getByTestId('status-bar-printer'); + await expect(printerPill).toBeVisible(); + + const shiftPill = page.getByTestId('status-bar-shift'); + await expect(shiftPill).toBeVisible(); + + // 6. Capture screenshot of clean frontline bar + await page.screenshot({ path: 'test-results/clean-frontline-status-bar.png' }); + + // 7. Test Slide-Up Telemetry Drawer + const drawerToggleBtn = page.getByTestId('btn-toggle-telemetry-drawer'); + await expect(drawerToggleBtn).toBeVisible(); + + const drawer = page.getByTestId('telemetry-drawer'); + // Initially drawer is closed + await expect(drawer).toHaveClass(/translate-y-full/); + + // Click to open drawer + await drawerToggleBtn.click(); + await expect(drawer).toHaveClass(/translate-y-0/); + await expect(drawer).toBeVisible(); + + // Verify Hardware Actions + const testPrintBtn = page.getByTestId('btn-test-receipt-print'); + await expect(testPrintBtn).toBeVisible(); + + const kickDrawerBtn = page.getByTestId('btn-kick-cash-drawer'); + await expect(kickDrawerBtn).toBeVisible(); + + // Verify Diagnostic Telemetry Metrics + await expect(page.getByTestId('telemetry-uptime')).toBeVisible(); + await expect(page.getByTestId('telemetry-port')).toBeVisible(); + await expect(page.getByTestId('telemetry-memory')).toBeVisible(); + + // 8. Capture screenshot of open telemetry drawer + await page.screenshot({ path: 'test-results/open-telemetry-drawer.png' }); + + // 9. Close drawer using close button + const closeDrawerBtn = page.getByTestId('btn-close-telemetry-drawer'); + await closeDrawerBtn.click(); + await expect(drawer).toHaveClass(/translate-y-full/); + }); +}); diff --git a/frontend/src/components/layout/StatusBar.tsx b/frontend/src/components/layout/StatusBar.tsx index 6ae8ef919..4ecbd28d5 100644 --- a/frontend/src/components/layout/StatusBar.tsx +++ b/frontend/src/components/layout/StatusBar.tsx @@ -1,8 +1,27 @@ 'use client'; import { useEffect, useState, useRef } from 'react'; -import { Server, HardDrive, Clock, AlertCircle, CheckCircle } from 'lucide-react'; +import { + User, + PrinterCheck, + PrinterX, + Lock, + LockOpen, + Clock, + Server, + HardDrive, + X, + ChevronUp, + ChevronDown, + Loader2, + Printer, +} from 'lucide-react'; import { useTranslations } from 'use-intl'; +import toast from 'react-hot-toast'; +import { useAuthStore } from '@/store/auth'; +import { usePrinterStore } from '@/hooks/usePrinter'; +import { printerService } from '@/lib/printer/PrinterService'; +import api from '@/lib/api'; import UpdateBadge from './UpdateBadge'; interface StatusInfo { @@ -12,14 +31,33 @@ interface StatusInfo { port: number; } +interface CashSession { + id: number; + status: string; + opened_by_name?: string; + opening_float_cents?: number; +} + export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge?: boolean }) { const tNav = useTranslations('nav'); const tCommon = useTranslations('common'); + const tPos = useTranslations('pos'); + const tSettings = useTranslations('settings'); + const tDashboard = useTranslations('dashboard'); + const tStaff = useTranslations('staff'); + + const { user, currentTenant } = useAuthStore(); + const { status: printerConnStatus, hardwarePrinter } = usePrinterStore(); + const [status, setStatus] = useState(null); - const [error, setError] = useState(null); - const intervalRef = useRef(null); + const [session, setSession] = useState(null); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [isTestingPrint, setIsTestingPrint] = useState(false); + const [isKickingDrawer, setIsKickingDrawer] = useState(false); + const inFlightRef = useRef(false); + // Fetch Electron system status (uptime, memory, port) useEffect(() => { async function fetchStatus() { if (typeof window === 'undefined' || !window.electronAPI?.getStatus) return; @@ -29,23 +67,56 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? try { const data = await window.electronAPI.getStatus(); setStatus(data); - setError(null); } catch { - setError(tNav('statusError')); + // Electron status fetch error gracefully ignored } finally { inFlightRef.current = false; } } fetchStatus(); - intervalRef.current = setInterval(fetchStatus, 10000); + const interval = setInterval(fetchStatus, 10000); + return () => clearInterval(interval); + }, []); + // Fetch active shift session (async continuation to satisfy cascading-renders rule) + useEffect(() => { + let cancelled = false; + const queryShift = () => { + api + .get('/cash-sessions/current') + .then((res) => { + if (!cancelled) setSession(res.data ?? null); + }) + .catch(() => { + if (!cancelled) setSession(null); + }); + }; + + queryShift(); + const interval = setInterval(queryShift, 15000); + const handleShiftChange = () => { + queryShift(); + }; + window.addEventListener('flo-shift-changed', handleShiftChange); return () => { - if (intervalRef.current) clearInterval(intervalRef.current); + cancelled = true; + clearInterval(interval); + window.removeEventListener('flo-shift-changed', handleShiftChange); }; - // oxlint-disable-next-line react-hooks/exhaustive-deps }, []); + // Handle drawer Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isDrawerOpen) { + setIsDrawerOpen(false); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isDrawerOpen]); + function formatUptime(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); @@ -53,75 +124,320 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? return tCommon('timeMinutes', { m: minutes }); } - if (typeof window === 'undefined' || !window.electronAPI?.getStatus) return null; + // Cashier info + const staffName = user?.name || user?.email || tNav('user'); + const roleKey = currentTenant?.role; + const roleLabel = roleKey + ? roleKey === 'owner' + ? tStaff('roleOwner') + : roleKey === 'manager' + ? tStaff('roleManager') + : roleKey === 'cashier' + ? tStaff('roleCashier') + : roleKey === 'server' + ? tStaff('roleServer') + : roleKey === 'chef' + ? tStaff('roleChef') + : roleKey.charAt(0).toUpperCase() + roleKey.slice(1) + : null; + + // Printer readiness + const isPrinterReady = hardwarePrinter !== null || printerConnStatus === 'connected'; + const isPrinterConnecting = printerConnStatus === 'connecting'; - const isRunning = status?.server === 'running'; - const isPending = !status && !error; + // Shift state + const isShiftOpen = session !== null && session.status === 'open'; - const serverStatusDisplay = status?.server - ? status.server === 'running' - ? tNav('serverStatusRunning') - : status.server === 'stopped' - ? tNav('serverStatusStopped') - : status.server === 'starting' - ? tNav('serverStatusStarting') - : status.server - : tNav('serverUnknown'); + // Hardware Quick Actions + const handleTestPrint = async () => { + setIsTestingPrint(true); + try { + const res = await api.post<{ success: boolean; webusb?: boolean; bytes?: number[] }>( + '/printers/test-active', + ); + if (res.data?.webusb && Array.isArray(res.data.bytes)) { + await printerService.print(Uint8Array.from(res.data.bytes)); + } + toast.success(tSettings('testPrintSent') || 'Test print sent!'); + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error || + tSettings('testPrintFailed') || + 'Test print failed'; + toast.error(msg); + } finally { + setIsTestingPrint(false); + } + }; + + const handleKickDrawer = async () => { + setIsKickingDrawer(true); + try { + const res = await api.post<{ success: boolean; webusb?: boolean; bytes?: number[] }>( + '/printers/kick-drawer', + ); + if (res.data?.webusb && Array.isArray(res.data.bytes)) { + await printerService.print(Uint8Array.from(res.data.bytes)); + } + toast.success(tSettings('cashDrawerPulse') || 'Cash drawer pulse sent!'); + } catch (err: unknown) { + if (printerService.isConnected) { + try { + await printerService.print(new Uint8Array([0x1b, 0x70, 0x00, 0x19, 0xfa])); + toast.success(tSettings('cashDrawerPulse') || 'Cash drawer pulse sent!'); + return; + } catch { + // ignore + } + } + const msg = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error || + 'Failed to open cash drawer'; + toast.error(msg); + } finally { + setIsKickingDrawer(false); + } + }; return ( -
+ <> + {/* Slide-Up Telemetry & Hardware Drawer Backdrop */} + {isDrawerOpen && ( +
setIsDrawerOpen(false)} + className="fixed inset-0 z-40 bg-black/30 backdrop-blur-[1px] transition-opacity" + aria-hidden="true" + /> + )} + + {/* Slide-Up Telemetry & Hardware Drawer */}
- {isRunning ? : } - - {tNav('serverLabel')} - {serverStatusDisplay} - -
+
+ {/* Header */} +
+
+ +

+ Terminal Telemetry & Diagnostics +

+
+ +
- {error && {error}} +
+ {/* Quick Actions Card */} +
+

+ Hardware Actions +

+
+ -
-
- - {tNav('portLabel')} - - {status?.port || '...'} - -
+ +
+
-
- - {tNav('heapLabel')} - - {status?.memory.heapUsed || 0} / {status?.memory.heapTotal || 0} MB - + {/* Diagnostic Metrics Card */} +
+

+ Diagnostic Telemetry +

+
+
+
+ + Uptime +
+
+ {status ? formatUptime(status.uptime) : '...'} +
+
+ +
+
+ + Port +
+
+ {status?.port || '3001'} +
+
+ +
+
+ + Memory +
+
+ {status?.memory + ? `${status.memory.heapUsed} / ${status.memory.heapTotal} MB` + : '...'} +
+
+
+
+
+
-
- - {tNav('uptimeLabel')} - - {status ? formatUptime(status.uptime) : '...'} + {/* Persistent Frontline Status Bar */} +
+ {/* Left: Active Cashier & Role Badge */} +
+
+ +
+ + {staffName} + {roleLabel && ( + + {roleLabel} + + )}
- {showUpdateBadge && } -
-
+ {/* Right: Thermal Printer, Shift State, and Telemetry Trigger */} +
+ {/* Thermal Printer Status Indicator */} +
+ {isPrinterReady ? ( + <> + + + {hardwarePrinter ? hardwarePrinter.name : tPos('printerReady')} + + + ) : isPrinterConnecting ? ( + <> + + + {tPos('printerConnecting')} + + + ) : ( + <> + + + {tSettings('printerOffline')} + + + )} +
+ + - {/* Centered business name and user identity */} + {/* Centered business name */}
{businessName} - -
); diff --git a/frontend/src/hooks/useCashSession.ts b/frontend/src/hooks/useCashSession.ts index 4096dd012..5778f583e 100644 --- a/frontend/src/hooks/useCashSession.ts +++ b/frontend/src/hooks/useCashSession.ts @@ -130,6 +130,7 @@ export function useCashSession() { setFloatInput(''); setOpenModalOpen(false); toast.success(t('shiftOpened')); + if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('flo-shift-changed')); await refresh(); } catch (err: unknown) { setSubmitError(axios.isAxiosError(err) ? err.response?.data?.error || err.message : 'Open failed'); @@ -154,6 +155,7 @@ export function useCashSession() { setClosedResult(res.data); setCountedInput(''); toast.success(t('shiftClosed')); + if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('flo-shift-changed')); await refresh(); } catch (err: unknown) { setSubmitError(axios.isAxiosError(err) ? err.response?.data?.error || err.message : 'Close failed'); diff --git a/main/routes/printers.ts b/main/routes/printers.ts index 66b78b77f..139455c5e 100644 --- a/main/routes/printers.ts +++ b/main/routes/printers.ts @@ -47,7 +47,7 @@ import { resolvePrinterProfile, capabilitiesForPrinter, } from '../printers/profiles'; -import { requirePermission } from '../services/authorization'; +import { requirePermission, requireAnyPermission } from '../services/authorization'; import { formatCurrencyForTenant, formatDateForTenant, @@ -498,6 +498,123 @@ router.post( }), ); +// POST /api/printers/kick-drawer — send a cash drawer pulse to the default/active printer +router.post( + '/kick-drawer', + requireAnyPermission('cash.movements.manage', 'printing.execute'), + asyncHandler(async (req: Request, res: Response) => { + try { + const db = getDatabase(); + const printer = + (db.prepare('SELECT * FROM printers WHERE is_default = 1').get() as any) || + (db.prepare('SELECT * FROM printers ORDER BY created_at ASC LIMIT 1').get() as any); + + const drawerPulseBytes = Buffer.from([0x1b, 0x70, 0x00, 0x19, 0xfa]); + + if (!printer) { + return res.status(404).json({ error: 'No printer configured for cash drawer kick' }); + } + + let result: { ok: boolean; detail?: string } = { ok: false }; + + switch (printer.connection_type) { + case 'network': + if (!printer.ip_address) + return res.status(400).json({ error: 'No IP address configured' }); + result = await printViaNetwork( + printer.ip_address, + printer.port || 9100, + drawerPulseBytes, + getHttpRequestSignal(req), + ); + break; + case 'usb': + result = await printViaUSB(drawerPulseBytes, printer.name, getHttpRequestSignal(req)); + break; + case 'webusb': + return res.json({ success: true, webusb: true, bytes: Array.from(drawerPulseBytes) }); + default: + return res.status(400).json({ error: 'Unsupported printer connection type' }); + } + + if (result.ok) { + res.json({ success: true }); + } else { + res.status(502).json({ + error: result.detail || 'Printer did not respond', + detail: result.detail, + }); + } + } catch (error: any) { + console.error('[API] Internal error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }), +); + +// POST /api/printers/test-active — send a test print job to the default/active printer +router.post( + '/test-active', + requirePermission('printing.execute'), + asyncHandler(async (req: Request, res: Response) => { + try { + const db = getDatabase(); + const printer = + (db.prepare('SELECT * FROM printers WHERE is_default = 1').get() as any) || + (db.prepare('SELECT * FROM printers ORDER BY created_at ASC LIMIT 1').get() as any); + + if (!printer) return res.status(404).json({ error: 'No default printer configured' }); + + const timezone = tenantSettingValue(db, 'timezone'); + if (!timezone) return res.status(409).json({ error: 'regional_not_configured' }); + + const profile = resolvePrinterProfile(printer); + const capabilities = capabilitiesForPrinter( + profile, + printer.paper_width || profile.defaultPaperWidth, + ); + const testData = buildTestPage( + printer.paper_width || profile.defaultPaperWidth, + profile.cutMode, + tenantLanguage(db), + timezone, + req.body?.rasterProbe === true ? capabilities : undefined, + ); + let result: { ok: boolean; detail?: string } = { ok: false }; + + switch (printer.connection_type) { + case 'network': + if (!printer.ip_address) + return res.status(400).json({ error: 'No IP address configured' }); + result = await printViaNetwork( + printer.ip_address, + printer.port || 9100, + testData, + getHttpRequestSignal(req), + ); + break; + case 'usb': + result = await printViaUSB(testData, printer.name, getHttpRequestSignal(req)); + break; + case 'webusb': + return res.json({ success: true, webusb: true, bytes: Array.from(testData) }); + } + + if (result.ok) { + res.json({ success: true }); + } else { + res.status(502).json({ + error: result.detail || 'Printer did not respond or print failed', + detail: result.detail, + }); + } + } catch (error: any) { + console.error('[API] Internal error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }), +); + // POST /api/printers/print-menu — print a filtered catalog using the configured printer. router.post( '/print-menu', diff --git a/tests/printer-api.test.ts b/tests/printer-api.test.ts index 4b33da035..13bfa5d1e 100644 --- a/tests/printer-api.test.ts +++ b/tests/printer-api.test.ts @@ -604,6 +604,28 @@ async function runTests() { ); } + // ── Test 10: hardware actions (kick-drawer and test-active) ───────────── + console.log('\nTest 10: hardware actions (kick-drawer and test-active)'); + { + const webusbRes = await request(app).post('/api/printers').send({ + name: 'Counter WebUSB Printer', + connection_type: 'webusb', + is_default: true, + }); + assert(webusbRes.status === 201, `creating WebUSB printer returns 201 (got ${webusbRes.status})`); + + const kickRes = await request(app).post('/api/printers/kick-drawer'); + assert(kickRes.status === 200, `kick-drawer returns 200 for WebUSB printer (got ${kickRes.status})`); + assert(kickRes.body.webusb === true, 'kick-drawer reports webusb: true'); + assert(Array.isArray(kickRes.body.bytes), 'kick-drawer returns pulse byte array'); + assert(kickRes.body.bytes[0] === 0x1b && kickRes.body.bytes[1] === 0x70, 'kick-drawer returns standard ESC p bytes'); + + const testActiveRes = await request(app).post('/api/printers/test-active'); + assert(testActiveRes.status === 200, `test-active returns 200 for WebUSB printer (got ${testActiveRes.status})`); + assert(testActiveRes.body.webusb === true, 'test-active reports webusb: true'); + assert(Array.isArray(testActiveRes.body.bytes) && testActiveRes.body.bytes.length > 0, 'test-active returns test page byte array'); + } + console.log('\n' + '='.repeat(50)); console.log(`${passed}/${passed + failed} passed, ${failed} failed`); From 80f33472673c4c670e3b72401f4f89eabef2bf76 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 11 Oct 2026 15:42:35 -0400 Subject: [PATCH 02/20] no-mistakes(review): Fix drawer authorization, focus, and shift state; Playwright unavailable --- frontend/e2e/cashier-status-bar.spec.ts | 33 +++++++++++++ frontend/src/components/layout/StatusBar.tsx | 51 ++++++++++++-------- 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/frontend/e2e/cashier-status-bar.spec.ts b/frontend/e2e/cashier-status-bar.spec.ts index b79b04b42..a9c3e0069 100644 --- a/frontend/e2e/cashier-status-bar.spec.ts +++ b/frontend/e2e/cashier-status-bar.spec.ts @@ -6,6 +6,14 @@ test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { test('frontline status bar displays cashier identity, printer, shift, and drawer actions', async ({ page, }) => { + await page.route('**/cash-sessions/current', (route) => + route.fulfill({ + status: 403, + contentType: 'application/json', + body: JSON.stringify({ error: 'Insufficient permissions' }), + }), + ); + // Inject Electron fixture so desktop capability and system telemetry are simulated await injectElectronFixture(page, { api: true, platform: 'darwin' }); @@ -53,6 +61,7 @@ test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { const shiftPill = page.getByTestId('status-bar-shift'); await expect(shiftPill).toBeVisible(); + await expect(shiftPill).toContainText('unknown'); // 6. Capture screenshot of clean frontline bar await page.screenshot({ path: 'test-results/clean-frontline-status-bar.png' }); @@ -64,11 +73,26 @@ test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { const drawer = page.getByTestId('telemetry-drawer'); // Initially drawer is closed await expect(drawer).toHaveClass(/translate-y-full/); + await expect(drawer).toHaveAttribute('aria-hidden', 'true'); + await expect(drawer).toHaveAttribute('inert', ''); + + for (const testId of [ + 'btn-close-telemetry-drawer', + 'btn-test-receipt-print', + 'btn-kick-cash-drawer', + ]) { + const canReceiveFocus = await page.getByTestId(testId).evaluate((element) => { + (element as HTMLElement).focus(); + return document.activeElement === element; + }); + expect(canReceiveFocus).toBe(false); + } // Click to open drawer await drawerToggleBtn.click(); await expect(drawer).toHaveClass(/translate-y-0/); await expect(drawer).toBeVisible(); + await expect(drawer).toHaveAttribute('aria-hidden', 'false'); // Verify Hardware Actions const testPrintBtn = page.getByTestId('btn-test-receipt-print'); @@ -89,5 +113,14 @@ test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { const closeDrawerBtn = page.getByTestId('btn-close-telemetry-drawer'); await closeDrawerBtn.click(); await expect(drawer).toHaveClass(/translate-y-full/); + await expect(drawerToggleBtn).toBeFocused(); + + await drawerToggleBtn.click(); + await page.keyboard.press('Escape'); + await expect(drawerToggleBtn).toBeFocused(); + + await drawerToggleBtn.click(); + await page.getByTestId('telemetry-drawer-backdrop').click({ position: { x: 10, y: 10 } }); + await expect(drawerToggleBtn).toBeFocused(); }); }); diff --git a/frontend/src/components/layout/StatusBar.tsx b/frontend/src/components/layout/StatusBar.tsx index 4ecbd28d5..e29067262 100644 --- a/frontend/src/components/layout/StatusBar.tsx +++ b/frontend/src/components/layout/StatusBar.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState, useRef } from 'react'; +import { useCallback, useEffect, useState, useRef } from 'react'; import { User, PrinterCheck, @@ -50,12 +50,13 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? const { status: printerConnStatus, hardwarePrinter } = usePrinterStore(); const [status, setStatus] = useState(null); - const [session, setSession] = useState(null); + const [session, setSession] = useState(undefined); const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [isTestingPrint, setIsTestingPrint] = useState(false); const [isKickingDrawer, setIsKickingDrawer] = useState(false); const inFlightRef = useRef(false); + const drawerToggleRef = useRef(null); // Fetch Electron system status (uptime, memory, port) useEffect(() => { @@ -89,7 +90,7 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? if (!cancelled) setSession(res.data ?? null); }) .catch(() => { - if (!cancelled) setSession(null); + if (!cancelled) setSession(undefined); }); }; @@ -106,16 +107,21 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? }; }, []); + const closeDrawer = useCallback(() => { + setIsDrawerOpen(false); + drawerToggleRef.current?.focus(); + }, []); + // Handle drawer Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && isDrawerOpen) { - setIsDrawerOpen(false); + closeDrawer(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [isDrawerOpen]); + }, [closeDrawer, isDrawerOpen]); function formatUptime(seconds: number): string { const hours = Math.floor(seconds / 3600); @@ -146,7 +152,8 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? const isPrinterConnecting = printerConnStatus === 'connecting'; // Shift state - const isShiftOpen = session !== null && session.status === 'open'; + const isShiftKnown = session !== undefined; + const isShiftOpen = session?.status === 'open'; // Hardware Quick Actions const handleTestPrint = async () => { @@ -181,15 +188,6 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? } toast.success(tSettings('cashDrawerPulse') || 'Cash drawer pulse sent!'); } catch (err: unknown) { - if (printerService.isConnected) { - try { - await printerService.print(new Uint8Array([0x1b, 0x70, 0x00, 0x19, 0xfa])); - toast.success(tSettings('cashDrawerPulse') || 'Cash drawer pulse sent!'); - return; - } catch { - // ignore - } - } const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Failed to open cash drawer'; @@ -205,7 +203,7 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? {isDrawerOpen && (
setIsDrawerOpen(false)} + onClick={closeDrawer} className="fixed inset-0 z-40 bg-black/30 backdrop-blur-[1px] transition-opacity" aria-hidden="true" /> @@ -216,6 +214,8 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? data-testid="telemetry-drawer" role="dialog" aria-label="Terminal Telemetry & Diagnostics" + aria-hidden={!isDrawerOpen} + inert={!isDrawerOpen} className={`fixed bottom-8 left-0 right-0 z-50 max-h-[80vh] overflow-y-auto border-t border-border bg-card p-4 shadow-2xl transition-all duration-300 ease-in-out md:p-6 ${ isDrawerOpen ? 'translate-y-0 opacity-100' @@ -234,7 +234,7 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? @@ -246,7 +249,7 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? {/* Quick Actions Card */}

- Hardware Actions + {tSettings('hardwarePrinters')}

@@ -284,13 +287,13 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? {/* Diagnostic Metrics Card */}

- Diagnostic Telemetry + {tSupport('tabDiagnostics')}

- Uptime + {tNav('uptimeLabel').trim()}
- Port + {tNav('portLabel')}
- Memory + {tNav('heapLabel').trim()}
(isDrawerOpen ? closeDrawer() : setIsDrawerOpen(true))} className="inline-flex h-6 items-center gap-1 rounded-md px-2 text-[11px] font-medium text-muted-foreground hover:bg-muted hover:text-foreground active:bg-muted transition-colors" - title="Terminal Telemetry & Diagnostics" + title={tSupport('tabDiagnostics')} + aria-label={tSupport('tabDiagnostics')} aria-expanded={isDrawerOpen} > - Telemetry + {tSupport('tabDiagnostics')} {isDrawerOpen ? : } From 8b9cd23717208bde3dbc2488921f14ab079f71fa Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 11 Oct 2026 15:54:10 -0400 Subject: [PATCH 04/20] no-mistakes(review): Localize drawer kick feedback (i18n check blocked: missing ts-node) --- frontend/src/components/layout/StatusBar.tsx | 4 ++-- frontend/src/lib/i18n/messages/ar.json | 2 ++ frontend/src/lib/i18n/messages/bn.json | 2 ++ frontend/src/lib/i18n/messages/de.json | 2 ++ frontend/src/lib/i18n/messages/en.json | 2 ++ frontend/src/lib/i18n/messages/es.json | 2 ++ frontend/src/lib/i18n/messages/fa.json | 2 ++ frontend/src/lib/i18n/messages/fil.json | 2 ++ frontend/src/lib/i18n/messages/fr.json | 2 ++ frontend/src/lib/i18n/messages/hi.json | 2 ++ frontend/src/lib/i18n/messages/id.json | 2 ++ frontend/src/lib/i18n/messages/it.json | 2 ++ frontend/src/lib/i18n/messages/ja.json | 2 ++ frontend/src/lib/i18n/messages/ko.json | 2 ++ frontend/src/lib/i18n/messages/ne.json | 2 ++ frontend/src/lib/i18n/messages/nl.json | 2 ++ frontend/src/lib/i18n/messages/pt.json | 2 ++ frontend/src/lib/i18n/messages/ru.json | 2 ++ frontend/src/lib/i18n/messages/sq.json | 2 ++ frontend/src/lib/i18n/messages/th.json | 2 ++ frontend/src/lib/i18n/messages/tr.json | 2 ++ frontend/src/lib/i18n/messages/ur.json | 2 ++ frontend/src/lib/i18n/messages/vi.json | 2 ++ frontend/src/lib/i18n/messages/zh-tw.json | 2 ++ frontend/src/lib/i18n/messages/zh.json | 2 ++ 25 files changed, 50 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/layout/StatusBar.tsx b/frontend/src/components/layout/StatusBar.tsx index 997163d82..d0375da4e 100644 --- a/frontend/src/components/layout/StatusBar.tsx +++ b/frontend/src/components/layout/StatusBar.tsx @@ -189,11 +189,11 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? if (res.data?.webusb && Array.isArray(res.data.bytes)) { await printerService.print(Uint8Array.from(res.data.bytes)); } - toast.success(tSettings('cashDrawerPulse') || 'Cash drawer pulse sent!'); + toast.success(tSettings('cashDrawerPulseSent')); } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || - 'Failed to open cash drawer'; + tSettings('cashDrawerPulseFailed'); toast.error(msg); } finally { setIsKickingDrawer(false); diff --git a/frontend/src/lib/i18n/messages/ar.json b/frontend/src/lib/i18n/messages/ar.json index 0802f6007..79b49e2b9 100644 --- a/frontend/src/lib/i18n/messages/ar.json +++ b/frontend/src/lib/i18n/messages/ar.json @@ -2206,6 +2206,8 @@ "testPrint": "طباعة تجريبية", "testPrintFailed": "فشل الاختبار الطباعي", "testPrintSent": "تم إرسال نسخة تجريبية من الطباعة!", + "cashDrawerPulseSent": "تم إرسال نبضة درج النقود.", + "cashDrawerPulseFailed": "تعذر إرسال نبضة درج النقود.", "testing": "تجربة...", "timezone": "المنطقة الزمنية", "businessDayStartTime": "وقت بدء يوم العمل", diff --git a/frontend/src/lib/i18n/messages/bn.json b/frontend/src/lib/i18n/messages/bn.json index a7795075b..c7a77643c 100644 --- a/frontend/src/lib/i18n/messages/bn.json +++ b/frontend/src/lib/i18n/messages/bn.json @@ -2206,6 +2206,8 @@ "testPrint": "টেস্ট প্রিন্ট", "testPrintFailed": "পরীক্ষার মুদ্রণ ব্যর্থ হয়েছে।", "testPrintSent": "পরীক্ষার প্রিন্ট পাঠানো হয়েছে!", + "cashDrawerPulseSent": "ক্যাশ ড্রয়ারের পালস পাঠানো হয়েছে।", + "cashDrawerPulseFailed": "ক্যাশ ড্রয়ারের পালস পাঠানো যায়নি।", "testing": "পরীক্ষা করা হচ্ছে...", "timezone": "টাইমজোন", "businessDayStartTime": "ব্যবসার দিন শুরুর সময়", diff --git a/frontend/src/lib/i18n/messages/de.json b/frontend/src/lib/i18n/messages/de.json index d9031f125..daaaefad9 100644 --- a/frontend/src/lib/i18n/messages/de.json +++ b/frontend/src/lib/i18n/messages/de.json @@ -2207,6 +2207,8 @@ "testPrint": "Testdruck", "testPrintFailed": "Testdruck fehlgeschlagen", "testPrintSent": "Testdruck gesendet!", + "cashDrawerPulseSent": "Kassenschubladen-Impuls gesendet.", + "cashDrawerPulseFailed": "Kassenschubladen-Impuls konnte nicht gesendet werden.", "testing": "Wird getestet…", "timezone": "Zeitzone", "businessDayStartTime": "Startzeit des Geschäftstages", diff --git a/frontend/src/lib/i18n/messages/en.json b/frontend/src/lib/i18n/messages/en.json index 0ac2f8b25..c515c6164 100644 --- a/frontend/src/lib/i18n/messages/en.json +++ b/frontend/src/lib/i18n/messages/en.json @@ -2206,6 +2206,8 @@ "testPrint": "Test print", "testPrintFailed": "Test print failed", "testPrintSent": "Test print sent!", + "cashDrawerPulseSent": "Cash drawer pulse sent.", + "cashDrawerPulseFailed": "Failed to send cash drawer pulse.", "testing": "Testing…", "timezone": "Timezone", "businessDayStartTime": "Business day start time", diff --git a/frontend/src/lib/i18n/messages/es.json b/frontend/src/lib/i18n/messages/es.json index 1a47c940a..90588779a 100644 --- a/frontend/src/lib/i18n/messages/es.json +++ b/frontend/src/lib/i18n/messages/es.json @@ -2207,6 +2207,8 @@ "testPrint": "Imprimir prueba", "testPrintFailed": "Impresión de prueba falló", "testPrintSent": "¡Impresión de prueba enviada!", + "cashDrawerPulseSent": "Se envió el pulso del cajón de efectivo.", + "cashDrawerPulseFailed": "No se pudo enviar el pulso del cajón de efectivo.", "testing": "Probando…", "timezone": "Zona Horaria", "businessDayStartTime": "Hora de inicio del día comercial", diff --git a/frontend/src/lib/i18n/messages/fa.json b/frontend/src/lib/i18n/messages/fa.json index a7eb29065..b94895481 100644 --- a/frontend/src/lib/i18n/messages/fa.json +++ b/frontend/src/lib/i18n/messages/fa.json @@ -2206,6 +2206,8 @@ "testPrint": "چاپ آزمون", "testPrintFailed": "چاپ آزمون انجام نشد", "testPrintSent": "چاپ آزمون ارسال شد!", + "cashDrawerPulseSent": "پالس کشوی پول ارسال شد.", + "cashDrawerPulseFailed": "ارسال پالس کشوی پول ناموفق بود.", "testing": "در حال آزمون…", "timezone": "منطقه زمانی", "businessDayStartTime": "زمان شروع روز کاری", diff --git a/frontend/src/lib/i18n/messages/fil.json b/frontend/src/lib/i18n/messages/fil.json index 446d29552..c1d13d4a1 100644 --- a/frontend/src/lib/i18n/messages/fil.json +++ b/frontend/src/lib/i18n/messages/fil.json @@ -2217,6 +2217,8 @@ "testPrint": "Pagsubok sa pag-print", "testPrintFailed": "Nabigo ang test print", "testPrintSent": "Naipadala ang test print!", + "cashDrawerPulseSent": "Naipadala ang pulse sa cash drawer.", + "cashDrawerPulseFailed": "Hindi naipadala ang pulse sa cash drawer.", "testing": "Sinusubukan…", "timezone": "Timezone", "businessDayStartTime": "Oras ng simula ng araw ng negosyo", diff --git a/frontend/src/lib/i18n/messages/fr.json b/frontend/src/lib/i18n/messages/fr.json index 54e38a38e..ce876941d 100644 --- a/frontend/src/lib/i18n/messages/fr.json +++ b/frontend/src/lib/i18n/messages/fr.json @@ -2206,6 +2206,8 @@ "testPrint": "Impression de test", "testPrintFailed": "Échec de l’impression de test", "testPrintSent": "Impression de test envoyée !", + "cashDrawerPulseSent": "Impulsion de tiroir-caisse envoyée.", + "cashDrawerPulseFailed": "Impossible d’envoyer l’impulsion du tiroir-caisse.", "testing": "Test…", "timezone": "Fuseau horaire", "businessDayStartTime": "Heure de début de la journée d'activité", diff --git a/frontend/src/lib/i18n/messages/hi.json b/frontend/src/lib/i18n/messages/hi.json index 6b48f8e4c..d3191174c 100644 --- a/frontend/src/lib/i18n/messages/hi.json +++ b/frontend/src/lib/i18n/messages/hi.json @@ -2217,6 +2217,8 @@ "testPrint": "टेस्ट प्रिंट", "testPrintFailed": "परीक्षण प्रिंट विफल रहा", "testPrintSent": "परीक्षण प्रिंट भेजा गया!", + "cashDrawerPulseSent": "कैश ड्रॉअर पल्स भेजा गया।", + "cashDrawerPulseFailed": "कैश ड्रॉअर पल्स भेजने में विफल।", "testing": "परीक्षण...", "timezone": "समय क्षेत्र", "businessDayStartTime": "व्यावसायिक दिन प्रारंभ होने का समय", diff --git a/frontend/src/lib/i18n/messages/id.json b/frontend/src/lib/i18n/messages/id.json index 2f8f6d23d..8b24f359a 100644 --- a/frontend/src/lib/i18n/messages/id.json +++ b/frontend/src/lib/i18n/messages/id.json @@ -2217,6 +2217,8 @@ "testPrint": "Uji cetak", "testPrintFailed": "Uji cetak gagal", "testPrintSent": "Uji cetak terkirim!", + "cashDrawerPulseSent": "Pulsa laci kas berhasil dikirim.", + "cashDrawerPulseFailed": "Gagal mengirim pulsa laci kas.", "testing": "Menguji…", "timezone": "Zona waktu", "businessDayStartTime": "Waktu mulai hari usaha", diff --git a/frontend/src/lib/i18n/messages/it.json b/frontend/src/lib/i18n/messages/it.json index fd09a55de..7e044e48d 100644 --- a/frontend/src/lib/i18n/messages/it.json +++ b/frontend/src/lib/i18n/messages/it.json @@ -2206,6 +2206,8 @@ "testPrint": "Stampa di test", "testPrintFailed": "Stampa di test non riuscita", "testPrintSent": "Stampa di test inviata!", + "cashDrawerPulseSent": "Impulso del cassetto inviato.", + "cashDrawerPulseFailed": "Impossibile inviare l’impulso al cassetto.", "testing": "Test in corso…", "timezone": "Fuso orario", "businessDayStartTime": "Ora di inizio giornata operativa", diff --git a/frontend/src/lib/i18n/messages/ja.json b/frontend/src/lib/i18n/messages/ja.json index d06f01558..32df450ee 100644 --- a/frontend/src/lib/i18n/messages/ja.json +++ b/frontend/src/lib/i18n/messages/ja.json @@ -2206,6 +2206,8 @@ "testPrint": "テスト印刷", "testPrintFailed": "テスト印刷に失敗しました", "testPrintSent": "テスト印刷を送信しました!", + "cashDrawerPulseSent": "キャッシュドロワーのパルスを送信しました。", + "cashDrawerPulseFailed": "キャッシュドロワーのパルスを送信できませんでした。", "testing": "テスト中…", "timezone": "タイムゾーン", "businessDayStartTime": "営業日の開始時刻", diff --git a/frontend/src/lib/i18n/messages/ko.json b/frontend/src/lib/i18n/messages/ko.json index 0bb64609c..13a8f341a 100644 --- a/frontend/src/lib/i18n/messages/ko.json +++ b/frontend/src/lib/i18n/messages/ko.json @@ -2206,6 +2206,8 @@ "testPrint": "테스트 인쇄", "testPrintFailed": "테스트 인쇄 실패", "testPrintSent": "테스트 인쇄 전송됨!", + "cashDrawerPulseSent": "현금 서랍 펄스를 보냈습니다.", + "cashDrawerPulseFailed": "현금 서랍 펄스를 보내지 못했습니다.", "testing": "테스트 중…", "timezone": "시간대", "businessDayStartTime": "영업일 시작 시각", diff --git a/frontend/src/lib/i18n/messages/ne.json b/frontend/src/lib/i18n/messages/ne.json index e954682ea..57ebb8702 100644 --- a/frontend/src/lib/i18n/messages/ne.json +++ b/frontend/src/lib/i18n/messages/ne.json @@ -2215,6 +2215,8 @@ "testPrint": "परीक्षण प्रिन्ट", "testPrintFailed": "परीक्षण प्रिन्ट असफल भयो", "testPrintSent": "परीक्षण प्रिन्ट पठाइयो!", + "cashDrawerPulseSent": "नगद दराजको पल्स पठाइयो।", + "cashDrawerPulseFailed": "नगद दराजको पल्स पठाउन सकिएन।", "testing": "परीक्षण…", "timezone": "समय क्षेत्र", "businessDayStartTime": "व्यापार दिन सुरु समय", diff --git a/frontend/src/lib/i18n/messages/nl.json b/frontend/src/lib/i18n/messages/nl.json index 996020e1f..20c6bcd26 100644 --- a/frontend/src/lib/i18n/messages/nl.json +++ b/frontend/src/lib/i18n/messages/nl.json @@ -2206,6 +2206,8 @@ "testPrint": "Testafdruk", "testPrintFailed": "Testafdruk mislukt", "testPrintSent": "Testafdruk verzonden!", + "cashDrawerPulseSent": "Kassaladepuls verzonden.", + "cashDrawerPulseFailed": "Verzenden van kassaladepuls mislukt.", "testing": "Testen…", "timezone": "Tijdzone", "businessDayStartTime": "Begintijd werkdag", diff --git a/frontend/src/lib/i18n/messages/pt.json b/frontend/src/lib/i18n/messages/pt.json index 5fc02bfa4..f1a70d85f 100644 --- a/frontend/src/lib/i18n/messages/pt.json +++ b/frontend/src/lib/i18n/messages/pt.json @@ -2206,6 +2206,8 @@ "testPrint": "Impressão de teste", "testPrintFailed": "Falha na impressão de teste", "testPrintSent": "Impressão de teste enviada!", + "cashDrawerPulseSent": "Pulso da gaveta de dinheiro enviado.", + "cashDrawerPulseFailed": "Não foi possível enviar o pulso da gaveta de dinheiro.", "testing": "Testando…", "timezone": "Fuso horário", "businessDayStartTime": "Hora de início do dia de trabalho", diff --git a/frontend/src/lib/i18n/messages/ru.json b/frontend/src/lib/i18n/messages/ru.json index c6279bf87..992c03a87 100644 --- a/frontend/src/lib/i18n/messages/ru.json +++ b/frontend/src/lib/i18n/messages/ru.json @@ -2206,6 +2206,8 @@ "testPrint": "Пробная печать", "testPrintFailed": "Пробная печать не удалась", "testPrintSent": "Тестовая печать отправлена!", + "cashDrawerPulseSent": "Импульс денежного ящика отправлен.", + "cashDrawerPulseFailed": "Не удалось отправить импульс денежного ящика.", "testing": "Тестирование…", "timezone": "Часовой пояс", "businessDayStartTime": "Время начала рабочего дня", diff --git a/frontend/src/lib/i18n/messages/sq.json b/frontend/src/lib/i18n/messages/sq.json index c140fd3f6..a2c812e70 100644 --- a/frontend/src/lib/i18n/messages/sq.json +++ b/frontend/src/lib/i18n/messages/sq.json @@ -2217,6 +2217,8 @@ "testPrint": "Test printim", "testPrintFailed": "Printimi i testimit dështoi", "testPrintSent": "Printimi testues u dërgua!", + "cashDrawerPulseSent": "Pulsi i sirtarit të parave u dërgua.", + "cashDrawerPulseFailed": "Dërgimi i pulsit të sirtarit të parave dështoi.", "testing": "Po teston…", "timezone": "Zona kohore", "businessDayStartTime": "Koha e fillimit të ditës së punës", diff --git a/frontend/src/lib/i18n/messages/th.json b/frontend/src/lib/i18n/messages/th.json index 36fed347b..cdeff5f8c 100644 --- a/frontend/src/lib/i18n/messages/th.json +++ b/frontend/src/lib/i18n/messages/th.json @@ -2206,6 +2206,8 @@ "testPrint": "ทดสอบการพิมพ์", "testPrintFailed": "ทดสอบการพิมพ์ไม่สำเร็จ", "testPrintSent": "ส่งการทดสอบการพิมพ์แล้ว!", + "cashDrawerPulseSent": "ส่งสัญญาณเปิดลิ้นชักเงินสดแล้ว", + "cashDrawerPulseFailed": "ส่งสัญญาณเปิดลิ้นชักเงินสดไม่สำเร็จ", "testing": "กำลังทดสอบ…", "timezone": "เขตเวลา", "businessDayStartTime": "เวลาเริ่มต้นวันทำการ", diff --git a/frontend/src/lib/i18n/messages/tr.json b/frontend/src/lib/i18n/messages/tr.json index 08ff69e65..9a29ca07f 100644 --- a/frontend/src/lib/i18n/messages/tr.json +++ b/frontend/src/lib/i18n/messages/tr.json @@ -2217,6 +2217,8 @@ "testPrint": "Test Fişi Yazdır", "testPrintFailed": "Test yazdırma başarısız oldu", "testPrintSent": "Test yazdırma komutu gönderildi", + "cashDrawerPulseSent": "Para çekmecesi sinyali gönderildi.", + "cashDrawerPulseFailed": "Para çekmecesi sinyali gönderilemedi.", "testing": "Test ediliyor...", "timezone": "Saat Dilimi", "businessDayStartTime": "İş günü başlangıç saati", diff --git a/frontend/src/lib/i18n/messages/ur.json b/frontend/src/lib/i18n/messages/ur.json index b18bfd9cb..c8cf6aa3b 100644 --- a/frontend/src/lib/i18n/messages/ur.json +++ b/frontend/src/lib/i18n/messages/ur.json @@ -2206,6 +2206,8 @@ "testPrint": "ٹیسٹ پرنٹ", "testPrintFailed": "ٹیسٹ پرنٹ ناکام", "testPrintSent": "ٹیسٹ پرنٹ بھیج دیا گیا!", + "cashDrawerPulseSent": "نقد درج کا پلس بھیج دیا گیا۔", + "cashDrawerPulseFailed": "نقد درج کا پلس بھیجنے میں ناکامی ہوئی۔", "testing": "ٹیسٹ ہو رہا ہے…", "timezone": "ٹائم زون", "businessDayStartTime": "کاروباری دن کا آغاز", diff --git a/frontend/src/lib/i18n/messages/vi.json b/frontend/src/lib/i18n/messages/vi.json index 3e79d636e..aacfa2b64 100644 --- a/frontend/src/lib/i18n/messages/vi.json +++ b/frontend/src/lib/i18n/messages/vi.json @@ -2217,6 +2217,8 @@ "testPrint": "In kiểm tra", "testPrintFailed": "In kiểm tra thất bại", "testPrintSent": "Đã gửi lệnh in kiểm tra!", + "cashDrawerPulseSent": "Đã gửi xung mở két tiền.", + "cashDrawerPulseFailed": "Không thể gửi xung mở két tiền.", "testing": "Đang kiểm tra…", "timezone": "Múi giờ", "businessDayStartTime": "Giờ bắt đầu ngày kinh doanh", diff --git a/frontend/src/lib/i18n/messages/zh-tw.json b/frontend/src/lib/i18n/messages/zh-tw.json index 3b41f0ddc..c31fc272e 100644 --- a/frontend/src/lib/i18n/messages/zh-tw.json +++ b/frontend/src/lib/i18n/messages/zh-tw.json @@ -2206,6 +2206,8 @@ "testPrint": "測試列印", "testPrintFailed": "測試列印失敗", "testPrintSent": "測試列印已傳送!", + "cashDrawerPulseSent": "已傳送錢箱脈衝。", + "cashDrawerPulseFailed": "無法傳送錢箱脈衝。", "testing": "測試中…", "timezone": "時區", "businessDayStartTime": "營業日起始時間", diff --git a/frontend/src/lib/i18n/messages/zh.json b/frontend/src/lib/i18n/messages/zh.json index 789eb4be2..d6bf8ba1e 100644 --- a/frontend/src/lib/i18n/messages/zh.json +++ b/frontend/src/lib/i18n/messages/zh.json @@ -2206,6 +2206,8 @@ "testPrint": "测试打印", "testPrintFailed": "测试打印失败", "testPrintSent": "测试打印已发送!", + "cashDrawerPulseSent": "已发送钱箱脉冲。", + "cashDrawerPulseFailed": "无法发送钱箱脉冲。", "testing": "测试中…", "timezone": "时区", "businessDayStartTime": "营业日起始时间", From 4a4c025d7ef4cadb11d99179d253d8ade073538d Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 11 Oct 2026 16:32:52 -0400 Subject: [PATCH 05/20] no-mistakes(review): Secure printer access/targeting (diff clean; test blocked: Electron missing) --- frontend/src/components/layout/StatusBar.tsx | 93 +++++++----- frontend/src/hooks/usePrinter.ts | 7 +- frontend/src/lib/i18n/messages/ar.json | 3 +- frontend/src/lib/i18n/messages/bn.json | 3 +- frontend/src/lib/i18n/messages/de.json | 3 +- frontend/src/lib/i18n/messages/en.json | 3 +- frontend/src/lib/i18n/messages/es.json | 3 +- frontend/src/lib/i18n/messages/fa.json | 3 +- frontend/src/lib/i18n/messages/fil.json | 3 +- frontend/src/lib/i18n/messages/fr.json | 3 +- frontend/src/lib/i18n/messages/hi.json | 3 +- frontend/src/lib/i18n/messages/id.json | 3 +- frontend/src/lib/i18n/messages/it.json | 3 +- frontend/src/lib/i18n/messages/ja.json | 3 +- frontend/src/lib/i18n/messages/ko.json | 3 +- frontend/src/lib/i18n/messages/ne.json | 3 +- frontend/src/lib/i18n/messages/nl.json | 3 +- frontend/src/lib/i18n/messages/pt.json | 3 +- frontend/src/lib/i18n/messages/ru.json | 3 +- frontend/src/lib/i18n/messages/sq.json | 3 +- frontend/src/lib/i18n/messages/th.json | 3 +- frontend/src/lib/i18n/messages/tr.json | 3 +- frontend/src/lib/i18n/messages/ur.json | 3 +- frontend/src/lib/i18n/messages/vi.json | 3 +- frontend/src/lib/i18n/messages/zh-tw.json | 3 +- frontend/src/lib/i18n/messages/zh.json | 3 +- frontend/src/lib/printer/PrinterService.ts | 20 +++ main/routes/printers.ts | 62 ++++---- tests/printer-api.test.ts | 141 ++++++++++++++++++- 29 files changed, 307 insertions(+), 88 deletions(-) diff --git a/frontend/src/components/layout/StatusBar.tsx b/frontend/src/components/layout/StatusBar.tsx index d0375da4e..d69645525 100644 --- a/frontend/src/components/layout/StatusBar.tsx +++ b/frontend/src/components/layout/StatusBar.tsx @@ -19,6 +19,7 @@ import { import { useTranslations } from 'use-intl'; import toast from 'react-hot-toast'; import { useAuthStore } from '@/store/auth'; +import { tenantCan } from '@/lib/permissions'; import { usePrinterStore } from '@/hooks/usePrinter'; import { printerService } from '@/lib/printer/PrinterService'; import api from '@/lib/api'; @@ -38,6 +39,23 @@ interface CashSession { opening_float_cents?: number; } +interface PrinterActionResponse { + webusb?: boolean; + bytes?: number[]; + printerName?: string; +} + +async function printWebUsbAction(response: PrinterActionResponse): Promise { + if (!response.webusb) return; + if (!Array.isArray(response.bytes) || !response.printerName) { + throw new Error('WebUSB printer action is missing its configured printer.'); + } + await printerService.printToConfiguredDevice( + Uint8Array.from(response.bytes), + response.printerName, + ); +} + export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge?: boolean }) { const tNav = useTranslations('nav'); const tCommon = useTranslations('common'); @@ -48,7 +66,7 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? const tSupport = useTranslations('support'); const { user, currentTenant } = useAuthStore(); - const { status: printerConnStatus, hardwarePrinter } = usePrinterStore(); + const { status: printerConnStatus, deviceInfo, defaultPrinter } = usePrinterStore(); const [status, setStatus] = useState(null); const [session, setSession] = useState(undefined); @@ -151,8 +169,14 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? : null; // Printer readiness - const isPrinterReady = hardwarePrinter !== null || printerConnStatus === 'connected'; - const isPrinterConnecting = printerConnStatus === 'connecting'; + const isPrinterReady = + defaultPrinter?.connection_type === 'webusb' && + printerConnStatus === 'connected' && + deviceInfo?.productName === defaultPrinter.name; + const isPrinterConfigured = defaultPrinter !== null; + const isPrinterConnecting = + defaultPrinter?.connection_type === 'webusb' && printerConnStatus === 'connecting'; + const canKickDrawer = tenantCan(currentTenant, 'cash.movements.manage'); // Shift state const isShiftKnown = session !== undefined; @@ -162,12 +186,8 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? const handleTestPrint = async () => { setIsTestingPrint(true); try { - const res = await api.post<{ success: boolean; webusb?: boolean; bytes?: number[] }>( - '/printers/test-active', - ); - if (res.data?.webusb && Array.isArray(res.data.bytes)) { - await printerService.print(Uint8Array.from(res.data.bytes)); - } + const res = await api.post('/printers/test-active'); + await printWebUsbAction(res.data); toast.success(tSettings('testPrintSent') || 'Test print sent!'); } catch (err: unknown) { const msg = @@ -183,12 +203,8 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? const handleKickDrawer = async () => { setIsKickingDrawer(true); try { - const res = await api.post<{ success: boolean; webusb?: boolean; bytes?: number[] }>( - '/printers/kick-drawer', - ); - if (res.data?.webusb && Array.isArray(res.data.bytes)) { - await printerService.print(Uint8Array.from(res.data.bytes)); - } + const res = await api.post('/printers/kick-drawer'); + await printWebUsbAction(res.data); toast.success(tSettings('cashDrawerPulseSent')); } catch (err: unknown) { const msg = @@ -267,20 +283,22 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? {tSettings('testPrint')} - + {canKickDrawer && ( + + )}
@@ -371,10 +389,10 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? data-testid="status-bar-printer" className="flex items-center gap-1.5 px-1.5 py-0.5" title={ - hardwarePrinter - ? hardwarePrinter.name - : isPrinterReady - ? tPos('printerReady') + isPrinterReady + ? tPos('printerReady') + : isPrinterConfigured + ? tSettings('printerConfigured') : tSettings('printerOffline') } > @@ -382,7 +400,7 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? <> - {hardwarePrinter ? hardwarePrinter.name : tPos('printerReady')} + {defaultPrinter?.name || tPos('printerReady')} ) : isPrinterConnecting ? ( @@ -392,6 +410,13 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? {tPos('printerConnecting')} + ) : isPrinterConfigured ? ( + <> + + + {tSettings('printerConfigured')} + + ) : ( <> diff --git a/frontend/src/hooks/usePrinter.ts b/frontend/src/hooks/usePrinter.ts index 116f331a3..ffb65d952 100644 --- a/frontend/src/hooks/usePrinter.ts +++ b/frontend/src/hooks/usePrinter.ts @@ -129,6 +129,7 @@ interface PrinterState { printMethod: PrintMode; hardwarePrinter: HardwarePrinter | null; webusbPrinter: HardwarePrinter | null; + defaultPrinter: HardwarePrinter | null; refreshHardwarePrinter: () => Promise; connect: () => Promise; disconnect: () => Promise; @@ -169,6 +170,7 @@ export const usePrinterStore = create()( printMethod: 'escpos', hardwarePrinter: null, webusbPrinter: null, + defaultPrinter: null, refreshHardwarePrinter: async () => { try { @@ -180,9 +182,10 @@ export const usePrinterStore = create()( null; const webusbPrinters = list.filter((p) => p.connection_type === 'webusb'); const webusbPrinter = webusbPrinters.length === 1 ? webusbPrinters[0] : null; - set({ hardwarePrinter: defaultPrinter, webusbPrinter }); + const configuredDefault = list.find((p) => p.is_default === 1) || null; + set({ hardwarePrinter: defaultPrinter, webusbPrinter, defaultPrinter: configuredDefault }); } catch { - set({ hardwarePrinter: null, webusbPrinter: null }); + set({ hardwarePrinter: null, webusbPrinter: null, defaultPrinter: null }); } }, diff --git a/frontend/src/lib/i18n/messages/ar.json b/frontend/src/lib/i18n/messages/ar.json index 79b49e2b9..f69433ed3 100644 --- a/frontend/src/lib/i18n/messages/ar.json +++ b/frontend/src/lib/i18n/messages/ar.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "على سبيل المثال: طابعة المطبخ", "printerNameRequired": "اسم الطابعة مطلوب", "printerOffline": "غير متصل", + "printerConfigured": "تم إعداد الطابعة", "printerOnline": "عبر الإنترنت", "printerPrinting": "الطباعة", "printerQuickAdded": "تمت إضافة {name}", @@ -2245,7 +2246,7 @@ "vpnMeshNetwork": "شبكة VPN / شبكة متشابكة", "vpnMeshNetworkHint": "استخدم هذا الخيار عندما تتصل الأجهزة عبر VPN أو شبكة متداخلة بين شبكات فرعية أو مواقع مختلفة", "waitingForApproval": "في انتظار الموافقة", - "webusbHint": "يتم توصيل طابعات WebUSB مباشرةً من المتصفح عبر زر «الاتصال» الموجود في شريط الأدوات. احفظ هذا الإعداد لتذكر تفضيلات عرض الورق.", + "webusbHint": "لإجراءات الطباعة السريعة، يجب أن يطابق الاسم تمامًا اسم منتج جهاز WebUSB واحد فقط. وصّل الجهاز عبر زر الاتصال في شريط الأدوات. يحفظ هذا الإدخال عرض الورق.", "webusbTestHint": "WebUSB: استخدم زر «اتصال» الموجود في شريط أدوات POS لإجراء الاختبار.", "whatsapp": "واتساب", "whatsappSharing": "المشاركة عبر واتساب", diff --git a/frontend/src/lib/i18n/messages/bn.json b/frontend/src/lib/i18n/messages/bn.json index c7a77643c..4630ff4ed 100644 --- a/frontend/src/lib/i18n/messages/bn.json +++ b/frontend/src/lib/i18n/messages/bn.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "যেমন রান্নাঘর প্রিন্টার", "printerNameRequired": "প্রিন্টারের নাম প্রয়োজন", "printerOffline": "অফলাইন", + "printerConfigured": "প্রিন্টার কনফিগার করা হয়েছে", "printerOnline": "অনলাইন", "printerPrinting": "প্রিন্টিং", "printerQuickAdded": "{name} যোগ করা হয়েছে", @@ -2245,7 +2246,7 @@ "vpnMeshNetwork": "ভিপিএন / মেশ নেটওয়ার্ক", "vpnMeshNetworkHint": "বিভিন্ন সাবনেট বা অবস্থানের মধ্যে VPN বা মেশ নেটওয়ার্কের মাধ্যমে ডিভাইস যুক্ত হলে এটি ব্যবহার করুন", "waitingForApproval": "অনুমোদনের অপেক্ষায়", - "webusbHint": "WebUSB প্রিন্টার সরাসরি ব্রাউজার থেকে টুলবার কানেক্ট বোতামের মাধ্যমে সংযুক্ত থাকে। কাগজের প্রস্থ পছন্দ মনে রাখতে এই এন্ট্রি সংরক্ষণ করুন।", + "webusbHint": "দ্রুত কাজের জন্য নামটি অবশ্যই একটি WebUSB ডিভাইসের পণ্যের নামের সঙ্গে হুবহু মিলতে হবে। টুলবারের Connect বোতাম দিয়ে ডিভাইসটি সংযুক্ত করুন। এই এন্ট্রিতে কাগজের প্রস্থ সংরক্ষিত থাকবে।", "webusbTestHint": "WebUSB: পরীক্ষা করতে POS টুলবারে সংযোগ বোতামটি ব্যবহার করুন।", "whatsapp": "হোয়াটসঅ্যাপ", "whatsappSharing": "হোয়াটসঅ্যাপ শেয়ারিং", diff --git a/frontend/src/lib/i18n/messages/de.json b/frontend/src/lib/i18n/messages/de.json index daaaefad9..6603623c3 100644 --- a/frontend/src/lib/i18n/messages/de.json +++ b/frontend/src/lib/i18n/messages/de.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "z. B. Küchendrucker", "printerNameRequired": "Druckername ist erforderlich", "printerOffline": "Offline", + "printerConfigured": "Drucker konfiguriert", "printerOnline": "Online", "printerPrinting": "Druckt…", "printerQuickAdded": "{name} hinzugefügt", @@ -2246,7 +2247,7 @@ "vpnMeshNetwork": "VPN / Mesh-Netzwerk", "vpnMeshNetworkHint": "Verwenden Sie diese Option, wenn Geräte über ein VPN oder Mesh-Netzwerk über verschiedene Subnetze oder Standorte hinweg verbunden sind", "waitingForApproval": "Wartet auf Genehmigung", - "webusbHint": "WebUSB-Drucker werden direkt über die Schaltfläche Verbinden in der Browserleiste verbunden. Speichern Sie diesen Eintrag, um die bevorzugte Papierbreite zu merken.", + "webusbHint": "Für Schnellaktionen muss der Name exakt dem Produktnamen genau eines WebUSB-Geräts entsprechen. Verbinden Sie es über die Schaltfläche „Verbinden“ in der Symbolleiste. Dieser Eintrag speichert die Papierbreite.", "webusbTestHint": "WebUSB: Verwenden Sie die Verbinden-Schaltfläche in der Kassenleiste zum Testen.", "whatsapp": "WhatsApp", "whatsappSharing": "WhatsApp-Freigabe", diff --git a/frontend/src/lib/i18n/messages/en.json b/frontend/src/lib/i18n/messages/en.json index c515c6164..5831e9720 100644 --- a/frontend/src/lib/i18n/messages/en.json +++ b/frontend/src/lib/i18n/messages/en.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "e.g. Kitchen Printer", "printerNameRequired": "Printer name is required", "printerOffline": "Offline", + "printerConfigured": "Printer configured", "printerOnline": "Online", "printerPrinting": "Printing", "printerQuickAdded": "{name} added", @@ -2245,7 +2246,7 @@ "vpnMeshNetwork": "VPN / Mesh Network", "vpnMeshNetworkHint": "Use when devices connect via a VPN or mesh network across different subnets or locations", "waitingForApproval": "Waiting for approval", - "webusbHint": "WebUSB printers are connected directly from the browser via the toolbar Connect button. Save this entry to remember the paper width preference.", + "webusbHint": "For quick actions, the name must exactly match the product name of one WebUSB device. Connect it with the toolbar Connect button. This entry saves the paper width.", "webusbTestHint": "WebUSB: use the Connect button in the POS toolbar to test.", "whatsapp": "WhatsApp", "whatsappSharing": "WhatsApp Sharing", diff --git a/frontend/src/lib/i18n/messages/es.json b/frontend/src/lib/i18n/messages/es.json index 90588779a..9e3d732e7 100644 --- a/frontend/src/lib/i18n/messages/es.json +++ b/frontend/src/lib/i18n/messages/es.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "ej. Impresora de cocina", "printerNameRequired": "El nombre de la impresora es obligatorio", "printerOffline": "Sin conexión", + "printerConfigured": "Impresora configurada", "printerOnline": "En línea", "printerPrinting": "Imprimiendo", "printerQuickAdded": "{name} agregada", @@ -2246,7 +2247,7 @@ "vpnMeshNetwork": "VPN / Red mesh", "vpnMeshNetworkHint": "Úsalo cuando los dispositivos se conecten mediante una VPN o una red mesh entre distintas subredes o ubicaciones", "waitingForApproval": "Esperando aprobación", - "webusbHint": "Las impresoras WebUSB se conectan directo desde el navegador con el botón Conectar de la barra. Guardá esta entrada para recordar el ancho de papel preferido.", + "webusbHint": "Para las acciones rápidas, el nombre debe coincidir exactamente con el nombre de producto de un único dispositivo WebUSB. Conéctelo con el botón Connect de la barra de herramientas. Esta entrada guarda el ancho del papel.", "webusbTestHint": "WebUSB: usá el botón Conectar en la barra del POS para probar.", "whatsapp": "WhatsApp", "whatsappSharing": "Compartir por WhatsApp", diff --git a/frontend/src/lib/i18n/messages/fa.json b/frontend/src/lib/i18n/messages/fa.json index b94895481..221cbcb05 100644 --- a/frontend/src/lib/i18n/messages/fa.json +++ b/frontend/src/lib/i18n/messages/fa.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "مثلاً چاپگر آشپزخانه", "printerNameRequired": "نام چاپگر لازم است", "printerOffline": "آفلاین", + "printerConfigured": "چاپگر پیکربندی شده است", "printerOnline": "آنلاین", "printerPrinting": "در حال چاپ", "printerQuickAdded": "{name} افزوده شد", @@ -2245,7 +2246,7 @@ "vpnMeshNetwork": "VPN / شبکه مش", "vpnMeshNetworkHint": "وقتی دستگاه‌ها از طریق VPN یا شبکه مش بین زیرشبکه‌ها یا مکان‌های مختلف متصل می‌شوند، از این گزینه استفاده کنید", "waitingForApproval": "در انتظار تأیید", - "webusbHint": "چاپگرهای WebUSB مستقیماً از مرورگر از طریق دکمه اتصال در نوار ابزار متصل می‌شوند. برای به خاطر سپردن ترجیح عرض کاغذ، این ورودی را ذخیره کنید.", + "webusbHint": "برای اقدامات سریع، نام باید دقیقاً با نام محصول یک دستگاه WebUSB مطابقت داشته باشد. دستگاه را با دکمه اتصال در نوار ابزار وصل کنید. این ورودی عرض کاغذ را ذخیره می‌کند.", "webusbTestHint": "WebUSB: برای آزمون از دکمه اتصال در نوار ابزار POS استفاده کنید.", "whatsapp": "واتساپ", "whatsappSharing": "اشتراک‌گذاری واتساپ", diff --git a/frontend/src/lib/i18n/messages/fil.json b/frontend/src/lib/i18n/messages/fil.json index c1d13d4a1..7d878dd5e 100644 --- a/frontend/src/lib/i18n/messages/fil.json +++ b/frontend/src/lib/i18n/messages/fil.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "e.g. Kusina Printer", "printerNameRequired": "Kailangan ang pangalan ng printer", "printerOffline": "Offline", + "printerConfigured": "Naka-configure ang printer", "printerOnline": "Online", "printerPrinting": "Pag-print", "printerQuickAdded": "Naidagdag ang printer: {name}", @@ -2256,7 +2257,7 @@ "vpnMeshNetwork": "VPN / Mesh Network", "vpnMeshNetworkHint": "Gamitin kapag nagkokonekta ang mga device sa pamamagitan ng VPN o mesh network sa magkakaibang subnet o lokasyon", "waitingForApproval": "Naghihintay ng pag-apruba", - "webusbHint": "WebUSB printers are connected directly from the browser via the toolbar Kumonekta button. I-save this entry to remember the paper width preference.", + "webusbHint": "Para sa quick action, kailangang eksaktong tumugma ang pangalan sa product name ng isang WebUSB device. Ikonekta ito gamit ang Connect button sa toolbar. Itinatabi ng entry na ito ang lapad ng papel.", "webusbTestHint": "WebUSB: use the Kumonekta button in the POS toolbar to test.", "whatsapp": "WhatsApp", "whatsappSharing": "Pagbabahagi sa WhatsApp", diff --git a/frontend/src/lib/i18n/messages/fr.json b/frontend/src/lib/i18n/messages/fr.json index ce876941d..29c36c622 100644 --- a/frontend/src/lib/i18n/messages/fr.json +++ b/frontend/src/lib/i18n/messages/fr.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "p. ex. Imprimante de cuisine", "printerNameRequired": "Le nom de l’imprimante est requis", "printerOffline": "Hors ligne", + "printerConfigured": "Imprimante configurée", "printerOnline": "En ligne", "printerPrinting": "Impression", "printerQuickAdded": "{name} ajoutée", @@ -2245,7 +2246,7 @@ "vpnMeshNetwork": "VPN / réseau maillé", "vpnMeshNetworkHint": "À utiliser lorsque les appareils se connectent via un VPN ou un réseau maillé entre différents sous-réseaux ou emplacements", "waitingForApproval": "En attente d’approbation", - "webusbHint": "Les imprimantes WebUSB sont connectées directement depuis le navigateur au moyen du bouton Connecter de la barre d’outils. Enregistrez cette entrée pour mémoriser la largeur de papier choisie.", + "webusbHint": "Pour les actions rapides, le nom doit correspondre exactement au nom de produit d’un seul appareil WebUSB. Connectez-le avec le bouton Connecter de la barre d’outils. Cette entrée mémorise la largeur du papier.", "webusbTestHint": "WebUSB : utilisez le bouton Connecter de la barre d’outils de la caisse pour effectuer un test.", "whatsapp": "WhatsApp", "whatsappSharing": "Partage WhatsApp", diff --git a/frontend/src/lib/i18n/messages/hi.json b/frontend/src/lib/i18n/messages/hi.json index d3191174c..b40532d2d 100644 --- a/frontend/src/lib/i18n/messages/hi.json +++ b/frontend/src/lib/i18n/messages/hi.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "जैसे रसोई प्रिंटर", "printerNameRequired": "प्रिंटर का नाम आवश्यक है", "printerOffline": "ऑफ़लाइन", + "printerConfigured": "प्रिंटर कॉन्फ़िगर किया गया", "printerOnline": "ऑनलाइन", "printerPrinting": "मुद्रण", "printerQuickAdded": "{name} जोड़ा गया", @@ -2256,7 +2257,7 @@ "vpnMeshNetwork": "वीपीएन/मेश नेटवर्क", "vpnMeshNetworkHint": "जब डिवाइस अलग-अलग सबनेट या स्थानों के बीच VPN या मेश नेटवर्क से जुड़ते हों, तब इसका उपयोग करें", "waitingForApproval": "मंजूरी का इंतजार है", - "webusbHint": "WebUSB प्रिंटर टूलबार कनेक्ट बटन के माध्यम से सीधे ब्राउज़र से जुड़े होते हैं। कागज़ की चौड़ाई की प्राथमिकता याद रखने के लिए इस प्रविष्टि को सहेजें।", + "webusbHint": "त्वरित कार्रवाइयों के लिए, नाम का एक WebUSB डिवाइस के उत्पाद नाम से बिल्कुल मेल होना चाहिए। टूलबार के Connect बटन से डिवाइस कनेक्ट करें। यह प्रविष्टि कागज़ की चौड़ाई सहेजती है।", "webusbTestHint": "WebUSB: परीक्षण करने के लिए POS टूलबार में कनेक्ट बटन का उपयोग करें।", "whatsapp": "WhatsApp", "whatsappSharing": "WhatsApp शेयरिंग", diff --git a/frontend/src/lib/i18n/messages/id.json b/frontend/src/lib/i18n/messages/id.json index 8b24f359a..90b4e109a 100644 --- a/frontend/src/lib/i18n/messages/id.json +++ b/frontend/src/lib/i18n/messages/id.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "misalnya Printer Dapur", "printerNameRequired": "Nama printer wajib diisi", "printerOffline": "Luring", + "printerConfigured": "Printer dikonfigurasi", "printerOnline": "Daring", "printerPrinting": "Mencetak", "printerQuickAdded": "{name} ditambahkan", @@ -2256,7 +2257,7 @@ "vpnMeshNetwork": "VPN / Jaringan Mesh", "vpnMeshNetworkHint": "Gunakan saat perangkat terhubung melalui VPN atau jaringan mesh di subnet atau lokasi yang berbeda", "waitingForApproval": "Menunggu persetujuan", - "webusbHint": "Printer WebUSB disambungkan langsung dari browser melalui tombol Sambung di bilah alat. Simpan entri ini untuk mengingat preferensi lebar kertas.", + "webusbHint": "Untuk tindakan cepat, nama harus sama persis dengan nama produk satu perangkat WebUSB. Hubungkan perangkat melalui tombol Connect di toolbar. Entri ini menyimpan preferensi lebar kertas.", "webusbTestHint": "WebUSB: gunakan tombol Sambung di bilah alat POS untuk menguji.", "whatsapp": "WhatsApp", "whatsappSharing": "Berbagi via WhatsApp", diff --git a/frontend/src/lib/i18n/messages/it.json b/frontend/src/lib/i18n/messages/it.json index 7e044e48d..85b98e631 100644 --- a/frontend/src/lib/i18n/messages/it.json +++ b/frontend/src/lib/i18n/messages/it.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "es. Stampante cucina", "printerNameRequired": "Il nome della stampante è obbligatorio", "printerOffline": "Offline", + "printerConfigured": "Stampante configurata", "printerOnline": "Online", "printerPrinting": "Stampa in corso", "printerQuickAdded": "{name} aggiunta", @@ -2245,7 +2246,7 @@ "vpnMeshNetwork": "VPN / Rete mesh", "vpnMeshNetworkHint": "Usa quando i dispositivi si connettono tramite VPN o rete mesh tra subnet o sedi diverse", "waitingForApproval": "In attesa di approvazione", - "webusbHint": "Le stampanti WebUSB sono collegate direttamente dal browser tramite il pulsante Connetti della barra strumenti. Salva questa voce per ricordare la preferenza di larghezza carta.", + "webusbHint": "Per le azioni rapide, il nome deve corrispondere esattamente al nome prodotto di un solo dispositivo WebUSB. Collegalo con il pulsante Connetti nella barra degli strumenti. Questa voce salva la larghezza della carta.", "webusbTestHint": "WebUSB: usa il pulsante Connetti nella barra strumenti del POS per provare.", "whatsapp": "WhatsApp", "whatsappSharing": "Condivisione WhatsApp", diff --git a/frontend/src/lib/i18n/messages/ja.json b/frontend/src/lib/i18n/messages/ja.json index 32df450ee..a2baed74a 100644 --- a/frontend/src/lib/i18n/messages/ja.json +++ b/frontend/src/lib/i18n/messages/ja.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "例: キッチンプリンター", "printerNameRequired": "プリンター名は必須です", "printerOffline": "オフライン", + "printerConfigured": "プリンターを設定済み", "printerOnline": "オンライン", "printerPrinting": "印刷中", "printerQuickAdded": "{name}を追加しました", @@ -2245,7 +2246,7 @@ "vpnMeshNetwork": "VPN / メッシュネットワーク", "vpnMeshNetworkHint": "デバイスが異なるサブネットや場所からVPNまたはメッシュネットワーク経由で接続する場合に使用します", "waitingForApproval": "承認待ち", - "webusbHint": "WebUSBプリンターはブラウザーのツールバーにある接続ボタンから直接接続します。この項目を保存すると用紙幅の設定を記憶できます。", + "webusbHint": "クイック操作では、名前が1台のWebUSBデバイスの製品名と完全に一致する必要があります。ツールバーの接続ボタンでデバイスを接続してください。この設定には用紙幅が保存されます。", "webusbTestHint": "WebUSB: POSツールバーの接続ボタンでテストしてください。", "whatsapp": "WhatsApp", "whatsappSharing": "WhatsApp共有", diff --git a/frontend/src/lib/i18n/messages/ko.json b/frontend/src/lib/i18n/messages/ko.json index 13a8f341a..d62e42bb0 100644 --- a/frontend/src/lib/i18n/messages/ko.json +++ b/frontend/src/lib/i18n/messages/ko.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "예: 주방 프린터", "printerNameRequired": "프린터 이름은 필수입니다", "printerOffline": "오프라인", + "printerConfigured": "프린터 설정 완료", "printerOnline": "온라인", "printerPrinting": "인쇄 중", "printerQuickAdded": "{name} 추가됨", @@ -2245,7 +2246,7 @@ "vpnMeshNetwork": "VPN / 메시 네트워크", "vpnMeshNetworkHint": "기기가 서로 다른 서브넷이나 위치에서 VPN 또는 메시 네트워크를 통해 연결될 때 사용하세요", "waitingForApproval": "승인 대기 중", - "webusbHint": "WebUSB 프린터는 툴바의 연결 버튼에서 브라우저로 직접 연결됩니다. 이 항목을 저장하면 용지 너비 환경설정이 기억됩니다.", + "webusbHint": "빠른 작업을 사용하려면 이름이 하나의 WebUSB 기기 제품명과 정확히 일치해야 합니다. 도구 모음의 연결 버튼으로 기기를 연결하세요. 이 항목에는 용지 너비가 저장됩니다.", "webusbTestHint": "WebUSB: 테스트하려면 POS 툴바의 연결 버튼을 사용하세요.", "whatsapp": "WhatsApp", "whatsappSharing": "WhatsApp 공유", diff --git a/frontend/src/lib/i18n/messages/ne.json b/frontend/src/lib/i18n/messages/ne.json index 57ebb8702..558f29df3 100644 --- a/frontend/src/lib/i18n/messages/ne.json +++ b/frontend/src/lib/i18n/messages/ne.json @@ -2045,6 +2045,7 @@ "printerNamePlaceholder": "जस्तै किचन प्रिन्टर", "printerNameRequired": "प्रिन्टर नाम आवश्यक छ", "printerOffline": "अफलाइन", + "printerConfigured": "प्रिन्टर कन्फिगर गरिएको छ", "printerOnline": "अनलाइन", "printerPrinting": "मुद्रण", "printerQuickAdded": "{name} थपियो", @@ -2254,7 +2255,7 @@ "vpnMeshNetwork": "VPN / जाल नेटवर्क", "vpnMeshNetworkHint": "उपकरणहरू फरक सबनेट वा स्थानहरूबाट VPN वा जाल नेटवर्कमार्फत जडान हुँदा प्रयोग गर्नुहोस्", "waitingForApproval": "स्वीकृतिको पर्खाइमा", - "webusbHint": "WebUSB प्रिन्टरहरू ब्राउजरबाट उपकरणपट्टी जडान बटन मार्फत सिधै जडान भएका छन्। कागज चौडाइ प्राथमिकता सम्झन यो प्रविष्टि बचत गर्नुहोस्।", + "webusbHint": "द्रुत कार्यहरूका लागि नाम एउटा WebUSB उपकरणको उत्पादन नामसँग ठ्याक्कै मिल्नुपर्छ। उपकरणलाई टुलबारको Connect बटनबाट जडान गर्नुहोस्। यस प्रविष्टिले कागजको चौडाइ सुरक्षित गर्छ।", "webusbTestHint": "WebUSB: परीक्षण गर्न POS उपकरणपट्टीमा जडान बटन प्रयोग गर्नुहोस्।", "whatsapp": "व्हाट्सएप", "whatsappSharing": "व्हाट्सएप साझेदारी", diff --git a/frontend/src/lib/i18n/messages/nl.json b/frontend/src/lib/i18n/messages/nl.json index 20c6bcd26..2193fb04a 100644 --- a/frontend/src/lib/i18n/messages/nl.json +++ b/frontend/src/lib/i18n/messages/nl.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "bijv. Keukenprinter", "printerNameRequired": "Printernaam is vereist", "printerOffline": "Offline", + "printerConfigured": "Printer ingesteld", "printerOnline": "Online", "printerPrinting": "Afdrukken", "printerQuickAdded": "{name} toegevoegd", @@ -2245,7 +2246,7 @@ "vpnMeshNetwork": "VPN/Mesh-netwerk", "vpnMeshNetworkHint": "Gebruik dit wanneer apparaten via een VPN- of meshnetwerk tussen verschillende subnetten of locaties verbinding maken", "waitingForApproval": "Wachten op goedkeuring", - "webusbHint": "WebUSB-printers zijn rechtstreeks vanuit de browser verbonden via de knop Verbinden op de werkbalk. Sla deze configuratie op om de voorkeur voor de papierbreedte te behouden.", + "webusbHint": "Voor snelle acties moet de naam exact overeenkomen met de productnaam van één WebUSB-apparaat. Verbind het apparaat met de knop Verbinden in de werkbalk. Deze vermelding bewaart de papierbreedte.", "webusbTestHint": "WebUSB: gebruik de knop Verbinden in de POS werkbalk om te testen.", "whatsapp": "WhatsApp", "whatsappSharing": "WhatsApp Delen", diff --git a/frontend/src/lib/i18n/messages/pt.json b/frontend/src/lib/i18n/messages/pt.json index f1a70d85f..779c4136c 100644 --- a/frontend/src/lib/i18n/messages/pt.json +++ b/frontend/src/lib/i18n/messages/pt.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "ex.: Impressora da Cozinha", "printerNameRequired": "O nome da impressora é obrigatório", "printerOffline": "Offline", + "printerConfigured": "Impressora configurada", "printerOnline": "Online", "printerPrinting": "Imprimindo", "printerQuickAdded": "{name} adicionada", @@ -2245,7 +2246,7 @@ "vpnMeshNetwork": "VPN / Rede em Malha", "vpnMeshNetworkHint": "Use quando os dispositivos se conectarem por uma VPN ou rede mesh entre sub-redes ou locais diferentes", "waitingForApproval": "Aguardando aprovação", - "webusbHint": "As impressoras WebUSB são conectadas diretamente do navegador pelo botão Conectar da barra de ferramentas. Salve esta entrada para lembrar a preferência de largura do papel.", + "webusbHint": "Para ações rápidas, o nome deve corresponder exatamente ao nome do produto de um único dispositivo WebUSB. Conecte-o pelo botão Conectar da barra de ferramentas. Esta entrada salva a largura do papel.", "webusbTestHint": "WebUSB: use o botão Conectar na barra de ferramentas do PDV para testar.", "whatsapp": "WhatsApp", "whatsappSharing": "Compartilhamento via WhatsApp", diff --git a/frontend/src/lib/i18n/messages/ru.json b/frontend/src/lib/i18n/messages/ru.json index 992c03a87..864b6422b 100644 --- a/frontend/src/lib/i18n/messages/ru.json +++ b/frontend/src/lib/i18n/messages/ru.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "например: кухонный принтер", "printerNameRequired": "Укажите имя принтера.", "printerOffline": "Не в сети", + "printerConfigured": "Принтер настроен", "printerOnline": "Онлайн", "printerPrinting": "Печать", "printerQuickAdded": "Принтер {name} добавлен", @@ -2245,7 +2246,7 @@ "vpnMeshNetwork": "VPN/ячеистая сеть", "vpnMeshNetworkHint": "Используйте, если устройства подключаются через VPN или mesh-сеть из разных подсетей или мест", "waitingForApproval": "Ожидание одобрения", - "webusbHint": "Принтеры WebUSB подключаются напрямую из браузера с помощью кнопки «Подключиться» на панели инструментов. Сохраните эту запись, чтобы запомнить настройки ширины бумаги.", + "webusbHint": "Для быстрых действий имя должно точно совпадать с названием продукта одного устройства WebUSB. Подключите его кнопкой «Подключить» на панели инструментов. В этой записи сохраняется ширина бумаги.", "webusbTestHint": "WebUSB: для проверки используйте кнопку «Подключиться» на панели инструментов POS.", "whatsapp": "WhatsApp", "whatsappSharing": "Общий доступ к WhatsApp", diff --git a/frontend/src/lib/i18n/messages/sq.json b/frontend/src/lib/i18n/messages/sq.json index a2c812e70..2e6d8fa98 100644 --- a/frontend/src/lib/i18n/messages/sq.json +++ b/frontend/src/lib/i18n/messages/sq.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "p.sh. Printer kuzhine", "printerNameRequired": "Kërkohet emri i printerit", "printerOffline": "Jashtë linje", + "printerConfigured": "Printeri është konfiguruar", "printerOnline": "Në lidhje", "printerPrinting": "Shtypje", "printerQuickAdded": "{name} u shtua", @@ -2256,7 +2257,7 @@ "vpnMeshNetwork": "Rrjeti VPN / Mesh", "vpnMeshNetworkHint": "Përdoreni kur pajisjet lidhen përmes një VPN-je ose rrjeti mesh nga nënrrjete ose vende të ndryshme", "waitingForApproval": "Në pritje të miratimit", - "webusbHint": "Printerët WebUSB lidhen drejtpërdrejt nga shfletuesi nëpërmjet butonit Lidhu në shiritin e veglave. Ruajeni këtë hyrje për të kujtuar preferencën e gjerësisë së letrës.", + "webusbHint": "Për veprimet e shpejta, emri duhet të përputhet saktësisht me emrin e produktit të një pajisjeje WebUSB. Lidheni me butonin Connect në shiritin e mjeteve. Kjo hyrje ruan gjerësinë e letrës.", "webusbTestHint": "WebUSB: përdorni butonin Connect në shiritin e veglave POS për të testuar.", "whatsapp": "WhatsApp", "whatsappSharing": "Ndarja në WhatsApp", diff --git a/frontend/src/lib/i18n/messages/th.json b/frontend/src/lib/i18n/messages/th.json index cdeff5f8c..cd0eff536 100644 --- a/frontend/src/lib/i18n/messages/th.json +++ b/frontend/src/lib/i18n/messages/th.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "เช่น เครื่องพิมพ์ครัว", "printerNameRequired": "ต้องระบุชื่อเครื่องพิมพ์", "printerOffline": "ออฟไลน์", + "printerConfigured": "ตั้งค่าเครื่องพิมพ์แล้ว", "printerOnline": "ออนไลน์", "printerPrinting": "กำลังพิมพ์", "printerQuickAdded": "เพิ่ม {name} แล้ว", @@ -2245,7 +2246,7 @@ "vpnMeshNetwork": "VPN / เครือข่าย Mesh", "vpnMeshNetworkHint": "ใช้เมื่ออุปกรณ์เชื่อมต่อผ่าน VPN หรือเครือข่าย Mesh ข้ามซับเน็ตหรือตำแหน่งที่แตกต่างกัน", "waitingForApproval": "รอการอนุมัติ", - "webusbHint": "เชื่อมต่อเครื่องพิมพ์ WebUSB โดยตรงจากเบราว์เซอร์ผ่านปุ่มเชื่อมต่อบนแถบเครื่องมือ บันทึกรายการนี้เพื่อจดจำค่ากำหนดความกว้างกระดาษ", + "webusbHint": "สำหรับการดำเนินการด่วน ชื่อต้องตรงกับชื่อผลิตภัณฑ์ของอุปกรณ์ WebUSB เพียงเครื่องเดียวทุกประการ เชื่อมต่ออุปกรณ์ด้วยปุ่ม Connect บนแถบเครื่องมือ รายการนี้จะบันทึกความกว้างกระดาษ", "webusbTestHint": "WebUSB: ใช้ปุ่มเชื่อมต่อบนแถบเครื่องมือ POS เพื่อทดสอบ", "whatsapp": "WhatsApp", "whatsappSharing": "การแชร์ผ่าน WhatsApp", diff --git a/frontend/src/lib/i18n/messages/tr.json b/frontend/src/lib/i18n/messages/tr.json index 9a29ca07f..cdad6c0bd 100644 --- a/frontend/src/lib/i18n/messages/tr.json +++ b/frontend/src/lib/i18n/messages/tr.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "Örn. Kasa Fiş Yazıcısı", "printerNameRequired": "Lütfen bir yazıcı adı girin", "printerOffline": "Çevrimdışı", + "printerConfigured": "Yazıcı yapılandırıldı", "printerOnline": "Çevrimiçi", "printerPrinting": "Yazdırılıyor...", "printerQuickAdded": "Yazıcı eklendi: {name}", @@ -2256,7 +2257,7 @@ "vpnMeshNetwork": "VPN / Mesh Ağı", "vpnMeshNetworkHint": "Cihazlar farklı alt ağlar veya konumlar arasında VPN ya da mesh ağı üzerinden bağlanıyorsa kullanın", "waitingForApproval": "Yönetici onayı bekleniyor...", - "webusbHint": "WebUSB tarayıcı üzerinden doğrudan USB termal yazıcı erişimi sağlar", + "webusbHint": "Hızlı işlemler için ad, tek bir WebUSB cihazının ürün adıyla tam olarak eşleşmelidir. Cihazı araç çubuğundaki Bağlan düğmesiyle bağlayın. Bu kayıt kağıt genişliğini saklar.", "webusbTestHint": "Uyumlu USB yazıcıyı bağlayıp test edin", "whatsapp": "WhatsApp", "whatsappSharing": "WhatsApp Fiş Paylaşımı", diff --git a/frontend/src/lib/i18n/messages/ur.json b/frontend/src/lib/i18n/messages/ur.json index c8cf6aa3b..883e43182 100644 --- a/frontend/src/lib/i18n/messages/ur.json +++ b/frontend/src/lib/i18n/messages/ur.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "مثلاً، کچن پرنٹر", "printerNameRequired": "پرنٹر کا نام درکار ہے", "printerOffline": "آف لائن", + "printerConfigured": "پرنٹر ترتیب دیا گیا ہے", "printerOnline": "آن لائن", "printerPrinting": "پرنٹ ہو رہا ہے", "printerQuickAdded": "{name} شامل ہو گیا", @@ -2245,7 +2246,7 @@ "vpnMeshNetwork": "VPN / میش نیٹ ورک", "vpnMeshNetworkHint": "جب آلات مختلف سب نیٹس یا مقامات کے درمیان VPN یا میش نیٹ ورک کے ذریعے جڑیں تو اسے استعمال کریں", "waitingForApproval": "منظوری کا انتظار", - "webusbHint": "WebUSB پرنٹرز ٹول بار کے Connect بٹن سے براہ راست براؤزر سے منسلک ہوتے ہیں۔ کاغذ کی چوڑائی کی ترجیح یاد رکھنے کے لیے یہ اندراج محفوظ کریں۔", + "webusbHint": "فوری کارروائیوں کے لیے نام کا ایک WebUSB ڈیوائس کے پروڈکٹ نام سے بالکل ملنا ضروری ہے۔ ٹول بار کے Connect بٹن سے ڈیوائس منسلک کریں۔ یہ اندراج کاغذ کی چوڑائی محفوظ کرتا ہے۔", "webusbTestHint": "WebUSB: آزمائش کے لیے POS ٹول بار کے Connect بٹن استعمال کریں۔", "whatsapp": "WhatsApp", "whatsappSharing": "WhatsApp شیئرنگ", diff --git a/frontend/src/lib/i18n/messages/vi.json b/frontend/src/lib/i18n/messages/vi.json index aacfa2b64..b0cfc9435 100644 --- a/frontend/src/lib/i18n/messages/vi.json +++ b/frontend/src/lib/i18n/messages/vi.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "ví dụ: Máy in bếp", "printerNameRequired": "Cần có tên máy in", "printerOffline": "Ngoại tuyến", + "printerConfigured": "Máy in đã được cấu hình", "printerOnline": "Trực tuyến", "printerPrinting": "Đang in", "printerQuickAdded": "Đã thêm {name}", @@ -2256,7 +2257,7 @@ "vpnMeshNetwork": "VPN / mạng lưới Mesh", "vpnMeshNetworkHint": "Dùng khi các thiết bị kết nối qua VPN hoặc mạng mesh giữa các mạng con hay địa điểm khác nhau", "waitingForApproval": "Đang chờ phê duyệt", - "webusbHint": "Máy in WebUSB được kết nối trực tiếp từ trình duyệt qua nút Kết nối trên thanh công cụ. Lưu mục này để ghi nhớ tùy chọn khổ giấy.", + "webusbHint": "Để dùng thao tác nhanh, tên phải khớp chính xác với tên sản phẩm của một thiết bị WebUSB. Kết nối thiết bị bằng nút Kết nối trên thanh công cụ. Mục này lưu lại khổ giấy.", "webusbTestHint": "WebUSB: dùng nút Kết nối trên thanh công cụ POS để kiểm tra.", "whatsapp": "WhatsApp", "whatsappSharing": "Chia sẻ WhatsApp", diff --git a/frontend/src/lib/i18n/messages/zh-tw.json b/frontend/src/lib/i18n/messages/zh-tw.json index c31fc272e..103491ef0 100644 --- a/frontend/src/lib/i18n/messages/zh-tw.json +++ b/frontend/src/lib/i18n/messages/zh-tw.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "例如:廚房印表機", "printerNameRequired": "印表機名稱必填", "printerOffline": "離線", + "printerConfigured": "印表機已設定", "printerOnline": "線上", "printerPrinting": "列印中", "printerQuickAdded": "已新增 {name}", @@ -2245,7 +2246,7 @@ "vpnMeshNetwork": "VPN / 網狀網路", "vpnMeshNetworkHint": "當裝置透過 VPN 或網狀網路跨不同子網路或地點連線時使用", "waitingForApproval": "等待批准", - "webusbHint": "WebUSB 印表機從瀏覽器工具欄的連線按鈕直接連線。儲存此條目以記住紙張寬度偏好。", + "webusbHint": "若要使用快速操作,名稱必須與單一 WebUSB 裝置的產品名稱完全相符。請使用工具列的連線按鈕連接裝置。此項目會儲存紙張寬度。", "webusbTestHint": "WebUSB:使用 POS 工具欄中的連線按鈕進行測試。", "whatsapp": "WhatsApp", "whatsappSharing": "WhatsApp 分享", diff --git a/frontend/src/lib/i18n/messages/zh.json b/frontend/src/lib/i18n/messages/zh.json index d6bf8ba1e..35b37521e 100644 --- a/frontend/src/lib/i18n/messages/zh.json +++ b/frontend/src/lib/i18n/messages/zh.json @@ -2047,6 +2047,7 @@ "printerNamePlaceholder": "例如:厨房打印机", "printerNameRequired": "打印机名称必填", "printerOffline": "离线", + "printerConfigured": "打印机已配置", "printerOnline": "在线", "printerPrinting": "打印中", "printerQuickAdded": "已添加 {name}", @@ -2245,7 +2246,7 @@ "vpnMeshNetwork": "VPN / 网状网络", "vpnMeshNetworkHint": "当设备通过 VPN 或网状网络跨不同子网或地点连接时使用", "waitingForApproval": "等待批准", - "webusbHint": "WebUSB 打印机从浏览器工具栏的连接按钮直接连接。保存此条目以记住纸张宽度偏好。", + "webusbHint": "要使用快速操作,名称必须与一个 WebUSB 设备的产品名称完全一致。请使用工具栏的连接按钮连接设备。此条目会保存纸张宽度。", "webusbTestHint": "WebUSB:使用 POS 工具栏中的连接按钮进行测试。", "whatsapp": "WhatsApp", "whatsappSharing": "WhatsApp 分享", diff --git a/frontend/src/lib/printer/PrinterService.ts b/frontend/src/lib/printer/PrinterService.ts index 65ec04d42..ce8fcda3d 100644 --- a/frontend/src/lib/printer/PrinterService.ts +++ b/frontend/src/lib/printer/PrinterService.ts @@ -206,6 +206,26 @@ class PrinterService { return this.reconnectPromise; } + async printToConfiguredDevice(data: Uint8Array, productName: string): Promise { + if (this._printMode === 'browser') { + throw new Error('Browser print mode is active. Use window.print() instead.'); + } + if (typeof navigator === 'undefined' || !navigator.usb) { + throw new Error('WebUSB API is not supported in this browser. Use Chrome or Edge 89+.'); + } + + await this.runExclusive(async () => { + const matches = (await navigator.usb!.getDevices()).filter( + (device) => device.productName === productName, + ); + if (matches.length !== 1) { + throw new Error('Configured WebUSB printer does not match one available device.'); + } + await this.openDevice(matches[0]); + await this.print(data); + }); + } + /** Waits for an in-flight silent reconnect to settle before caller checks connection. */ async awaitPendingReconnect(): Promise { if (this.reconnectPromise) await this.reconnectPromise; diff --git a/main/routes/printers.ts b/main/routes/printers.ts index 139455c5e..187519a65 100644 --- a/main/routes/printers.ts +++ b/main/routes/printers.ts @@ -47,7 +47,7 @@ import { resolvePrinterProfile, capabilitiesForPrinter, } from '../printers/profiles'; -import { requirePermission, requireAnyPermission } from '../services/authorization'; +import { hasPermission, requirePermission, requireAnyPermission } from '../services/authorization'; import { formatCurrencyForTenant, formatDateForTenant, @@ -169,6 +169,10 @@ function printerShape(printer: any) { }; } +function getDefaultPrinter(db: ReturnType): any { + return db.prepare('SELECT * FROM printers WHERE is_default = 1').get() as any; +} + // Hydrates order items with add-ons from normalized order_item_addons. export function getEffectiveOrderItems(db: any, orderId: string): any[] { return attachEffectiveAddons( @@ -179,20 +183,24 @@ export function getEffectiveOrderItems(db: any, orderId: string): any[] { ); } -// GET /api/printers — list all -router.get('/', requirePermission('printers.manage'), (_req: Request, res: Response) => { - try { - const db = getDatabase(); - const printers = db - .prepare('SELECT * FROM printers ORDER BY is_default DESC, name') - .all() - .map(printerShape); - res.json({ printers }); - } catch (error: any) { - console.error('[API] Internal error:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); +// GET /api/printers +router.get( + '/', + requireAnyPermission('printers.manage', 'printing.execute'), + (req: Request, res: Response) => { + try { + const db = getDatabase(); + const userId = String((req as any).user?.userId || ''); + const printers = hasPermission(userId, 'printers.manage') + ? db.prepare('SELECT * FROM printers ORDER BY is_default DESC, name').all() + : [getDefaultPrinter(db)].filter(Boolean); + res.json({ printers: printers.map(printerShape) }); + } catch (error: any) { + console.error('[API] Internal error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }, +); // GET /api/printers/detect — detect connected USB/network printers router.get( @@ -501,13 +509,11 @@ router.post( // POST /api/printers/kick-drawer — send a cash drawer pulse to the default/active printer router.post( '/kick-drawer', - requireAnyPermission('cash.movements.manage', 'printing.execute'), + requirePermission('cash.movements.manage'), asyncHandler(async (req: Request, res: Response) => { try { const db = getDatabase(); - const printer = - (db.prepare('SELECT * FROM printers WHERE is_default = 1').get() as any) || - (db.prepare('SELECT * FROM printers ORDER BY created_at ASC LIMIT 1').get() as any); + const printer = getDefaultPrinter(db); const drawerPulseBytes = Buffer.from([0x1b, 0x70, 0x00, 0x19, 0xfa]); @@ -532,7 +538,12 @@ router.post( result = await printViaUSB(drawerPulseBytes, printer.name, getHttpRequestSignal(req)); break; case 'webusb': - return res.json({ success: true, webusb: true, bytes: Array.from(drawerPulseBytes) }); + return res.json({ + success: true, + webusb: true, + bytes: Array.from(drawerPulseBytes), + printerName: printer.name, + }); default: return res.status(400).json({ error: 'Unsupported printer connection type' }); } @@ -559,9 +570,7 @@ router.post( asyncHandler(async (req: Request, res: Response) => { try { const db = getDatabase(); - const printer = - (db.prepare('SELECT * FROM printers WHERE is_default = 1').get() as any) || - (db.prepare('SELECT * FROM printers ORDER BY created_at ASC LIMIT 1').get() as any); + const printer = getDefaultPrinter(db); if (!printer) return res.status(404).json({ error: 'No default printer configured' }); @@ -597,7 +606,12 @@ router.post( result = await printViaUSB(testData, printer.name, getHttpRequestSignal(req)); break; case 'webusb': - return res.json({ success: true, webusb: true, bytes: Array.from(testData) }); + return res.json({ + success: true, + webusb: true, + bytes: Array.from(testData), + printerName: printer.name, + }); } if (result.ok) { diff --git a/tests/printer-api.test.ts b/tests/printer-api.test.ts index 13bfa5d1e..0ca4cdae9 100644 --- a/tests/printer-api.test.ts +++ b/tests/printer-api.test.ts @@ -35,6 +35,7 @@ const { printerRoutes } = require('../main/routes/printers'); const { kitchenStationRoutes } = require('../main/routes/kitchen-stations'); const { printReceipt, printReceiptDetailed } = require('../main/printers/thermal'); const { cloudSync } = require('../main/services/cloud-sync'); +const { printerService } = require('../frontend/src/lib/printer/PrinterService'); let passed = 0; let failed = 0; @@ -75,10 +76,22 @@ getDatabase() ) .run(now(), now()); +getDatabase() + .prepare( + `INSERT OR IGNORE INTO users (id, name, email, password, role, is_active, created_at, updated_at) + VALUES ('server-1', 'Server', 'server@flo.local', 'unused', 'server', 1, ?, ?)`, + ) + .run(now(), now()); + const app = express(); app.use(express.json()); +let currentUserId = 'owner-1'; app.use((req: any, _res: any, next: any) => { - req.user = { userId: 'owner-1', email: 'owner@flo.local', role: 'owner' }; + req.user = { + userId: currentUserId, + email: currentUserId === 'server-1' ? 'server@flo.local' : 'owner@flo.local', + role: currentUserId === 'server-1' ? 'server' : 'owner', + }; next(); }); app.use('/api/printers', printerRoutes); @@ -110,6 +123,44 @@ function defaultId(): string | undefined { return row?.id; } +function makeWebUsbTestDevice(productName: string, vendorId: number) { + const sent: number[][] = []; + const configuration = { + interfaces: [ + { + interfaceNumber: 0, + alternates: [ + { + interfaceClass: 0x07, + endpoints: [{ type: 'bulk', direction: 'out', endpointNumber: 1 }], + }, + ], + }, + ], + }; + const device: any = { + vendorId, + productId: 1, + productName, + manufacturerName: 'Test', + serialNumber: String(vendorId), + configuration: null, + configurations: [configuration], + open: async () => {}, + close: async () => {}, + selectConfiguration: async () => { + device.configuration = configuration; + }, + claimInterface: async () => {}, + releaseInterface: async () => {}, + transferOut: async (_endpoint: number, data: ArrayBuffer) => { + sent.push(Array.from(new Uint8Array(data))); + return { status: 'ok' }; + }, + }; + return { device, sent }; +} + async function runTests() { console.log('Printer API Tests (supertest)'); console.log('='.repeat(50)); @@ -614,16 +665,98 @@ async function runTests() { }); assert(webusbRes.status === 201, `creating WebUSB printer returns 201 (got ${webusbRes.status})`); + currentUserId = 'server-1'; + const serverPrintersRes = await request(app).get('/api/printers'); + assert(serverPrintersRes.status === 200, 'printing.execute can read default printer readiness'); + assert( + Array.isArray(serverPrintersRes.body.printers) && + serverPrintersRes.body.printers.length === 1 && + serverPrintersRes.body.printers[0].id === webusbRes.body.printer.id, + 'printing.execute receives only the active printer', + ); + + const deniedKickRes = await request(app).post('/api/printers/kick-drawer'); + assert(deniedKickRes.status === 403, 'printing.execute alone cannot kick the cash drawer'); + + const serverTestActiveRes = await request(app).post('/api/printers/test-active'); + assert(serverTestActiveRes.status === 200, 'printing.execute can test the active printer'); + assert(serverTestActiveRes.body.webusb === true, 'server test print selects WebUSB default'); + + currentUserId = 'owner-1'; + const ownerPrintersRes = await request(app).get('/api/printers'); + assert(ownerPrintersRes.body.printers.length > 1, 'printers.manage can still read the full printer list'); + const kickRes = await request(app).post('/api/printers/kick-drawer'); assert(kickRes.status === 200, `kick-drawer returns 200 for WebUSB printer (got ${kickRes.status})`); assert(kickRes.body.webusb === true, 'kick-drawer reports webusb: true'); + assert( + kickRes.body.printerName === 'Counter WebUSB Printer', + 'kick-drawer identifies its selected printer', + ); assert(Array.isArray(kickRes.body.bytes), 'kick-drawer returns pulse byte array'); - assert(kickRes.body.bytes[0] === 0x1b && kickRes.body.bytes[1] === 0x70, 'kick-drawer returns standard ESC p bytes'); + assert( + kickRes.body.bytes[0] === 0x1b && kickRes.body.bytes[1] === 0x70, + 'kick-drawer returns standard ESC p bytes', + ); const testActiveRes = await request(app).post('/api/printers/test-active'); - assert(testActiveRes.status === 200, `test-active returns 200 for WebUSB printer (got ${testActiveRes.status})`); + assert( + testActiveRes.status === 200, + `test-active returns 200 for WebUSB printer (got ${testActiveRes.status})`, + ); assert(testActiveRes.body.webusb === true, 'test-active reports webusb: true'); - assert(Array.isArray(testActiveRes.body.bytes) && testActiveRes.body.bytes.length > 0, 'test-active returns test page byte array'); + assert( + testActiveRes.body.printerName === 'Counter WebUSB Printer', + 'test-active identifies its selected printer', + ); + assert( + Array.isArray(testActiveRes.body.bytes) && testActiveRes.body.bytes.length > 0, + 'test-active returns test page byte array', + ); + + const deviceA = makeWebUsbTestDevice('Printer A', 1); + const deviceB = makeWebUsbTestDevice('Counter WebUSB Printer', 2); + const devices = [deviceA.device, deviceB.device]; + const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + usb: { + getDevices: async () => devices, + addEventListener: () => {}, + removeEventListener: () => {}, + }, + }, + }); + + try { + await printerService.printToConfiguredDevice( + Uint8Array.from(testActiveRes.body.bytes), + testActiveRes.body.printerName, + ); + assert(deviceA.sent.length === 0, 'WebUSB test action does not write to the first granted device'); + assert(deviceB.sent.length === 1, 'WebUSB test action writes to the configured default device'); + + devices.push(makeWebUsbTestDevice('Counter WebUSB Printer', 3).device); + let rejectedAmbiguousDevice = false; + try { + await printerService.printToConfiguredDevice( + Uint8Array.from(kickRes.body.bytes), + kickRes.body.printerName, + ); + } catch { + rejectedAmbiguousDevice = true; + } + assert(rejectedAmbiguousDevice, 'WebUSB action refuses an ambiguous device match'); + assert(deviceB.sent.length === 1, 'ambiguous WebUSB match sends no drawer pulse'); + } finally { + await printerService.disconnect(); + if (originalNavigator) { + Object.defineProperty(globalThis, 'navigator', originalNavigator); + } else { + delete (globalThis as any).navigator; + } + } } console.log('\n' + '='.repeat(50)); From 56e24cc7bc4852ce639b210a0228066de08454bd Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 11 Oct 2026 16:47:18 -0400 Subject: [PATCH 06/20] no-mistakes(review): Secure printer readiness; test blocked by missing Electron --- frontend/src/components/layout/StatusBar.tsx | 87 ++++++++++++++++---- frontend/src/hooks/usePrinter.ts | 20 ++++- frontend/src/lib/printer/PrinterService.ts | 56 ++++++++++--- main/routes/printers.ts | 15 +++- tests/printer-api.test.ts | 8 +- 5 files changed, 153 insertions(+), 33 deletions(-) diff --git a/frontend/src/components/layout/StatusBar.tsx b/frontend/src/components/layout/StatusBar.tsx index d69645525..9fa66b6c1 100644 --- a/frontend/src/components/layout/StatusBar.tsx +++ b/frontend/src/components/layout/StatusBar.tsx @@ -24,6 +24,7 @@ import { usePrinterStore } from '@/hooks/usePrinter'; import { printerService } from '@/lib/printer/PrinterService'; import api from '@/lib/api'; import UpdateBadge from './UpdateBadge'; +import { Ltr } from './Ltr'; interface StatusInfo { server: string; @@ -66,13 +67,14 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? const tSupport = useTranslations('support'); const { user, currentTenant } = useAuthStore(); - const { status: printerConnStatus, deviceInfo, defaultPrinter } = usePrinterStore(); + const { status: printerConnStatus, deviceInfo, defaultPrinter, printMethod } = usePrinterStore(); const [status, setStatus] = useState(null); const [session, setSession] = useState(undefined); const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [isTestingPrint, setIsTestingPrint] = useState(false); const [isKickingDrawer, setIsKickingDrawer] = useState(false); + const [isConfiguredWebUsbReady, setIsConfiguredWebUsbReady] = useState(false); const inFlightRef = useRef(false); const drawerToggleRef = useRef(null); @@ -128,6 +130,52 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? }; }, []); + useEffect(() => { + let cancelled = false; + let latestRequest = 0; + setIsConfiguredWebUsbReady(false); + if ( + defaultPrinter?.connection_type !== 'webusb' || + printMethod !== 'escpos' || + printerConnStatus !== 'connected' + ) { + return () => { + cancelled = true; + }; + } + + const refreshReadiness = () => { + const request = ++latestRequest; + setIsConfiguredWebUsbReady(false); + printerService + .isConfiguredDeviceReady(defaultPrinter.name) + .then((ready) => { + if (!cancelled && request === latestRequest) setIsConfiguredWebUsbReady(ready); + }) + .catch(() => { + if (!cancelled && request === latestRequest) setIsConfiguredWebUsbReady(false); + }); + }; + + refreshReadiness(); + navigator.usb?.addEventListener('connect', refreshReadiness); + navigator.usb?.addEventListener('disconnect', refreshReadiness); + + return () => { + cancelled = true; + navigator.usb?.removeEventListener('connect', refreshReadiness); + navigator.usb?.removeEventListener('disconnect', refreshReadiness); + }; + }, [ + defaultPrinter?.connection_type, + defaultPrinter?.name, + deviceInfo?.productId, + deviceInfo?.productName, + deviceInfo?.serialNumber, + printMethod, + printerConnStatus, + ]); + const closeDrawer = useCallback(() => { setIsDrawerOpen(false); drawerToggleRef.current?.focus(); @@ -171,12 +219,15 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? // Printer readiness const isPrinterReady = defaultPrinter?.connection_type === 'webusb' && + printMethod === 'escpos' && printerConnStatus === 'connected' && - deviceInfo?.productName === defaultPrinter.name; + deviceInfo?.productName === defaultPrinter.name && + isConfiguredWebUsbReady; const isPrinterConfigured = defaultPrinter !== null; const isPrinterConnecting = defaultPrinter?.connection_type === 'webusb' && printerConnStatus === 'connecting'; const canKickDrawer = tenantCan(currentTenant, 'cash.movements.manage'); + const canTestPrint = tenantCan(currentTenant, 'printing.execute'); // Shift state const isShiftKnown = session !== undefined; @@ -268,20 +319,22 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? {tSettings('hardwarePrinters')}
- + {canTestPrint && ( + + )} {canKickDrawer && ( @@ -123,7 +133,9 @@ export default function PrinterStatus() { {hardwarePrinter && (

- + {hardwarePrinter.name}

diff --git a/frontend/src/hooks/usePrinter.ts b/frontend/src/hooks/usePrinter.ts index a9c6b0457..8a533e303 100644 --- a/frontend/src/hooks/usePrinter.ts +++ b/frontend/src/hooks/usePrinter.ts @@ -108,23 +108,18 @@ type ReceiptTenant = Pick< >; export interface HardwarePrinter { - id?: number | string; + id: number | string; name: string; connection_type: 'network' | 'usb' | 'webusb'; ip_address?: string | null; port?: number | null; paper_width?: string | null; is_default: number; + readiness_state?: 'configured'; profile_id?: string; capabilities?: ThermalPrinterCapabilities; } -interface PrinterReadiness { - name: string; - readiness_state: 'configured'; - print_method: HardwarePrinter['connection_type']; -} - interface PrinterState { status: PrinterStatus; deviceInfo: PrinterInfo | null; @@ -181,17 +176,7 @@ export const usePrinterStore = create()( refreshHardwarePrinter: async () => { try { const res = await api.get('/printers'); - const list: HardwarePrinter[] = ( - (res.data.printers as (HardwarePrinter | PrinterReadiness)[] | undefined) || [] - ).map((printer) => - 'readiness_state' in printer - ? { - name: printer.name, - connection_type: printer.print_method, - is_default: 1, - } - : printer, - ); + const list: HardwarePrinter[] = res.data.printers || []; const defaultPrinter = list.find((p) => p.is_default === 1 && p.connection_type !== 'webusb') || list.find((p) => p.connection_type !== 'webusb') || diff --git a/main/routes/printers.ts b/main/routes/printers.ts index 7b48c01fa..31cb27614 100644 --- a/main/routes/printers.ts +++ b/main/routes/printers.ts @@ -171,9 +171,11 @@ function printerShape(printer: any) { function printerReadinessShape(printer: any) { return { + id: printer.id, name: printer.name, + connection_type: printer.connection_type, + is_default: printer.is_default, readiness_state: 'configured', - print_method: printer.connection_type, }; } @@ -562,10 +564,11 @@ router.post( if (result.ok) { res.json({ success: true }); } else { - res.status(502).json({ - error: result.detail || 'Printer did not respond', - detail: result.detail, - }); + console.error( + '[Printer] Cash drawer kick failed:', + result.detail || 'Printer did not respond', + ); + res.status(502).json({ error: 'Cash drawer kick failed' }); } } catch (error: any) { console.error('[API] Internal error:', error); @@ -628,10 +631,11 @@ router.post( if (result.ok) { res.json({ success: true }); } else { - res.status(502).json({ - error: result.detail || 'Printer did not respond or print failed', - detail: result.detail, - }); + console.error( + '[Printer] Active test print failed:', + result.detail || 'Printer did not respond or print failed', + ); + res.status(502).json({ error: 'Test print failed' }); } } catch (error: any) { console.error('[API] Internal error:', error); diff --git a/tests/printer-api.test.ts b/tests/printer-api.test.ts index 150e05137..a46d65937 100644 --- a/tests/printer-api.test.ts +++ b/tests/printer-api.test.ts @@ -671,11 +671,13 @@ async function runTests() { assert( Array.isArray(serverPrintersRes.body.printers) && serverPrintersRes.body.printers.length === 1 && + serverPrintersRes.body.printers[0].id === webusbRes.body.printer.id && serverPrintersRes.body.printers[0].name === 'Counter WebUSB Printer' && + serverPrintersRes.body.printers[0].connection_type === 'webusb' && + serverPrintersRes.body.printers[0].is_default === 1 && serverPrintersRes.body.printers[0].readiness_state === 'configured' && - serverPrintersRes.body.printers[0].print_method === 'webusb' && Object.keys(serverPrintersRes.body.printers[0]).sort().join(',') === - 'name,print_method,readiness_state', + 'connection_type,id,is_default,name,readiness_state', 'printing.execute receives only default-printer readiness fields', ); @@ -718,6 +720,46 @@ async function runTests() { 'test-active returns test page byte array', ); + const unavailableServer = net.createServer(); + await new Promise((resolve, reject) => { + unavailableServer.once('error', reject); + unavailableServer.listen(0, '127.0.0.1', resolve); + }); + const unavailablePort = (unavailableServer.address() as net.AddressInfo).port; + await new Promise((resolve, reject) => { + unavailableServer.close((error) => (error ? reject(error) : resolve())); + }); + const networkPrinterRes = await request(app).post('/api/printers').send({ + name: 'Unavailable local printer', + connection_type: 'network', + ip_address: '127.0.0.1', + port: unavailablePort, + is_default: true, + }); + assert(networkPrinterRes.status === 201, 'unavailable network printer fixture is configured'); + + const failedTestPrintRes = await request(app).post('/api/printers/test-active'); + const failedTestPrintBody = JSON.stringify(failedTestPrintRes.body); + assert( + failedTestPrintRes.status === 502 && + failedTestPrintRes.body.error === 'Test print failed' && + !('detail' in failedTestPrintRes.body) && + !failedTestPrintBody.includes('127.0.0.1') && + !failedTestPrintBody.includes(String(unavailablePort)), + 'test-active hides network details when printing fails', + ); + + const failedKickRes = await request(app).post('/api/printers/kick-drawer'); + const failedKickBody = JSON.stringify(failedKickRes.body); + assert( + failedKickRes.status === 502 && + failedKickRes.body.error === 'Cash drawer kick failed' && + !('detail' in failedKickRes.body) && + !failedKickBody.includes('127.0.0.1') && + !failedKickBody.includes(String(unavailablePort)), + 'kick-drawer hides network details when pulsing fails', + ); + const deviceA = makeWebUsbTestDevice('Printer A', 1); const deviceB = makeWebUsbTestDevice('Counter WebUSB Printer', 2); const devices = [deviceA.device, deviceB.device]; From 13ded1fb54d6e140065771becbc95ff5244ee121 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 11 Oct 2026 17:17:45 -0400 Subject: [PATCH 08/20] no-mistakes(review): Normalize printer state and session type; TypeScript check unavailable --- frontend/src/components/layout/StatusBar.tsx | 8 +------- frontend/src/hooks/usePrinter.ts | 4 +++- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/layout/StatusBar.tsx b/frontend/src/components/layout/StatusBar.tsx index 9fa66b6c1..1c04a1292 100644 --- a/frontend/src/components/layout/StatusBar.tsx +++ b/frontend/src/components/layout/StatusBar.tsx @@ -21,6 +21,7 @@ import toast from 'react-hot-toast'; import { useAuthStore } from '@/store/auth'; import { tenantCan } from '@/lib/permissions'; import { usePrinterStore } from '@/hooks/usePrinter'; +import type { CashSession } from '@/hooks/useCashSession'; import { printerService } from '@/lib/printer/PrinterService'; import api from '@/lib/api'; import UpdateBadge from './UpdateBadge'; @@ -33,13 +34,6 @@ interface StatusInfo { port: number; } -interface CashSession { - id: number; - status: string; - opened_by_name?: string; - opening_float_cents?: number; -} - interface PrinterActionResponse { webusb?: boolean; bytes?: number[]; diff --git a/frontend/src/hooks/usePrinter.ts b/frontend/src/hooks/usePrinter.ts index 8a533e303..1ca38ee3b 100644 --- a/frontend/src/hooks/usePrinter.ts +++ b/frontend/src/hooks/usePrinter.ts @@ -176,7 +176,9 @@ export const usePrinterStore = create()( refreshHardwarePrinter: async () => { try { const res = await api.get('/printers'); - const list: HardwarePrinter[] = res.data.printers || []; + const list: HardwarePrinter[] = (res.data.printers || []).map( + (printer: HardwarePrinter) => ({ ...printer, readiness_state: 'configured' }), + ); const defaultPrinter = list.find((p) => p.is_default === 1 && p.connection_type !== 'webusb') || list.find((p) => p.connection_type !== 'webusb') || From 10200523d5fb253a8df4e55f44ca5c5a90325225 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 11 Oct 2026 17:25:14 -0400 Subject: [PATCH 09/20] no-mistakes(review): Align printer targeting and drawer focus; Playwright unavailable --- frontend/e2e/cashier-status-bar.spec.ts | 8 +++++- frontend/src/components/layout/StatusBar.tsx | 27 ++++++++++++++++++++ frontend/src/lib/printer/PrinterService.ts | 3 ++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/frontend/e2e/cashier-status-bar.spec.ts b/frontend/e2e/cashier-status-bar.spec.ts index 16db6977a..b4a5a5122 100644 --- a/frontend/e2e/cashier-status-bar.spec.ts +++ b/frontend/e2e/cashier-status-bar.spec.ts @@ -130,13 +130,20 @@ test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { await expect(drawer).toHaveClass(/translate-y-0/); await expect(drawer).toBeVisible(); await expect(drawer).toHaveAttribute('aria-hidden', 'false'); + await expect(drawer).toHaveAttribute('aria-modal', 'true'); // Verify Hardware Actions + const closeDrawerBtn = page.getByTestId('btn-close-telemetry-drawer'); + await expect(closeDrawerBtn).toBeFocused(); const testPrintBtn = page.getByTestId('btn-test-receipt-print'); await expect(testPrintBtn).toBeVisible(); const kickDrawerBtn = page.getByTestId('btn-kick-cash-drawer'); await expect(kickDrawerBtn).toBeVisible(); + await page.keyboard.press('Shift+Tab'); + await expect(kickDrawerBtn).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(closeDrawerBtn).toBeFocused(); // Verify Diagnostic Telemetry Metrics await expect(page.getByTestId('telemetry-uptime')).toBeVisible(); @@ -147,7 +154,6 @@ test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { await page.screenshot({ path: 'test-results/open-telemetry-drawer.png' }); // 9. Close drawer using close button - const closeDrawerBtn = page.getByTestId('btn-close-telemetry-drawer'); await closeDrawerBtn.click(); await expect(drawer).toHaveClass(/translate-y-full/); await expect(drawerToggleBtn).toBeFocused(); diff --git a/frontend/src/components/layout/StatusBar.tsx b/frontend/src/components/layout/StatusBar.tsx index 1c04a1292..dd6a6c376 100644 --- a/frontend/src/components/layout/StatusBar.tsx +++ b/frontend/src/components/layout/StatusBar.tsx @@ -72,6 +72,7 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? const inFlightRef = useRef(false); const drawerToggleRef = useRef(null); + const drawerRef = useRef(null); // Fetch Electron system status (uptime, memory, port) useEffect(() => { @@ -175,6 +176,11 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? drawerToggleRef.current?.focus(); }, []); + useEffect(() => { + if (!isDrawerOpen) return; + drawerRef.current?.querySelector('button:not([disabled])')?.focus(); + }, [isDrawerOpen]); + // Handle drawer Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -276,10 +282,31 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? {/* Slide-Up Telemetry & Hardware Drawer */}

{ + if (event.key !== 'Tab') return; + const focusableElements = drawerRef.current?.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ); + if (!focusableElements?.length) { + event.preventDefault(); + return; + } + const firstFocusable = focusableElements[0]; + const lastFocusable = focusableElements[focusableElements.length - 1]; + if (event.shiftKey && document.activeElement === firstFocusable) { + event.preventDefault(); + lastFocusable.focus(); + } else if (!event.shiftKey && document.activeElement === lastFocusable) { + event.preventDefault(); + firstFocusable.focus(); + } + }} className={`fixed bottom-8 left-0 right-0 z-50 max-h-[80vh] overflow-y-auto border-t border-border bg-card p-4 shadow-2xl transition-all duration-300 ease-in-out md:p-6 ${ isDrawerOpen ? 'translate-y-0 opacity-100' diff --git a/frontend/src/lib/printer/PrinterService.ts b/frontend/src/lib/printer/PrinterService.ts index 26294ee2c..55078ef0e 100644 --- a/frontend/src/lib/printer/PrinterService.ts +++ b/frontend/src/lib/printer/PrinterService.ts @@ -135,7 +135,8 @@ class PrinterService { private isSameDevice(left: USBDevice, right: USBDevice): boolean { return left.vendorId === right.vendorId && left.productId === right.productId - && left.serialNumber === right.serialNumber; + && left.serialNumber === right.serialNumber + && left.productName === right.productName; } private async findUniqueConfiguredDevice(productName: string): Promise { From b37598a61b7f6bc48b238e117d4126801dd4e6fa Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 11 Oct 2026 17:34:36 -0400 Subject: [PATCH 10/20] no-mistakes(review): Show unavailable ports; diff check passed, Playwright unavailable --- frontend/src/components/layout/StatusBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/layout/StatusBar.tsx b/frontend/src/components/layout/StatusBar.tsx index dd6a6c376..428b3e65d 100644 --- a/frontend/src/components/layout/StatusBar.tsx +++ b/frontend/src/components/layout/StatusBar.tsx @@ -404,7 +404,7 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? data-testid="telemetry-port" className="mt-1 font-medium tabular-nums text-foreground" > - {status?.port || '3001'} + {status?.port ?? '...'}
From bcb05afe03e999304a9d17c85151d1b30022977a Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 11 Oct 2026 17:42:13 -0400 Subject: [PATCH 11/20] no-mistakes(review): Fix status bar findings; Playwright missing, diff check passed --- frontend/e2e/cashier-status-bar.spec.ts | 3 +++ frontend/src/hooks/usePrinter.ts | 2 +- main/routes/printers.ts | 5 ----- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/e2e/cashier-status-bar.spec.ts b/frontend/e2e/cashier-status-bar.spec.ts index b4a5a5122..c5c80c216 100644 --- a/frontend/e2e/cashier-status-bar.spec.ts +++ b/frontend/e2e/cashier-status-bar.spec.ts @@ -71,6 +71,9 @@ test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { await expect(titleBarIdentity).toBeVisible(); // Must not contain UserCircle icon or role badge await expect(titleBarIdentity.locator('svg')).toHaveCount(0); + await expect(roleBadge).not.toBeEmpty(); + await expect(titleBarIdentity).not.toContainText(await cashierName.innerText()); + await expect(titleBarIdentity).not.toContainText(await roleBadge.innerText()); } // 4. Verify developer 'Heap' and 'Port' numbers are absent from persistent frontline bar diff --git a/frontend/src/hooks/usePrinter.ts b/frontend/src/hooks/usePrinter.ts index 1ca38ee3b..520f692ef 100644 --- a/frontend/src/hooks/usePrinter.ts +++ b/frontend/src/hooks/usePrinter.ts @@ -108,7 +108,7 @@ type ReceiptTenant = Pick< >; export interface HardwarePrinter { - id: number | string; + id: string; name: string; connection_type: 'network' | 'usb' | 'webusb'; ip_address?: string | null; diff --git a/main/routes/printers.ts b/main/routes/printers.ts index 31cb27614..e3ee45134 100644 --- a/main/routes/printers.ts +++ b/main/routes/printers.ts @@ -592,16 +592,11 @@ router.post( if (!timezone) return res.status(409).json({ error: 'regional_not_configured' }); const profile = resolvePrinterProfile(printer); - const capabilities = capabilitiesForPrinter( - profile, - printer.paper_width || profile.defaultPaperWidth, - ); const testData = buildTestPage( printer.paper_width || profile.defaultPaperWidth, profile.cutMode, tenantLanguage(db), timezone, - req.body?.rasterProbe === true ? capabilities : undefined, ); let result: { ok: boolean; detail?: string } = { ok: false }; From e9f7d96cc49519937058c2d49d743423f61510ea Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 11 Oct 2026 17:49:37 -0400 Subject: [PATCH 12/20] no-mistakes(review): Restore server status telemetry; E2E unavailable, diff check passed --- frontend/e2e/cashier-status-bar.spec.ts | 1 + frontend/src/components/layout/StatusBar.tsx | 27 +++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/frontend/e2e/cashier-status-bar.spec.ts b/frontend/e2e/cashier-status-bar.spec.ts index c5c80c216..c247c9456 100644 --- a/frontend/e2e/cashier-status-bar.spec.ts +++ b/frontend/e2e/cashier-status-bar.spec.ts @@ -149,6 +149,7 @@ test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { await expect(closeDrawerBtn).toBeFocused(); // Verify Diagnostic Telemetry Metrics + await expect(page.getByTestId('telemetry-server-status')).toContainText('running'); await expect(page.getByTestId('telemetry-uptime')).toBeVisible(); await expect(page.getByTestId('telemetry-port')).toBeVisible(); await expect(page.getByTestId('telemetry-memory')).toBeVisible(); diff --git a/frontend/src/components/layout/StatusBar.tsx b/frontend/src/components/layout/StatusBar.tsx index 428b3e65d..d17b27da3 100644 --- a/frontend/src/components/layout/StatusBar.tsx +++ b/frontend/src/components/layout/StatusBar.tsx @@ -381,7 +381,32 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge?

{tSupport('tabDiagnostics')}

-
+
+
+
+ + {tNav('serverLabel').trim()} +
+
+ {status?.server === 'running' + ? tNav('serverStatusRunning') + : status?.server === 'starting' + ? tNav('serverStatusStarting') + : status?.server === 'stopped' + ? tNav('serverStatusStopped') + : status?.server || tNav('serverUnknown')} +
+
+
From a3f9f78dfde8930bb7bc24162e2302ba5753734f Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 11 Oct 2026 18:07:23 -0400 Subject: [PATCH 13/20] no-mistakes(review): Gate open-shift kicks; API test blocked (Electron missing) --- docs/reference/api.md | 4 +++- frontend/e2e/cashier-status-bar.spec.ts | 17 +++++++++++++++-- frontend/src/components/layout/StatusBar.tsx | 2 +- main/routes/printers.ts | 4 ++++ tests/printer-api.test.ts | 10 ++++++++++ 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/reference/api.md b/docs/reference/api.md index 9242ea532..d9118b871 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -481,7 +481,7 @@ Router: `main/routes/printers.ts`. Full path: `/api/printers`. | Method | Path | Authorization | Parameters | Response | | --- | --- | --- | --- | --- | -| `GET` | `/` | any authenticated role | none | `{ printers: [ ... ] }`, default first. | +| `GET` | `/` | `printers.manage` or `printing.execute` | none | `{ printers: [ ... ] }`; `printers.manage` receives the full list, while `printing.execute` receives only the default printer's `id`, `name`, `connection_type`, `is_default`, and `readiness_state`. | | `GET` | `/detect` | any authenticated role | none | - | | `GET` | `/supported` | any authenticated role | none | - | | `GET` | `/:id` | any authenticated role | path: `id` | Single printer. | @@ -490,6 +490,8 @@ 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` | `/kick-drawer` | `cash.movements.manage` | none | Requires an open cash shift; returns `409` with `{ error: "open_cash_shift_required" }` otherwise. Success is `{ success: true }` for USB/network printers or includes `webusb: true`, `bytes`, and `printerName` for browser delivery. `404` if no printer is configured, `400` if the selected connection is unsupported or lacks an IP address, and `502` if the pulse fails. | +| `POST` | `/test-active` | `printing.execute` | none | Sends a standard test page to the default printer; returns `{ success: true }` or includes `webusb: true`, `bytes`, and `printerName` for browser delivery. `404` if no default printer is configured, `400` if the network printer has no IP address, `409` if regional settings are missing, and `502` if printing fails. | | `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`. | diff --git a/frontend/e2e/cashier-status-bar.spec.ts b/frontend/e2e/cashier-status-bar.spec.ts index c247c9456..0ca304ced 100644 --- a/frontend/e2e/cashier-status-bar.spec.ts +++ b/frontend/e2e/cashier-status-bar.spec.ts @@ -7,11 +7,16 @@ test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { page, }) => { let holdShiftResponses = false; + let shiftIsOpen = false; let shiftRequestCount = 0; let releaseStaleShiftResponse: (() => Promise) | undefined; await page.route('**/cash-sessions/current', async (route) => { if (!holdShiftResponses) { - await route.fulfill({ status: 200, contentType: 'application/json', body: 'null' }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: shiftIsOpen ? JSON.stringify({ status: 'open' }) : 'null', + }); return; } @@ -88,6 +93,7 @@ test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { const shiftPill = page.getByTestId('status-bar-shift'); await expect(shiftPill).toBeVisible(); await expect(shiftPill).toContainText('Open shift'); + await expect(page.getByTestId('btn-kick-cash-drawer')).toHaveCount(0); holdShiftResponses = true; await page.evaluate(() => window.dispatchEvent(new Event('flo-shift-changed'))); @@ -103,6 +109,14 @@ test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { await releaseStaleShiftResponse?.(); await expect(shiftPill).toContainText('unknown'); + const kickDrawerBtn = page.getByTestId('btn-kick-cash-drawer'); + await expect(kickDrawerBtn).toHaveCount(0); + shiftIsOpen = true; + holdShiftResponses = false; + await page.evaluate(() => window.dispatchEvent(new Event('flo-shift-changed'))); + await expect(shiftPill).toContainText('Shift opened'); + await expect(kickDrawerBtn).toHaveCount(1); + // 6. Capture screenshot of clean frontline bar await page.screenshot({ path: 'test-results/clean-frontline-status-bar.png' }); @@ -141,7 +155,6 @@ test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { const testPrintBtn = page.getByTestId('btn-test-receipt-print'); await expect(testPrintBtn).toBeVisible(); - const kickDrawerBtn = page.getByTestId('btn-kick-cash-drawer'); await expect(kickDrawerBtn).toBeVisible(); await page.keyboard.press('Shift+Tab'); await expect(kickDrawerBtn).toBeFocused(); diff --git a/frontend/src/components/layout/StatusBar.tsx b/frontend/src/components/layout/StatusBar.tsx index d17b27da3..8be766aa7 100644 --- a/frontend/src/components/layout/StatusBar.tsx +++ b/frontend/src/components/layout/StatusBar.tsx @@ -226,12 +226,12 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? const isPrinterConfigured = defaultPrinter !== null; const isPrinterConnecting = defaultPrinter?.connection_type === 'webusb' && printerConnStatus === 'connecting'; - const canKickDrawer = tenantCan(currentTenant, 'cash.movements.manage'); const canTestPrint = tenantCan(currentTenant, 'printing.execute'); // Shift state const isShiftKnown = session !== undefined; const isShiftOpen = session?.status === 'open'; + const canKickDrawer = tenantCan(currentTenant, 'cash.movements.manage') && isShiftOpen; // Hardware Quick Actions const handleTestPrint = async () => { diff --git a/main/routes/printers.ts b/main/routes/printers.ts index e3ee45134..1ea5a1374 100644 --- a/main/routes/printers.ts +++ b/main/routes/printers.ts @@ -48,6 +48,7 @@ import { capabilitiesForPrinter, } from '../printers/profiles'; import { hasPermission, requirePermission, requireAnyPermission } from '../services/authorization'; +import { getOpenSession } from '../services/shift-session-gate'; import { formatCurrencyForTenant, formatDateForTenant, @@ -526,6 +527,9 @@ router.post( asyncHandler(async (req: Request, res: Response) => { try { const db = getDatabase(); + if (!getOpenSession(db)) { + return res.status(409).json({ error: 'open_cash_shift_required' }); + } const printer = getDefaultPrinter(db); const drawerPulseBytes = Buffer.from([0x1b, 0x70, 0x00, 0x19, 0xfa]); diff --git a/tests/printer-api.test.ts b/tests/printer-api.test.ts index a46d65937..5c64f9b92 100644 --- a/tests/printer-api.test.ts +++ b/tests/printer-api.test.ts @@ -692,6 +692,16 @@ async function runTests() { const ownerPrintersRes = await request(app).get('/api/printers'); assert(ownerPrintersRes.body.printers.length > 1, 'printers.manage can still read the full printer list'); + const noShiftKickRes = await request(app).post('/api/printers/kick-drawer'); + assert( + noShiftKickRes.status === 409 && noShiftKickRes.body.error === 'open_cash_shift_required', + 'kick-drawer requires an open cash shift', + ); + db.prepare(` + INSERT INTO cash_sessions (opened_by, opened_by_name, opened_at, opening_float_cents, status) + VALUES ('owner-1', 'Owner', ?, 0, 'open') + `).run(now()); + const kickRes = await request(app).post('/api/printers/kick-drawer'); assert(kickRes.status === 200, `kick-drawer returns 200 for WebUSB printer (got ${kickRes.status})`); assert(kickRes.body.webusb === true, 'kick-drawer reports webusb: true'); From a929c0c0a093714ccc6c0af9d5f3dc61674608e1 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 11 Oct 2026 18:24:46 -0400 Subject: [PATCH 14/20] no-mistakes(document): Audit cashier status bar docs and lint --- frontend/e2e/cashier-status-bar.spec.ts | 171 ++++++++++++++++++------ 1 file changed, 133 insertions(+), 38 deletions(-) diff --git a/frontend/e2e/cashier-status-bar.spec.ts b/frontend/e2e/cashier-status-bar.spec.ts index 0ca304ced..386cdce48 100644 --- a/frontend/e2e/cashier-status-bar.spec.ts +++ b/frontend/e2e/cashier-status-bar.spec.ts @@ -1,46 +1,39 @@ import { test, expect } from '@playwright/test'; +import { createServer, type Server } from 'node:net'; import { E2E_BASE_URL as BASE } from './helpers/urls'; import { injectElectronFixture } from './helpers/electron-fixture'; +let printerServer: Server | null = null; + +test.afterAll(async () => { + if (!printerServer?.listening) return; + await new Promise((resolve, reject) => { + printerServer!.close((error) => (error ? reject(error) : resolve())); + }); +}); + test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { test('frontline status bar displays cashier identity, printer, shift, and drawer actions', async ({ page, + browser, }) => { - let holdShiftResponses = false; - let shiftIsOpen = false; + const printJobs: Buffer[] = []; + printerServer = createServer((socket) => { + const chunks: Buffer[] = []; + socket.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + socket.on('end', () => printJobs.push(Buffer.concat(chunks))); + }); + await new Promise((resolve, reject) => { + printerServer!.once('error', reject); + printerServer!.listen(0, '127.0.0.1', resolve); + }); + const printerAddress = printerServer.address(); + if (!printerAddress || typeof printerAddress === 'string') { + throw new Error('Disposable printer did not receive a TCP port'); + } + let shiftRequestCount = 0; let releaseStaleShiftResponse: (() => Promise) | undefined; - await page.route('**/cash-sessions/current', async (route) => { - if (!holdShiftResponses) { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: shiftIsOpen ? JSON.stringify({ status: 'open' }) : 'null', - }); - return; - } - - shiftRequestCount += 1; - if (shiftRequestCount === 1) { - await new Promise((resolve) => { - releaseStaleShiftResponse = async () => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ status: 'open' }), - }); - resolve(); - }; - }); - return; - } - - await route.fulfill({ - status: 403, - contentType: 'application/json', - body: JSON.stringify({ error: 'Insufficient permissions' }), - }); - }); // Inject Electron fixture so desktop capability and system telemetry are simulated await injectElectronFixture(page, { api: true, platform: 'darwin' }); @@ -95,7 +88,40 @@ test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { await expect(shiftPill).toContainText('Open shift'); await expect(page.getByTestId('btn-kick-cash-drawer')).toHaveCount(0); - holdShiftResponses = true; + const closedShiftKick = await page.evaluate(async () => { + const token = localStorage.getItem('token'); + if (!token) throw new Error('Authenticated test session is missing'); + const response = await fetch('/api/printers/kick-drawer', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + const body = await response.json(); + return { status: response.status, error: body.error }; + }); + expect(closedShiftKick).toEqual({ status: 409, error: 'open_cash_shift_required' }); + + await page.route('**/cash-sessions/current', async (route) => { + shiftRequestCount += 1; + if (shiftRequestCount === 1) { + await new Promise((resolve) => { + releaseStaleShiftResponse = async () => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'open' }), + }); + resolve(); + }; + }); + return; + } + + await route.fulfill({ + status: 403, + contentType: 'application/json', + body: JSON.stringify({ error: 'Insufficient permissions' }), + }); + }); await page.evaluate(() => window.dispatchEvent(new Event('flo-shift-changed'))); await expect.poll(() => shiftRequestCount).toBeGreaterThanOrEqual(1); @@ -108,13 +134,46 @@ test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { await releaseStaleShiftResponse?.(); await expect(shiftPill).toContainText('unknown'); + await page.unroute('**/cash-sessions/current'); const kickDrawerBtn = page.getByTestId('btn-kick-cash-drawer'); await expect(kickDrawerBtn).toHaveCount(0); - shiftIsOpen = true; - holdShiftResponses = false; - await page.evaluate(() => window.dispatchEvent(new Event('flo-shift-changed'))); + + const setupStatuses = await page.evaluate(async (port) => { + const token = localStorage.getItem('token'); + if (!token) throw new Error('Authenticated test session is missing'); + const headers = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }; + const printer = await fetch('/api/printers', { + method: 'POST', + headers, + body: JSON.stringify({ + name: 'Disposable E2E Printer', + connection_type: 'network', + ip_address: '127.0.0.1', + port, + paper_width: 'cols-48', + is_default: true, + cash_drawer_pulse_enabled: true, + }), + }); + const session = await fetch('/api/cash-sessions/open', { + method: 'POST', + headers, + body: JSON.stringify({ opening_float_cents: 0 }), + }); + return { printer: printer.status, session: session.status }; + }, printerAddress.port); + expect(setupStatuses).toEqual({ printer: 201, session: 200 }); + + await page.reload(); await expect(shiftPill).toContainText('Shift opened'); + await expect(page.getByTestId('status-bar-printer')).toHaveAttribute( + 'title', + /configured/i, + ); await expect(kickDrawerBtn).toHaveCount(1); // 6. Capture screenshot of clean frontline bar @@ -168,9 +227,45 @@ test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { await expect(page.getByTestId('telemetry-memory')).toBeVisible(); // 8. Capture screenshot of open telemetry drawer + await drawer.evaluate((element) => + Promise.all(element.getAnimations().map((animation) => animation.finished)), + ); await page.screenshot({ path: 'test-results/open-telemetry-drawer.png' }); - // 9. Close drawer using close button + // 9. Drive both hardware actions through the real API and local TCP printer. + await testPrintBtn.click(); + await expect(page.getByText('Test print sent!')).toBeVisible(); + await expect.poll(() => printJobs.length).toBe(1); + expect(printJobs[0].length).toBeGreaterThan(5); + + await kickDrawerBtn.click(); + await expect(page.getByText('Cash drawer pulse sent.')).toBeVisible(); + await expect.poll(() => printJobs.length).toBe(2); + expect(Array.from(printJobs[1])).toEqual([0x1b, 0x70, 0x00, 0x19, 0xfa]); + + const restrictedPage = await browser.newPage(); + await injectElectronFixture(restrictedPage, { api: true, platform: 'darwin' }); + await restrictedPage.goto(`${BASE}/auth/login`); + await restrictedPage.locator('#email').fill('server@flo.local'); + await restrictedPage.locator('#password').fill('E2ePass123!'); + await restrictedPage.locator('button[type="submit"]').click(); + await restrictedPage.waitForURL(/\/orders/, { timeout: 20000 }); + await expect(restrictedPage.getByTestId('status-bar-shift')).toContainText(/unknown/i); + await expect(restrictedPage.getByTestId('btn-kick-cash-drawer')).toHaveCount(0); + + const deniedKickStatus = await restrictedPage.evaluate(async () => { + const token = localStorage.getItem('token'); + if (!token) throw new Error('Authenticated test session is missing'); + const response = await fetch('/api/printers/kick-drawer', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + return response.status; + }); + expect(deniedKickStatus).toBe(403); + await restrictedPage.close(); + + // 10. Close drawer using close button await closeDrawerBtn.click(); await expect(drawer).toHaveClass(/translate-y-full/); await expect(drawerToggleBtn).toBeFocused(); From 557da288cf663f4d883623390b5095820158e18d Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 11 Oct 2026 18:39:47 -0400 Subject: [PATCH 15/20] no-mistakes(ci): Fixed the PR-caused failures: the telemetry footer no longer creates a duplicate status landmark, the drawer uses RTL-safe logical anchors, the redundant readiness reset is removed, and the new printer routes use explicit types to stay within the lint warning budget. Formatted the changed printer API test. The Required Checks Gate failure was downstream of these checks. Passed: lint budget (backend 1000/1000, frontend 0/5), direct frontend Oxlint, Oxfmt on the changed root files, and `git diff --check`. Local test suites could not start because dependencies are absent (`ts-node` and Electron); builds and Playwright were also blocked by missing local dependencies. The changed-file format script likewise could not find the local Oxfmt binary, though direct Oxfmt passed. CI also reported a delivery-slip E2E failure outside the affected paths; it could not be reproduced locally and remains unverified --- frontend/src/components/layout/StatusBar.tsx | 5 +--- main/routes/printers.ts | 24 ++++++++++++++----- tests/printer-api.test.ts | 25 ++++++++++++++++---- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/layout/StatusBar.tsx b/frontend/src/components/layout/StatusBar.tsx index 8be766aa7..2419e210d 100644 --- a/frontend/src/components/layout/StatusBar.tsx +++ b/frontend/src/components/layout/StatusBar.tsx @@ -128,7 +128,6 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? useEffect(() => { let cancelled = false; let latestRequest = 0; - setIsConfiguredWebUsbReady(false); if ( defaultPrinter?.connection_type !== 'webusb' || printMethod !== 'escpos' || @@ -307,7 +306,7 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? firstFocusable.focus(); } }} - className={`fixed bottom-8 left-0 right-0 z-50 max-h-[80vh] overflow-y-auto border-t border-border bg-card p-4 shadow-2xl transition-all duration-300 ease-in-out md:p-6 ${ + className={`fixed bottom-8 start-0 end-0 z-50 max-h-[80vh] overflow-y-auto border-t border-border bg-card p-4 shadow-2xl transition-all duration-300 ease-in-out md:p-6 ${ isDrawerOpen ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-full opacity-0' @@ -455,8 +454,6 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? {/* Persistent Frontline Status Bar */}
{/* Left: Active Cashier & Role Badge */} diff --git a/main/routes/printers.ts b/main/routes/printers.ts index 1ea5a1374..964be5aab 100644 --- a/main/routes/printers.ts +++ b/main/routes/printers.ts @@ -170,7 +170,17 @@ function printerShape(printer: any) { }; } -function printerReadinessShape(printer: any) { +interface DefaultPrinter { + id: string; + name: string; + connection_type: (typeof CONNECTION_TYPES)[number]; + ip_address: string | null; + port: number | null; + is_default: number; + paper_width: string | null; +} + +function printerReadinessShape(printer: DefaultPrinter) { return { id: printer.id, name: printer.name, @@ -180,8 +190,10 @@ function printerReadinessShape(printer: any) { }; } -function getDefaultPrinter(db: ReturnType): any { - return db.prepare('SELECT * FROM printers WHERE is_default = 1').get() as any; +function getDefaultPrinter(db: ReturnType): DefaultPrinter | undefined { + return db.prepare('SELECT * FROM printers WHERE is_default = 1').get() as + | DefaultPrinter + | undefined; } // Hydrates order items with add-ons from normalized order_item_addons. @@ -201,7 +213,7 @@ router.get( (req: Request, res: Response) => { try { const db = getDatabase(); - const userId = String((req as any).user?.userId || ''); + const userId = String((req as Request & { user?: { userId?: string } }).user?.userId || ''); const canManagePrinters = hasPermission(userId, 'printers.manage'); const printers = canManagePrinters ? db.prepare('SELECT * FROM printers ORDER BY is_default DESC, name').all() @@ -574,7 +586,7 @@ router.post( ); res.status(502).json({ error: 'Cash drawer kick failed' }); } - } catch (error: any) { + } catch (error: unknown) { console.error('[API] Internal error:', error); res.status(500).json({ error: 'Internal server error' }); } @@ -636,7 +648,7 @@ router.post( ); res.status(502).json({ error: 'Test print failed' }); } - } catch (error: any) { + } catch (error: unknown) { console.error('[API] Internal error:', error); res.status(500).json({ error: 'Internal server error' }); } diff --git a/tests/printer-api.test.ts b/tests/printer-api.test.ts index 5c64f9b92..f52f821d4 100644 --- a/tests/printer-api.test.ts +++ b/tests/printer-api.test.ts @@ -663,7 +663,10 @@ async function runTests() { connection_type: 'webusb', is_default: true, }); - assert(webusbRes.status === 201, `creating WebUSB printer returns 201 (got ${webusbRes.status})`); + assert( + webusbRes.status === 201, + `creating WebUSB printer returns 201 (got ${webusbRes.status})`, + ); currentUserId = 'server-1'; const serverPrintersRes = await request(app).get('/api/printers'); @@ -690,7 +693,10 @@ async function runTests() { currentUserId = 'owner-1'; const ownerPrintersRes = await request(app).get('/api/printers'); - assert(ownerPrintersRes.body.printers.length > 1, 'printers.manage can still read the full printer list'); + assert( + ownerPrintersRes.body.printers.length > 1, + 'printers.manage can still read the full printer list', + ); const noShiftKickRes = await request(app).post('/api/printers/kick-drawer'); assert( @@ -703,7 +709,10 @@ async function runTests() { `).run(now()); const kickRes = await request(app).post('/api/printers/kick-drawer'); - assert(kickRes.status === 200, `kick-drawer returns 200 for WebUSB printer (got ${kickRes.status})`); + assert( + kickRes.status === 200, + `kick-drawer returns 200 for WebUSB printer (got ${kickRes.status})`, + ); assert(kickRes.body.webusb === true, 'kick-drawer reports webusb: true'); assert( kickRes.body.printerName === 'Counter WebUSB Printer', @@ -790,8 +799,14 @@ async function runTests() { Uint8Array.from(testActiveRes.body.bytes), testActiveRes.body.printerName, ); - assert(deviceA.sent.length === 0, 'WebUSB test action does not write to the first granted device'); - assert(deviceB.sent.length === 1, 'WebUSB test action writes to the configured default device'); + assert( + deviceA.sent.length === 0, + 'WebUSB test action does not write to the first granted device', + ); + assert( + deviceB.sent.length === 1, + 'WebUSB test action writes to the configured default device', + ); devices.push(makeWebUsbTestDevice('Counter WebUSB Printer', 3).device); let rejectedAmbiguousDevice = false; From 1dede5470d5282d5e2060cff0034ca50c5ee6bf2 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 11 Oct 2026 18:46:07 -0400 Subject: [PATCH 16/20] no-mistakes(ci): Fixed the TypeScript error in `GET /api/printers` by mapping each permission branch separately, preserving the full manager response and restricted readiness response. The reported build, Playwright, shard, summary, and gate failures traced to this compiler error or its downstream effects. `git diff --check` passed. Local build, lint, and focused printer API checks could not run: `tsc`, `oxlint`, and Electron are unavailable in the worktree --- main/routes/printers.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/main/routes/printers.ts b/main/routes/printers.ts index 964be5aab..542ac9eb0 100644 --- a/main/routes/printers.ts +++ b/main/routes/printers.ts @@ -215,12 +215,14 @@ router.get( const db = getDatabase(); const userId = String((req as Request & { user?: { userId?: string } }).user?.userId || ''); const canManagePrinters = hasPermission(userId, 'printers.manage'); - const printers = canManagePrinters - ? db.prepare('SELECT * FROM printers ORDER BY is_default DESC, name').all() - : [getDefaultPrinter(db)].filter(Boolean); - res.json({ - printers: printers.map(canManagePrinters ? printerShape : printerReadinessShape), - }); + if (canManagePrinters) { + const printers = db.prepare('SELECT * FROM printers ORDER BY is_default DESC, name').all(); + res.json({ printers: printers.map(printerShape) }); + return; + } + + const defaultPrinter = getDefaultPrinter(db); + res.json({ printers: defaultPrinter ? [printerReadinessShape(defaultPrinter)] : [] }); } catch (error: any) { console.error('[API] Internal error:', error); res.status(500).json({ error: 'Internal server error' }); From 564562cec3e9586a1144bc5103644d8e336e33dd Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 11 Oct 2026 18:55:35 -0400 Subject: [PATCH 17/20] no-mistakes(ci): Updated the E2E test to locate the status bar through its cashier element and formatted the seven frontend files Oxfmt flagged. This addresses the shard failure and the downstream summary and gate failures. CI lint passed; `oxfmt --check` and `git diff --check` passed locally. The focused Playwright test could not run locally because the worktree has no Playwright executable --- frontend/e2e/cashier-status-bar.spec.ts | 11 ++-- frontend/src/components/layout/StatusBar.tsx | 4 +- frontend/src/components/layout/TitleBar.tsx | 11 +++- frontend/src/components/pos/PrinterStatus.tsx | 44 ++++++++----- frontend/src/hooks/useCashSession.ts | 61 ++++++++++++++----- frontend/src/hooks/usePrinter.ts | 6 +- frontend/src/lib/printer/PrinterService.ts | 39 ++++++------ 7 files changed, 117 insertions(+), 59 deletions(-) diff --git a/frontend/e2e/cashier-status-bar.spec.ts b/frontend/e2e/cashier-status-bar.spec.ts index 386cdce48..65f7ff8ac 100644 --- a/frontend/e2e/cashier-status-bar.spec.ts +++ b/frontend/e2e/cashier-status-bar.spec.ts @@ -46,7 +46,9 @@ test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { await page.waitForURL(/\/pos/, { timeout: 20000 }); // 1. Verify persistent frontline status bar is rendered - const statusBar = page.locator('footer[role="status"]'); + const statusBar = page.locator('footer').filter({ + has: page.getByTestId('status-bar-cashier'), + }); await expect(statusBar).toBeVisible(); // 2. Verify Cashier Name & Role badge are rendered on bottom-left @@ -62,7 +64,7 @@ test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { // 3. Verify desktop TitleBar does NOT render duplicate user name or role const titleBar = page.getByTestId('desktop-title-bar'); - if (await titleBar.count() > 0) { + if ((await titleBar.count()) > 0) { await expect(titleBar).toBeVisible(); // Identity container in title bar must only have the business name const titleBarIdentity = titleBar.locator('.flo-title-bar__identity'); @@ -170,10 +172,7 @@ test.describe('Clean Cashier Status Bar & Telemetry Drawer', () => { await page.reload(); await expect(shiftPill).toContainText('Shift opened'); - await expect(page.getByTestId('status-bar-printer')).toHaveAttribute( - 'title', - /configured/i, - ); + await expect(page.getByTestId('status-bar-printer')).toHaveAttribute('title', /configured/i); await expect(kickDrawerBtn).toHaveCount(1); // 6. Capture screenshot of clean frontline bar diff --git a/frontend/src/components/layout/StatusBar.tsx b/frontend/src/components/layout/StatusBar.tsx index 2419e210d..2d5107ff5 100644 --- a/frontend/src/components/layout/StatusBar.tsx +++ b/frontend/src/components/layout/StatusBar.tsx @@ -453,9 +453,7 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge?
{/* Persistent Frontline Status Bar */} -