diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb8d7af2e..f1b171c0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,8 @@ jobs: timeout-minutes: 25 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - name: Set up Node.js 22 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/docs/reference/api.md b/docs/reference/api.md index e904e718f..a04a78b42 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 selected connection is unsupported or lacks an 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 new file mode 100644 index 000000000..2f03684db --- /dev/null +++ b/frontend/e2e/cashier-status-bar.spec.ts @@ -0,0 +1,308 @@ +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, + browser, + }) => { + let testPrintRequests = 0; + let kickDrawerRequests = 0; + let shiftRequestCount = 0; + let releaseStaleShiftResponse: (() => Promise) | undefined; + + // Inject Electron fixture so desktop capability and system telemetry are simulated + await injectElectronFixture(page, { api: true, platform: 'darwin' }); + await page.route('**/api/printers', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + printers: [ + { + id: 'disposable-e2e-printer', + name: 'Disposable E2E Printer', + connection_type: 'network', + ip_address: '127.0.0.1', + port: 9100, + paper_width: 'cols-48', + is_default: 1, + cash_drawer_pulse_enabled: true, + }, + ], + }), + }), + ); + + // 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').filter({ + has: page.getByTestId('status-bar-cashier'), + }); + 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); + 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 + 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(); + await expect(shiftPill).toContainText('Open shift'); + await expect(page.getByTestId('btn-kick-cash-drawer')).toHaveCount(0); + + 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); + + const deniedShiftResponse = page.waitForResponse( + (response) => response.url().includes('/cash-sessions/current') && response.status() === 403, + ); + await page.evaluate(() => window.dispatchEvent(new Event('flo-shift-changed'))); + await deniedShiftResponse; + await expect(shiftPill).toContainText('unknown'); + + 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); + + await page.route('**/cash-sessions/current', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ status: 'open' }), + }), + ); + await page.route('**/api/printers/test-active', (route) => { + testPrintRequests += 1; + return route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + }); + await page.route('**/api/printers/kick-drawer', (route) => { + kickDrawerRequests += 1; + return route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + }); + + await page.reload(); + await expect(shiftPill).toContainText('Shift opened'); + await expect(page.getByTestId('status-bar-printer')).toHaveAttribute('title', /network/i); + await expect(page.getByTestId('status-bar-printer')).toContainText('Disposable E2E Printer'); + await expect(kickDrawerBtn).toHaveCount(1); + + // 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/); + 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'); + 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(); + + 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-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(); + + // 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. Drive both hardware actions through their API requests. + await testPrintBtn.click(); + await expect(page.getByText('Test print sent!')).toBeVisible(); + await expect.poll(() => testPrintRequests).toBe(1); + + await kickDrawerBtn.click(); + await expect(page.getByText('Cash drawer pulse sent.')).toBeVisible(); + await expect.poll(() => kickDrawerRequests).toBe(1); + + 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(); + + 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(); + }); + + test.afterAll(async ({ request }) => { + try { + const loginRes = await request.post(`${BASE}/api/auth/login`, { + data: { email: 'owner@flo.local', password: 'E2ePass123!' }, + }); + if (loginRes.ok()) { + const { token } = await loginRes.json(); + const authHeaders = { Authorization: `Bearer ${token}` }; + + const sessionRes = await request.get(`${BASE}/api/cash-sessions/current`, { + headers: authHeaders, + }); + if (sessionRes.ok()) { + const session = await sessionRes.json(); + if (session?.id && session?.status === 'open') { + await request.post(`${BASE}/api/cash-sessions/${session.id}/close`, { + headers: authHeaders, + data: { counted_cash_cents: 0 }, + }); + } + } + + const printersRes = await request.get(`${BASE}/api/printers`, { headers: authHeaders }); + if (printersRes.ok()) { + const { printers = [] } = await printersRes.json(); + for (const printer of printers) { + if ( + (printer.name === 'Disposable E2E Printer' || + printer.name === 'Counter WebUSB Printer') && + !printer.is_default + ) { + await request.delete(`${BASE}/api/printers/${printer.id}`, { headers: authHeaders }); + } + } + } + } + } catch { + // Graceful teardown + } + }); +}); diff --git a/frontend/src/components/layout/StatusBar.tsx b/frontend/src/components/layout/StatusBar.tsx index 6ae8ef919..1c7645a61 100644 --- a/frontend/src/components/layout/StatusBar.tsx +++ b/frontend/src/components/layout/StatusBar.tsx @@ -1,9 +1,31 @@ 'use client'; -import { useEffect, useState, useRef } from 'react'; -import { Server, HardDrive, Clock, AlertCircle, CheckCircle } from 'lucide-react'; +import { useCallback, useEffect, useState, useRef } from '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 { 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'; +import { Ltr } from './Ltr'; interface StatusInfo { server: string; @@ -12,14 +34,47 @@ interface StatusInfo { port: 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'); + const tPos = useTranslations('pos'); + const tSettings = useTranslations('settings'); + const tDashboard = useTranslations('dashboard'); + const tStaff = useTranslations('staff'); + const tSupport = useTranslations('support'); + + const { user, currentTenant } = useAuthStore(); + const { status: printerConnStatus, deviceInfo, defaultPrinter, printMethod } = usePrinterStore(); + const [status, setStatus] = useState(null); - const [error, setError] = useState(null); - const intervalRef = useRef(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); + const drawerRef = useRef(null); + // Fetch Electron system status (uptime, memory, port) useEffect(() => { async function fetchStatus() { if (typeof window === 'undefined' || !window.electronAPI?.getStatus) return; @@ -29,23 +84,113 @@ 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; + let latestRequest = 0; + const queryShift = () => { + const request = ++latestRequest; + api + .get('/cash-sessions/current') + .then((res) => { + if (!cancelled && request === latestRequest) setSession(res.data ?? null); + }) + .catch(() => { + if (!cancelled && request === latestRequest) setSession(undefined); + }); + }; + + queryShift(); + const interval = setInterval(queryShift, 15000); + const handleShiftChange = () => { + queryShift(); + }; + window.addEventListener('flo-shift-changed', handleShiftChange); + return () => { + cancelled = true; + clearInterval(interval); + window.removeEventListener('flo-shift-changed', handleShiftChange); + }; + }, []); + + useEffect(() => { + let cancelled = false; + let latestRequest = 0; + 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 () => { - if (intervalRef.current) clearInterval(intervalRef.current); + cancelled = true; + navigator.usb?.removeEventListener('connect', refreshReadiness); + navigator.usb?.removeEventListener('disconnect', refreshReadiness); }; - // oxlint-disable-next-line react-hooks/exhaustive-deps + }, [ + defaultPrinter?.connection_type, + defaultPrinter?.name, + deviceInfo?.productId, + deviceInfo?.productName, + deviceInfo?.serialNumber, + printMethod, + printerConnStatus, + ]); + + const closeDrawer = useCallback(() => { + setIsDrawerOpen(false); + drawerToggleRef.current?.focus(); }, []); + useEffect(() => { + if (!isDrawerOpen) return; + drawerRef.current?.querySelector('button:not([disabled])')?.focus(); + }, [isDrawerOpen]); + + // Handle drawer Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isDrawerOpen) { + closeDrawer(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [closeDrawer, isDrawerOpen]); + function formatUptime(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); @@ -53,75 +198,389 @@ 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 & status labeling + const isWebUsb = defaultPrinter?.connection_type === 'webusb'; + const isNetwork = defaultPrinter?.connection_type === 'network'; + const isUsb = defaultPrinter?.connection_type === 'usb'; + const isServerPrinter = isNetwork || isUsb; + + const isWebUsbReady = + isWebUsb && + printMethod === 'escpos' && + printerConnStatus === 'connected' && + deviceInfo?.productName === defaultPrinter.name && + isConfiguredWebUsbReady; + const isPrinterConnecting = isWebUsb && printerConnStatus === 'connecting'; + const isPrinterReady = isServerPrinter || isWebUsbReady; + const isPrinterConfigured = defaultPrinter !== null; + const canTestPrint = tenantCan(currentTenant, 'printing.execute'); + + const printerTitle = !isPrinterConfigured + ? tSettings('printerOffline') + : isServerPrinter + ? `${defaultPrinter.name} (${isNetwork ? tSettings('connectionNetwork') : tSettings('connectionUsb')})` + : isWebUsbReady + ? `${defaultPrinter.name} (${tSettings('connectionWebusb')})` + : isPrinterConnecting + ? tPos('printerConnecting') + : `${defaultPrinter.name} (${tSettings('connectionWebusb')}) — ${tSettings('printerConfigured')}`; + + // Shift state + const isShiftKnown = session !== undefined; + const isShiftOpen = session?.status === 'open'; + const canKickDrawer = tenantCan(currentTenant, 'cash.movements.manage') && isShiftOpen; - const isRunning = status?.server === 'running'; - const isPending = !status && !error; + // Hardware Quick Actions + const handleTestPrint = async () => { + setIsTestingPrint(true); + try { + const res = await api.post('/printers/test-active'); + await printWebUsbAction(res.data); + 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 serverStatusDisplay = status?.server - ? status.server === 'running' - ? tNav('serverStatusRunning') - : status.server === 'stopped' - ? tNav('serverStatusStopped') - : status.server === 'starting' - ? tNav('serverStatusStarting') - : status.server - : tNav('serverUnknown'); + const handleKickDrawer = async () => { + setIsKickingDrawer(true); + try { + const res = await api.post('/printers/kick-drawer'); + await printWebUsbAction(res.data); + toast.success(tSettings('cashDrawerPulseSent')); + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error || + tSettings('cashDrawerPulseFailed'); + toast.error(msg); + } finally { + setIsKickingDrawer(false); + } + }; return ( -
+ <> + {/* Slide-Up Telemetry & Hardware Drawer Backdrop */} + {isDrawerOpen && ( +
+ {/* Right: Thermal Printer, Shift State, and Telemetry Trigger */} +
+ {/* Thermal Printer Status Indicator */} +
+ {isPrinterReady ? ( + <> + + + {defaultPrinter?.name || tPos('printerReady')} + + + ) : isPrinterConnecting ? ( + <> + + + {tPos('printerConnecting')} + + + ) : isPrinterConfigured ? ( + <> + + + {tSettings('printerConfigured')} + + + ) : ( + <> + + + {tSettings('printerOffline')} + + + )} +
+ + - {/* Centered business name and user identity */} + {/* Centered business name */}
- - {businessName} - -
diff --git a/frontend/src/components/pos/PrinterStatus.tsx b/frontend/src/components/pos/PrinterStatus.tsx index 33916c25a..259243b30 100644 --- a/frontend/src/components/pos/PrinterStatus.tsx +++ b/frontend/src/components/pos/PrinterStatus.tsx @@ -59,14 +59,25 @@ const STATUS_CONFIG: Record< export default function PrinterStatus() { // Printer status and hardware configuration are synced at the dashboard layout level. const { - status, deviceInfo, lastError, - connect, disconnect, clearError, - printMethod, hardwarePrinter, + status, + deviceInfo, + lastError, + connect, + disconnect, + clearError, + printMethod, + hardwarePrinter, } = usePrinterStore(); const t = useTranslations('pos'); + const tSettings = useTranslations('settings'); const router = useRouter(); - const effectiveStatus: PrinterStatus = hardwarePrinter ? 'connected' : status; + const isConfiguredOnly = hardwarePrinter?.readiness_state === 'configured'; + const effectiveStatus: PrinterStatus = hardwarePrinter + ? isConfiguredOnly + ? 'disconnected' + : 'connected' + : status; const cfg = STATUS_CONFIG[effectiveStatus]; const Icon = cfg.Icon; @@ -104,12 +115,13 @@ export default function PrinterStatus() { size="sm" className={`h-10 flex items-center gap-1.5 ${cfg.color} border-current/30`} > - + - {hardwarePrinter ? hardwarePrinter.name : t(cfg.labelKey)} + {isConfiguredOnly + ? tSettings('printerConfigured') + : hardwarePrinter + ? hardwarePrinter.name + : t(cfg.labelKey)} @@ -123,14 +135,25 @@ export default function PrinterStatus() { {hardwarePrinter && (

- + {hardwarePrinter.name}

{hardwarePrinter.connection_type} - {hardwarePrinter.connection_type === 'network' && hardwarePrinter.ip_address - ? <> · {hardwarePrinter.ip_address}{hardwarePrinter.port ? ':' + hardwarePrinter.port : ''} - : ''} + {hardwarePrinter.connection_type === 'network' && hardwarePrinter.ip_address ? ( + <> + {' '} + ·{' '} + + {hardwarePrinter.ip_address} + {hardwarePrinter.port ? ':' + hardwarePrinter.port : ''} + + + ) : ( + '' + )} {hardwarePrinter.paper_width ? ` · ${hardwarePrinter.paper_width}` : ''}

@@ -141,7 +164,12 @@ export default function PrinterStatus() {

{deviceInfo.productName ?? t('printerUnknownDevice')}

-

{deviceInfo.manufacturerName ?? `VID:${deviceInfo.vendorId.toString(16).toUpperCase()}`}

+

+ + {deviceInfo.manufacturerName ?? + `VID:${deviceInfo.vendorId.toString(16).toUpperCase()}`} + +

)} @@ -172,16 +200,14 @@ export default function PrinterStatus() { className="text-sm cursor-pointer text-red-600 focus:text-red-600" > -{t('printerDisconnect')} + {t('printerDisconnect')} )} )} {printMethod === 'browser' && ( -
- {t('printerBrowserMode')} -
+
{t('printerBrowserMode')}
)} diff --git a/frontend/src/hooks/useCashSession.ts b/frontend/src/hooks/useCashSession.ts index 4096dd012..a37bac90f 100644 --- a/frontend/src/hooks/useCashSession.ts +++ b/frontend/src/hooks/useCashSession.ts @@ -74,7 +74,9 @@ export function useCashSession() { } catch (err: unknown) { return { data: null, - error: axios.isAxiosError(err) ? err.response?.data?.error || err.message : 'Failed to load shift', + error: axios.isAxiosError(err) + ? err.response?.data?.error || err.message + : 'Failed to load shift', }; } }; @@ -130,9 +132,12 @@ 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'); + setSubmitError( + axios.isAxiosError(err) ? err.response?.data?.error || err.message : 'Open failed', + ); } finally { setSubmitting(false); } @@ -150,13 +155,18 @@ export function useCashSession() { setSubmitting(true); setSubmitError(null); try { - const res = await api.post(`/cash-sessions/${session.id}/close`, { counted_cash_cents: countedCents }); + const res = await api.post(`/cash-sessions/${session.id}/close`, { + counted_cash_cents: countedCents, + }); 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'); + setSubmitError( + axios.isAxiosError(err) ? err.response?.data?.error || err.message : 'Close failed', + ); } finally { setSubmitting(false); } @@ -183,7 +193,9 @@ export function useCashSession() { } catch (err: unknown) { const msg = axios.isAxiosError(err) ? err.response?.data?.error || err.response?.data?.detail || err.message - : (err instanceof Error ? err.message : 'Print failed'); + : err instanceof Error + ? err.message + : 'Print failed'; toast.error(msg); return false; } finally { @@ -193,17 +205,40 @@ export function useCashSession() { const countedCentsOrNull = displayToCents(countedInput); const shiftLoadFailedMessage = t('shiftLoadFailed'); - const variancePreviewCents = session && countedCentsOrNull !== null - ? countedCentsOrNull - session.expected_cash_cents - : null; + const variancePreviewCents = + session && countedCentsOrNull !== null + ? countedCentsOrNull - session.expected_cash_cents + : null; return { - session, loading, error, refresh, - openModalOpen, setOpenModalOpen, closeModalOpen, setCloseModalOpen, - floatInput, setFloatInput, countedInput, setCountedInput, - submitting, submitError, setSubmitError, closedResult, setClosedResult, - printing, variancePreviewCents, minorFactor, fmt, unitAdapter, - t, tCommon, openShift, closeShift, printClosure, shiftLoadFailedMessage, + session, + loading, + error, + refresh, + openModalOpen, + setOpenModalOpen, + closeModalOpen, + setCloseModalOpen, + floatInput, + setFloatInput, + countedInput, + setCountedInput, + submitting, + submitError, + setSubmitError, + closedResult, + setClosedResult, + printing, + variancePreviewCents, + minorFactor, + fmt, + unitAdapter, + t, + tCommon, + openShift, + closeShift, + printClosure, + shiftLoadFailedMessage, }; } diff --git a/frontend/src/hooks/usePrinter.ts b/frontend/src/hooks/usePrinter.ts index 116f331a3..9b7de695c 100644 --- a/frontend/src/hooks/usePrinter.ts +++ b/frontend/src/hooks/usePrinter.ts @@ -115,6 +115,7 @@ export interface HardwarePrinter { port?: number | null; paper_width?: string | null; is_default: number; + readiness_state?: 'configured'; profile_id?: string; capabilities?: ThermalPrinterCapabilities; } @@ -129,6 +130,7 @@ interface PrinterState { printMethod: PrintMode; hardwarePrinter: HardwarePrinter | null; webusbPrinter: HardwarePrinter | null; + defaultPrinter: HardwarePrinter | null; refreshHardwarePrinter: () => Promise; connect: () => Promise; disconnect: () => Promise; @@ -169,20 +171,28 @@ export const usePrinterStore = create()( printMethod: 'escpos', hardwarePrinter: null, webusbPrinter: null, + defaultPrinter: null, 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') || 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 c9a25c5a8..0005498e1 100644 --- a/frontend/src/lib/i18n/messages/ar.json +++ b/frontend/src/lib/i18n/messages/ar.json @@ -2065,6 +2065,7 @@ "printerNamePlaceholder": "على سبيل المثال: طابعة المطبخ", "printerNameRequired": "اسم الطابعة مطلوب", "printerOffline": "غير متصل", + "printerConfigured": "تم إعداد الطابعة", "printerOnline": "عبر الإنترنت", "printerPrinting": "الطباعة", "printerQuickAdded": "تمت إضافة {name}", @@ -2224,6 +2225,8 @@ "testPrint": "طباعة تجريبية", "testPrintFailed": "فشل الاختبار الطباعي", "testPrintSent": "تم إرسال نسخة تجريبية من الطباعة!", + "cashDrawerPulseSent": "تم إرسال نبضة درج النقود.", + "cashDrawerPulseFailed": "تعذر إرسال نبضة درج النقود.", "testing": "تجربة...", "timezone": "المنطقة الزمنية", "businessDayStartTime": "وقت بدء يوم العمل", @@ -2261,7 +2264,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 cb88d42fe..8589ff268 100644 --- a/frontend/src/lib/i18n/messages/bn.json +++ b/frontend/src/lib/i18n/messages/bn.json @@ -2065,6 +2065,7 @@ "printerNamePlaceholder": "যেমন রান্নাঘর প্রিন্টার", "printerNameRequired": "প্রিন্টারের নাম প্রয়োজন", "printerOffline": "অফলাইন", + "printerConfigured": "প্রিন্টার কনফিগার করা হয়েছে", "printerOnline": "অনলাইন", "printerPrinting": "প্রিন্টিং", "printerQuickAdded": "{name} যোগ করা হয়েছে", @@ -2224,6 +2225,8 @@ "testPrint": "টেস্ট প্রিন্ট", "testPrintFailed": "পরীক্ষার মুদ্রণ ব্যর্থ হয়েছে।", "testPrintSent": "পরীক্ষার প্রিন্ট পাঠানো হয়েছে!", + "cashDrawerPulseSent": "ক্যাশ ড্রয়ারের পালস পাঠানো হয়েছে।", + "cashDrawerPulseFailed": "ক্যাশ ড্রয়ারের পালস পাঠানো যায়নি।", "testing": "পরীক্ষা করা হচ্ছে...", "timezone": "টাইমজোন", "businessDayStartTime": "ব্যবসার দিন শুরুর সময়", @@ -2261,7 +2264,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 f7edc909a..dc141cedb 100644 --- a/frontend/src/lib/i18n/messages/de.json +++ b/frontend/src/lib/i18n/messages/de.json @@ -2065,6 +2065,7 @@ "printerNamePlaceholder": "z. B. Küchendrucker", "printerNameRequired": "Druckername ist erforderlich", "printerOffline": "Offline", + "printerConfigured": "Drucker konfiguriert", "printerOnline": "Online", "printerPrinting": "Druckt…", "printerQuickAdded": "{name} hinzugefügt", @@ -2225,6 +2226,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", @@ -2262,7 +2265,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 e592d71cb..c443a4ea5 100644 --- a/frontend/src/lib/i18n/messages/en.json +++ b/frontend/src/lib/i18n/messages/en.json @@ -2065,6 +2065,7 @@ "printerNamePlaceholder": "e.g. Kitchen Printer", "printerNameRequired": "Printer name is required", "printerOffline": "Offline", + "printerConfigured": "Printer configured", "printerOnline": "Online", "printerPrinting": "Printing", "printerQuickAdded": "{name} added", @@ -2224,6 +2225,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", @@ -2261,7 +2264,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 e44f1d8bd..4fb3a5094 100644 --- a/frontend/src/lib/i18n/messages/es.json +++ b/frontend/src/lib/i18n/messages/es.json @@ -2065,6 +2065,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", @@ -2225,6 +2226,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", @@ -2262,7 +2265,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 6b31bf086..65282cf2a 100644 --- a/frontend/src/lib/i18n/messages/fa.json +++ b/frontend/src/lib/i18n/messages/fa.json @@ -2065,6 +2065,7 @@ "printerNamePlaceholder": "مثلاً چاپگر آشپزخانه", "printerNameRequired": "نام چاپگر لازم است", "printerOffline": "آفلاین", + "printerConfigured": "چاپگر پیکربندی شده است", "printerOnline": "آنلاین", "printerPrinting": "در حال چاپ", "printerQuickAdded": "{name} افزوده شد", @@ -2224,6 +2225,8 @@ "testPrint": "چاپ آزمون", "testPrintFailed": "چاپ آزمون انجام نشد", "testPrintSent": "چاپ آزمون ارسال شد!", + "cashDrawerPulseSent": "پالس کشوی پول ارسال شد.", + "cashDrawerPulseFailed": "ارسال پالس کشوی پول ناموفق بود.", "testing": "در حال آزمون…", "timezone": "منطقه زمانی", "businessDayStartTime": "زمان شروع روز کاری", @@ -2261,7 +2264,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 b0c0c6c47..9d721611b 100644 --- a/frontend/src/lib/i18n/messages/fil.json +++ b/frontend/src/lib/i18n/messages/fil.json @@ -2065,6 +2065,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}", @@ -2235,6 +2236,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", @@ -2272,7 +2275,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 c8ffb7982..e3fc41c6d 100644 --- a/frontend/src/lib/i18n/messages/fr.json +++ b/frontend/src/lib/i18n/messages/fr.json @@ -2065,6 +2065,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", @@ -2224,6 +2225,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é", @@ -2261,7 +2264,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 8aeb13fd6..5c592c1bc 100644 --- a/frontend/src/lib/i18n/messages/hi.json +++ b/frontend/src/lib/i18n/messages/hi.json @@ -2065,6 +2065,7 @@ "printerNamePlaceholder": "जैसे रसोई प्रिंटर", "printerNameRequired": "प्रिंटर का नाम आवश्यक है", "printerOffline": "ऑफ़लाइन", + "printerConfigured": "प्रिंटर कॉन्फ़िगर किया गया", "printerOnline": "ऑनलाइन", "printerPrinting": "मुद्रण", "printerQuickAdded": "{name} जोड़ा गया", @@ -2235,6 +2236,8 @@ "testPrint": "टेस्ट प्रिंट", "testPrintFailed": "परीक्षण प्रिंट विफल रहा", "testPrintSent": "परीक्षण प्रिंट भेजा गया!", + "cashDrawerPulseSent": "कैश ड्रॉअर पल्स भेजा गया।", + "cashDrawerPulseFailed": "कैश ड्रॉअर पल्स भेजने में विफल।", "testing": "परीक्षण...", "timezone": "समय क्षेत्र", "businessDayStartTime": "व्यावसायिक दिन प्रारंभ होने का समय", @@ -2272,7 +2275,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 658cc04dd..59beeeb0d 100644 --- a/frontend/src/lib/i18n/messages/id.json +++ b/frontend/src/lib/i18n/messages/id.json @@ -2065,6 +2065,7 @@ "printerNamePlaceholder": "misalnya Printer Dapur", "printerNameRequired": "Nama printer wajib diisi", "printerOffline": "Luring", + "printerConfigured": "Printer dikonfigurasi", "printerOnline": "Daring", "printerPrinting": "Mencetak", "printerQuickAdded": "{name} ditambahkan", @@ -2235,6 +2236,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", @@ -2272,7 +2275,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 a89768d5d..a3d463350 100644 --- a/frontend/src/lib/i18n/messages/it.json +++ b/frontend/src/lib/i18n/messages/it.json @@ -2065,6 +2065,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", @@ -2224,6 +2225,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", @@ -2261,7 +2264,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 b1f9ffdc2..bd0347df6 100644 --- a/frontend/src/lib/i18n/messages/ja.json +++ b/frontend/src/lib/i18n/messages/ja.json @@ -2065,6 +2065,7 @@ "printerNamePlaceholder": "例: キッチンプリンター", "printerNameRequired": "プリンター名は必須です", "printerOffline": "オフライン", + "printerConfigured": "プリンターを設定済み", "printerOnline": "オンライン", "printerPrinting": "印刷中", "printerQuickAdded": "{name}を追加しました", @@ -2224,6 +2225,8 @@ "testPrint": "テスト印刷", "testPrintFailed": "テスト印刷に失敗しました", "testPrintSent": "テスト印刷を送信しました!", + "cashDrawerPulseSent": "キャッシュドロワーのパルスを送信しました。", + "cashDrawerPulseFailed": "キャッシュドロワーのパルスを送信できませんでした。", "testing": "テスト中…", "timezone": "タイムゾーン", "businessDayStartTime": "営業日の開始時刻", @@ -2261,7 +2264,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 d89bb1f16..9dadbbe1c 100644 --- a/frontend/src/lib/i18n/messages/ko.json +++ b/frontend/src/lib/i18n/messages/ko.json @@ -2065,6 +2065,7 @@ "printerNamePlaceholder": "예: 주방 프린터", "printerNameRequired": "프린터 이름은 필수입니다", "printerOffline": "오프라인", + "printerConfigured": "프린터 설정 완료", "printerOnline": "온라인", "printerPrinting": "인쇄 중", "printerQuickAdded": "{name} 추가됨", @@ -2224,6 +2225,8 @@ "testPrint": "테스트 인쇄", "testPrintFailed": "테스트 인쇄 실패", "testPrintSent": "테스트 인쇄 전송됨!", + "cashDrawerPulseSent": "현금 서랍 펄스를 보냈습니다.", + "cashDrawerPulseFailed": "현금 서랍 펄스를 보내지 못했습니다.", "testing": "테스트 중…", "timezone": "시간대", "businessDayStartTime": "영업일 시작 시각", @@ -2261,7 +2264,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 90434ad2a..4fb09dfdb 100644 --- a/frontend/src/lib/i18n/messages/ne.json +++ b/frontend/src/lib/i18n/messages/ne.json @@ -2063,6 +2063,7 @@ "printerNamePlaceholder": "जस्तै किचन प्रिन्टर", "printerNameRequired": "प्रिन्टर नाम आवश्यक छ", "printerOffline": "अफलाइन", + "printerConfigured": "प्रिन्टर कन्फिगर गरिएको छ", "printerOnline": "अनलाइन", "printerPrinting": "मुद्रण", "printerQuickAdded": "{name} थपियो", @@ -2233,6 +2234,8 @@ "testPrint": "परीक्षण प्रिन्ट", "testPrintFailed": "परीक्षण प्रिन्ट असफल भयो", "testPrintSent": "परीक्षण प्रिन्ट पठाइयो!", + "cashDrawerPulseSent": "नगद दराजको पल्स पठाइयो।", + "cashDrawerPulseFailed": "नगद दराजको पल्स पठाउन सकिएन।", "testing": "परीक्षण…", "timezone": "समय क्षेत्र", "businessDayStartTime": "व्यापार दिन सुरु समय", @@ -2270,7 +2273,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 f5b3db2e8..1942a5c66 100644 --- a/frontend/src/lib/i18n/messages/nl.json +++ b/frontend/src/lib/i18n/messages/nl.json @@ -2065,6 +2065,7 @@ "printerNamePlaceholder": "bijv. Keukenprinter", "printerNameRequired": "Printernaam is vereist", "printerOffline": "Offline", + "printerConfigured": "Printer ingesteld", "printerOnline": "Online", "printerPrinting": "Afdrukken", "printerQuickAdded": "{name} toegevoegd", @@ -2224,6 +2225,8 @@ "testPrint": "Testafdruk", "testPrintFailed": "Testafdruk mislukt", "testPrintSent": "Testafdruk verzonden!", + "cashDrawerPulseSent": "Kassaladepuls verzonden.", + "cashDrawerPulseFailed": "Verzenden van kassaladepuls mislukt.", "testing": "Testen…", "timezone": "Tijdzone", "businessDayStartTime": "Begintijd werkdag", @@ -2261,7 +2264,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 3ec470a97..1d4ee8012 100644 --- a/frontend/src/lib/i18n/messages/pt.json +++ b/frontend/src/lib/i18n/messages/pt.json @@ -2065,6 +2065,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", @@ -2224,6 +2225,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", @@ -2261,7 +2264,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 149bb0d06..39517d414 100644 --- a/frontend/src/lib/i18n/messages/ru.json +++ b/frontend/src/lib/i18n/messages/ru.json @@ -2065,6 +2065,7 @@ "printerNamePlaceholder": "например: кухонный принтер", "printerNameRequired": "Укажите имя принтера.", "printerOffline": "Не в сети", + "printerConfigured": "Принтер настроен", "printerOnline": "Онлайн", "printerPrinting": "Печать", "printerQuickAdded": "Принтер {name} добавлен", @@ -2224,6 +2225,8 @@ "testPrint": "Пробная печать", "testPrintFailed": "Пробная печать не удалась", "testPrintSent": "Тестовая печать отправлена!", + "cashDrawerPulseSent": "Импульс денежного ящика отправлен.", + "cashDrawerPulseFailed": "Не удалось отправить импульс денежного ящика.", "testing": "Тестирование…", "timezone": "Часовой пояс", "businessDayStartTime": "Время начала рабочего дня", @@ -2261,7 +2264,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 dc89bfe2b..0e0899960 100644 --- a/frontend/src/lib/i18n/messages/sq.json +++ b/frontend/src/lib/i18n/messages/sq.json @@ -2065,6 +2065,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", @@ -2235,6 +2236,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", @@ -2272,7 +2275,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 ee91dc4c0..164af8c07 100644 --- a/frontend/src/lib/i18n/messages/th.json +++ b/frontend/src/lib/i18n/messages/th.json @@ -2065,6 +2065,7 @@ "printerNamePlaceholder": "เช่น เครื่องพิมพ์ครัว", "printerNameRequired": "ต้องระบุชื่อเครื่องพิมพ์", "printerOffline": "ออฟไลน์", + "printerConfigured": "ตั้งค่าเครื่องพิมพ์แล้ว", "printerOnline": "ออนไลน์", "printerPrinting": "กำลังพิมพ์", "printerQuickAdded": "เพิ่ม {name} แล้ว", @@ -2224,6 +2225,8 @@ "testPrint": "ทดสอบการพิมพ์", "testPrintFailed": "ทดสอบการพิมพ์ไม่สำเร็จ", "testPrintSent": "ส่งการทดสอบการพิมพ์แล้ว!", + "cashDrawerPulseSent": "ส่งสัญญาณเปิดลิ้นชักเงินสดแล้ว", + "cashDrawerPulseFailed": "ส่งสัญญาณเปิดลิ้นชักเงินสดไม่สำเร็จ", "testing": "กำลังทดสอบ…", "timezone": "เขตเวลา", "businessDayStartTime": "เวลาเริ่มต้นวันทำการ", @@ -2261,7 +2264,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 b63b1867a..e7dbb4a0e 100644 --- a/frontend/src/lib/i18n/messages/tr.json +++ b/frontend/src/lib/i18n/messages/tr.json @@ -2065,6 +2065,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}", @@ -2235,6 +2236,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", @@ -2272,7 +2275,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 c89cb7631..81f9796ee 100644 --- a/frontend/src/lib/i18n/messages/ur.json +++ b/frontend/src/lib/i18n/messages/ur.json @@ -2065,6 +2065,7 @@ "printerNamePlaceholder": "مثلاً، کچن پرنٹر", "printerNameRequired": "پرنٹر کا نام درکار ہے", "printerOffline": "آف لائن", + "printerConfigured": "پرنٹر ترتیب دیا گیا ہے", "printerOnline": "آن لائن", "printerPrinting": "پرنٹ ہو رہا ہے", "printerQuickAdded": "{name} شامل ہو گیا", @@ -2224,6 +2225,8 @@ "testPrint": "ٹیسٹ پرنٹ", "testPrintFailed": "ٹیسٹ پرنٹ ناکام", "testPrintSent": "ٹیسٹ پرنٹ بھیج دیا گیا!", + "cashDrawerPulseSent": "نقد درج کا پلس بھیج دیا گیا۔", + "cashDrawerPulseFailed": "نقد درج کا پلس بھیجنے میں ناکامی ہوئی۔", "testing": "ٹیسٹ ہو رہا ہے…", "timezone": "ٹائم زون", "businessDayStartTime": "کاروباری دن کا آغاز", @@ -2261,7 +2264,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 ffcc70c18..cbe9589b7 100644 --- a/frontend/src/lib/i18n/messages/vi.json +++ b/frontend/src/lib/i18n/messages/vi.json @@ -2065,6 +2065,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}", @@ -2235,6 +2236,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", @@ -2272,7 +2275,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 1ba28cbc8..1178dfa21 100644 --- a/frontend/src/lib/i18n/messages/zh-tw.json +++ b/frontend/src/lib/i18n/messages/zh-tw.json @@ -2065,6 +2065,7 @@ "printerNamePlaceholder": "例如:廚房印表機", "printerNameRequired": "印表機名稱必填", "printerOffline": "離線", + "printerConfigured": "印表機已設定", "printerOnline": "線上", "printerPrinting": "列印中", "printerQuickAdded": "已新增 {name}", @@ -2224,6 +2225,8 @@ "testPrint": "測試列印", "testPrintFailed": "測試列印失敗", "testPrintSent": "測試列印已傳送!", + "cashDrawerPulseSent": "已傳送錢箱脈衝。", + "cashDrawerPulseFailed": "無法傳送錢箱脈衝。", "testing": "測試中…", "timezone": "時區", "businessDayStartTime": "營業日起始時間", @@ -2261,7 +2264,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 7fdf5cb9b..ec6fd655d 100644 --- a/frontend/src/lib/i18n/messages/zh.json +++ b/frontend/src/lib/i18n/messages/zh.json @@ -2065,6 +2065,7 @@ "printerNamePlaceholder": "例如:厨房打印机", "printerNameRequired": "打印机名称必填", "printerOffline": "离线", + "printerConfigured": "打印机已配置", "printerOnline": "在线", "printerPrinting": "打印中", "printerQuickAdded": "已添加 {name}", @@ -2224,6 +2225,8 @@ "testPrint": "测试打印", "testPrintFailed": "测试打印失败", "testPrintSent": "测试打印已发送!", + "cashDrawerPulseSent": "已发送钱箱脉冲。", + "cashDrawerPulseFailed": "无法发送钱箱脉冲。", "testing": "测试中…", "timezone": "时区", "businessDayStartTime": "营业日起始时间", @@ -2261,7 +2264,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..9d70399a1 100644 --- a/frontend/src/lib/printer/PrinterService.ts +++ b/frontend/src/lib/printer/PrinterService.ts @@ -1,10 +1,6 @@ /** WebUSB ESC/POS thermal printer driver with browser print fallback. */ -export type PrinterStatus = - | 'disconnected' - | 'connecting' - | 'connected' - | 'error'; +export type PrinterStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; export type PrintMode = 'escpos' | 'browser'; @@ -88,11 +84,7 @@ class PrinterService { /** Opens (or re-opens) a device and claims its ESC/POS interface. */ private async openDevice(device: USBDevice): Promise { if (this.device) { - // Check if this is the exact same physical device. - const sameDevice = this.device.vendorId === device.vendorId - && this.device.productId === device.productId - && this.device.serialNumber === device.serialNumber; - if (sameDevice) { + if (this.isSameDevice(this.device, device)) { return; } // Release claim/interface on the previously connected device before switching. @@ -114,7 +106,7 @@ class PrinterService { this.claimedInterface = iface.interfaceNumber; // Discover the bulk-OUT endpoint number from the descriptor const outEndpoint = alt.endpoints.find( - (ep) => ep.type === 'bulk' && ep.direction === 'out' + (ep) => ep.type === 'bulk' && ep.direction === 'out', ); if (outEndpoint) this.endpointOut = outEndpoint.endpointNumber; break; @@ -136,6 +128,22 @@ class PrinterService { navigator.usb.addEventListener('disconnect', this.handleDisconnect); } + private isSameDevice(left: USBDevice, right: USBDevice): boolean { + return ( + left.vendorId === right.vendorId && + left.productId === right.productId && + left.serialNumber === right.serialNumber && + left.productName === right.productName + ); + } + + private async findUniqueConfiguredDevice(productName: string): Promise { + const matches = (await navigator.usb!.getDevices()).filter( + (device) => device.productName === productName, + ); + return matches.length === 1 ? matches[0] : null; + } + // Serializes openDevice() to prevent concurrent mutations of device/interface state. private connectLock: Promise = Promise.resolve(); @@ -153,9 +161,7 @@ class PrinterService { } if (!navigator.usb) { - throw new Error( - 'WebUSB API is not supported in this browser. Use Chrome or Edge 89+.' - ); + throw new Error('WebUSB API is not supported in this browser. Use Chrome or Edge 89+.'); } this.setStatus('connecting'); @@ -206,6 +212,53 @@ class PrinterService { return this.reconnectPromise; } + async isConfiguredDeviceReady(productName: string): Promise { + if ( + this._printMode === 'browser' || + !this.isConnected || + !this.device || + typeof navigator === 'undefined' || + !navigator.usb + ) { + return false; + } + + return this.runExclusive(async () => { + const activeDevice = this.device; + if (this._printMode === 'browser' || this._status !== 'connected' || !activeDevice) { + return false; + } + const configuredDevice = await this.findUniqueConfiguredDevice(productName); + return ( + configuredDevice !== null && + this._status === 'connected' && + this.device === activeDevice && + this.isSameDevice(activeDevice, configuredDevice) + ); + }); + } + + 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 () => { + if (this._printMode === 'browser') { + throw new Error('Browser print mode is active. Use window.print() instead.'); + } + const configuredDevice = await this.findUniqueConfiguredDevice(productName); + if (!configuredDevice) { + throw new Error('Configured WebUSB printer does not match one available device.'); + } + await this.openDevice(configuredDevice); + 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; @@ -258,8 +311,13 @@ class PrinterService { return typeof window === 'undefined' ? null : window.open('', '_blank'); } - async printViaBrowser(htmlContent: string, paperWidth: 58 | 80, reservedWindow?: Window | null): Promise { - const printWindow = reservedWindow === undefined ? this.reserveBrowserPrintWindow() : reservedWindow; + async printViaBrowser( + htmlContent: string, + paperWidth: 58 | 80, + reservedWindow?: Window | null, + ): Promise { + const printWindow = + reservedWindow === undefined ? this.reserveBrowserPrintWindow() : reservedWindow; if (!printWindow) { throw new Error('Please allow popups to print'); } @@ -289,7 +347,9 @@ class PrinterService { // Parse receipt markup and strip executable/script nodes before printing. const parsed = new DOMParser().parseFromString(htmlContent, 'text/html'); - parsed.querySelectorAll('script, link, meta, base, iframe, object, embed, form').forEach((node) => node.remove()); + parsed + .querySelectorAll('script, link, meta, base, iframe, object, embed, form') + .forEach((node) => node.remove()); parsed.querySelectorAll('*').forEach((element) => { for (const attribute of Array.from(element.attributes)) { const name = attribute.name.toLowerCase(); @@ -304,7 +364,9 @@ class PrinterService { } }); printWindow.document.body.replaceChildren( - ...Array.from(parsed.body.childNodes).map((node) => printWindow.document.importNode(node, true)), + ...Array.from(parsed.body.childNodes).map((node) => + printWindow.document.importNode(node, true), + ), ); printWindow.document.close(); printWindow.print(); diff --git a/main/routes/printers.ts b/main/routes/printers.ts index 66b78b77f..cc5e20fc4 100644 --- a/main/routes/printers.ts +++ b/main/routes/printers.ts @@ -47,7 +47,8 @@ import { resolvePrinterProfile, capabilitiesForPrinter, } from '../printers/profiles'; -import { requirePermission } from '../services/authorization'; +import { hasPermission, requirePermission, requireAnyPermission } from '../services/authorization'; +import { getOpenSession } from '../services/shift-session-gate'; import { formatCurrencyForTenant, formatDateForTenant, @@ -169,6 +170,32 @@ function printerShape(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, + connection_type: printer.connection_type, + is_default: printer.is_default, + readiness_state: 'configured', + }; +} + +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. export function getEffectiveOrderItems(db: any, orderId: string): any[] { return attachEffectiveAddons( @@ -179,20 +206,29 @@ 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 Request & { user?: { userId?: string } }).user?.userId || ''); + const canManagePrinters = hasPermission(userId, 'printers.manage'); + 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' }); + } + }, +); // GET /api/printers/detect — detect connected USB/network printers router.get( @@ -498,6 +534,131 @@ router.post( }), ); +// POST /api/printers/kick-drawer — send a cash drawer pulse to the default/active printer +router.post( + '/kick-drawer', + requirePermission('cash.movements.manage'), + 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]); + + 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), + printerName: printer.name, + }); + default: + return res.status(400).json({ error: 'Unsupported printer connection type' }); + } + + if (result.ok) { + res.json({ success: true }); + } else { + console.error( + '[Printer] Cash drawer kick failed:', + result.detail || 'Printer did not respond', + ); + res.status(502).json({ error: 'Cash drawer kick failed' }); + } + } catch (error: unknown) { + 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 = getDefaultPrinter(db); + + 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 testData = buildTestPage( + printer.paper_width || profile.defaultPaperWidth, + profile.cutMode, + tenantLanguage(db), + timezone, + ); + 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), + printerName: printer.name, + }); + default: + return res.status(400).json({ error: 'Unsupported printer connection type' }); + } + + if (result.ok) { + res.json({ success: true }); + } else { + 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: unknown) { + 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..751ad9766 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)); @@ -604,6 +655,198 @@ 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})`, + ); + + 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 && + 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' && + Object.keys(serverPrintersRes.body.printers[0]).sort().join(',') === + 'connection_type,id,is_default,name,readiness_state', + 'printing.execute receives only default-printer readiness fields', + ); + + 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 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'); + 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', + ); + + 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( + 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 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', + ); + + db.prepare('PRAGMA ignore_check_constraints = ON').run(); + db.prepare("UPDATE printers SET connection_type = 'serial' WHERE is_default = 1").run(); + const unsupportedKickRes = await request(app).post('/api/printers/kick-drawer'); + assert( + unsupportedKickRes.status === 400 && + unsupportedKickRes.body.error === 'Unsupported printer connection type', + 'kick-drawer returns 400 for unsupported printer connection type', + ); + const unsupportedActiveRes = await request(app).post('/api/printers/test-active'); + assert( + unsupportedActiveRes.status === 400 && + unsupportedActiveRes.body.error === 'Unsupported printer connection type', + 'test-active returns 400 for unsupported printer connection type', + ); + db.prepare("UPDATE printers SET connection_type = 'network' WHERE is_default = 1").run(); + db.prepare('PRAGMA ignore_check_constraints = OFF').run(); + + 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)); console.log(`${passed}/${passed + failed} passed, ${failed} failed`);