diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index c1bc9485c..157629238 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -158,6 +158,33 @@ racing lookups are ignored by lookup id, rapid clicks open at most one allocator explicit split-opening request is in flight competing payment and split mutations are disabled until it finally releases. +### Add-on groups have one editor + +Add-on groups are managed only in Products, on the tab selected by `/products?tab=addons`. The +Products page reads `tab` (`products`, `categories`, or `addons`; anything else selects Products) +inside a Suspense boundary and writes tab clicks back with `router.replace`, keeping other query +parameters. The tab is shown to restaurants and to any business whose role grants `catalog.manage`. +The legacy `/addon-groups` route is kept for bookmarks and the static export; it makes no API call and +client-redirects to `/products?tab=addons` with `router.replace`. + +The group editor echoes each saved add-on's `tax_category_id`, `tax_behavior`, and +`inherit_parent_tax_category` back on save, and sends `stock_quantity` only when the stock field was +edited, so an ordinary save cannot overwrite stock moved since the editor opened. + +### Charge identity is chosen, not typed + +[`ChargesSettingsCard`](../../frontend/src/components/settings/ChargesSettingsCard.tsx) offers Service, +Packaging, and Custom kinds for a new charge. Service and Packaging use the reserved IDs +`service_charge` and `packaging_charge` (which feed the standard report columns); if that ID already +exists, choosing the kind opens the saved definition instead of applying defaults. Custom charges get +an opaque `charge-<32 hex>` ID from `crypto.getRandomValues`, generated once per draft. IDs are never +derived from names or editable, so renaming a charge keeps its identity. Choosing a kind writes +nothing; only Save persists. Editing an existing charge reloads definitions before building the +replacement list. An edit therefore does not recreate a charge already deleted on the server or +replace other definitions from its old snapshot. A failed reload retains the draft and blocks saving +until Retry succeeds. Creation retries reuse the draft's stable ID, including after a committed +attempt's response is lost. + ### The KDS live feed is a WebSocket, not a request Two surfaces open a socket to the same `/kds` path. The base ports for that path are `:3001` and @@ -597,7 +624,7 @@ frontend/src/ │ ├── products/ Product image uploader │ ├── providers/ I18nProvider │ ├── settings/ The settings screen's building blocks: -│ │ BetaChannelToggle, CurrencyResetDialog, DatabaseSettingsTab, +│ │ BetaChannelToggle, ChargesSettingsCard, CurrencyResetDialog, DatabaseSettingsTab, │ │ GeneralSettingsTab, HealthCheckDialog, │ │ InitializeDatabaseDialog, LocalePreferencesPanel, MasterPinPrompt, │ │ PaymentMethodsSettings, PermissionAuditLog, PermissionMatrix, diff --git a/frontend/e2e/i18n-batch-5e.spec.ts b/frontend/e2e/i18n-batch-5e.spec.ts index 85b9f038b..6d1d98af8 100644 --- a/frontend/e2e/i18n-batch-5e.spec.ts +++ b/frontend/e2e/i18n-batch-5e.spec.ts @@ -19,7 +19,9 @@ async function login(page: Page, email: string): Promise { } test.describe('Batch 5E Migrated Pages & Components E2E Validation', () => { - test('Batch 5E Pages render correctly in English, Spanish, and Persian with useTranslations and valid leaf keys', async ({ page }) => { + test('Batch 5E Pages render correctly in English, Spanish, and Persian with useTranslations and valid leaf keys', async ({ + page, + }) => { await login(page, 'owner@flo.local'); // ========================================== @@ -58,8 +60,15 @@ test.describe('Batch 5E Migrated Pages & Components E2E Validation', () => { // 1c. Addon Groups (EN) await page.goto(`${BASE}/addon-groups`); + await page.waitForURL( + (url) => url.pathname.startsWith('/products') && url.searchParams.get('tab') === 'addons', + ); await expect(page.locator('html')).toHaveAttribute('dir', 'ltr'); - await expect(page.getByRole('heading', { level: 1 })).toHaveText('Addon Groups'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Products'); + await expect(page.getByRole('tab', { name: 'Addon Groups' })).toHaveAttribute( + 'data-state', + 'active', + ); await captureScreenshot(page, 'batch-5e-addon-groups-en.png'); // 1d. Staff (EN) @@ -107,8 +116,15 @@ test.describe('Batch 5E Migrated Pages & Components E2E Validation', () => { // 2c. Addon Groups (ES) await page.goto(`${BASE}/addon-groups`); + await page.waitForURL( + (url) => url.pathname.startsWith('/products') && url.searchParams.get('tab') === 'addons', + ); await expect(page.locator('html')).toHaveAttribute('dir', 'ltr'); - await expect(page.getByRole('heading', { level: 1 })).toHaveText('Grupos de adicionales'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Productos'); + await expect(page.getByRole('tab', { name: 'Grupos de adicionales' })).toHaveAttribute( + 'data-state', + 'active', + ); await captureScreenshot(page, 'batch-5e-addon-groups-es.png'); // 2d. Staff (ES) @@ -132,7 +148,9 @@ test.describe('Batch 5E Migrated Pages & Components E2E Validation', () => { // 2g. Print Test (ES) await page.goto(`${BASE}/print-test`); await expect(page.locator('html')).toHaveAttribute('dir', 'ltr'); - await expect(page.getByRole('heading', { level: 1 })).toHaveText('Página de prueba de impresión'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'Página de prueba de impresión', + ); await captureScreenshot(page, 'batch-5e-print-test-es.png'); // ========================================== @@ -156,8 +174,15 @@ test.describe('Batch 5E Migrated Pages & Components E2E Validation', () => { // 3c. Addon Groups (FA) await page.goto(`${BASE}/addon-groups`); + await page.waitForURL( + (url) => url.pathname.startsWith('/products') && url.searchParams.get('tab') === 'addons', + ); await expect(page.locator('html')).toHaveAttribute('dir', 'rtl'); - await expect(page.getByRole('heading', { level: 1 })).toHaveText('گروه‌های افزونه'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('کالاها'); + await expect(page.getByRole('tab', { name: 'گروه‌های افزودنی' })).toHaveAttribute( + 'data-state', + 'active', + ); await captureScreenshot(page, 'batch-5e-addon-groups-fa.png'); // 3d. Staff (FA) @@ -183,7 +208,6 @@ test.describe('Batch 5E Migrated Pages & Components E2E Validation', () => { await expect(page.locator('html')).toHaveAttribute('dir', 'rtl'); await expect(page.getByRole('heading', { level: 1 })).toHaveText('صفحه آزمون چاپ'); await captureScreenshot(page, 'batch-5e-print-test-fa.png'); - } finally { await setLanguage(page, 'en'); } diff --git a/frontend/e2e/settings-request-budget.spec.ts b/frontend/e2e/settings-request-budget.spec.ts index 45e68ae16..52eccfd48 100644 --- a/frontend/e2e/settings-request-budget.spec.ts +++ b/frontend/e2e/settings-request-budget.spec.ts @@ -1379,19 +1379,21 @@ test('Charges stay read-only after a failed refresh until retry succeeds', async expect(reads).toBe(3); }); -test('Charges block duplicate IDs and confirm deletion before saving', async ({ page }) => { +test('Charges use presets, edit existing standard definitions, and confirm deletion before saving', async ({ + page, +}) => { await startMockedSettingsSession(page); const charge = { - id: 'existing_fee', - name: 'Existing fee', - type: 'fixed', - value: 3, - calculation_basis: 'gross', - order_types: ['dine_in'], - is_optional: false, - is_default_active: true, - is_active: true, - tax_category_id: null, + id: 'service_charge', + name: 'Table service', + type: 'percentage', + value: 10, + calculation_basis: 'net', + order_types: ['dine_in', 'takeaway'], + is_optional: true, + is_default_active: false, + is_active: false, + tax_category_id: 'standard', }; let writes = 0; let savedCharges: unknown[] = []; @@ -1414,26 +1416,277 @@ test('Charges block duplicate IDs and confirm deletion before saving', async ({ }); await page.goto(`${BASE}/settings?tab=pos`); - await expect(page.getByText('Existing fee', { exact: true })).toBeVisible(); + await expect(page.getByText('Table service', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Add Charge', exact: true }).click(); const form = page.getByRole('dialog'); - await form.getByLabel('Name').fill('Another fee'); - await form.getByLabel('Charge ID').fill('existing_fee'); - await form.getByLabel('Value').fill('5'); - await form.getByRole('button', { name: 'Save', exact: true }).click(); - await expect(form.getByRole('alert')).toContainText('Could not save charges'); - await expect(page.getByText('Existing fee', { exact: true })).toBeVisible(); + + await expect(form.getByLabel('Charge ID')).toHaveCount(0); + + await expect(form.getByLabel('Fee Type')).toHaveValue('custom'); + await expect(form.getByLabel('Name')).toHaveValue(''); + await expect(form.getByLabel('Value')).toHaveValue(''); + await expect(form.getByRole('button', { name: 'Save', exact: true })).toBeDisabled(); + + await form.getByLabel('Fee Type').selectOption('packaging'); + expect(writes).toBe(0); + await expect(form.getByLabel('Name')).toHaveValue('Packaging charge'); + await expect(form.getByLabel(/^type/i)).toHaveValue('fixed'); + await expect(form.getByLabel('Value')).toHaveValue(''); + await expect(form.getByLabel('Dine In')).not.toBeChecked(); + await expect(form.getByLabel('Takeaway')).toBeChecked(); + await expect(form.getByLabel('Delivery')).toBeChecked(); + await expect(form.getByLabel('Online')).toBeChecked(); + await expect(form.getByLabel('Apply automatically')).toBeChecked(); + await expect(form.getByLabel('Cashier can waive')).not.toBeChecked(); + await expect(form.getByRole('button', { name: 'Save', exact: true })).toBeDisabled(); + + // An existing reserved ID opens the saved definition instead of creation defaults. + await form.getByLabel('Fee Type').selectOption('service'); expect(writes).toBe(0); + await expect(form.getByRole('heading', { name: 'Edit Charge' })).toBeVisible(); + await expect(form.getByLabel('Fee Type')).toBeDisabled(); + await expect(form.getByLabel('Name')).toHaveValue('Table service'); + await expect(form.getByLabel('Value')).toHaveValue('10'); + await expect(form.getByLabel('Takeaway')).toBeChecked(); + await expect(form.getByLabel('Cashier can waive')).toBeChecked(); + await expect(form.getByLabel('Apply automatically')).not.toBeChecked(); await form.getByRole('button', { name: 'Cancel', exact: true }).click(); + expect(writes).toBe(0); + + await page.getByRole('button', { name: 'Edit', exact: true }).click(); + await form.getByLabel('Name').fill('Service'); + await form.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(() => writes).toBe(1); + expect(savedCharges).toEqual([{ ...charge, name: 'Service' }]); + await page.getByRole('button', { name: 'Delete', exact: true }).click(); const confirmation = page.getByRole('dialog'); - await expect(confirmation).toContainText('Delete Existing fee?'); + await expect(confirmation).toContainText('Delete Service?'); await confirmation.getByRole('button', { name: 'Delete', exact: true }).click(); - await expect.poll(() => writes).toBe(1); + await expect.poll(() => writes).toBe(2); expect(savedCharges).toEqual([]); }); +test('Charges generate stable custom IDs, allow duplicate names, and preserve IDs on rename/retry', async ({ + page, +}) => { + await startMockedSettingsSession(page); + let writes = 0; + let savedCharges: Array<{ id: string; name: string; value: number }> = []; + let shouldFailNextSave = false; + let failedCharges: Array<{ id: string }> = []; + + await page.addInitScript(() => { + Object.defineProperty(globalThis.crypto, 'randomUUID', { value: undefined }); + }); + await page.route('**/api/settings/charges', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ charges: savedCharges }), + }); + return; + } + writes += 1; + if (shouldFailNextSave) { + failedCharges = route.request().postDataJSON().charges; + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: 'Save failed' }), + }); + return; + } + savedCharges = route.request().postDataJSON().charges; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ charges: savedCharges }), + }); + }); + + await page.goto(`${BASE}/settings?tab=pos`); + + await page.getByRole('button', { name: 'Add Charge', exact: true }).click(); + let form = page.getByRole('dialog'); + await form.getByLabel('Fee Type').selectOption('service'); + await expect(form.getByRole('heading', { name: 'Add Charge' })).toBeVisible(); + await expect(form.getByLabel('Name')).toHaveValue('Service charge'); + await expect(form.getByLabel(/^type/i)).toHaveValue('percentage'); + await expect(form.getByLabel('Value')).toHaveValue(''); + await expect(form.getByLabel('Dine In')).toBeChecked(); + await expect(form.getByLabel('Takeaway')).not.toBeChecked(); + await form.getByRole('button', { name: 'Cancel', exact: true }).click(); + expect(writes).toBe(0); + + // Create first custom charge with a non-ASCII name + await page.getByRole('button', { name: 'Add Charge', exact: true }).click(); + form = page.getByRole('dialog'); + await form.getByLabel('Name').fill('رسوم التوصيل'); + await form.getByLabel('Value').fill('5'); + await form.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(() => writes).toBe(1); + expect(savedCharges.length).toBe(1); + const firstId = savedCharges[0].id; + expect(firstId).toMatch(/^charge-[a-f0-9]{32}$/); + + // Create a second custom charge with the same name; a failed save and retry keep its new ID + await page.getByRole('button', { name: 'Add Charge', exact: true }).click(); + form = page.getByRole('dialog'); + await form.getByLabel('Name').fill('رسوم التوصيل'); + await form.getByLabel('Value').fill('8'); + shouldFailNextSave = true; + await form.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(() => writes).toBe(2); + await expect(form).toBeVisible(); + const failedDraftId = failedCharges[1]?.id; + shouldFailNextSave = false; + await form.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(() => writes).toBe(3); + expect(savedCharges.length).toBe(2); + const secondId = savedCharges[1].id; + expect(secondId).toMatch(/^charge-[a-f0-9]{32}$/); + expect(secondId).toBe(failedDraftId); + expect(firstId).not.toBe(secondId); + + // Edit the first charge: change its name, ID must remain firstId + await page.getByRole('button', { name: 'Edit', exact: true }).first().click(); + form = page.getByRole('dialog'); + await expect(form.getByRole('heading', { name: 'Edit Charge' })).toBeVisible(); + await expect(form.getByLabel('Fee Type')).toBeDisabled(); + await form.getByLabel('Name').fill('Envío exprés 配送'); + + // Failed save keeps dialog open and retry preserves the exact same ID + shouldFailNextSave = true; + await form.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(() => writes).toBe(4); + await expect(form).toBeVisible(); + + shouldFailNextSave = false; + await form.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(() => writes).toBe(5); + expect(savedCharges.length).toBe(2); + expect(savedCharges[0].id).toBe(firstId); + expect(savedCharges[0].name).toBe('Envío exprés 配送'); +}); + +for (const outcome of ['deleted', 'updated', 'unavailable'] as const) { + test(`Charges reconcile an open editor with ${outcome} server definitions before saving`, async ({ + page, + }) => { + await startMockedSettingsSession(page); + const original = { + id: 'service_charge', + name: 'Original service', + type: 'percentage', + value: 10, + calculation_basis: 'gross', + order_types: ['dine_in'], + is_optional: false, + is_default_active: true, + is_active: true, + tax_category_id: null as string | null, + }; + const other = { ...original, id: 'other_fee', name: 'Other fee', value: 3 }; + let serverCharges = [original, other]; + let failReads = false; + let writes = 0; + let reads = 0; + await page.route('**/api/settings/charges', async (route) => { + if (route.request().method() === 'GET') { + reads += 1; + await route.fulfill({ + status: failReads ? 503 : 200, + json: failReads ? { error: 'Read unavailable' } : { charges: serverCharges }, + }); + return; + } + writes += 1; + serverCharges = route.request().postDataJSON().charges; + await route.fulfill({ status: 200, json: { charges: serverCharges } }); + }); + await page.goto(`${BASE}/settings?tab=pos`); + await expect(page.getByText(original.name, { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Edit', exact: true }).first().click(); + const form = page.getByRole('dialog'); + await form.getByLabel('Name').fill('Renamed service'); + + const latestOther = { ...other, value: 7 }; + const latestOriginal = { ...original, is_active: false, tax_category_id: 'standard' }; + serverCharges = outcome === 'deleted' ? [latestOther] : [latestOriginal, latestOther]; + failReads = outcome === 'unavailable'; + const readsBeforeSave = reads; + await form.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(() => reads).toBeGreaterThan(readsBeforeSave); + + if (outcome === 'deleted') { + await expect(form).toBeHidden(); + await expect( + page.getByText('This charge is no longer available. Please reopen and try again.', { + exact: true, + }), + ).toBeVisible(); + expect(writes).toBe(0); + expect(serverCharges).toEqual([latestOther]); + } else if (outcome === 'updated') { + await expect(form).toBeHidden(); + expect(writes).toBe(1); + expect(serverCharges).toEqual([{ ...latestOriginal, name: 'Renamed service' }, latestOther]); + } else { + await expect(page.getByRole('alert')).toContainText('Failed to reload settings'); + await expect(form.getByLabel('Name')).toHaveValue('Renamed service'); + await expect(form.getByRole('button', { name: 'Save', exact: true })).toBeDisabled(); + expect(writes).toBe(0); + failReads = false; + await page.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect(form.getByRole('button', { name: 'Save', exact: true })).toBeEnabled(); + await form.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(form).toBeHidden(); + expect(writes).toBe(1); + expect(serverCharges).toEqual([{ ...latestOriginal, name: 'Renamed service' }, latestOther]); + } + }); +} + +test('Charges retry a creation whose committed response was lost without treating its stable ID as a duplicate', async ({ + page, +}) => { + await startMockedSettingsSession(page); + let serverCharges: Array<{ id: string; name: string }> = []; + let firstId = ''; + let writes = 0; + await page.route('**/api/settings/charges', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ status: 200, json: { charges: serverCharges } }); + return; + } + writes += 1; + serverCharges = route.request().postDataJSON().charges; + if (writes === 1) { + firstId = serverCharges[0].id; + await route.fulfill({ status: 503, json: { error: 'Response lost after commit' } }); + return; + } + await route.fulfill({ status: 200, json: { charges: serverCharges } }); + }); + await page.goto(`${BASE}/settings?tab=pos`); + await page.getByRole('button', { name: 'Add Charge', exact: true }).click(); + const form = page.getByRole('dialog'); + await form.getByLabel('Name').fill('Delivery fee'); + await form.getByLabel('Value').fill('5'); + await form.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(() => writes).toBe(1); + await expect(form).toBeVisible(); + await expect(form.getByRole('button', { name: 'Save', exact: true })).toBeEnabled(); + await form.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(form).toBeHidden(); + expect(writes).toBe(2); + expect(serverCharges).toEqual([expect.objectContaining({ id: firstId, name: 'Delivery fee' })]); +}); + test('Charges ignore an older failed load after a newer load succeeds', async ({ page }) => { await startMockedSettingsSession(page); const latestCharge = { @@ -1551,7 +1804,7 @@ test('Charges keep saved definitions when a pending load completes later', async await page.route('**/api/settings/charges', async (route) => { if (route.request().method() === 'GET') { reads += 1; - if (reads === 2) { + if (reads === 3) { signalStaleReadStarted(); await staleReadHeld; } @@ -1606,7 +1859,7 @@ test('Charges keep saved definitions when a pending load completes later', async await expect(page.getByText('Updated fee', { exact: true })).toBeVisible(); await expect(page.getByText('Existing fee', { exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Add Charge', exact: true })).toBeEnabled(); - expect(reads).toBe(2); + expect(reads).toBe(3); }); test('Charges generate distinct native IDs for non-ASCII names', async ({ page }) => { @@ -1623,7 +1876,7 @@ test('Charges generate distinct native IDs for non-ASCII names', async ({ page } await route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify({ charges: [] }), + body: JSON.stringify({ charges: savedCharges }), }); return; } diff --git a/frontend/e2e/setup-clarity.spec.ts b/frontend/e2e/setup-clarity.spec.ts new file mode 100644 index 000000000..8213e9046 --- /dev/null +++ b/frontend/e2e/setup-clarity.spec.ts @@ -0,0 +1,349 @@ +import { test, expect } from '@playwright/test'; +import { E2E_BASE_URL as BASE } from './helpers/urls'; +import { getE2eToken } from './helpers/test-auth'; + +test.describe('FloCafe Manager Setup Clarity - Add-ons (#1155)', () => { + const ownerToken = getE2eToken('e2e-owner', 'owner@flo.local', 'owner'); + const cashierToken = getE2eToken('e2e-cashier', 'cashier@flo.local', 'cashier'); + const authHeaders = { Authorization: `Bearer ${ownerToken}` }; + + test('direct legacy /addon-groups navigates and replaces history to /products?tab=addons', async ({ + page, + }) => { + await page.addInitScript((token) => { + localStorage.setItem('token', token); + }, ownerToken); + + await page.goto(`${BASE}/dashboard`); + await page.goto(`${BASE}/addon-groups`); + + await page.waitForURL( + (url) => url.pathname.startsWith('/products') && url.searchParams.get('tab') === 'addons', + { timeout: 10_000 }, + ); + expect(page.url()).toContain('/products'); + expect(page.url()).toContain('tab=addons'); + + // Going back in browser history should return to dashboard, not bounce back into /addon-groups + await page.goBack(); + await page.waitForURL((url) => url.pathname.startsWith('/dashboard'), { timeout: 10_000 }); + expect(page.url()).toContain('/dashboard'); + }); + + test('canonical tab deep link, reload, and query navigation work as expected', async ({ + page, + }) => { + await page.addInitScript((token) => { + localStorage.setItem('token', token); + }, ownerToken); + + // 1. Direct visit to /products?tab=addons selects Add-on Groups + await page.goto(`${BASE}/products?tab=addons&origin=setup`); + const addonsTabTrigger = page.getByRole('tab', { name: /add-?on groups/i }); + await expect(addonsTabTrigger).toHaveAttribute('data-state', 'active'); + await expect(page.getByRole('button', { name: /add add-?on group/i })).toBeVisible(); + + // 2. Reload preserves selection + await page.reload(); + await expect(addonsTabTrigger).toHaveAttribute('data-state', 'active'); + + const search = page.getByRole('textbox', { name: /search/i }); + await search.fill('retained setup filter'); + + // 3. Tab clicks update the query with router.replace + const productsTabTrigger = page.getByRole('tab', { name: /^products/i }); + await productsTabTrigger.click(); + await page.waitForURL( + (url) => url.searchParams.get('tab') === 'products' || !url.searchParams.has('tab'), + { timeout: 5_000 }, + ); + await expect(productsTabTrigger).toHaveAttribute('data-state', 'active'); + + const categoriesTabTrigger = page.getByRole('tab', { name: /categories/i }); + await categoriesTabTrigger.click(); + await page.waitForURL((url) => url.searchParams.get('tab') === 'categories', { + timeout: 5_000, + }); + await expect(categoriesTabTrigger).toHaveAttribute('data-state', 'active'); + + await addonsTabTrigger.click(); + await page.waitForURL((url) => url.searchParams.get('tab') === 'addons', { timeout: 5_000 }); + await expect(addonsTabTrigger).toHaveAttribute('data-state', 'active'); + + await expect(search).toHaveValue('retained setup filter'); + expect(new URL(page.url()).searchParams.get('origin')).toBe('setup'); + + // 4. Invalid tab query value safely falls back to products tab + await page.goto(`${BASE}/products?tab=invalid_value`); + await expect(productsTabTrigger).toHaveAttribute('data-state', 'active'); + }); + + test('authorized non-restaurant business can manage add-on groups in Products', async ({ + page, + request, + }) => { + // Read original business setting + const bRes = await request.get(`${BASE}/api/settings/business`, { headers: authHeaders }); + expect(bRes.ok()).toBeTruthy(); + const originalBusiness = await bRes.json(); + + try { + // Set business_type to 'retail' (non-restaurant) + const updateRes = await request.put(`${BASE}/api/settings/business`, { + headers: authHeaders, + data: { ...originalBusiness, business_type: 'retail' }, + }); + expect(updateRes.ok()).toBeTruthy(); + + await page.addInitScript((token) => { + localStorage.setItem('token', token); + }, ownerToken); + + await page.goto(`${BASE}/products?tab=addons`); + + // Add-on Groups tab trigger should be visible for authorized user even in retail + const addonsTabTrigger = page.getByRole('tab', { name: /add-?on groups/i }); + await expect(addonsTabTrigger).toBeVisible(); + await expect(addonsTabTrigger).toHaveAttribute('data-state', 'active'); + + // Add Add-on Group button should be visible + await expect(page.getByRole('button', { name: /add add-?on group/i })).toBeVisible(); + } finally { + // Restore business setting + await request.put(`${BASE}/api/settings/business`, { + headers: authHeaders, + data: originalBusiness, + }); + } + }); + + test('unauthorized user without catalog.manage is blocked from /products and /addon-groups', async ({ + page, + }) => { + await page.addInitScript((token) => { + localStorage.setItem('token', token); + }, cashierToken); + + await page.goto(`${BASE}/products?tab=addons`); + // Cashier should be bounced away from /products by AuthGuard (to /pos) + await page.waitForURL((url) => !url.pathname.startsWith('/products'), { timeout: 10_000 }); + expect(page.url()).not.toContain('/products'); + + await page.goto(`${BASE}/addon-groups`); + // Cashier should also be bounced away from /addon-groups + await page.waitForURL( + (url) => !url.pathname.startsWith('/addon-groups') && !url.pathname.startsWith('/products'), + { timeout: 10_000 }, + ); + expect(page.url()).not.toContain('/addon-groups'); + }); + + test('canonical group creation and editing in Products', async ({ page, request }) => { + const runId = Date.now(); + const groupName = `Group_${runId}`; + + await page.addInitScript((token) => { + localStorage.setItem('token', token); + }, ownerToken); + + await page.goto(`${BASE}/products?tab=addons`); + await page.getByRole('button', { name: /add add-?on group/i }).click(); + + // Modal opens + const modal = page.locator('div.fixed.inset-0'); + await expect(modal).toBeVisible(); + await modal.locator('form input[type="text"]').first().fill(groupName); + + // Add an add-on item + await modal.getByRole('button', { name: /\+ add addon/i }).click(); + await modal + .locator('input[placeholder*="Name" i], input[placeholder*="Addon" i]') + .last() + .fill('Vanilla'); + await modal.locator('input[aria-label="Price"]').last().fill('15'); + + let posts = 0; + await page.route('**/api/addon-groups', async (route) => { + if (route.request().method() === 'POST') { + posts += 1; + await new Promise((resolve) => setTimeout(resolve, 300)); + } + await route.continue(); + }); + + // Two submits in the same task: the in-flight guard must allow only one POST. + await modal.locator('form').evaluate((form: HTMLFormElement) => { + form.requestSubmit(); + form.requestSubmit(); + }); + await expect(modal).not.toBeVisible(); + expect(posts).toBe(1); + + // Verify row in table + await expect(page.getByText(groupName)).toHaveCount(1); + + // Cleanup via API + const listRes = await request.get(`${BASE}/api/addon-groups`, { headers: authHeaders }); + const groups = (await listRes.json()).addon_groups; + const created = groups.find((g: { name: string; id: number }) => g.name === groupName); + if (created) { + await request.delete(`${BASE}/api/addon-groups/${created.id}`, { headers: authHeaders }); + } + }); + + test('preserves saved tax metadata on existing add-on edits', async ({ page, request }) => { + const runId = Date.now(); + const groupName = `TaxGroup_${runId}`; + + // Create group with non-default tax metadata directly via API + const createRes = await request.post(`${BASE}/api/addon-groups`, { + headers: authHeaders, + data: { + name: groupName, + is_required: false, + min_selection: 0, + max_selection: 2, + addons: [ + { + name: 'Taxed Extra', + price: 25, + tax_category_id: 'standard', + tax_behavior: 'exclusive', + inherit_parent_tax_category: false, + track_inventory: false, + low_stock_threshold: 0, + }, + ], + }, + }); + expect(createRes.status()).toBe(201); + const createdGroup = (await createRes.json()).addon_group; + const createdAddonId = createdGroup.addons[0].id; + + try { + await page.addInitScript((token) => { + localStorage.setItem('token', token); + }, ownerToken); + + await page.goto(`${BASE}/products?tab=addons`); + await expect(page.getByText(groupName)).toBeVisible(); + + // Click Edit for this group + const row = page.locator('tr').filter({ hasText: groupName }); + await row.getByRole('button', { name: /edit/i }).click(); + + const modal = page.locator('div.fixed.inset-0'); + await expect(modal).toBeVisible(); + + // Edit only the add-on name, leaving tax metadata untouched + const nameInput = modal.locator('input[value="Taxed Extra"]'); + await nameInput.fill('Renamed Taxed Extra'); + + // Save (Update) + await modal.getByRole('button', { name: /create|update/i }).click(); + await expect(modal).not.toBeVisible(); + + // Verify the persisted tax fields via API + const getRes = await request.get(`${BASE}/api/addon-groups/${createdGroup.id}`, { + headers: authHeaders, + }); + expect(getRes.ok()).toBeTruthy(); + const updatedGroup = (await getRes.json()).addon_group; + const updatedAddon = updatedGroup.addons.find( + (a: { + id: number; + name: string; + tax_category_id?: string | null; + tax_behavior?: string; + inherit_parent_tax_category?: boolean; + }) => a.id === createdAddonId, + ); + + expect(updatedAddon.name).toBe('Renamed Taxed Extra'); + expect(updatedAddon.tax_category_id).toBe('standard'); + expect(updatedAddon.tax_behavior).toBe('exclusive'); + expect(updatedAddon.inherit_parent_tax_category).toBe(false); + } finally { + await request.delete(`${BASE}/api/addon-groups/${createdGroup.id}`, { headers: authHeaders }); + } + }); + + test('does not overwrite inventory stock during ordinary edits without stock changes', async ({ + page, + request, + }) => { + const runId = Date.now(); + const groupName = `StockGroup_${runId}`; + + // Create group with inventory tracking and initial stock 10 + const createRes = await request.post(`${BASE}/api/addon-groups`, { + headers: authHeaders, + data: { + name: groupName, + is_required: false, + min_selection: 0, + max_selection: 2, + addons: [ + { + name: 'Stock Item', + price: 10, + track_inventory: true, + stock_quantity: 10, + low_stock_threshold: 2, + }, + ], + }, + }); + expect(createRes.status()).toBe(201); + const createdGroup = (await createRes.json()).addon_group; + const addonId = createdGroup.addons[0].id; + + try { + await page.addInitScript((token) => { + localStorage.setItem('token', token); + }, ownerToken); + + await page.goto(`${BASE}/products?tab=addons`); + const row = page.locator('tr').filter({ hasText: groupName }); + await row.getByRole('button', { name: /edit/i }).click(); + + const modal = page.locator('div.fixed.inset-0'); + await expect(modal).toBeVisible(); + + // Stock moves after the editor loaded its snapshot of 10. + const adjustRes = await request.put(`${BASE}/api/addon-groups/${createdGroup.id}`, { + headers: authHeaders, + data: { + name: groupName, + addons: [ + { + id: addonId, + name: 'Stock Item', + price: 10, + track_inventory: true, + stock_quantity: 7, + low_stock_threshold: 2, + }, + ], + }, + }); + expect(adjustRes.ok()).toBeTruthy(); + + await modal.locator('form input[type="text"]').first().fill(`${groupName}_Edited`); + await modal.getByRole('button', { name: /create|update/i }).click(); + await expect(modal).not.toBeVisible(); + + const checkRes = await request.get(`${BASE}/api/addon-groups/${createdGroup.id}`, { + headers: authHeaders, + }); + const checkedGroup = (await checkRes.json()).addon_group; + expect(checkedGroup.name).toBe(`${groupName}_Edited`); + const checkedAddon = checkedGroup.addons.find( + (a: { id: number; stock_quantity?: number }) => a.id === addonId, + ); + expect(checkedAddon.stock_quantity).toBe(7); + } finally { + await request.delete(`${BASE}/api/addon-groups/${createdGroup.id}`, { headers: authHeaders }); + } + }); +}); diff --git a/frontend/src/app/(dashboard)/addon-groups/page.tsx b/frontend/src/app/(dashboard)/addon-groups/page.tsx index bbc465020..26ea279da 100644 --- a/frontend/src/app/(dashboard)/addon-groups/page.tsx +++ b/frontend/src/app/(dashboard)/addon-groups/page.tsx @@ -1,550 +1,20 @@ 'use client'; -import { useState, useEffect } from 'react'; -import api from '@/lib/api'; -import { Button } from '@/components/ui/button'; +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; import { useTranslations } from 'use-intl'; -import toast from 'react-hot-toast'; -import { Plus, Pencil, Trash2, X, ChevronDown, ChevronRight } from 'lucide-react'; -import type { AddonGroup, Addon } from '@/lib/types'; -import { useAuthStore } from '@/store/auth'; -import { getCurrencyUnitAdapter } from '@/lib/countries'; -import { useFormatCurrency } from '@/hooks/useFormatCurrency'; -import { useAmountFormat } from '@/hooks/useAmountFormat'; -import { useConfirm } from '@/hooks/use-confirm'; -import { roundCurrencyValue } from '@/lib/currency-input'; -import CurrencyAmountInput from '@/components/ui/CurrencyAmountInput'; -export default function AddonGroupsPage() { - const t = useTranslations('addonGroups'); - const tCommon = useTranslations('common'); - const tPos = useTranslations('pos'); - const tProducts = useTranslations('products'); - const tTables = useTranslations('tables'); - const { currentTenant } = useAuthStore(); - const unitAdapter = getCurrencyUnitAdapter(currentTenant?.currency ?? '', currentTenant?.country); - const amountFormat = useAmountFormat(); - const [groups, setGroups] = useState([]); - const [loading, setLoading] = useState(true); - const { confirm, ConfirmDialog } = useConfirm(); - const [showForm, setShowForm] = useState(false); - const [editingGroup, setEditingGroup] = useState(null); - const [expandedGroup, setExpandedGroup] = useState(null); - const [mutating, setMutating] = useState(false); - - // Group form - const [form, setForm] = useState({ - name: '', - description: '', - is_required: false, - allow_multiple_quantities: false, - min_selection: '0', - max_selection: '1', - }); - - // Addon form (inline) - const [addonForm, setAddonForm] = useState({ name: '', price: '0' }); - const [addingAddonTo, setAddingAddonTo] = useState(null); - const [editingAddon, setEditingAddon] = useState<{ - groupId: number | string; - addon: Addon; - } | null>(null); - - const fmt = useFormatCurrency(); - - const extractErrorMessage = (_err: unknown, fallback: string) => fallback; - - const fetchGroups = async () => { - try { - const { data } = await api.get('/addon-groups'); - setGroups(data.addon_groups || []); - } catch { - toast.error(t('failedToLoad')); - } finally { - setLoading(false); - } - }; +export default function AddonGroupsRedirectPage() { + const router = useRouter(); + const t = useTranslations('common'); useEffect(() => { - api - .get('/addon-groups') - .then(({ data }) => setGroups(data.addon_groups || [])) - .catch(() => toast.error(t('failedToLoad'))) - .finally(() => setLoading(false)); - // oxlint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const resetForm = () => { - setForm({ - name: '', - description: '', - is_required: false, - allow_multiple_quantities: false, - min_selection: '0', - max_selection: '1', - }); - setEditingGroup(null); - setShowForm(false); - }; - - const openEdit = (group: AddonGroup) => { - setEditingGroup(group); - setForm({ - name: group.name, - description: group.description || '', - is_required: Boolean(group.is_required), - allow_multiple_quantities: Boolean(group.allow_multiple_quantities), - min_selection: String(group.min_selection), - max_selection: String(group.max_selection), - }); - setShowForm(true); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (mutating) return; - const minVal = Number(form.min_selection); - const maxVal = Number(form.max_selection); - if (minVal > maxVal) { - toast.error(t('minExceedsMax')); - return; - } - const activeAddonCount = editingGroup - ? (editingGroup.addons?.filter((a) => a.is_active).length ?? 0) - : 0; - if (minVal > activeAddonCount) { - toast.error(t('minExceedsAvailable', { count: activeAddonCount })); - return; - } - try { - setMutating(true); - const payload = { - ...form, - min_selection: minVal, - max_selection: maxVal, - }; - if (editingGroup) { - await api.put(`/addon-groups/${editingGroup.id}`, payload); - toast.success(t('groupUpdated')); - } else { - await api.post('/addon-groups', payload); - toast.success(t('groupCreated')); - } - resetForm(); - fetchGroups(); - } catch (err: unknown) { - toast.error(extractErrorMessage(err, tCommon('failedToSave'))); - } finally { - setMutating(false); - } - }; - - const handleDeleteGroup = async (id: number | string) => { - if (mutating) return; - if ( - !(await confirm(t('deleteGroupConfirm'), { - destructive: true, - confirmLabel: tCommon('delete'), - })) - ) - return; - try { - setMutating(true); - await api.delete(`/addon-groups/${id}`); - toast.success(t('groupDeleted')); - fetchGroups(); - } catch { - toast.error(tCommon('failedToDelete')); - } finally { - setMutating(false); - } - }; - - // Addon CRUD - const handleAddAddon = async (groupId: number | string) => { - if (mutating) return; - if (!addonForm.name.trim()) return; - try { - setMutating(true); - await api.post(`/addon-groups/${groupId}/addons`, { - name: addonForm.name, - price: roundCurrencyValue(Number(addonForm.price), unitAdapter.maxDecimals), - }); - toast.success(t('addonAdded')); - setAddonForm({ name: '', price: '0' }); - setAddingAddonTo(null); - fetchGroups(); - } catch { - toast.error(t('failedToAddAddon')); - } finally { - setMutating(false); - } - }; - - const handleUpdateAddon = async () => { - if (mutating) return; - if (!editingAddon || !addonForm.name.trim()) return; - try { - setMutating(true); - await api.put(`/addon-groups/${editingAddon.groupId}/addons/${editingAddon.addon.id}`, { - name: addonForm.name, - price: roundCurrencyValue(Number(addonForm.price), unitAdapter.maxDecimals), - }); - toast.success(t('addonUpdated')); - setAddonForm({ name: '', price: '0' }); - setEditingAddon(null); - fetchGroups(); - } catch { - toast.error(t('failedToUpdateAddon')); - } finally { - setMutating(false); - } - }; - - const handleDeleteAddon = async (groupId: number | string, addonId: number | string) => { - if (mutating) return; - if ( - !(await confirm(t('deleteAddonConfirm'), { - destructive: true, - confirmLabel: tCommon('delete'), - })) - ) - return; - try { - setMutating(true); - await api.delete(`/addon-groups/${groupId}/addons/${addonId}`); - toast.success(t('addonDeleted')); - fetchGroups(); - } catch (err: unknown) { - toast.error(extractErrorMessage(err, t('failedToDeleteAddon'))); - } finally { - setMutating(false); - } - }; - - if (loading) { - return ( -
-
-
- ); - } + router.replace('/products?tab=addons'); + }, [router]); return ( -
-
-

{t('title')}

- -
- -
- {groups.map((group) => { - const isExpanded = expandedGroup === group.id; - return ( -
- {/* Group Header */} -
- -
- - -
-
- - {/* Expanded: Addons */} - {isExpanded && ( -
-
- {group.addons?.map((addon) => ( -
- {editingAddon?.addon.id === addon.id ? ( -
- - - - -
- ) : ( - <> - {addon.name} -
- - {Number(addon.price) === 0 - ? tPos('free') - : fmt(Number(addon.price))} - - - -
- - )} -
- ))} -
- - {/* Add Addon Form */} - {addingAddonTo === group.id ? ( -
- - - - -
- ) : ( - - )} -
- )} -
- ); - })} - {groups.length === 0 && ( -

{t('noAddonGroups')}

- )} -
- - {/* Group Form Modal */} - {showForm && ( -
-
-
-

- {editingGroup ? t('editGroup') : tProducts('addAddonGroupForm')} -

- -
-
-
- - setForm({ ...form, name: e.target.value })} - className="w-full px-3 py-2 border border-gray-300 dark:border-border rounded-lg focus:ring-2 focus:ring-brand outline-none" - required - /> -
-
- - setForm({ ...form, description: e.target.value })} - className="w-full px-3 py-2 border border-gray-300 dark:border-border rounded-lg focus:ring-2 focus:ring-brand outline-none" - /> -
-
- setForm({ ...form, is_required: e.target.checked })} - className="rounded border-gray-300 dark:border-border text-brand focus:ring-brand" - /> - {tProducts('requiredSelection')} -
-
- - setForm({ ...form, allow_multiple_quantities: e.target.checked }) - } - className="rounded border-gray-300 dark:border-border text-brand focus:ring-brand" - /> - -
-
-
- - setForm({ ...form, min_selection: e.target.value })} - className="w-full px-3 py-2 border border-gray-300 dark:border-border rounded-lg focus:ring-2 focus:ring-brand outline-none" - /> -
-
- - setForm({ ...form, max_selection: e.target.value })} - className="w-full px-3 py-2 border border-gray-300 dark:border-border rounded-lg focus:ring-2 focus:ring-brand outline-none" - /> -
-
- -
-
-
- )} - {ConfirmDialog} +
+ {t('loading')}
); } diff --git a/frontend/src/app/(dashboard)/products/page.tsx b/frontend/src/app/(dashboard)/products/page.tsx index f51711cdd..1700aac46 100644 --- a/frontend/src/app/(dashboard)/products/page.tsx +++ b/frontend/src/app/(dashboard)/products/page.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback, Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import api from '@/lib/api'; import { useAuthStore } from '@/store/auth'; import { Button } from '@/components/ui/button'; @@ -25,7 +26,7 @@ import { ChevronDown, Search, } from 'lucide-react'; -import type { Product, Category, AddonGroup } from '@/lib/types'; +import type { Product, Category, AddonGroup, Addon } from '@/lib/types'; import { buildVariantsPayload, hasInvalidVariantRow, @@ -116,6 +117,23 @@ interface AddonEditorRow { * so re-saving a group must not overwrite stock an order has since moved. */ stock_edited: boolean; + tax_category_id?: Addon['tax_category_id']; + tax_behavior?: Addon['tax_behavior']; + inherit_parent_tax_category?: Addon['inherit_parent_tax_category']; +} + +function resolveProductsTab(value: string | null | undefined): TabType { + return value === 'categories' || value === 'addons' ? value : 'products'; +} + +function ProductsTabQuerySync({ onTabChange }: { onTabChange: (tab: TabType) => void }) { + const tabParam = useSearchParams()?.get('tab'); + + useEffect(() => { + onTabChange(resolveProductsTab(tabParam)); + }, [tabParam, onTabChange]); + + return null; } function taxCategoryOptionLabel(tc: { label: string; rate_percent?: number | null }): string { @@ -218,7 +236,10 @@ export default function ProductsPage() { const [showBulkTaxModal, setShowBulkTaxModal] = useState(false); const [bulkTaxCategoryId, setBulkTaxCategoryId] = useState(''); const [bulkTaxApplying, setBulkTaxApplying] = useState(false); + const [savingAddonGroup, setSavingAddonGroup] = useState(false); + const addonGroupSaveInFlight = useRef(false); + const router = useRouter(); const currency = getCurrencySymbol( currentTenant?.currency || '', getCountryByCode(currentTenant?.country ?? '')?.locale, @@ -228,16 +249,28 @@ export default function ProductsPage() { const amountFormat = useAmountFormat(); const isRestaurant = (currentTenant?.business_type ?? 'restaurant') === 'restaurant'; const isOwnerOrManager = tenantCan(currentTenant, 'catalog.manage'); + const canManageAddons = isRestaurant || isOwnerOrManager; const productAddonGroupOptions = [...addonGroups, ...productInactiveAddonGroups]; const categoryAddonGroupOptions = [...addonGroups, ...categoryAddonGroups]; + const handleTabChange = useCallback( + (value: string) => { + const nextTab = resolveProductsTab(value); + setActiveTab(nextTab); + const params = new URLSearchParams(window.location.search); + params.set('tab', nextTab); + router.replace(`${window.location.pathname}?${params.toString()}`, { scroll: false }); + }, + [router], + ); + const fetchData = async () => { try { const requests: Promise<{ data: Record }>[] = [ api.get('/products?include_inactive_variants=true'), api.get('/categories'), ]; - if (isRestaurant) requests.push(api.get('/addon-groups')); + if (canManageAddons) requests.push(api.get('/addon-groups')); const [prodRes, catRes, agRes] = await Promise.all(requests); setProducts((prodRes.data.products as Product[]) || []); setCategories((catRes.data.categories as Category[]) || []); @@ -255,7 +288,7 @@ export default function ProductsPage() { api.get('/products?include_inactive_variants=true', { signal: controller.signal }), api.get('/categories', { signal: controller.signal }), ]; - if (isRestaurant) requests.push(api.get('/addon-groups', { signal: controller.signal })); + if (canManageAddons) requests.push(api.get('/addon-groups', { signal: controller.signal })); Promise.all(requests) .then(([prodRes, catRes, agRes]) => { setProducts((prodRes.data.products as Product[]) || []); @@ -733,6 +766,7 @@ export default function ProductsPage() { }; const resetAddonForm = () => { + if (savingAddonGroup) return; setAddonForm({ name: '', description: '', @@ -766,6 +800,9 @@ export default function ProductsPage() { stock_quantity: String(a.stock_quantity ?? 0), low_stock_threshold: String(a.low_stock_threshold ?? 0), stock_edited: false, + tax_category_id: a.tax_category_id, + tax_behavior: a.tax_behavior, + inherit_parent_tax_category: a.inherit_parent_tax_category, })) || [], ); setShowAddonModal(true); @@ -773,6 +810,9 @@ export default function ProductsPage() { const handleAddonGroupSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (addonGroupSaveInFlight.current) return; + addonGroupSaveInFlight.current = true; + setSavingAddonGroup(true); try { const payload = { name: addonForm.name, @@ -788,6 +828,12 @@ export default function ProductsPage() { is_active: addon.is_active, track_inventory: addon.track_inventory, low_stock_threshold: Math.max(0, Number(addon.low_stock_threshold) || 0), + // Saved tax settings are echoed back; omitted fields fall back to backend defaults. + ...(addon.tax_category_id !== undefined && { tax_category_id: addon.tax_category_id }), + ...(addon.tax_behavior !== undefined && { tax_behavior: addon.tax_behavior }), + ...(addon.inherit_parent_tax_category !== undefined && { + inherit_parent_tax_category: addon.inherit_parent_tax_category, + }), // Omitted unless the merchant edited it: the API reads a submitted // stock_quantity as an absolute set and writes a ledger movement. ...(addon.stock_edited @@ -806,6 +852,9 @@ export default function ProductsPage() { fetchData(); } catch { toast.error(t('failedToSaveAddonGroup')); + } finally { + addonGroupSaveInFlight.current = false; + setSavingAddonGroup(false); } }; @@ -883,9 +932,16 @@ export default function ProductsPage() { ]), ); + const tabQuerySync = ( + + + + ); + if (loading) { return (
+ {tabQuerySync}
); @@ -893,6 +949,7 @@ export default function ProductsPage() { return (
+ {tabQuerySync}

{t('title')}

@@ -923,7 +980,7 @@ export default function ProductsPage() { setActiveTab(value as TabType)} + onValueChange={handleTabChange} className="dashboard-scroll-frame flex min-h-0 flex-1 flex-col" > @@ -933,7 +990,7 @@ export default function ProductsPage() { {t('tabCategories')} - {isRestaurant && ( + {canManageAddons && ( {t('tabAddonGroups')} @@ -2439,8 +2496,12 @@ export default function ProductsPage() { ))}
-
diff --git a/frontend/src/components/pos/CollapsiblePosChoices.tsx b/frontend/src/components/pos/CollapsiblePosChoices.tsx index e4ed901b6..8a714be87 100644 --- a/frontend/src/components/pos/CollapsiblePosChoices.tsx +++ b/frontend/src/components/pos/CollapsiblePosChoices.tsx @@ -1,6 +1,14 @@ 'use client'; -import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react'; +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from 'react'; import { useTranslations } from 'use-intl'; export interface FittingCalculationInput { @@ -236,11 +244,16 @@ export default function CollapsiblePosChoices({ } }, [choices, mandatoryKeys]); - // Measurement pass: mount all chips, read widths, then apply visible subset. + const committedMeasureRef = useRef(measure); + useLayoutEffect(() => { + committedMeasureRef.current = measure; + }, [measure]); + + // Scheduled frames must measure the latest committed choice list. useEffect(() => { if (!probeMounted || expanded) return; const frame = requestAnimationFrame(() => { - measure(); + committedMeasureRef.current(); setProbeMounted(false); }); return () => cancelAnimationFrame(frame); diff --git a/frontend/src/components/settings/ChargesSettingsCard.tsx b/frontend/src/components/settings/ChargesSettingsCard.tsx index 04721890b..fa556efc0 100644 --- a/frontend/src/components/settings/ChargesSettingsCard.tsx +++ b/frontend/src/components/settings/ChargesSettingsCard.tsx @@ -5,15 +5,29 @@ import { Percent, Plus, Trash2, Pencil } from 'lucide-react'; import { useTranslations } from 'use-intl'; import toast from 'react-hot-toast'; import { Button } from '@/components/ui/button'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; import { useConfirm } from '@/hooks/use-confirm'; import { ORDER_TYPE_LABEL_KEYS, type OrderType } from '@/lib/order-types'; import { useFormatCurrency } from '@/hooks/useFormatCurrency'; -import { VALID_CHARGE_ORDER_TYPES, type ChargeDefinition } from '@/lib/charges'; +import { + VALID_CHARGE_ORDER_TYPES, + type STANDARD_CHARGE_COLUMN_IDS, + type ChargeDefinition, +} from '@/lib/charges'; import { useChargesStore } from '@/store/charges'; +export type ChargePresetKind = 'service' | 'packaging' | 'custom'; + interface ChargeForm { id: string; + presetKind: ChargePresetKind; name: string; type: 'percentage' | 'fixed'; value: string; @@ -23,9 +37,45 @@ interface ChargeForm { is_default_active: boolean; } +const STANDARD_PRESETS: Record< + Exclude, + { + id: (typeof STANDARD_CHARGE_COLUMN_IDS)[number]; + nameKey: 'entityServiceCharge' | 'entityPackaging'; + type: ChargeForm['type']; + order_types: readonly OrderType[]; + } +> = { + service: { + id: 'service_charge', + nameKey: 'entityServiceCharge', + type: 'percentage', + order_types: ['dine_in'], + }, + packaging: { + id: 'packaging_charge', + nameKey: 'entityPackaging', + type: 'fixed', + order_types: ['takeaway', 'delivery', 'online'], + }, +}; + +function generateCustomChargeId(): string { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + return `charge-${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`; +} + +function getPresetKind(id: string): ChargePresetKind { + if (id === STANDARD_PRESETS.service.id) return 'service'; + if (id === STANDARD_PRESETS.packaging.id) return 'packaging'; + return 'custom'; +} + function emptyForm(): ChargeForm { return { - id: '', + id: generateCustomChargeId(), + presetKind: 'custom', name: '', type: 'percentage', value: '', @@ -39,6 +89,7 @@ function emptyForm(): ChargeForm { function toForm(charge: ChargeDefinition): ChargeForm { return { id: charge.id, + presetKind: getPresetKind(charge.id), name: charge.name, type: charge.type, value: String(charge.value), @@ -50,12 +101,7 @@ function toForm(charge: ChargeDefinition): ChargeForm { } function toDefinition(form: ChargeForm, existing?: ChargeDefinition): ChargeDefinition { - // A blank id falls back to the name so the form never submits an empty id. - const rawId = (form.id.trim() || form.name).trim(); - const normalizedId = rawId.toLowerCase().replace(/[^a-z0-9_-]+/g, '_'); - const id = !form.id.trim() && !/[a-z0-9]/.test(normalizedId) - ? `charge-${Array.from(globalThis.crypto.getRandomValues(new Uint32Array(4)), (value) => value.toString(16).padStart(8, '0')).join('')}` - : normalizedId; + const id = existing ? existing.id : form.id; return { id, name: form.name.trim(), @@ -74,6 +120,7 @@ export function ChargesSettingsCard({ canManage }: { canManage: boolean }) { const t = useTranslations('settings'); const tOrders = useTranslations('orders'); const tCommon = useTranslations('common'); + const tTax = useTranslations('tax'); const fmt = useFormatCurrency(); const { confirm, ConfirmDialog } = useConfirm(); @@ -115,24 +162,61 @@ export function ChargesSettingsCard({ canManage }: { canManage: boolean }) { await persist(charges.filter((candidate) => candidate.id !== charge.id)); }; - const submitForm = async () => { - if (!editing || loading || loadError) return; - const existing = editingOriginalId ? charges.find((charge) => charge.id === editingOriginalId) : undefined; - const definition = toDefinition(editing, existing); - if (charges.some((charge) => charge.id === definition.id && charge.id !== editingOriginalId)) { - setDuplicateId(true); + const handlePresetChange = (kind: ChargePresetKind) => { + if (saving || editingOriginalId !== null) return; + setDuplicateId(false); + if (kind === 'custom') { + setEditing(emptyForm()); + return; + } + const preset = STANDARD_PRESETS[kind]; + const existing = charges.find((charge) => charge.id === preset.id); + if (existing) { + setEditing(toForm(existing)); + setEditingOriginalId(existing.id); return; } - // Editing replaces the row it came from, so renaming the id renames that - // charge instead of adding a second one beside it. - const targetId = editingOriginalId && charges.some((charge) => charge.id === editingOriginalId) - ? editingOriginalId - : definition.id; - const next = charges.some((charge) => charge.id === targetId) - ? charges.map((charge) => (charge.id === targetId ? definition : charge)) - : [...charges, definition]; + setEditing({ + ...emptyForm(), + id: preset.id, + presetKind: kind, + name: tTax(preset.nameKey), + type: preset.type, + order_types: [...preset.order_types], + }); + }; + + const submitForm = async () => { + if (!editing || loading || loadError || saving) return; setSaving(true); try { + let currentCharges = charges; + if (editingOriginalId) { + await load(); + const latest = useChargesStore.getState(); + if (latest.loading || latest.error) return; + currentCharges = latest.charges; + } + const existing = editingOriginalId + ? currentCharges.find((charge) => charge.id === editingOriginalId) + : undefined; + if (editingOriginalId && !existing) { + toast.error(t('chargeReopenRequired')); + closeForm(); + return; + } + const definition = toDefinition(editing, existing); + if ( + currentCharges.some( + (charge) => charge.id === definition.id && charge.id !== editingOriginalId, + ) + ) { + setDuplicateId(true); + return; + } + const next = editingOriginalId + ? currentCharges.map((charge) => (charge.id === editingOriginalId ? definition : charge)) + : [...currentCharges, definition]; await save(next); setEditing(null); setEditingOriginalId(null); @@ -150,6 +234,18 @@ export function ChargesSettingsCard({ canManage }: { canManage: boolean }) { setDuplicateId(false); }; + const loadErrorNotice = loadError ? ( +
+ {t('reloadFailed')} + +
+ ) : null; + return (
@@ -158,26 +254,31 @@ export function ChargesSettingsCard({ canManage }: { canManage: boolean }) {

{t('chargesAndSurcharges')}

{canManage && ( - )}

{t('chargesAndSurchargesHint')}

- {loadError && ( -
- {t('reloadFailed')} - -
- )} + {!editing && loadErrorNotice} {charges.length === 0 ? (

{t('noChargesConfigured')}

) : (
{charges.map((charge) => ( -
+

{charge.name}

@@ -187,17 +288,34 @@ export function ChargesSettingsCard({ canManage }: { canManage: boolean }) { {' · '} {charge.calculation_basis === 'net' ? t('chargeBasisNet') : t('chargeBasisGross')} {' · '} - {charge.order_types.map((type) => tOrders(ORDER_TYPE_LABEL_KEYS[type])).join(', ')} + {charge.order_types + .map((type) => tOrders(ORDER_TYPE_LABEL_KEYS[type])) + .join(', ')} {charge.is_optional ? ` · ${t('chargeWaivable')}` : ''} {!charge.is_default_active ? ` · ${t('chargeOptionalApply')}` : ''}

{canManage && (
- -
@@ -207,40 +325,57 @@ export function ChargesSettingsCard({ canManage }: { canManage: boolean }) {
)} - { if (!open) closeForm(); }}> + { + if (!open) closeForm(); + }} + > - {t('addCharge')} + {editingOriginalId ? t('editCharge') : t('addCharge')} {t('chargesAndSurchargesHint')} + {loadErrorNotice} {editing && (
@@ -309,8 +451,15 @@ export function ChargesSettingsCard({ canManage }: { canManage: boolean }) {
)} - - + diff --git a/frontend/src/lib/i18n/messages/ar.json b/frontend/src/lib/i18n/messages/ar.json index 41f706778..16f40c42b 100644 --- a/frontend/src/lib/i18n/messages/ar.json +++ b/frontend/src/lib/i18n/messages/ar.json @@ -2446,8 +2446,6 @@ "chargesSaved": "تم حفظ الرسوم", "chargesSaveFailed": "تعذر حفظ الرسوم", "chargeName": "الاسم", - "chargeId": "المعرف", - "chargeIdHint": "أحرف إنجليزية صغيرة وأرقام وشرطة أو شرطة سفلية. يستخدم service_charge وpackaging_charge أعمدة التقارير الحالية.", "chargeType": "النوع", "chargeTypePercentage": "نسبة مئوية", "chargeTypeFixed": "مبلغ ثابت", @@ -2466,7 +2464,11 @@ "uploadLogo": "تحميل الشعار", "removeLogo": "إزالة الشعار", "logoUploaded": "تم تحميل الشعار بنجاح", - "showLogo": "شعار المتجر" + "showLogo": "شعار المتجر", + "chargeKind": "نوع الرسم", + "chargePresetCustom": "مخصص", + "editCharge": "تحرير الرسم", + "chargeReopenRequired": "هذا الرسم لم يعد متاحًا. يُرجى إعادة فتحه والمحاولة مجددًا." }, "setup": { "alreadyCompleted": "تم الانتهاء من عملية الإعداد في هذا التثبيت. يتم إعادة التوجيه إلى صفحة تسجيل الدخول…", diff --git a/frontend/src/lib/i18n/messages/bn.json b/frontend/src/lib/i18n/messages/bn.json index 8b5206bc9..ea8e9b062 100644 --- a/frontend/src/lib/i18n/messages/bn.json +++ b/frontend/src/lib/i18n/messages/bn.json @@ -2446,8 +2446,6 @@ "chargesSaved": "ফি সংরক্ষিত", "chargesSaveFailed": "ফি সংরক্ষণ করা যায়নি", "chargeName": "নাম", - "chargeId": "আইডি", - "chargeIdHint": "ছোট ইংরেজি অক্ষর, সংখ্যা, ড্যাশ বা আন্ডারস্কোর। service_charge ও packaging_charge বিদ্যমান রিপোর্ট কলাম পূরণ করে।", "chargeType": "ধরন", "chargeTypePercentage": "শতাংশ", "chargeTypeFixed": "নির্দিষ্ট পরিমাণ", @@ -2466,7 +2464,11 @@ "uploadLogo": "লোগো আপলোড করুন", "removeLogo": "লোগো সরান", "logoUploaded": "লোগো সফলভাবে আপলোড হয়েছে", - "showLogo": "স্টোর লোগো" + "showLogo": "স্টোর লোগো", + "chargeKind": "ফির ধরন", + "chargePresetCustom": "কাস্টম", + "editCharge": "ফি সম্পাদনা করুন", + "chargeReopenRequired": "এই ফি আর উপলব্ধ নেই। অনুগ্রহ করে পুনরায় খুলুন এবং আবার চেষ্টা করুন।" }, "setup": { "alreadyCompleted": "এই ইনস্টলে সেটআপ ইতিমধ্যেই সম্পন্ন হয়েছে। লগইন করার জন্য পুনঃনির্দেশ করা হচ্ছে...", diff --git a/frontend/src/lib/i18n/messages/de.json b/frontend/src/lib/i18n/messages/de.json index 692f06baf..51b2331bf 100644 --- a/frontend/src/lib/i18n/messages/de.json +++ b/frontend/src/lib/i18n/messages/de.json @@ -2446,8 +2446,6 @@ "chargesSaved": "Gebühren gespeichert", "chargesSaveFailed": "Gebühren konnten nicht gespeichert werden", "chargeName": "Bezeichnung", - "chargeId": "Gebühren-ID", - "chargeIdHint": "Kleinbuchstaben, Ziffern, Bindestrich oder Unterstrich. service_charge und packaging_charge speisen die bestehenden Berichtsspalten.", "chargeType": "Typ", "chargeTypePercentage": "Prozent", "chargeTypeFixed": "Fester Betrag", @@ -2466,7 +2464,11 @@ "uploadLogo": "Logo hochladen", "removeLogo": "Logo entfernen", "logoUploaded": "Logo erfolgreich hochgeladen", - "showLogo": "Geschäftslogo" + "showLogo": "Geschäftslogo", + "chargeKind": "Gebührenart", + "chargePresetCustom": "Benutzerdefiniert", + "editCharge": "Gebühr bearbeiten", + "chargeReopenRequired": "Diese Gebühr ist nicht mehr verfügbar. Bitte erneut öffnen und wiederholen." }, "setup": { "alreadyCompleted": "Die Ersteinrichtung wurde bereits abgeschlossen. Weiterleitung zur Anmeldung…", diff --git a/frontend/src/lib/i18n/messages/en.json b/frontend/src/lib/i18n/messages/en.json index 3bf71928b..9d3182f8f 100644 --- a/frontend/src/lib/i18n/messages/en.json +++ b/frontend/src/lib/i18n/messages/en.json @@ -2446,8 +2446,6 @@ "chargesSaved": "Charges saved", "chargesSaveFailed": "Could not save charges", "chargeName": "Name", - "chargeId": "Charge ID", - "chargeIdHint": "Lowercase letters, digits, dash or underscore. service_charge and packaging_charge feed the existing report columns.", "chargeType": "Type", "chargeTypePercentage": "Percentage", "chargeTypeFixed": "Fixed amount", @@ -2466,7 +2464,11 @@ "uploadLogo": "Upload Logo", "removeLogo": "Remove Logo", "logoUploaded": "Logo uploaded successfully", - "showLogo": "Store Logo" + "showLogo": "Store Logo", + "chargeKind": "Fee Type", + "chargePresetCustom": "Custom", + "editCharge": "Edit Charge", + "chargeReopenRequired": "This charge is no longer available. Please reopen and try again." }, "setup": { "alreadyCompleted": "Setup has already been completed on this install. Redirecting to login…", diff --git a/frontend/src/lib/i18n/messages/es.json b/frontend/src/lib/i18n/messages/es.json index a9c23a54d..116d76ff2 100644 --- a/frontend/src/lib/i18n/messages/es.json +++ b/frontend/src/lib/i18n/messages/es.json @@ -2446,8 +2446,6 @@ "chargesSaved": "Cargos guardados", "chargesSaveFailed": "No se pudieron guardar los cargos", "chargeName": "Nombre", - "chargeId": "ID del cargo", - "chargeIdHint": "Letras minúsculas, dígitos, guion o guion bajo. service_charge y packaging_charge alimentan las columnas de informe existentes.", "chargeType": "Tipo", "chargeTypePercentage": "Porcentaje", "chargeTypeFixed": "Importe fijo", @@ -2466,7 +2464,11 @@ "uploadLogo": "Subir logotipo", "removeLogo": "Eliminar logotipo", "logoUploaded": "Logotipo subido correctamente", - "showLogo": "Logotipo de la tienda" + "showLogo": "Logotipo de la tienda", + "chargeKind": "Tipo de cargo", + "chargePresetCustom": "Personalizado", + "editCharge": "Editar cargo", + "chargeReopenRequired": "Este cargo ya no está disponible. Vuelva a abrirlo e inténtelo de nuevo." }, "setup": { "alreadyCompleted": "La configuración ya se completó en esta instalación. Redirigiendo al inicio de sesión…", diff --git a/frontend/src/lib/i18n/messages/fa.json b/frontend/src/lib/i18n/messages/fa.json index 34e85c66a..5f811471c 100644 --- a/frontend/src/lib/i18n/messages/fa.json +++ b/frontend/src/lib/i18n/messages/fa.json @@ -2446,8 +2446,6 @@ "chargesSaved": "هزینه‌ها ذخیره شد", "chargesSaveFailed": "ذخیره هزینه‌ها ممکن نشد", "chargeName": "نام", - "chargeId": "شناسه", - "chargeIdHint": "حروف کوچک انگلیسی، رقم، خط تیره یا زیرخط. service_charge و packaging_charge ستون‌های گزارش موجود را پر می‌کنند.", "chargeType": "نوع", "chargeTypePercentage": "درصدی", "chargeTypeFixed": "مبلغ ثابت", @@ -2466,7 +2464,11 @@ "uploadLogo": "بارگذاری لوگو", "removeLogo": "حذف لوگو", "logoUploaded": "لوگو با موفقیت بارگذاری شد", - "showLogo": "لوگوی فروشگاه" + "showLogo": "لوگوی فروشگاه", + "chargeKind": "نوع هزینه", + "chargePresetCustom": "سفارشی", + "editCharge": "ویرایش هزینه", + "chargeReopenRequired": "این هزینه دیگر در دسترس نیست. لطفاً دوباره باز کنید و مجدداً تلاش کنید." }, "setup": { "alreadyCompleted": "راه‌اندازی در این نصب پیش‌تر انجام شده است. در حال بازگشت به ورود…", diff --git a/frontend/src/lib/i18n/messages/fil.json b/frontend/src/lib/i18n/messages/fil.json index f30321264..b078148f1 100644 --- a/frontend/src/lib/i18n/messages/fil.json +++ b/frontend/src/lib/i18n/messages/fil.json @@ -2446,8 +2446,6 @@ "chargesSaved": "Nai-save ang mga bayarin", "chargesSaveFailed": "Hindi ma-save ang mga bayarin", "chargeName": "Pangalan", - "chargeId": "ID ng bayarin", - "chargeIdHint": "Maliit na titik, numero, dash o underscore. Ang service_charge at packaging_charge ay pumupun sa umiiral na mga column ng ulat.", "chargeType": "Uri", "chargeTypePercentage": "Porsyento", "chargeTypeFixed": "Halagang nakapirming", @@ -2466,7 +2464,11 @@ "uploadLogo": "I-upload ang Logo", "removeLogo": "Alisin ang Logo", "logoUploaded": "Matagumpay na na-upload ang logo", - "showLogo": "Logo ng Tindahan" + "showLogo": "Logo ng Tindahan", + "chargeKind": "Uri ng bayarin", + "chargePresetCustom": "Pasadya", + "editCharge": "I-edit ang bayarin", + "chargeReopenRequired": "Hindi na available ang bayaring ito. Pakibuksan muli at subukang muli." }, "setup": { "alreadyCompleted": "Nakumpleto na ang setup sa installation na ito. Nireredirect sa pag-login…", diff --git a/frontend/src/lib/i18n/messages/fr.json b/frontend/src/lib/i18n/messages/fr.json index 7cd31e459..26429e6ca 100644 --- a/frontend/src/lib/i18n/messages/fr.json +++ b/frontend/src/lib/i18n/messages/fr.json @@ -2446,8 +2446,6 @@ "chargesSaved": "Frais enregistrés", "chargesSaveFailed": "Impossible d'enregistrer les frais", "chargeName": "Nom", - "chargeId": "Identifiant du frais", - "chargeIdHint": "Lettres minuscules, chiffres, tiret ou tiret bas. service_charge et packaging_charge alimentent les colonnes de rapport existantes.", "chargeType": "Nature", "chargeTypePercentage": "Pourcentage", "chargeTypeFixed": "Montant fixe", @@ -2466,7 +2464,11 @@ "uploadLogo": "Télécharger le logo", "removeLogo": "Supprimer le logo", "logoUploaded": "Logo téléchargé avec succès", - "showLogo": "Logo du magasin" + "showLogo": "Logo du magasin", + "chargeKind": "Type de frais", + "chargePresetCustom": "Personnalisé", + "editCharge": "Modifier le frais", + "chargeReopenRequired": "Ce frais n’est plus disponible. Veuillez rouvrir et réessayer." }, "setup": { "alreadyCompleted": "La configuration a déjà été effectuée sur cette installation. Redirection vers la connexion…", diff --git a/frontend/src/lib/i18n/messages/hi.json b/frontend/src/lib/i18n/messages/hi.json index 6195c7405..b2dbe0ecf 100644 --- a/frontend/src/lib/i18n/messages/hi.json +++ b/frontend/src/lib/i18n/messages/hi.json @@ -2446,8 +2446,6 @@ "chargesSaved": "शुल्क सहेजे गए", "chargesSaveFailed": "शुल्क सहेजे नहीं जा सके", "chargeName": "नाम", - "chargeId": "आईडी", - "chargeIdHint": "छोटे अंग्रेज़ी अक्षर, अंक, डैश या अंडरस्कोर। service_charge और packaging_charge मौजूदा रिपोर्ट कॉलम को भरते हैं।", "chargeType": "प्रकार", "chargeTypePercentage": "प्रतिशत", "chargeTypeFixed": "निश्चित राशि", @@ -2466,7 +2464,11 @@ "uploadLogo": "लोगो अपलोड करें", "removeLogo": "लोगो हटाएं", "logoUploaded": "लोगो सफलतापूर्वक अपलोड हो गया", - "showLogo": "स्टोर लोगो" + "showLogo": "स्टोर लोगो", + "chargeKind": "शुल्क प्रकार", + "chargePresetCustom": "कस्टम", + "editCharge": "शुल्क संपादित करें", + "chargeReopenRequired": "यह शुल्क अब उपलब्ध नहीं है। कृपया इसे पुनः खोलें और फिर से प्रयास करें।" }, "setup": { "alreadyCompleted": "इस इंस्टाल पर सेटअप पहले ही पूरा हो चुका है. लॉगिन करने के लिए पुनः निर्देशित किया जा रहा है...", diff --git a/frontend/src/lib/i18n/messages/id.json b/frontend/src/lib/i18n/messages/id.json index c67bbb833..2fc40f418 100644 --- a/frontend/src/lib/i18n/messages/id.json +++ b/frontend/src/lib/i18n/messages/id.json @@ -2446,8 +2446,6 @@ "chargesSaved": "Biaya disimpan", "chargesSaveFailed": "Biaya tidak dapat disimpan", "chargeName": "Nama", - "chargeId": "ID biaya", - "chargeIdHint": "Huruf kecil, angka, tanda hubung, atau garis bawah. service_charge dan packaging_charge mengisi kolom laporan yang sudah ada.", "chargeType": "Jenis", "chargeTypePercentage": "Persentase", "chargeTypeFixed": "Nominal tetap", @@ -2466,7 +2464,11 @@ "uploadLogo": "Unggah Logo", "removeLogo": "Hapus Logo", "logoUploaded": "Logo berhasil diunggah", - "showLogo": "Logo Toko" + "showLogo": "Logo Toko", + "chargeKind": "Jenis biaya", + "chargePresetCustom": "Kustom", + "editCharge": "Ubah biaya", + "chargeReopenRequired": "Biaya ini sudah tidak tersedia. Silakan buka kembali dan coba lagi." }, "setup": { "alreadyCompleted": "Pengaturan awal sudah selesai dilakukan pada instalasi ini. Mengalihkan ke layar masuk…", diff --git a/frontend/src/lib/i18n/messages/it.json b/frontend/src/lib/i18n/messages/it.json index 998a2e6be..f32ebec9f 100644 --- a/frontend/src/lib/i18n/messages/it.json +++ b/frontend/src/lib/i18n/messages/it.json @@ -2446,8 +2446,6 @@ "chargesSaved": "Spese salvate", "chargesSaveFailed": "Impossibile salvare le spese", "chargeName": "Nome", - "chargeId": "ID spesa", - "chargeIdHint": "Lettere minuscole, cifre, trattino o underscore. service_charge e packaging_charge alimentano le colonne dei report esistenti.", "chargeType": "Tipo", "chargeTypePercentage": "Percentuale", "chargeTypeFixed": "Importo fisso", @@ -2466,7 +2464,11 @@ "uploadLogo": "Carica logo", "removeLogo": "Rimuovi logo", "logoUploaded": "Logo caricato con successo", - "showLogo": "Logo del negozio" + "showLogo": "Logo del negozio", + "chargeKind": "Tipo di spesa", + "chargePresetCustom": "Personalizzato", + "editCharge": "Modifica spesa", + "chargeReopenRequired": "Questa spesa non è più disponibile. Riapri e riprova." }, "setup": { "alreadyCompleted": "La configurazione è già stata completata su questa installazione. Reindirizzamento all'accesso…", diff --git a/frontend/src/lib/i18n/messages/ja.json b/frontend/src/lib/i18n/messages/ja.json index 3260271b9..51497afbc 100644 --- a/frontend/src/lib/i18n/messages/ja.json +++ b/frontend/src/lib/i18n/messages/ja.json @@ -2446,8 +2446,6 @@ "chargesSaved": "料金を保存しました", "chargesSaveFailed": "料金を保存できませんでした", "chargeName": "名称", - "chargeId": "料金 ID", - "chargeIdHint": "小文字の英数字、ハイフン、アンダースコア。service_charge と packaging_charge は既存のレポート列に反映されます。", "chargeType": "種類", "chargeTypePercentage": "パーセント", "chargeTypeFixed": "固定額", @@ -2466,7 +2464,11 @@ "uploadLogo": "ロゴをアップロード", "removeLogo": "ロゴを削除", "logoUploaded": "ロゴをアップロードしました", - "showLogo": "店舗ロゴ" + "showLogo": "店舗ロゴ", + "chargeKind": "料金種別", + "chargePresetCustom": "カスタム", + "editCharge": "料金を編集", + "chargeReopenRequired": "この料金は利用できなくなりました。再度開いてお試しください。" }, "setup": { "alreadyCompleted": "このインストールではセットアップが完了しています。ログインへ移動します…", diff --git a/frontend/src/lib/i18n/messages/ko.json b/frontend/src/lib/i18n/messages/ko.json index 20b625d31..36668582a 100644 --- a/frontend/src/lib/i18n/messages/ko.json +++ b/frontend/src/lib/i18n/messages/ko.json @@ -2446,8 +2446,6 @@ "chargesSaved": "수수료가 저장되었습니다", "chargesSaveFailed": "수수료를 저장하지 못했습니다", "chargeName": "이름", - "chargeId": "수수료 ID", - "chargeIdHint": "소문자 영문, 숫자, 하이픈 또는 밑줄. service_charge와 packaging_charge는 기존 보고서 열을 채웁니다.", "chargeType": "유형", "chargeTypePercentage": "백분율", "chargeTypeFixed": "고정 금액", @@ -2466,7 +2464,11 @@ "uploadLogo": "로고 업로드", "removeLogo": "로고 삭제", "logoUploaded": "로고가 성공적으로 업로드되었습니다", - "showLogo": "매장 로고" + "showLogo": "매장 로고", + "chargeKind": "수수료 유형", + "chargePresetCustom": "사용자 지정", + "editCharge": "수수료 수정", + "chargeReopenRequired": "이 수수료는 더 이상 사용할 수 없습니다. 다시 열어 시도해 주세요." }, "setup": { "alreadyCompleted": "이 설치에서는 초기 설정이 이미 완료되었습니다. 로그인으로 이동합니다…", diff --git a/frontend/src/lib/i18n/messages/ne.json b/frontend/src/lib/i18n/messages/ne.json index 209900d2a..384f077bb 100644 --- a/frontend/src/lib/i18n/messages/ne.json +++ b/frontend/src/lib/i18n/messages/ne.json @@ -2446,8 +2446,6 @@ "chargesSaved": "शुल्क सुरक्षित भयो", "chargesSaveFailed": "शुल्क सुरक्षित गर्न सकिएन", "chargeName": "नाम", - "chargeId": "आईडी", - "chargeIdHint": "सानो अंग्रेजी अक्षर, अङ्क, हाइफन वा अन्डरस्कोर। service_charge र packaging_charge ले विद्यमान प्रतिवेदन स्तम्भहरू भर्छन्।", "chargeType": "प्रकार", "chargeTypePercentage": "प्रतिशत", "chargeTypeFixed": "निश्चित राशि", @@ -2466,7 +2464,11 @@ "uploadLogo": "लोगो अपलोड गर्नुहोस्", "removeLogo": "लोगो हटाउनुहोस्", "logoUploaded": "लोगो सफलतापूर्वक अपलोड गरियो", - "showLogo": "स्टोर लोगो" + "showLogo": "स्टोर लोगो", + "chargeKind": "शुल्क प्रकार", + "chargePresetCustom": "अनुकूलित", + "editCharge": "शुल्क सम्पादन गर्नुहोस्", + "chargeReopenRequired": "यो शुल्क अब उपलब्ध छैन। कृपया पुनः खोल्नुहोस् र फेरि प्रयास गर्नुहोस्।" }, "setup": { "alreadyCompleted": "यस स्थापनामा सेटअप पहिले नै पूरा भइसकेको छ। लगइन गर्न रिडिरेक्ट गर्दै...", diff --git a/frontend/src/lib/i18n/messages/nl.json b/frontend/src/lib/i18n/messages/nl.json index 82dd17732..a445bf69b 100644 --- a/frontend/src/lib/i18n/messages/nl.json +++ b/frontend/src/lib/i18n/messages/nl.json @@ -2446,8 +2446,6 @@ "chargesSaved": "Kosten opgeslagen", "chargesSaveFailed": "Kosten konden niet worden opgeslagen", "chargeName": "Naam", - "chargeId": "Kosten-ID", - "chargeIdHint": "Kleine letters, cijfers, streepje of underscore. service_charge en packaging_charge voeden de bestaande rapportkolommen.", "chargeType": "Soort", "chargeTypePercentage": "Procent", "chargeTypeFixed": "Vast bedrag", @@ -2466,7 +2464,11 @@ "uploadLogo": "Logo uploaden", "removeLogo": "Logo verwijderen", "logoUploaded": "Logo succesvol geüpload", - "showLogo": "Winkellogo" + "showLogo": "Winkellogo", + "chargeKind": "Kostensoort", + "chargePresetCustom": "Aangepast", + "editCharge": "Kosten bewerken", + "chargeReopenRequired": "Deze kosten zijn niet meer beschikbaar. Open opnieuw en probeer het nog eens." }, "setup": { "alreadyCompleted": "De installatie is bij deze installatie al voltooid. Omleiden naar inloggen…", diff --git a/frontend/src/lib/i18n/messages/pt.json b/frontend/src/lib/i18n/messages/pt.json index b3ba33797..4d809ba2f 100644 --- a/frontend/src/lib/i18n/messages/pt.json +++ b/frontend/src/lib/i18n/messages/pt.json @@ -2446,8 +2446,6 @@ "chargesSaved": "Taxas guardadas", "chargesSaveFailed": "Não foi possível guardar as taxas", "chargeName": "Nome", - "chargeId": "ID da taxa", - "chargeIdHint": "Letras minúsculas, dígitos, hífen ou underscore. service_charge e packaging_charge alimentam as colunas de relatório existentes.", "chargeType": "Tipo", "chargeTypePercentage": "Percentagem", "chargeTypeFixed": "Valor fixo", @@ -2466,7 +2464,11 @@ "uploadLogo": "Carregar logotipo", "removeLogo": "Remover logotipo", "logoUploaded": "Logotipo carregado com sucesso", - "showLogo": "Logotipo da loja" + "showLogo": "Logotipo da loja", + "chargeKind": "Tipo de taxa", + "chargePresetCustom": "Personalizado", + "editCharge": "Editar taxa", + "chargeReopenRequired": "Esta taxa não está mais disponível. Reabra e tente novamente." }, "setup": { "alreadyCompleted": "A configuração já foi concluída nesta instalação. Redirecionando para o login…", diff --git a/frontend/src/lib/i18n/messages/ru.json b/frontend/src/lib/i18n/messages/ru.json index 6dde6d39c..76e56552b 100644 --- a/frontend/src/lib/i18n/messages/ru.json +++ b/frontend/src/lib/i18n/messages/ru.json @@ -2446,8 +2446,6 @@ "chargesSaved": "Сборы сохранены", "chargesSaveFailed": "Не удалось сохранить сборы", "chargeName": "Название", - "chargeId": "Идентификатор", - "chargeIdHint": "Строчные буквы, цифры, дефис или подчёркивание. service_charge и packaging_charge заполняют существующие колонки отчётов.", "chargeType": "Тип", "chargeTypePercentage": "Процент", "chargeTypeFixed": "Фиксированная сумма", @@ -2466,7 +2464,11 @@ "uploadLogo": "Загрузить логотип", "removeLogo": "Удалить логотип", "logoUploaded": "Логотип успешно загружен", - "showLogo": "Логотип магазина" + "showLogo": "Логотип магазина", + "chargeKind": "Тип сбора", + "chargePresetCustom": "Пользовательский", + "editCharge": "Изменить сбор", + "chargeReopenRequired": "Этот сбор больше недоступен. Откройте его снова и повторите попытку." }, "setup": { "alreadyCompleted": "Настройка этой установки уже завершена. Перенаправление на вход…", diff --git a/frontend/src/lib/i18n/messages/sq.json b/frontend/src/lib/i18n/messages/sq.json index 051b10a4d..b6b262295 100644 --- a/frontend/src/lib/i18n/messages/sq.json +++ b/frontend/src/lib/i18n/messages/sq.json @@ -2446,8 +2446,6 @@ "chargesSaved": "Ngarkesat u ruajtën", "chargesSaveFailed": "Ngarkesat nuk mund të ruheshin", "chargeName": "Emri", - "chargeId": "ID e ngarkesës", - "chargeIdHint": "Shkronja të vogla, shifra, vijë ose vijë e nënvizës. service_charge dhe packaging_charge furnizojnë kolonat ekzistuese të raporteve.", "chargeType": "Lloji", "chargeTypePercentage": "Përqindje", "chargeTypeFixed": "Shumë fikse", @@ -2466,7 +2464,11 @@ "uploadLogo": "Ngarko Logon", "removeLogo": "Hiq Logon", "logoUploaded": "Logoja u ngarkua me sukses", - "showLogo": "Logoja e Dyqanit" + "showLogo": "Logoja e Dyqanit", + "chargeKind": "Lloji i tarifës", + "chargePresetCustom": "E personalizuar", + "editCharge": "Ndrysho tarifën", + "chargeReopenRequired": "Kjo tarifë nuk është më e disponueshme. Ju lutemi rihapeni dhe provoni përsëri." }, "setup": { "alreadyCompleted": "Konfigurimi në këtë instalim është përfunduar. Po ridrejtohemi te hyrja...", diff --git a/frontend/src/lib/i18n/messages/th.json b/frontend/src/lib/i18n/messages/th.json index 6c1cc7f7d..dbbc8e9cc 100644 --- a/frontend/src/lib/i18n/messages/th.json +++ b/frontend/src/lib/i18n/messages/th.json @@ -2446,8 +2446,6 @@ "chargesSaved": "บันทึกค่าธรรมเนียมแล้ว", "chargesSaveFailed": "ไม่สามารถบันทึกค่าธรรมเนียมได้", "chargeName": "ชื่อ", - "chargeId": "รหัส", - "chargeIdHint": "ตัวอักษรอังกฤษพิมพ์เล็ก ตัวเลข ยัติภังค์ หรือขีดล่าง service_charge และ packaging_charge จะเติมคอลัมน์รายงานเดิม", "chargeType": "ประเภท", "chargeTypePercentage": "เปอร์เซ็นต์", "chargeTypeFixed": "จำนวนเงินคงที่", @@ -2466,7 +2464,11 @@ "uploadLogo": "อัปโหลดโลโก้", "removeLogo": "ลบโลโก้", "logoUploaded": "อัปโหลดโลโก้สำเร็จแล้ว", - "showLogo": "โลโก้ร้านค้า" + "showLogo": "โลโก้ร้านค้า", + "chargeKind": "ประเภทค่าธรรมเนียม", + "chargePresetCustom": "กำหนดเอง", + "editCharge": "แก้ไขค่าธรรมเนียม", + "chargeReopenRequired": "ค่าธรรมเนียมนี้ไม่พร้อมใช้งานแล้ว โปรดเปิดใหม่อีกครั้งแล้วลองใหม่" }, "setup": { "alreadyCompleted": "ตั้งค่าการติดตั้งนี้เสร็จสมบูรณ์แล้ว กำลังกลับไปหน้าเข้าสู่ระบบ…", diff --git a/frontend/src/lib/i18n/messages/tr.json b/frontend/src/lib/i18n/messages/tr.json index 17673ccc4..83e71b370 100644 --- a/frontend/src/lib/i18n/messages/tr.json +++ b/frontend/src/lib/i18n/messages/tr.json @@ -2446,8 +2446,6 @@ "chargesSaved": "Ücretler kaydedildi", "chargesSaveFailed": "Ücretler kaydedilemedi", "chargeName": "Ad", - "chargeId": "Kimlik", - "chargeIdHint": "Küçük harfler, rakamlar, tire veya alt çizgi. service_charge ve packaging_charge mevcut rapor sütunlarını besler.", "chargeType": "Tür", "chargeTypePercentage": "Yüzde", "chargeTypeFixed": "Sabit tutar", @@ -2466,7 +2464,11 @@ "uploadLogo": "Logo Yükle", "removeLogo": "Logoyu Kaldır", "logoUploaded": "Logo başarıyla yüklendi", - "showLogo": "Mağaza Logosu" + "showLogo": "Mağaza Logosu", + "chargeKind": "Ücret türü", + "chargePresetCustom": "Özel", + "editCharge": "Ücreti düzenle", + "chargeReopenRequired": "Bu ücret artık mevcut değil. Lütfen yeniden açıp tekrar deneyin." }, "setup": { "alreadyCompleted": "Kurulum bu cihazda zaten tamamlandı. Giriş ekranına yönlendiriliyorsunuz…", diff --git a/frontend/src/lib/i18n/messages/ur.json b/frontend/src/lib/i18n/messages/ur.json index 80047e12e..4ef4698de 100644 --- a/frontend/src/lib/i18n/messages/ur.json +++ b/frontend/src/lib/i18n/messages/ur.json @@ -2446,8 +2446,6 @@ "chargesSaved": "فیس محفوظ ہو گئی", "chargesSaveFailed": "فیس محفوظ نہیں ہو سکیں", "chargeName": "نام", - "chargeId": "شناخت", - "chargeIdHint": "چھوٹے انگریزی حروف، ہندسے، ڈیش یا انڈر سکور۔ service_charge اور packaging_charge موجود رپورٹ کالمز کو بھرتے ہیں۔", "chargeType": "قسم", "chargeTypePercentage": "فیصد", "chargeTypeFixed": "مقررہ رقم", @@ -2466,7 +2464,11 @@ "uploadLogo": "لوگو اپ لوڈ کریں", "removeLogo": "لوگو ہٹائیں", "logoUploaded": "لوگو کامیابی کے ساتھ اپ لوڈ ہو گیا", - "showLogo": "دکان کا لوگو" + "showLogo": "دکان کا لوگو", + "chargeKind": "فیس کی قسم", + "chargePresetCustom": "حسب ضرورت", + "editCharge": "فیس میں ترمیم کریں", + "chargeReopenRequired": "یہ فیس اب دستیاب نہیں ہے۔ براہ کرم دوبارہ کھولیں اور کوشش کریں۔" }, "setup": { "alreadyCompleted": "اس انسٹال کا سیٹ اپ پہلے ہی مکمل ہو چکا ہے۔ سائن ان پر بھیج رہا ہے…", diff --git a/frontend/src/lib/i18n/messages/vi.json b/frontend/src/lib/i18n/messages/vi.json index e19eaf9c4..60cac54c5 100644 --- a/frontend/src/lib/i18n/messages/vi.json +++ b/frontend/src/lib/i18n/messages/vi.json @@ -2446,8 +2446,6 @@ "chargesSaved": "Đã lưu phí", "chargesSaveFailed": "Không thể lưu phí", "chargeName": "Tên", - "chargeId": "Mã", - "chargeIdHint": "Chữ thường, chữ số, dấu gạch ngang hoặc gạch dưới. service_charge và packaging_charge điền vào các cột báo cáo hiện có.", "chargeType": "Loại", "chargeTypePercentage": "Phần trăm", "chargeTypeFixed": "Số tiền cố định", @@ -2466,7 +2464,11 @@ "uploadLogo": "Tải Lên Logo", "removeLogo": "Xóa Logo", "logoUploaded": "Đã tải lên logo thành công", - "showLogo": "Logo Cửa Hàng" + "showLogo": "Logo Cửa Hàng", + "chargeKind": "Loại phí", + "chargePresetCustom": "Tùy chỉnh", + "editCharge": "Chỉnh sửa phí", + "chargeReopenRequired": "Khoản phí này không còn khả dụng. Vui lòng mở lại và thử lại." }, "setup": { "alreadyCompleted": "Bản cài đặt này đã hoàn tất thiết lập. Đang chuyển đến đăng nhập…", diff --git a/frontend/src/lib/i18n/messages/zh-tw.json b/frontend/src/lib/i18n/messages/zh-tw.json index 0930ee097..bef3c6932 100644 --- a/frontend/src/lib/i18n/messages/zh-tw.json +++ b/frontend/src/lib/i18n/messages/zh-tw.json @@ -2446,8 +2446,6 @@ "chargesSaved": "費用已儲存", "chargesSaveFailed": "無法儲存費用", "chargeName": "名稱", - "chargeId": "識別碼", - "chargeIdHint": "小寫字母、數字、連字號或底線。service_charge 與 packaging_charge 會寫入既有的報表欄位。", "chargeType": "類型", "chargeTypePercentage": "百分比", "chargeTypeFixed": "固定金額", @@ -2466,7 +2464,11 @@ "uploadLogo": "上傳標誌", "removeLogo": "移除標誌", "logoUploaded": "標誌上傳成功", - "showLogo": "店鋪標誌" + "showLogo": "店鋪標誌", + "chargeKind": "費用類型", + "chargePresetCustom": "自訂", + "editCharge": "編輯費用", + "chargeReopenRequired": "此費用已無法使用。請重新開啟後再試。" }, "setup": { "alreadyCompleted": "此安裝已完成設定。正在跳轉到登入…", diff --git a/frontend/src/lib/i18n/messages/zh.json b/frontend/src/lib/i18n/messages/zh.json index 0ff372ea9..40ead1d85 100644 --- a/frontend/src/lib/i18n/messages/zh.json +++ b/frontend/src/lib/i18n/messages/zh.json @@ -2446,8 +2446,6 @@ "chargesSaved": "费用已保存", "chargesSaveFailed": "无法保存费用", "chargeName": "名称", - "chargeId": "标识", - "chargeIdHint": "小写字母、数字、连字符或下划线。service_charge 和 packaging_charge 会写入现有的报表列。", "chargeType": "类型", "chargeTypePercentage": "百分比", "chargeTypeFixed": "固定金额", @@ -2466,7 +2464,11 @@ "uploadLogo": "上传标志", "removeLogo": "移除标志", "logoUploaded": "标志上传成功", - "showLogo": "店铺标志" + "showLogo": "店铺标志", + "chargeKind": "费用类型", + "chargePresetCustom": "自定义", + "editCharge": "编辑费用", + "chargeReopenRequired": "此费用已不可用。请重新打开后再试。" }, "setup": { "alreadyCompleted": "此安装已完成设置。正在跳转到登录…", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 3d827ce3e..2b2db0f70 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -152,6 +152,9 @@ export interface Addon { low_stock_threshold: number; is_active: boolean; sort_order: number; + tax_category_id?: string | null; + tax_behavior?: Product['tax_behavior']; + inherit_parent_tax_category?: boolean; } export interface Table { diff --git a/tests/payment-modal-currency-adapter.test.ts b/tests/payment-modal-currency-adapter.test.ts index 434b0e0e9..138eee504 100644 --- a/tests/payment-modal-currency-adapter.test.ts +++ b/tests/payment-modal-currency-adapter.test.ts @@ -596,6 +596,7 @@ async function runCatalogSaveBoundaryTests() { const Icon = () => React.createElement('span'); const currentTenant = { currency: 'JPY', country: 'JP' }; + let replacedPath = ''; const calls: Array<{ method: string; path: string; payload?: any }> = []; const api = { get: async () => ({ data: { products: [], categories: [], addon_groups: [] } }), @@ -665,28 +666,49 @@ async function runCatalogSaveBoundaryTests() { ChevronDown: Icon, ChevronRight: Icon, }, + 'next/navigation': { + useRouter: () => ({ + replace: (path: string) => { + replacedPath = path; + }, + push: () => undefined, + }), + useSearchParams: () => new URLSearchParams(), + }, }; const originalLoad = moduleApi._load; const originalUseState = React.useState; const originalUseEffect = React.useEffect; const originalUseRef = React.useRef; + const originalUseCallback = React.useCallback; moduleApi._load = function (request: string, parent: any, isMain: boolean) { if (request in mocks) return mocks[request]; return originalLoad.call(this, request, parent, isMain); }; React.useEffect = (() => undefined) as typeof React.useEffect; + React.useCallback = ((fn: any) => fn) as typeof React.useCallback; try { const ProductsPage = require('../frontend/src/app/(dashboard)/products/page').default; - const renderProductsPage = (activeTab: 'products' | 'addons') => { + const renderProductsPage = ( + activeTab: 'products' | 'addons', + options?: { + editingAddonGroup?: any; + addonForm?: any; + addonList?: any[]; + }, + ) => { let stateCall = 0; React.useState = ((initial: unknown) => { stateCall += 1; if (stateCall === 1) return [activeTab, () => undefined]; if (stateCall === 9) return [false, () => undefined]; if (stateCall === 10) return [activeTab === 'products', () => undefined]; + if (stateCall === 13) return [options?.editingAddonGroup ?? null, () => undefined]; + if (stateCall === 15 && options?.addonForm) return [options.addonForm, () => undefined]; if (stateCall === 16) return [activeTab === 'addons', () => undefined]; - if (stateCall === 17) return [[{ name: 'Extra Sauce', price: 1.5 }], () => undefined]; + if (stateCall === 17) + return [options?.addonList ?? [{ name: 'Extra Sauce', price: 1.5 }], () => undefined]; if (stateCall === 18) return [ { @@ -753,9 +775,7 @@ async function runCatalogSaveBoundaryTests() { 'inline add-on price is rounded at the save handler', ); - const AddonGroupsPage = require('../frontend/src/app/(dashboard)/addon-groups/page').default; - const addon = { id: 'addon-1', name: 'Old Sauce', price: 1, is_active: true }; - const group = { + const updateGroup = { id: 'group-1', name: 'Extras', description: null, @@ -764,50 +784,97 @@ async function runCatalogSaveBoundaryTests() { min_selection: 0, max_selection: 1, is_active: true, - addons: [addon], + addons: [], }; - const renderAddonPage = (editing: boolean) => { - let addonStateCall = 0; - React.useState = ((initial: unknown) => { - addonStateCall += 1; - if (addonStateCall === 1) return [[group], () => undefined]; - if (addonStateCall === 2) return [false, () => undefined]; - if (addonStateCall === 5) return ['group-1', () => undefined]; - if (addonStateCall === 8) return [{ name: 'New Sauce', price: '1.5' }, () => undefined]; - if (addonStateCall === 9) return [editing ? null : 'group-1', () => undefined]; - if (addonStateCall === 10) - return [editing ? { groupId: 'group-1', addon } : null, () => undefined]; - return [initial, () => undefined]; - }) as typeof React.useState; - return AddonGroupsPage(); + const updateAddon = { + id: 'addon-1', + name: 'Old Sauce', + price: '1.5', + is_active: true, + track_inventory: true, + stock_quantity: '10', + low_stock_threshold: '2', + stock_edited: false, + tax_category_id: 'tax-cat-1', + tax_behavior: 'exclusive', + inherit_parent_tax_category: false, }; - const addTree = renderAddonPage(false); - const addButton = collectElements( - addTree, - (element) => elementText(element) === 'products.addButton', - )[0]; - assert(addButton, 'AddonGroupsPage exposes the add add-on handler'); - await addButton.props.onClick(); - const addSave = calls.find( - (call) => call.method === 'post' && call.path === '/addon-groups/group-1/addons', + const updateAddonGroupTree = renderProductsPage('addons', { + editingAddonGroup: updateGroup, + addonForm: { + name: 'Extras', + description: '', + is_required: false, + allow_multiple_quantities: false, + min_selection: 0, + max_selection: 1, + }, + addonList: [updateAddon], + }); + const updateAddonGroupForms = collectElements( + updateAddonGroupTree, + (element) => typeof element.props?.onSubmit === 'function', + ); + assert.equal( + updateAddonGroupForms.length, + 1, + 'ProductsPage exposes the add-on group update form', ); - assert.equal(addSave?.payload?.price, 2, 'add add-on handler rounds JPY price'); - - const updateTree = renderAddonPage(true); - const updateButton = collectElements( - updateTree, - (element) => elementText(element) === 'common.save', - )[0]; - assert(updateButton, 'AddonGroupsPage exposes the update add-on handler'); - await updateButton.props.onClick(); - const updateSave = calls.find( - (call) => call.method === 'put' && call.path === '/addon-groups/group-1/addons/addon-1', + await updateAddonGroupForms[0].props.onSubmit({ preventDefault: () => undefined }); + const addonGroupUpdate = calls.find( + (call) => call.method === 'put' && call.path === '/addon-groups/group-1', + ); + assert(addonGroupUpdate, 'ProductsPage sends put request for existing add-on group'); + assert.equal( + addonGroupUpdate?.payload?.addons?.[0]?.price, + 2, + 'update add-on handler rounds JPY price', + ); + assert.equal( + 'stock_quantity' in (addonGroupUpdate?.payload?.addons?.[0] ?? {}), + false, + 'untouched add-on stock_quantity is omitted from update payload', + ); + assert.equal( + addonGroupUpdate?.payload?.addons?.[0]?.tax_category_id, + 'tax-cat-1', + 'tax_category_id is preserved on update', + ); + assert.equal( + addonGroupUpdate?.payload?.addons?.[0]?.tax_behavior, + 'exclusive', + 'tax_behavior is preserved on update', + ); + assert.equal( + addonGroupUpdate?.payload?.addons?.[0]?.inherit_parent_tax_category, + false, + 'inherit_parent_tax_category is preserved on update', + ); + + const AddonGroupsPage = require('../frontend/src/app/(dashboard)/addon-groups/page').default; + const initialCallCount = calls.length; + let redirectEffectRan = false; + React.useEffect = ((effect: () => void) => { + effect(); + redirectEffectRan = true; + }) as typeof React.useEffect; + AddonGroupsPage(); + assert(redirectEffectRan, 'AddonGroupsPage executes redirect effect'); + assert.equal( + replacedPath, + '/products?tab=addons', + 'AddonGroupsPage compatibility route redirects to /products?tab=addons', + ); + assert.equal( + calls.length, + initialCallCount, + 'AddonGroupsPage redirect performs zero API reads or writes', ); - assert.equal(updateSave?.payload?.price, 2, 'update add-on handler rounds JPY price'); } finally { React.useState = originalUseState; React.useEffect = originalUseEffect; React.useRef = originalUseRef; + React.useCallback = originalUseCallback; moduleApi._load = originalLoad; } console.log(' ✓ Product and add-on save handlers verified'); diff --git a/tests/rtl-dashboard-pos-common.test.ts b/tests/rtl-dashboard-pos-common.test.ts index ee6c8d7ce..a4742b600 100644 --- a/tests/rtl-dashboard-pos-common.test.ts +++ b/tests/rtl-dashboard-pos-common.test.ts @@ -121,11 +121,25 @@ function loadComponents(): { getLanguageLocale: (lang: string) => string; React: typeof import('react'); ReactDOMServer: typeof import('react-dom/server'); + getRouterReplacedRoute: () => string; + resetRouterReplacedRoute: () => void; } { + let routerReplacedRoute = ''; + const routerMock = { + replace: (target: string) => { + routerReplacedRoute = target; + }, + push: (target: string) => { + routerReplacedRoute = target; + }, + }; + const moduleApi = require('module') as { _resolveFilename: (...args: any[]) => string; + _load: (...args: any[]) => any; }; const originalResolveFilename = moduleApi._resolveFilename; + const originalLoad = moduleApi._load; moduleApi._resolveFilename = function ( request: string, @@ -144,6 +158,16 @@ function loadComponents(): { return originalResolveFilename.call(this, resolvedRequest, parent, isMain, options); }; + moduleApi._load = function (request: string, parent: any, isMain: boolean) { + if (request === 'next/navigation') { + return { + useRouter: () => routerMock, + useSearchParams: () => new URLSearchParams(), + }; + } + return originalLoad.call(this, request, parent, isMain); + }; + try { const React = frontendRequire('react'); const ReactDOMServer = frontendRequire('react-dom/server'); @@ -169,9 +193,14 @@ function loadComponents(): { getLanguageLocale, React, ReactDOMServer, + getRouterReplacedRoute: () => routerReplacedRoute, + resetRouterReplacedRoute: () => { + routerReplacedRoute = ''; + }, }; } finally { moduleApi._resolveFilename = originalResolveFilename; + moduleApi._load = originalLoad; } } @@ -256,6 +285,8 @@ function run(): void { getLanguageLocale, React, ReactDOMServer, + getRouterReplacedRoute, + resetRouterReplacedRoute, } = loadComponents(); React.useSyncExternalStore = (_subscribe: any, getSnapshot: () => any) => getSnapshot(); @@ -445,64 +476,19 @@ function run(): void { } console.log(' ✓ getLanguageDirection resolves direction from shared language metadata'); - const sampleAddonGroup = { - id: 'group-rtl-1', - name: 'Extras', - description: 'Optional extras', - is_required: false, - allow_multiple_quantities: false, - min_selection: 0, - max_selection: 2, - is_active: true, - addons: [{ id: 'addon-rtl-1', name: 'Extra Shot', price: 0, is_active: true }], - }; const originalUseState = React.useState; const originalUseEffect = React.useEffect; const originalApiGet = api.get; try { - const hookStates: unknown[] = []; - let mountedEffectsRan = false; + resetRouterReplacedRoute(); + + let apiCallsCount = 0; api.get = (url: string) => { - assert(url === '/addon-groups', `Unexpected api.get URL in AddonGroupsPage: ${url}`); - const response = { data: { addon_groups: [sampleAddonGroup] } }; - const chain = { - then(onFulfilled?: (val: typeof response) => unknown) { - if (onFulfilled) onFulfilled(response); - return chain; - }, - catch() { - return chain; - }, - finally(onFinally?: () => void) { - if (onFinally) onFinally(); - return chain; - }, - }; - return chain; + apiCallsCount += 1; + throw new Error(`Unexpected API call to ${url}`); }; const renderAddonGroupsPage = () => { - let hookSlot = 0; - const pendingEffects: Array<() => void | (() => void)> = []; - React.useState = ((initial: unknown) => { - const slot = hookSlot++; - if (slot >= hookStates.length) { - hookStates.push(typeof initial === 'function' ? (initial as () => unknown)() : initial); - } - const setState = (next: unknown) => { - hookStates[slot] = - typeof next === 'function' - ? (next as (prev: unknown) => unknown)(hookStates[slot]) - : next; - }; - return [hookStates[slot], setState]; - }) as typeof React.useState; - React.useEffect = ((effect: () => void | (() => void)) => { - if (!mountedEffectsRan) { - pendingEffects.push(effect); - } - }) as typeof React.useEffect; - let capturedTree: any; function AddonGroupsProbe() { capturedTree = AddonGroupsPage(); @@ -515,106 +501,27 @@ function run(): void { React.createElement(AddonGroupsProbe), ), ); - if (!mountedEffectsRan) { - mountedEffectsRan = true; - for (const effect of pendingEffects) effect(); - } return { tree: capturedTree, markup }; }; - // Initial mount pass runs useEffect -> api.get('/addon-groups'), populating state via setters. - renderAddonGroupsPage(); - - const collapsedRender = renderAddonGroupsPage(); - const collapsedChevronRights = collectElements( - collapsedRender.tree, - (el) => el.type === ChevronRight, - ); - const collapsedChevronDowns = collectElements( - collapsedRender.tree, - (el) => el.type === ChevronDown, - ); - assert( - collapsedChevronRights.length === 1 && collapsedChevronDowns.length === 0, - `Collapsed AddonGroupsPage header must render ChevronRight and not ChevronDown (got ${collapsedChevronRights.length} ChevronRight, ${collapsedChevronDowns.length} ChevronDown)`, - ); - const collapsedClassTokens = String(collapsedChevronRights[0].props?.className ?? '') - .trim() - .split(/\s+/); - assert( - collapsedClassTokens.includes('rtl-flip'), - `Collapsed AddonGroupsPage ChevronRight must include rtl-flip in className, got: "${collapsedChevronRights[0].props?.className}"`, - ); + const { markup } = renderAddonGroupsPage(); + assert(apiCallsCount === 0, 'AddonGroupsPage compatibility redirect performs zero API reads'); assert( - collapsedRender.markup.includes('lucide-chevron-right') && - collapsedRender.markup.includes('rtl-flip') && - !collapsedRender.markup.includes('lucide-chevron-down'), - `Collapsed AddonGroupsPage markup must render lucide-chevron-right with rtl-flip`, + markup.includes(faMessages.common.loading), + `AddonGroupsPage compatibility redirect renders translated loading state in RTL, got: ${markup}`, ); - const collapsedToggleBtn = collectElements( - collapsedRender.tree, - (el) => - el.type === 'button' && - collectElements(el, (child) => child.type === ChevronRight).length > 0, - )[0]; - assert( - typeof collapsedToggleBtn?.props?.onClick === 'function', - 'Collapsed AddonGroupsPage header must expose an onClick toggle button', - ); - collapsedToggleBtn.props.onClick(); - - const expandedRender = renderAddonGroupsPage(); - const expandedChevronRights = collectElements( - expandedRender.tree, - (el) => el.type === ChevronRight, - ); - const expandedChevronDowns = collectElements( - expandedRender.tree, - (el) => el.type === ChevronDown, - ); - assert( - expandedChevronDowns.length === 1 && expandedChevronRights.length === 0, - `Expanded AddonGroupsPage header must render ChevronDown and not ChevronRight (got ${expandedChevronDowns.length} ChevronDown, ${expandedChevronRights.length} ChevronRight)`, - ); - const expandedClassTokens = String(expandedChevronDowns[0].props?.className ?? '') - .trim() - .split(/\s+/); - assert( - !expandedClassTokens.includes('rtl-flip'), - `Expanded AddonGroupsPage ChevronDown must not include rtl-flip in className, got: "${expandedChevronDowns[0].props?.className}"`, - ); - assert( - expandedRender.markup.includes('lucide-chevron-down') && - !expandedRender.markup.includes('lucide-chevron-right') && - !expandedRender.markup.includes('rtl-flip'), - 'Expanded AddonGroupsPage markup must render lucide-chevron-down without rtl-flip', - ); - - const expandedToggleBtn = collectElements( - expandedRender.tree, - (el) => - el.type === 'button' && - collectElements(el, (child) => child.type === ChevronDown).length > 0, - )[0]; - assert( - typeof expandedToggleBtn?.props?.onClick === 'function', - 'Expanded AddonGroupsPage header must expose an onClick toggle button', - ); - expandedToggleBtn.props.onClick(); - - const recollapsedRender = renderAddonGroupsPage(); - const recollapsedChevronRights = collectElements( - recollapsedRender.tree, - (el) => el.type === ChevronRight, - ); - const recollapsedChevronDowns = collectElements( - recollapsedRender.tree, - (el) => el.type === ChevronDown, - ); + // Run effect to verify redirect + let effectRan = false; + React.useEffect = ((effect: () => void) => { + effect(); + effectRan = true; + }) as typeof React.useEffect; + renderAddonGroupsPage(); + assert(effectRan, 'AddonGroupsPage executes redirect effect'); assert( - recollapsedChevronRights.length === 1 && recollapsedChevronDowns.length === 0, - `Toggling expanded AddonGroupsPage header must collapse back to ChevronRight (got ${recollapsedChevronRights.length} ChevronRight, ${recollapsedChevronDowns.length} ChevronDown)`, + getRouterReplacedRoute() === '/products?tab=addons', + `AddonGroupsPage redirects to canonical /products?tab=addons, got: ${getRouterReplacedRoute()}`, ); } finally { React.useState = originalUseState; @@ -622,7 +529,7 @@ function run(): void { api.get = originalApiGet; } console.log( - ' ✓ AddonGroupsPage renders ChevronRight with rtl-flip when collapsed and ChevronDown without rtl-flip when expanded', + ' ✓ AddonGroupsPage compatibility redirect performs zero API calls, renders translated loading state in RTL, and redirects to /products?tab=addons', ); console.log('\n✅ All RTL/LTR Dashboard, POS, and common flow checks passed.');