From 8d924d49da18c54b2e44460ec695a523bc67917b Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Fri, 16 Oct 2026 20:38:51 -0400 Subject: [PATCH 1/4] feat(catalog): consolidate add-on editor and add charge presets Make Products > Add-on Groups (/products?tab=addons) the single add-on editor, keep /addon-groups as a client redirect, preserve saved add-on tax metadata and untouched stock on save, and guard duplicate saves. Add Service, Packaging, and Custom kinds to the charge dialog with stable reserved or generated IDs instead of a raw ID field. Fixes #1155 Fixes #1157 --- docs/architecture/frontend.md | 25 +- frontend/e2e/i18n-batch-5e.spec.ts | 12 +- frontend/e2e/settings-request-budget.spec.ts | 175 +++++- frontend/e2e/setup-clarity.spec.ts | 309 ++++++++++ .../src/app/(dashboard)/addon-groups/page.tsx | 548 +----------------- .../src/app/(dashboard)/products/page.tsx | 77 ++- .../settings/ChargesSettingsCard.tsx | 116 +++- frontend/src/lib/i18n/messages/ar.json | 8 +- frontend/src/lib/i18n/messages/bn.json | 8 +- frontend/src/lib/i18n/messages/de.json | 8 +- frontend/src/lib/i18n/messages/en.json | 8 +- frontend/src/lib/i18n/messages/es.json | 8 +- frontend/src/lib/i18n/messages/fa.json | 8 +- frontend/src/lib/i18n/messages/fil.json | 8 +- frontend/src/lib/i18n/messages/fr.json | 8 +- frontend/src/lib/i18n/messages/hi.json | 8 +- frontend/src/lib/i18n/messages/id.json | 8 +- frontend/src/lib/i18n/messages/it.json | 8 +- frontend/src/lib/i18n/messages/ja.json | 8 +- frontend/src/lib/i18n/messages/ko.json | 8 +- frontend/src/lib/i18n/messages/ne.json | 8 +- frontend/src/lib/i18n/messages/nl.json | 8 +- frontend/src/lib/i18n/messages/pt.json | 8 +- frontend/src/lib/i18n/messages/ru.json | 8 +- frontend/src/lib/i18n/messages/sq.json | 8 +- frontend/src/lib/i18n/messages/th.json | 8 +- frontend/src/lib/i18n/messages/tr.json | 8 +- frontend/src/lib/i18n/messages/ur.json | 8 +- frontend/src/lib/i18n/messages/vi.json | 8 +- frontend/src/lib/i18n/messages/zh-tw.json | 8 +- frontend/src/lib/i18n/messages/zh.json | 8 +- frontend/src/lib/types.ts | 3 + tests/payment-modal-currency-adapter.test.ts | 149 +++-- tests/rtl-dashboard-pos-common.test.ts | 195 ++----- 34 files changed, 944 insertions(+), 857 deletions(-) create mode 100644 frontend/e2e/setup-clarity.spec.ts diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index dc28a4830..bd82c47ae 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -158,6 +158,29 @@ 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. + ### 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 @@ -595,7 +618,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..bc4c2fde0 100644 --- a/frontend/e2e/i18n-batch-5e.spec.ts +++ b/frontend/e2e/i18n-batch-5e.spec.ts @@ -58,8 +58,10 @@ 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 +109,10 @@ 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) @@ -156,8 +160,10 @@ 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) diff --git a/frontend/e2e/settings-request-budget.spec.ts b/frontend/e2e/settings-request-budget.spec.ts index 45e68ae16..9205dd6fd 100644 --- a/frontend/e2e/settings-request-budget.spec.ts +++ b/frontend/e2e/settings-request-budget.spec.ts @@ -1379,19 +1379,19 @@ 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 +1414,161 @@ 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 配送'); +}); + test('Charges ignore an older failed load after a newer load succeeds', async ({ page }) => { await startMockedSettingsSession(page); const latestCharge = { diff --git a/frontend/e2e/setup-clarity.spec.ts b/frontend/e2e/setup-clarity.spec.ts new file mode 100644 index 000000000..439721986 --- /dev/null +++ b/frontend/e2e/setup-clarity.spec.ts @@ -0,0 +1,309 @@ +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`); + 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'); + + // 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'); + + // 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/settings/ChargesSettingsCard.tsx b/frontend/src/components/settings/ChargesSettingsCard.tsx index 04721890b..2c48a092d 100644 --- a/frontend/src/components/settings/ChargesSettingsCard.tsx +++ b/frontend/src/components/settings/ChargesSettingsCard.tsx @@ -9,11 +9,14 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di 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 +26,40 @@ 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 +73,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 +85,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 +104,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,21 +146,46 @@ export function ChargesSettingsCard({ canManage }: { canManage: boolean }) { await persist(charges.filter((candidate) => candidate.id !== charge.id)); }; + 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; + } + 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) return; const existing = editingOriginalId ? charges.find((charge) => charge.id === editingOriginalId) : undefined; + if (editingOriginalId && !existing) { + toast.error(t('chargeReopenRequired')); + closeForm(); + void load(); + return; + } const definition = toDefinition(editing, existing); if (charges.some((charge) => charge.id === definition.id && charge.id !== editingOriginalId)) { setDuplicateId(true); 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)) + const next = editingOriginalId + ? charges.map((charge) => (charge.id === editingOriginalId ? definition : charge)) : [...charges, definition]; setSaving(true); try { @@ -210,11 +266,24 @@ export function ChargesSettingsCard({ canManage }: { canManage: boolean }) { { if (!open) closeForm(); }}> - {t('addCharge')} + {editingOriginalId ? t('editCharge') : t('addCharge')} {t('chargesAndSurchargesHint')} {editing && (
+ -