From e971c41d1a33f028c56cb57af7024d8c20850d76 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 10 Oct 2026 11:54:51 +0200 Subject: [PATCH] chore(client): delete the old budget panel components that only their own tests still imported BudgetPanel, its summary, category table, inline edit cell, add row, per person line, pie chart, ring avatar and the useBudgetPanel hook were replaced by the costs panel and nothing outside their own tests imports them any more. BudgetPanel.constants, BudgetPanel.helpers and BudgetPanelMemberChips stay in use. The rtl, format, offline and eslint baselines drop the deleted files. --- client/scripts/eslint-baseline.json | 6 +- client/scripts/format-baseline.json | 14 - client/scripts/offline-baseline.json | 2 - client/scripts/rtl-baseline.json | 2 - .../components/Budget/BudgetPanel.test.tsx | 502 -------------- client/src/components/Budget/BudgetPanel.tsx | 146 ----- .../Budget/BudgetPanelAddItemRow.test.tsx | 119 ---- .../Budget/BudgetPanelAddItemRow.tsx | 68 -- .../Budget/BudgetPanelCategoryTable.test.tsx | 613 ------------------ .../Budget/BudgetPanelCategoryTable.tsx | 273 -------- .../Budget/BudgetPanelInlineEditCell.test.tsx | 166 ----- .../Budget/BudgetPanelInlineEditCell.tsx | 71 -- .../Budget/BudgetPanelPerPersonInline.tsx | 64 -- .../Budget/BudgetPanelPieChart.test.tsx | 54 -- .../components/Budget/BudgetPanelPieChart.tsx | 53 -- .../Budget/BudgetPanelRingAvatar.tsx | 22 - .../components/Budget/BudgetPanelSummary.tsx | 289 --------- .../src/components/Budget/useBudgetPanel.ts | 213 ------ 18 files changed, 3 insertions(+), 2674 deletions(-) delete mode 100644 client/src/components/Budget/BudgetPanel.test.tsx delete mode 100644 client/src/components/Budget/BudgetPanel.tsx delete mode 100644 client/src/components/Budget/BudgetPanelAddItemRow.test.tsx delete mode 100644 client/src/components/Budget/BudgetPanelAddItemRow.tsx delete mode 100644 client/src/components/Budget/BudgetPanelCategoryTable.test.tsx delete mode 100644 client/src/components/Budget/BudgetPanelCategoryTable.tsx delete mode 100644 client/src/components/Budget/BudgetPanelInlineEditCell.test.tsx delete mode 100644 client/src/components/Budget/BudgetPanelInlineEditCell.tsx delete mode 100644 client/src/components/Budget/BudgetPanelPerPersonInline.tsx delete mode 100644 client/src/components/Budget/BudgetPanelPieChart.test.tsx delete mode 100644 client/src/components/Budget/BudgetPanelPieChart.tsx delete mode 100644 client/src/components/Budget/BudgetPanelRingAvatar.tsx delete mode 100644 client/src/components/Budget/BudgetPanelSummary.tsx delete mode 100644 client/src/components/Budget/useBudgetPanel.ts diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index 5eb8f9cf9..21f34886e 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -2,13 +2,13 @@ "src": { "@typescript-eslint/no-explicit-any": 155, "@typescript-eslint/no-unused-expressions": 2, - "@typescript-eslint/no-unused-vars": 158, + "@typescript-eslint/no-unused-vars": 155, "no-empty": 19, "no-useless-assignment": 7, "no-useless-escape": 1, "preserve-caught-error": 53, - "react-hooks/exhaustive-deps": 123, - "react-refresh/only-export-components": 47, + "react-hooks/exhaustive-deps": 121, + "react-refresh/only-export-components": 46, "unused-disable-directive": 6 }, "suppressed": { diff --git a/client/scripts/format-baseline.json b/client/scripts/format-baseline.json index fd5a2e5b1..b4b08776c 100644 --- a/client/scripts/format-baseline.json +++ b/client/scripts/format-baseline.json @@ -148,21 +148,8 @@ "src/components/Budget/BudgetPanel.constants.ts", "src/components/Budget/BudgetPanel.helpers.test.ts", "src/components/Budget/BudgetPanel.helpers.ts", - "src/components/Budget/BudgetPanel.test.tsx", - "src/components/Budget/BudgetPanel.tsx", - "src/components/Budget/BudgetPanelAddItemRow.test.tsx", - "src/components/Budget/BudgetPanelAddItemRow.tsx", - "src/components/Budget/BudgetPanelCategoryTable.test.tsx", - "src/components/Budget/BudgetPanelCategoryTable.tsx", - "src/components/Budget/BudgetPanelInlineEditCell.test.tsx", - "src/components/Budget/BudgetPanelInlineEditCell.tsx", "src/components/Budget/BudgetPanelMemberChips.test.tsx", "src/components/Budget/BudgetPanelMemberChips.tsx", - "src/components/Budget/BudgetPanelPerPersonInline.tsx", - "src/components/Budget/BudgetPanelPieChart.test.tsx", - "src/components/Budget/BudgetPanelPieChart.tsx", - "src/components/Budget/BudgetPanelRingAvatar.tsx", - "src/components/Budget/BudgetPanelSummary.tsx", "src/components/Budget/CostsPanel.helpers.test.ts", "src/components/Budget/CostsPanel.helpers.ts", "src/components/Budget/CostsPanel.table.test.tsx", @@ -183,7 +170,6 @@ "src/components/Budget/expenseFx.ts", "src/components/Budget/receiptUploads.test.ts", "src/components/Budget/receiptUploads.ts", - "src/components/Budget/useBudgetPanel.ts", "src/components/Budget/useFreezeMissingRates.test.ts", "src/components/Budget/useFreezeMissingRates.ts", "src/components/Budget/usePercentSplit.test.ts", diff --git a/client/scripts/offline-baseline.json b/client/scripts/offline-baseline.json index fd63fab1d..1b27ea839 100644 --- a/client/scripts/offline-baseline.json +++ b/client/scripts/offline-baseline.json @@ -21,10 +21,8 @@ "src/components/Admin/usePackingTemplateAdmin.ts", "src/components/Admin/usePermissionsAdmin.ts", "src/components/BackgroundTasks/BackgroundTasksWidget.tsx", - "src/components/Budget/BudgetPanelPerPersonInline.tsx", "src/components/Budget/ReceiptPreviewModal.tsx", "src/components/Budget/receiptUploads.ts", - "src/components/Budget/useBudgetPanel.ts", "src/components/Budget/useCostsLedger.ts", "src/components/Budget/useSettlementForm.ts", "src/components/Collab/CollabChatLinkPreview.tsx", diff --git a/client/scripts/rtl-baseline.json b/client/scripts/rtl-baseline.json index 5c270ae28..078942f13 100644 --- a/client/scripts/rtl-baseline.json +++ b/client/scripts/rtl-baseline.json @@ -1,8 +1,6 @@ { "components/Admin/AdminPluginsPanel.tsx": 2, "components/Budget/BudgetPanelMemberChips.tsx": 6, - "components/Budget/BudgetPanelPieChart.tsx": 1, - "components/Budget/BudgetPanelSummary.tsx": 2, "components/Budget/CostsPanel.tsx": 3, "components/Budget/ReceiptPreviewModal.tsx": 2, "components/Collab/CollabChatEmojiPicker.tsx": 3, diff --git a/client/src/components/Budget/BudgetPanel.test.tsx b/client/src/components/Budget/BudgetPanel.test.tsx deleted file mode 100644 index dfe36963e..000000000 --- a/client/src/components/Budget/BudgetPanel.test.tsx +++ /dev/null @@ -1,502 +0,0 @@ -// FE-COMP-BUDGET-001 to FE-COMP-BUDGET-040 -import { render, screen, waitFor } from '../../../tests/helpers/render'; -import userEvent from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; -import { server } from '../../../tests/helpers/msw/server'; -import { useAuthStore } from '../../store/authStore'; -import { useTripStore } from '../../store/tripStore'; -import { useSettingsStore } from '../../store/settingsStore'; -import { usePermissionsStore } from '../../store/permissionsStore'; -import { resetAllStores, seedStore } from '../../../tests/helpers/store'; -import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories'; -import BudgetPanel from './BudgetPanel'; - -beforeEach(() => { - resetAllStores(); - // Settlement and per-person APIs needed by BudgetPanel - server.use( - http.get('/api/trips/:id/budget/settlement', () => - HttpResponse.json({ balances: [], flows: [] }) - ), - http.get('/api/trips/:id/budget/per-person', () => - HttpResponse.json({ summary: [] }) - ), - ); - seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); - seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) }); -}); - -describe('BudgetPanel', () => { - it('FE-COMP-BUDGET-001: renders empty state when no budget items', async () => { - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })) - ); - render(); - expect(await screen.findByText('No budget created yet')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-002: shows empty state text body', async () => { - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })) - ); - render(); - expect(await screen.findByText(/Create categories and entries/i)).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-003: shows category input in empty state when user can edit', async () => { - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })) - ); - render(); - expect(await screen.findByPlaceholderText('Enter category name...')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-004: renders budget items from store after load', async () => { - const item = buildBudgetItem({ trip_id: 1, name: 'Hotel Paris', category: 'Accommodation' }); - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) - ); - render(); - expect(await screen.findByText('Hotel Paris')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-005: renders category section header', async () => { - const item = buildBudgetItem({ trip_id: 1, name: 'Flight to Rome', category: 'Transport' }); - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) - ); - render(); - // 'Transport' appears in the category section header and the spend breakdown chart. - expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0); - }); - - it('FE-COMP-BUDGET-006: renders budget table headers', async () => { - const item = buildBudgetItem({ trip_id: 1, category: 'Food' }); - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) - ); - render(); - await screen.findByText('Name'); - // 'Total' appears both as a table header and in the chart total label. - expect((await screen.findAllByText('Total')).length).toBeGreaterThan(0); - }); - - it('FE-COMP-BUDGET-007: shows Budget title heading', async () => { - const item = buildBudgetItem({ trip_id: 1, category: 'Other' }); - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) - ); - render(); - expect(await screen.findByText('Budget')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-008: shows CSV export button', async () => { - const item = buildBudgetItem({ trip_id: 1, category: 'Other' }); - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) - ); - render(); - expect(await screen.findByText('CSV')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-009: add item row visible in table', async () => { - const item = buildBudgetItem({ trip_id: 1, category: 'Food' }); - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) - ); - render(); - expect(await screen.findByPlaceholderText('New Entry')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-010: adding new item via form calls POST and shows item', async () => { - const user = userEvent.setup(); - const initialItem = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Existing' }); - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [initialItem] })), - http.post('/api/trips/1/budget', async ({ request }) => { - const body = await request.json() as Record; - const item = buildBudgetItem({ trip_id: 1, name: String(body.name || 'New Item'), category: 'Food' }); - return HttpResponse.json({ item }); - }) - ); - render(); - const nameInput = await screen.findByPlaceholderText('New Entry'); - await user.type(nameInput, 'Restaurant Dinner'); - const addBtn = screen.getByTitle('Add Reservation'); - await user.click(addBtn); - expect(await screen.findByText('Restaurant Dinner')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-011: delete button present for items when user can edit', async () => { - const item = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Test Item' }); - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) - ); - render(); - await screen.findByText('Test Item'); - // Delete button has title="Delete" - expect(screen.getByTitle('Delete')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-012: delete item removes it from the UI', async () => { - const user = userEvent.setup(); - const item = buildBudgetItem({ id: 42, trip_id: 1, category: 'Food', name: 'Item To Delete' }); - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), - http.delete('/api/trips/1/budget/42', () => HttpResponse.json({ success: true })) - ); - render(); - await screen.findByText('Item To Delete'); - await user.click(screen.getByTitle('Delete')); - await waitFor(() => { - expect(screen.queryByText('Item To Delete')).not.toBeInTheDocument(); - }); - }); - - it('FE-COMP-BUDGET-013: multiple items in same category all render', async () => { - const item1 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel A' }); - const item2 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel B' }); - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })) - ); - render(); - await screen.findByText('Hotel A'); - expect(await screen.findByText('Hotel B')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-014: items from different categories render separate sections', async () => { - const item1 = buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Flight' }); - const item2 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel' }); - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })) - ); - render(); - // Each category appears in its section header and again in the breakdown chart. - expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0); - expect((await screen.findAllByText('Hotels')).length).toBeGreaterThan(0); - }); - - it('FE-COMP-BUDGET-015: currency from settings store is used for default_currency display', async () => { - seedStore(useSettingsStore, { settings: buildSettings({ default_currency: 'USD' }) }); - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })) - ); - render(); - // Component renders even in empty state - expect(await screen.findByText('No budget created yet')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-016: trip currency EUR is shown in header for item rows', async () => { - seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) }); - const item = buildBudgetItem({ trip_id: 1, category: 'Other', name: 'Misc', total_price: 50 }); - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) - ); - render(); - expect(await screen.findByText('Misc')).toBeInTheDocument(); - // Row exists - EUR formatting would appear in values - }); - - it('FE-COMP-BUDGET-017: Delete Category button shown in category header', async () => { - const item = buildBudgetItem({ trip_id: 1, category: 'ToDelete', name: 'Item' }); - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) - ); - render(); - // 'ToDelete' appears in the category header and the breakdown chart. - expect((await screen.findAllByText('ToDelete')).length).toBeGreaterThan(0); - expect(screen.getByTitle('Delete Category')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-018: renders add item button (+ icon) in add row', async () => { - const item = buildBudgetItem({ trip_id: 1, category: 'Other' }); - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) - ); - render(); - await screen.findByPlaceholderText('New Entry'); - // The add button is present - expect(screen.getByTitle('Add Reservation')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-019: add item with Enter key submits the form', async () => { - const user = userEvent.setup(); - const initialItem = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Existing' }); - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [initialItem] })), - http.post('/api/trips/1/budget', async ({ request }) => { - const body = await request.json() as Record; - const item = buildBudgetItem({ trip_id: 1, name: String(body.name), category: 'Food' }); - return HttpResponse.json({ item }); - }) - ); - render(); - const nameInput = await screen.findByPlaceholderText('New Entry'); - await user.type(nameInput, 'Pizza{Enter}'); - expect(await screen.findByText('Pizza')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-020: component renders without crashing with empty tripMembers', async () => { - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })) - ); - render(); - expect(await screen.findByText('No budget created yet')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-021: inline edit name cell — clicking a name cell makes it editable', async () => { - const user = userEvent.setup(); - const item = { ...buildBudgetItem({ id: 21, trip_id: 1, category: 'Food', name: 'Old Name' }), total_price: 10 }; - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) - ); - render(); - await screen.findByText('Old Name'); - await user.click(screen.getByText('Old Name')); - expect(screen.getByDisplayValue('Old Name')).toBeInTheDocument(); - await user.keyboard('{Escape}'); - expect(screen.queryByDisplayValue('Old Name')).not.toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-022: inline edit name cell — saving new name calls PUT API', async () => { - const user = userEvent.setup(); - const item = { ...buildBudgetItem({ id: 10, trip_id: 1, category: 'Food', name: 'Old Name' }), total_price: 10 }; - let putCalled = false; - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), - http.put('/api/trips/1/budget/10', async ({ request }) => { - const b = await request.json() as Record; - putCalled = true; - return HttpResponse.json({ item: { ...item, name: b.name } }); - }) - ); - render(); - await screen.findByText('Old Name'); - await user.click(screen.getByText('Old Name')); - const input = screen.getByDisplayValue('Old Name'); - await user.clear(input); - await user.type(input, 'New Name'); - await user.tab(); - await waitFor(() => expect(putCalled).toBe(true)); - }); - - it('FE-COMP-BUDGET-023: total price is shown formatted with currency symbol', async () => { - const item = { ...buildBudgetItem({ id: 23, trip_id: 1, category: 'Restaurants', name: 'Dinner' }), total_price: 45.5 }; - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) - ); - render(); - await screen.findByText('Dinner'); - // The formatted number appears in the InlineEditCell for total price (and grand total card) - expect(screen.getAllByText('45.50').length).toBeGreaterThan(0); - // The currency symbol appears (in category subtotal or grand total card) - expect(screen.getAllByText(/€/).length).toBeGreaterThan(0); - }); - - it('FE-COMP-BUDGET-024: delete category button removes all items in that category', async () => { - const user = userEvent.setup(); - const item = { ...buildBudgetItem({ id: 24, trip_id: 1, category: 'Flights', name: 'Flight to Paris' }), total_price: 200 }; - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), - http.delete('/api/trips/1/budget/24', () => HttpResponse.json({ success: true })) - ); - render(); - await screen.findAllByText('Flights'); - await screen.findByText('Flight to Paris'); - await user.click(screen.getByTitle('Delete Category')); - await waitFor(() => { - expect(screen.queryAllByText('Flights').length).toBe(0); - expect(screen.queryByText('Flight to Paris')).not.toBeInTheDocument(); - }); - }); - - it('FE-COMP-BUDGET-025: CSV export button triggers download via URL.createObjectURL', async () => { - const createObjectURL = vi.fn(() => 'blob:test'); - vi.spyOn(URL, 'createObjectURL').mockImplementation(createObjectURL); - const user = userEvent.setup(); - const item = { ...buildBudgetItem({ trip_id: 1, category: 'Other', name: 'Misc' }), total_price: 10 }; - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) - ); - render(); - await screen.findByText('CSV'); - await user.click(screen.getByText('CSV')); - expect(createObjectURL).toHaveBeenCalled(); - vi.restoreAllMocks(); - }); - - it('FE-COMP-BUDGET-026: category total row shows sum of items in category', async () => { - const item1 = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 20 }; - const item2 = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Dinner' }), total_price: 30 }; - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })) - ); - render(); - await screen.findByText('Lunch'); - // The category header shows subtotal formatted as "50.00 €" (also appears in pie legend) - expect(screen.getAllByText('50.00 €').length).toBeGreaterThan(0); - }); - - it('FE-COMP-BUDGET-027: add new category input is visible in empty state', async () => { - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })) - ); - render(); - expect(await screen.findByPlaceholderText('Enter category name...')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-028: creating a new category via input calls POST and adds a section', async () => { - const user = userEvent.setup(); - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })), - http.post('/api/trips/1/budget', () => - HttpResponse.json({ item: { ...buildBudgetItem({ category: 'Souvenirs', name: 'New Entry' }), total_price: 0 } }) - ) - ); - render(); - const input = await screen.findByPlaceholderText('Enter category name...'); - await user.type(input, 'Souvenirs{Enter}'); - expect(await screen.findByText('Souvenirs')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-029: settlement section renders flows with usernames', async () => { - const user = userEvent.setup(); - const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Dinner' }), total_price: 100 }; - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), - http.get('/api/trips/1/budget/settlement', () => - HttpResponse.json({ - balances: [ - { user_id: 1, username: 'alice', balance: -10, avatar_url: null }, - { user_id: 2, username: 'bob', balance: 10, avatar_url: null }, - ], - flows: [ - { from: { user_id: 1, username: 'alice', avatar_url: null }, to: { user_id: 2, username: 'bob', avatar_url: null }, amount: 10 }, - ], - }) - ) - ); - const tripMembers = [ - { id: 1, username: 'alice', avatar_url: null }, - { id: 2, username: 'bob', avatar_url: null }, - ]; - render(); - await screen.findByText('Dinner'); - // Click the settlement toggle button (role button with name containing "settlement") - const settlementBtn = await screen.findByRole('button', { name: /settlement/i }); - await user.click(settlementBtn); - // alice and bob should appear in balances section - await screen.findByText('alice'); - expect(await screen.findByText('bob')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-030: per-person summary renders usernames', async () => { - const item = { - ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Shared Dinner' }), - total_price: 75, - members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: 0 }], - }; - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), - http.get('/api/trips/1/budget/summary/per-person', () => - HttpResponse.json({ summary: [{ user_id: 1, username: 'testuser', avatar_url: null, total_assigned: 75 }] }) - ) - ); - const tripMembers = [ - { id: 1, username: 'testuser', avatar_url: null }, - { id: 2, username: 'other', avatar_url: null }, - ]; - render(); - await screen.findByText('Shared Dinner'); - expect(await screen.findByText('testuser')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-032: grand total row shows sum across all categories', async () => { - const item1 = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Flight' }), total_price: 100 }; - const item2 = { ...buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel' }), total_price: 200 }; - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })) - ); - render(); - await screen.findByText('Flight'); - await screen.findByText('Hotel'); - // Grand total card shows 300.00 (integer and decimal are rendered in separate spans) - expect(document.body.textContent?.replace(/\s+/g, '')).toMatch(/300[,.]00/); - }); - - it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => { - // Restrict budget_edit to trip owners only; user is not the owner (owner_id=1, user.id > 1) - seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } }); - // Use a user with id != 1 so they're not the owner - seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); - seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) }); - const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 }; - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) - ); - render(); - await screen.findByText('Read Only Item'); - // In read-only mode the Delete button should not be visible - expect(screen.queryByTitle('Delete')).not.toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => { - seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } }); - seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); - seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) }); - const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' }; - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) - ); - render(); - await screen.findByText('Train'); - // expense_date is rendered as plain text in read-only mode - expect(await screen.findByText('2025-06-15')).toBeInTheDocument(); - }); - - it('FE-COMP-BUDGET-035: settlement section with avatar renders user avatar image', async () => { - const user = userEvent.setup(); - const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 60 }; - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), - http.get('/api/trips/1/budget/settlement', () => - HttpResponse.json({ - balances: [ - { user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg', balance: -30 }, - { user_id: 2, username: 'bob', avatar_url: null, balance: 30 }, - ], - flows: [{ from: { user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { user_id: 2, username: 'bob', avatar_url: null }, amount: 30 }] - }) - ), - http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] })), - ); - const tripMembers = [ - { id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, - { id: 2, username: 'bob', avatar_url: null }, - ]; - render(); - await screen.findByText('Lunch'); - // Trigger settlement display - const settlementBtn = await screen.findByRole('button', { name: /settlement/i }); - await user.click(settlementBtn); - await screen.findByText('alice'); - // Avatar image should be rendered for alice - const avatarImg = screen.getAllByRole('img'); - expect(avatarImg.length).toBeGreaterThan(0); - }); - - it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => { - seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } }); - seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); - seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) }); - const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null }; - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) - ); - render(); - await screen.findByText('Snack'); - // When expense_date is null, the fallback '—' is shown - const dashes = screen.getAllByText('—'); - expect(dashes.length).toBeGreaterThan(0); - }); -}); diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx deleted file mode 100644 index bca89cd97..000000000 --- a/client/src/components/Budget/BudgetPanel.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { Plus, Calculator, Download } from 'lucide-react' -import CustomSelect from '../shared/CustomSelect' -import { currenciesWith, SYMBOLS } from './BudgetPanel.constants' -import { useBudgetPanel } from './useBudgetPanel' -import type { TripMember } from './BudgetPanelMemberChips' -import BudgetCategoryTable from './BudgetPanelCategoryTable' -import BudgetSummary from './BudgetPanelSummary' - -export { splitColorFor } from './BudgetPanel.helpers' - -// ── Main Component ─────────────────────────────────────────────────────────── -interface BudgetPanelProps { - tripId: number - tripMembers?: TripMember[] -} - -export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) { - const { - budgetItems, - setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories, - t, locale, isDark, theme, - newCategoryName, setNewCategoryName, - editingCat, setEditingCat, - settlement, settlementOpen, setSettlementOpen, - currency, canEdit, fmt, hasMultipleMembers, - dragCat, setDragCat, dragOverCat, setDragOverCat, - dragItem, setDragItem, dragOverItem, setDragOverItem, dragItemCat, setDragItemCat, - setCurrency, - grouped, categoryNames, categoryColor, grandTotal, pieSegments, - handleAddItem, handleUpdateField, handleDeleteItem, handleDeleteCategory, handleRenameCategory, handleAddCategory, handleExportCsv, - th, td, - } = useBudgetPanel(tripId, tripMembers) - - // ── Empty State ────────────────────────────────────────────────────────── - if (!budgetItems || budgetItems.length === 0) { - return ( -
-
- -
-

{t('budget.emptyTitle')}

-

{t('budget.emptyText')}

- {canEdit && ( -
- setNewCategoryName(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleAddCategory()} - placeholder={t('budget.emptyPlaceholder')} - style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} /> - -
- )} -
- ) - } - - // ── Main Layout ────────────────────────────────────────────────────────── - return ( -
-
-
-

- {t('budget.title')} -

-
-
- ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))} - searchable - /> -
- {canEdit && ( -
- setNewCategoryName(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }} - placeholder={t('budget.categoryName')} - style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }} - /> - -
- )} - -
-
-
- -
-
- {categoryNames.map(cat => ( - - ))} -
- - -
-
- ) -} diff --git a/client/src/components/Budget/BudgetPanelAddItemRow.test.tsx b/client/src/components/Budget/BudgetPanelAddItemRow.test.tsx deleted file mode 100644 index f65a3d135..000000000 --- a/client/src/components/Budget/BudgetPanelAddItemRow.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -// FE-W4AIR-001 to FE-W4AIR-009 -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, fireEvent } from '../../../tests/helpers/render' -import AddItemRow from './BudgetPanelAddItemRow' - -const t = (key: string) => key - -function setup() { - const onAdd = vi.fn() - const utils = render(
) - return { onAdd, ...utils } -} - -const nameInput = () => screen.getByPlaceholderText('budget.newEntry') -const priceInput = () => screen.getByPlaceholderText('0,00') -const noteInput = () => screen.getByPlaceholderText('budget.table.note') -const numberInputs = () => screen.getAllByPlaceholderText('-') - -beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }) -}) - -afterEach(() => { - vi.useRealTimers() -}) - -describe('BudgetPanelAddItemRow', () => { - it('FE-W4AIR-001: the add button stays disabled until a name is typed', () => { - setup() - const button = screen.getByRole('button', { name: 'reservations.add' }) - - expect(button).toBeDisabled() - fireEvent.change(nameInput(), { target: { value: 'Ferry' } }) - expect(button).toBeEnabled() - }) - - it('FE-W4AIR-002: submits the trimmed name with parsed numbers', () => { - const { onAdd } = setup() - - fireEvent.change(nameInput(), { target: { value: ' Ferry ' } }) - fireEvent.change(priceInput(), { target: { value: '129,90' } }) - fireEvent.change(numberInputs()[0], { target: { value: '2' } }) - fireEvent.change(numberInputs()[1], { target: { value: '3' } }) - fireEvent.change(noteInput(), { target: { value: ' one way ' } }) - fireEvent.click(screen.getByRole('button', { name: 'reservations.add' })) - - expect(onAdd).toHaveBeenCalledWith({ - name: 'Ferry', total_price: 129.9, persons: 2, days: 3, note: 'one way', expense_date: null, - }) - }) - - it('FE-W4AIR-003: falls back to zero price and null optionals', () => { - const { onAdd } = setup() - - fireEvent.change(nameInput(), { target: { value: 'Ferry' } }) - fireEvent.click(screen.getByRole('button', { name: 'reservations.add' })) - - expect(onAdd).toHaveBeenCalledWith({ - name: 'Ferry', total_price: 0, persons: null, days: null, note: null, expense_date: null, - }) - }) - - it('FE-W4AIR-004: ignores a whitespace-only name', () => { - const { onAdd } = setup() - - fireEvent.change(nameInput(), { target: { value: ' ' } }) - fireEvent.keyDown(nameInput(), { key: 'Enter' }) - - expect(onAdd).not.toHaveBeenCalled() - }) - - it('FE-W4AIR-005: Enter in any field submits the row', () => { - const { onAdd } = setup() - fireEvent.change(nameInput(), { target: { value: 'Ferry' } }) - - fireEvent.keyDown(priceInput(), { key: 'Enter' }) - expect(onAdd).toHaveBeenCalledTimes(1) - }) - - it('FE-W4AIR-006: a non-Enter key does not submit', () => { - const { onAdd } = setup() - fireEvent.change(nameInput(), { target: { value: 'Ferry' } }) - - fireEvent.keyDown(nameInput(), { key: 'a' }) - - expect(onAdd).not.toHaveBeenCalled() - }) - - it('FE-W4AIR-007: clears the row and refocuses the name field after adding', () => { - setup() - fireEvent.change(nameInput(), { target: { value: 'Ferry' } }) - fireEvent.change(priceInput(), { target: { value: '12' } }) - - fireEvent.click(screen.getByRole('button', { name: 'reservations.add' })) - - expect(nameInput()).toHaveValue('') - expect(priceInput()).toHaveValue('') - vi.advanceTimersByTime(60) - expect(nameInput()).toHaveFocus() - }) - - it('FE-W4AIR-008: pasting a formatted amount normalizes the separators', () => { - setup() - - fireEvent.paste(priceInput(), { clipboardData: { getData: () => '1.234,56 EUR' } }) - expect(priceInput()).toHaveValue('1234.56') - - fireEvent.paste(priceInput(), { clipboardData: { getData: () => '$2,345.67' } }) - expect(priceInput()).toHaveValue('2345.67') - }) - - it('FE-W4AIR-009: pasting a separator-free amount keeps the digits', () => { - setup() - - fireEvent.paste(priceInput(), { clipboardData: { getData: () => 'EUR 4200' } }) - - expect(priceInput()).toHaveValue('4200') - }) -}) diff --git a/client/src/components/Budget/BudgetPanelAddItemRow.tsx b/client/src/components/Budget/BudgetPanelAddItemRow.tsx deleted file mode 100644 index f19e438d2..000000000 --- a/client/src/components/Budget/BudgetPanelAddItemRow.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useState, useRef } from 'react' -import { Plus } from 'lucide-react' -import { CustomDatePicker } from '../shared/CustomDateTimePicker' -import { normalizePastedAmount } from './BudgetPanel.helpers' - -interface AddItemRowProps { - onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void - t: (key: string) => string -} - -export default function AddItemRow({ onAdd, t }: AddItemRowProps) { - const [name, setName] = useState('') - const [price, setPrice] = useState('') - const [persons, setPersons] = useState('') - const [days, setDays] = useState('') - const [note, setNote] = useState('') - const [expenseDate, setExpenseDate] = useState('') - const nameRef = useRef(null) - - const handleAdd = () => { - if (!name.trim()) return - onAdd({ name: name.trim(), total_price: Number.parseFloat(String(price).replace(',', '.')) || 0, persons: Number.parseInt(persons) || null, days: Number.parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null }) - setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('') - setTimeout(() => nameRef.current?.focus(), 50) - } - - const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' } - - return ( - - - setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - placeholder={t('budget.newEntry')} style={inp} /> - - - setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - onPaste={e => { e.preventDefault(); setPrice(normalizePastedAmount(e.clipboardData.getData('text'))) }} - placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} /> - - - setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> - - - setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> - - - - - - - - -
- -
- - - setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} /> - - - - - - ) -} diff --git a/client/src/components/Budget/BudgetPanelCategoryTable.test.tsx b/client/src/components/Budget/BudgetPanelCategoryTable.test.tsx deleted file mode 100644 index 16179733e..000000000 --- a/client/src/components/Budget/BudgetPanelCategoryTable.test.tsx +++ /dev/null @@ -1,613 +0,0 @@ -// FE-W4BCT-001 to FE-W4BCT-053 -import type { CSSProperties } from 'react' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import type { BudgetItem } from '../../types' -import { render, screen, fireEvent, createEvent, within } from '../../../tests/helpers/render' -import BudgetCategoryTable from './BudgetPanelCategoryTable' -import type { TripMember } from './BudgetPanelMemberChips' - -const contribFor = vi.fn((_id: number) => [] as unknown[]) - -vi.mock('../Plugins/PluginContributions', () => ({ - usePluginViewContributions: () => contribFor, - PluginCardFooter: ({ items }: { items: unknown[] }) =>
{items.length}
, -})) - -const TRIP_MEMBERS: TripMember[] = [ - { id: 1, username: 'ada', avatar_url: null }, - { id: 2, username: 'bob', avatar_url: null }, -] - -function budgetItem(overrides: Partial = {}): BudgetItem { - return { - id: 1, trip_id: 7, category: 'Food', name: 'Ferry', total_price: 100, - persons: 2, days: 5, note: null, expense_date: null, reservation_id: null, members: [], - ...overrides, - } as unknown as BudgetItem -} - -type Props = Parameters[0] - -function setup(overrides: Partial = {}, items: BudgetItem[] = [budgetItem()]) { - const spies = { - setEditingCat: vi.fn(), - setDragCat: vi.fn(), - setDragOverCat: vi.fn(), - setDragItem: vi.fn(), - setDragOverItem: vi.fn(), - setDragItemCat: vi.fn(), - reorderBudgetCategories: vi.fn(async () => {}), - reorderBudgetItems: vi.fn(async () => {}), - handleRenameCategory: vi.fn(async () => {}), - handleDeleteCategory: vi.fn(async () => {}), - handleDeleteItem: vi.fn(async () => {}), - handleUpdateField: vi.fn(async () => {}), - handleAddItem: vi.fn(async () => {}), - setBudgetItemMembers: vi.fn(async () => ({ members: [], item: {} })), - toggleBudgetMemberPaid: vi.fn(async () => {}), - } - const props = { - cat: 'Food', - grouped: new Map([['Food', items]]), - categoryColor: () => '#ef4444', - canEdit: true, - editingCat: null, - dragCat: null, - dragOverCat: null, - dragItem: null, - dragOverItem: null, - dragItemCat: null, - categoryNames: ['Transport', 'Food', 'Hotels'], - tripId: 7, - currency: 'EUR', - locale: 'en-US', - t: (key: string) => key, - fmt: (v: number | null | undefined, cur: string) => `${v ?? '-'} ${cur}`, - hasMultipleMembers: false, - tripMembers: TRIP_MEMBERS, - th: {} as CSSProperties, - td: {} as CSSProperties, - ...spies, - ...overrides, - } as unknown as Props - - const utils = render() - return { ...spies, ...utils } -} - -/** dragleave carrying a relatedTarget — jsdom lacks DragEvent, so fireEvent drops it. */ -function dragLeaveInto(target: Element, relatedTarget: Element) { - const event = createEvent.dragLeave(target) - Object.defineProperty(event, 'relatedTarget', { value: relatedTarget }) - fireEvent(target, event) -} - -beforeEach(() => { - contribFor.mockReset() - contribFor.mockReturnValue([]) -}) - -describe('BudgetCategoryTable — header', () => { - it('FE-W4BCT-001: shows the category name and the summed subtotal', () => { - setup({}, [budgetItem(), budgetItem({ id: 2, total_price: 50 })]) - - expect(screen.getByText('Food')).toBeInTheDocument() - expect(screen.getByText('150 EUR')).toBeInTheDocument() - }) - - it('FE-W4BCT-002: treats a priceless item as zero in the subtotal', () => { - const { container } = setup({}, [budgetItem({ total_price: null } as Partial)]) - - // The subtotal sits in the black category header, before the table. - expect(container.querySelectorAll('span')[1]).toHaveTextContent('0 EUR') - }) - - it('FE-W4BCT-003: renders an empty category with only the add row', () => { - setup({ grouped: new Map() } as Partial) - - expect(screen.getByPlaceholderText('budget.newEntry')).toBeInTheDocument() - expect(screen.queryByDisplayValue('Ferry')).toBeNull() - }) - - it('FE-W4BCT-004: the pencil starts renaming the category', () => { - const { setEditingCat } = setup() - - fireEvent.click(screen.getAllByRole('button')[0]) - - expect(setEditingCat).toHaveBeenCalledWith({ name: 'Food', value: 'Food' }) - }) - - it('FE-W4BCT-005: Enter in the rename input commits and closes the editor', () => { - const { handleRenameCategory, setEditingCat } = setup({ editingCat: { name: 'Food', value: 'Groceries' } }) - const input = screen.getByDisplayValue('Groceries') - - fireEvent.keyDown(input, { key: 'Enter' }) - - expect(handleRenameCategory).toHaveBeenCalledWith('Food', 'Groceries') - expect(setEditingCat).toHaveBeenCalledWith(null) - }) - - it('FE-W4BCT-006: blurring the rename input commits it', () => { - const { handleRenameCategory } = setup({ editingCat: { name: 'Food', value: 'Groceries' } }) - - fireEvent.blur(screen.getByDisplayValue('Groceries')) - - expect(handleRenameCategory).toHaveBeenCalledWith('Food', 'Groceries') - }) - - it('FE-W4BCT-007: Escape abandons the rename', () => { - const { handleRenameCategory, setEditingCat } = setup({ editingCat: { name: 'Food', value: 'Groceries' } }) - - fireEvent.keyDown(screen.getByDisplayValue('Groceries'), { key: 'Escape' }) - - expect(handleRenameCategory).not.toHaveBeenCalled() - expect(setEditingCat).toHaveBeenCalledWith(null) - }) - - it('FE-W4BCT-008: typing updates the pending rename value', () => { - const { setEditingCat } = setup({ editingCat: { name: 'Food', value: 'Food' } }) - - fireEvent.change(screen.getByDisplayValue('Food'), { target: { value: 'Fuel' } }) - - expect(setEditingCat).toHaveBeenCalledWith({ name: 'Food', value: 'Fuel' }) - }) - - it('FE-W4BCT-009: the trash button deletes the category', () => { - const { handleDeleteCategory } = setup() - - fireEvent.click(screen.getByTitle('budget.deleteCategory')) - - expect(handleDeleteCategory).toHaveBeenCalledWith('Food') - }) - - it('FE-W4BCT-010: a read-only table hides every editing affordance', () => { - setup({ canEdit: false }) - - expect(screen.queryByTitle('budget.deleteCategory')).toBeNull() - expect(screen.queryByTitle('common.delete')).toBeNull() - expect(screen.queryByPlaceholderText('budget.newEntry')).toBeNull() - expect(document.querySelectorAll('[draggable="true"]')).toHaveLength(0) - }) -}) - -describe('BudgetCategoryTable — rows', () => { - it('FE-W4BCT-011: derives the per-person, per-day and per-person-day figures', () => { - setup({}, [budgetItem({ total_price: 100, persons: 2, days: 5 })]) - - expect(screen.getByText('50 EUR')).toBeInTheDocument() - expect(screen.getByText('20 EUR')).toBeInTheDocument() - expect(screen.getByText('10 EUR')).toBeInTheDocument() - }) - - it('FE-W4BCT-012: blanks the per-person columns for a custom member split', () => { - setup({}, [budgetItem({ - total_price: 100, persons: 2, days: 5, - members: [{ user_id: 1, username: 'ada', amount: 70 }, { user_id: 2, username: 'bob', amount: 30 }], - } as unknown as Partial)]) - - // per-day still resolves; per-person and per-person-day are dashed out. - expect(screen.getByText('20 EUR')).toBeInTheDocument() - expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(2) - }) - - it('FE-W4BCT-013: deleting a row reports its id', () => { - const { handleDeleteItem } = setup() - - fireEvent.click(screen.getByTitle('common.delete')) - - expect(handleDeleteItem).toHaveBeenCalledWith(1) - }) - - it('FE-W4BCT-014: editing the name cell saves through handleUpdateField', () => { - const { handleUpdateField } = setup() - - fireEvent.click(screen.getByText('Ferry')) - const input = screen.getByDisplayValue('Ferry') - fireEvent.change(input, { target: { value: 'Bus' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - - expect(handleUpdateField).toHaveBeenCalledWith(1, 'name', 'Bus') - }) - - it('FE-W4BCT-015: a reservation-linked row locks the name cell', () => { - setup({}, [budgetItem({ reservation_id: 42 } as Partial)]) - - fireEvent.click(screen.getByText('Ferry')) - - expect(screen.queryByDisplayValue('Ferry')).toBeNull() - expect(screen.getByText('Ferry')).not.toHaveAttribute('title') - }) - - it('FE-W4BCT-016: the persons cell coerces the entry to an integer', () => { - const { handleUpdateField } = setup() - - fireEvent.click(screen.getByText('2')) - const input = screen.getByDisplayValue('2') - fireEvent.change(input, { target: { value: '4.7' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - - expect(handleUpdateField).toHaveBeenCalledWith(1, 'persons', 4) - }) - - it('FE-W4BCT-017: clearing the days cell stores null', () => { - const { handleUpdateField } = setup() - - fireEvent.click(screen.getByText('5')) - const input = screen.getByDisplayValue('5') - fireEvent.change(input, { target: { value: '' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - - expect(handleUpdateField).toHaveBeenCalledWith(1, 'days', null) - }) - - it('FE-W4BCT-018: swaps the persons cell for member chips on a multi-member trip', () => { - setup({ hasMultipleMembers: true }, [budgetItem({ members: [{ user_id: 1, username: 'ada', paid: 0 }] } as unknown as Partial)]) - - // One chip in the persons column and one in the mobile stack under the name. - expect(screen.getAllByText('A')).toHaveLength(2) - }) - - it('FE-W4BCT-019: shows the raw expense date instead of a picker when read-only', () => { - setup({ canEdit: false }, [budgetItem({ expense_date: '2026-06-15' } as Partial)]) - - expect(screen.getByText('2026-06-15')).toBeInTheDocument() - }) - - it('FE-W4BCT-020: falls back to an em dash for a read-only row without a date', () => { - setup({ canEdit: false }) - - expect(screen.getByText('—')).toBeInTheDocument() - }) - - it('FE-W4BCT-021: appends a plugin footer row when a plugin contributes', () => { - contribFor.mockReturnValue([{ kind: 'column' }]) - setup() - - expect(screen.getByTestId('plugin-footer')).toHaveTextContent('1') - }) - - it('FE-W4BCT-022: adding an item routes the payload into the category', () => { - const { handleAddItem } = setup() - - fireEvent.change(screen.getByPlaceholderText('budget.newEntry'), { target: { value: 'Taxi' } }) - fireEvent.click(screen.getByTitle('reservations.add')) - - expect(handleAddItem).toHaveBeenCalledWith('Food', expect.objectContaining({ name: 'Taxi' })) - }) -}) - -describe('BudgetCategoryTable — drag and drop', () => { - it('FE-W4BCT-023: the category handle starts and ends a category drag', () => { - const { setDragCat, setDragOverCat, container } = setup() - const handle = container.querySelectorAll('[draggable="true"]')[0] as HTMLElement - - fireEvent.dragStart(handle, { dataTransfer: { effectAllowed: '', setData: vi.fn() } }) - expect(setDragCat).toHaveBeenCalledWith('Food') - - fireEvent.dragEnd(handle) - expect(setDragCat).toHaveBeenLastCalledWith(null) - expect(setDragOverCat).toHaveBeenCalledWith(null) - }) - - it('FE-W4BCT-024: dragging another category over this one marks the drop line', () => { - const { setDragOverCat, container } = setup({ dragCat: 'Transport' }) - - fireEvent.dragOver(container.firstElementChild!, { dataTransfer: { dropEffect: '' } }) - - expect(setDragOverCat).toHaveBeenCalledWith('Food') - }) - - it('FE-W4BCT-025: ignores a drag-over from the same category or from an item', () => { - const same = setup({ dragCat: 'Food' }) - fireEvent.dragOver(same.container.firstElementChild!, { dataTransfer: { dropEffect: '' } }) - expect(same.setDragOverCat).not.toHaveBeenCalled() - - const item = setup({ dragCat: 'Transport', dragItem: 5 }) - fireEvent.dragOver(item.container.firstElementChild!, { dataTransfer: { dropEffect: '' } }) - expect(item.setDragOverCat).not.toHaveBeenCalled() - }) - - it('FE-W4BCT-026: dropping a category reorders the list around this one', () => { - const { reorderBudgetCategories, setDragCat, container } = setup({ dragCat: 'Hotels' }) - - fireEvent.drop(container.firstElementChild!) - - expect(reorderBudgetCategories).toHaveBeenCalledWith(7, ['Transport', 'Hotels', 'Food']) - expect(setDragCat).toHaveBeenCalledWith(null) - }) - - it('FE-W4BCT-027: dropping a category on itself only clears the drag state', () => { - const { reorderBudgetCategories, setDragOverCat, container } = setup({ dragCat: 'Food' }) - - fireEvent.drop(container.firstElementChild!) - - expect(reorderBudgetCategories).not.toHaveBeenCalled() - expect(setDragOverCat).toHaveBeenCalledWith(null) - }) - - it('FE-W4BCT-028: leaving the category clears the drop marker', () => { - const { setDragOverCat, container } = setup({ dragCat: 'Transport' }) - - fireEvent.dragLeave(container.firstElementChild!, { relatedTarget: document.body }) - - expect(setDragOverCat).toHaveBeenCalledWith(null) - }) - - it('FE-W4BCT-029: dragging a row over a sibling marks it as the target', () => { - const rows = [budgetItem(), budgetItem({ id: 2, name: 'Bus' })] - const { setDragOverItem, container } = setup({ dragItem: 2, dragItemCat: 'Food' }, rows) - - fireEvent.dragOver(container.querySelectorAll('tbody tr')[0], { dataTransfer: { dropEffect: '' } }) - - expect(setDragOverItem).toHaveBeenCalledWith(1) - }) - - it('FE-W4BCT-030: dropping a row reorders the ids inside the category', () => { - const rows = [budgetItem(), budgetItem({ id: 2, name: 'Bus' }), budgetItem({ id: 3, name: 'Taxi' })] - const { reorderBudgetItems, setDragItem, container } = setup({ dragItem: 3, dragItemCat: 'Food' }, rows) - - fireEvent.drop(container.querySelectorAll('tbody tr')[0]) - - expect(reorderBudgetItems).toHaveBeenCalledWith(7, [3, 1, 2]) - expect(setDragItem).toHaveBeenCalledWith(null) - }) - - it('FE-W4BCT-031: a row from another category is not reordered here', () => { - const { reorderBudgetItems, container } = setup({ dragItem: 9, dragItemCat: 'Transport' }) - - fireEvent.drop(container.querySelectorAll('tbody tr')[0]) - - expect(reorderBudgetItems).not.toHaveBeenCalled() - }) - - it('FE-W4BCT-032: the row handle starts and ends an item drag', () => { - const { setDragItem, setDragItemCat, container } = setup() - const handle = container.querySelectorAll('[draggable="true"]')[1] as HTMLElement - - fireEvent.dragStart(handle, { dataTransfer: { effectAllowed: '' } }) - expect(setDragItem).toHaveBeenCalledWith(1) - expect(setDragItemCat).toHaveBeenCalledWith('Food') - - fireEvent.dragEnd(handle) - expect(setDragItem).toHaveBeenLastCalledWith(null) - expect(setDragItemCat).toHaveBeenLastCalledWith(null) - }) - - it('FE-W4BCT-033: leaving a row clears the item drop marker', () => { - const { setDragOverItem, container } = setup() - - fireEvent.dragLeave(container.querySelectorAll('tbody tr')[0], { relatedTarget: document.body }) - - expect(setDragOverItem).toHaveBeenCalledWith(null) - }) - - it('FE-W4BCT-034: dims the dragged category and marks the drop line', () => { - const dragged = setup({ dragCat: 'Food' }) - expect((dragged.container.firstElementChild as HTMLElement).style.opacity).toBe('0.4') - - const over = setup({ dragCat: 'Transport', dragOverCat: 'Food' }) - const marker = within(over.container.firstElementChild as HTMLElement).getAllByRole('generic') - expect(marker.length).toBeGreaterThan(0) - expect((over.container.firstElementChild as HTMLElement).firstElementChild).toHaveStyle({ height: '4px' }) - }) - - it('FE-W4BCT-035: dims the dragged row and outlines the drop target', () => { - const dragged = setup({ dragItem: 1 }) - expect((dragged.container.querySelector('tbody tr') as HTMLElement).style.opacity).toBe('0.4') - - const over = setup({ dragOverItem: 1 }) - expect((over.container.querySelector('tbody tr') as HTMLElement).style.boxShadow).toBe('inset 4px 0 0 0 var(--accent)') - }) - - it('FE-W4BCT-036: the table body accepts a category drop only while one is being dragged', () => { - const dragging = setup({ dragCat: 'Transport' }) - const activeTransfer = { dropEffect: '' } - fireEvent.dragOver(dragging.container.querySelector('table')!.parentElement!, { dataTransfer: activeTransfer }) - expect(activeTransfer.dropEffect).toBe('move') - - const idle = setup() - const idleTransfer = { dropEffect: '' } - fireEvent.dragOver(idle.container.querySelector('table')!.parentElement!, { dataTransfer: idleTransfer }) - expect(idleTransfer.dropEffect).toBe('') - }) - - it('FE-W4BCT-037: a row accepts a dragged category without becoming an item drop target', () => { - const { setDragOverItem, container } = setup({ dragCat: 'Transport' }) - const transfer = { dropEffect: '' } - - fireEvent.dragOver(container.querySelectorAll('tbody tr')[0], { dataTransfer: transfer }) - - expect(transfer.dropEffect).toBe('move') - expect(setDragOverItem).not.toHaveBeenCalled() - }) - - it('FE-W4BCT-038: moving between a row and its own cells keeps the drop marker', () => { - const { setDragOverItem, setDragOverCat, container } = setup({ dragCat: 'Transport', dragItem: 2, dragItemCat: 'Food' }) - const row = container.querySelectorAll('tbody tr')[0] - - // jsdom has no DragEvent, so relatedTarget has to be attached by hand. - dragLeaveInto(row, row.querySelector('td')!) - dragLeaveInto(container.firstElementChild as HTMLElement, container.querySelector('table')!) - - expect(setDragOverItem).not.toHaveBeenCalled() - expect(setDragOverCat).not.toHaveBeenCalled() - }) -}) - -describe('BudgetCategoryTable — hover affordances', () => { - it('FE-W4BCT-039: the rename pencil brightens while hovered', () => { - setup() - const pencil = screen.getAllByRole('button')[0] - - fireEvent.mouseEnter(pencil) - expect(pencil.style.color).toBe('rgb(255, 255, 255)') - - fireEvent.mouseLeave(pencil) - expect(pencil.style.color).toBe('rgba(255, 255, 255, 0.4)') - }) - - it('FE-W4BCT-040: the delete-category button fades in while hovered', () => { - setup() - const trash = screen.getByTitle('budget.deleteCategory') - - fireEvent.mouseEnter(trash) - expect(trash.style.opacity).toBe('1') - - fireEvent.mouseLeave(trash) - expect(trash.style.opacity).toBe('0.6') - }) - - it('FE-W4BCT-041: a row highlights while hovered', () => { - const { container } = setup() - const row = container.querySelector('tbody tr') as HTMLElement - - fireEvent.mouseEnter(row) - expect(row.style.background).toBe('var(--bg-hover)') - - fireEvent.mouseLeave(row) - expect(row.style.background).toBe('transparent') - }) - - it('FE-W4BCT-042: the delete-row button turns red while hovered', () => { - setup() - const trash = screen.getByTitle('common.delete') - - fireEvent.mouseEnter(trash) - expect(trash.style.color).toBe('rgb(239, 68, 68)') - - fireEvent.mouseLeave(trash) - expect(trash.style.color).toBe('rgb(209, 213, 219)') - }) -}) - -describe('BudgetCategoryTable — remaining cells', () => { - it('FE-W4BCT-043: editing the total saves the parsed number', () => { - const { handleUpdateField } = setup() - - fireEvent.click(screen.getByText('100.00')) - const input = screen.getByDisplayValue('100') - fireEvent.change(input, { target: { value: '120,50' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - - expect(handleUpdateField).toHaveBeenCalledWith(1, 'total_price', 120.5) - }) - - it('FE-W4BCT-044: a zero-decimal currency drops the decimals from the amount placeholder', () => { - setup({ currency: 'JPY' }, [budgetItem({ total_price: null } as Partial)]) - - expect(screen.getByText('0')).toBeInTheDocument() - expect(screen.queryByText('0,00')).toBeNull() - }) - - it('FE-W4BCT-045: editing the note saves it', () => { - const { handleUpdateField } = setup({}, [budgetItem({ note: 'Return trip' } as Partial)]) - - fireEvent.click(screen.getByText('Return trip')) - const input = screen.getByDisplayValue('Return trip') - fireEvent.change(input, { target: { value: 'One way' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - - expect(handleUpdateField).toHaveBeenCalledWith(1, 'note', 'One way') - }) - - it('FE-W4BCT-046: picking a day from the date cell stores the ISO date', () => { - const { handleUpdateField, container } = setup({}, [budgetItem({ expense_date: '2026-06-15' } as Partial)]) - const dateCell = container.querySelectorAll('tbody tr')[0].querySelectorAll('td')[7] - - fireEvent.click(dateCell.querySelector('button')!) - const day20 = screen.getAllByRole('button').find(b => b.textContent?.trim() === '20') - fireEvent.click(day20!) - - expect(handleUpdateField).toHaveBeenCalledWith(1, 'expense_date', '2026-06-20') - }) - - it('FE-W4BCT-047: clearing the date cell stores null', () => { - const { handleUpdateField, container } = setup({}, [budgetItem({ expense_date: '2026-06-15' } as Partial)]) - const dateCell = container.querySelectorAll('tbody tr')[0].querySelectorAll('td')[7] - - fireEvent.click(dateCell.querySelector('button')!) - fireEvent.click(screen.getByLabelText('Clear date')) - - expect(handleUpdateField).toHaveBeenCalledWith(1, 'expense_date', null) - }) - - it('FE-W4BCT-048: a non-numeric persons entry stores null', () => { - const { handleUpdateField } = setup() - - fireEvent.click(screen.getByText('2')) - const input = screen.getByDisplayValue('2') - fireEvent.change(input, { target: { value: 'abc' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - - expect(handleUpdateField).toHaveBeenCalledWith(1, 'persons', null) - }) - - it('FE-W4BCT-049: zero days is stored as null rather than 0', () => { - const { handleUpdateField } = setup() - - fireEvent.click(screen.getByText('5')) - const input = screen.getByDisplayValue('5') - fireEvent.change(input, { target: { value: '0' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - - expect(handleUpdateField).toHaveBeenCalledWith(1, 'days', null) - }) - - it('FE-W4BCT-050: an item without persons, days or members dashes out every derived column', () => { - const { container } = setup({}, [budgetItem({ persons: null, days: null, members: undefined } as unknown as Partial)]) - const cells = container.querySelectorAll('tbody tr')[0].querySelectorAll('td') - - // per person, per day, per person-day — none of them is derivable. - expect(cells[4]).toHaveTextContent('-') - expect(cells[5]).toHaveTextContent('-') - expect(cells[6]).toHaveTextContent('-') - expect(screen.queryByText('100.00 EUR')).toBeNull() - }) -}) - -describe('BudgetCategoryTable — member chips', () => { - const withMembers = () => setup( - { hasMultipleMembers: true }, - [budgetItem({ members: [{ user_id: 1, username: 'ada', paid: 0 }] } as unknown as Partial)], - ) - - it('FE-W4BCT-051: tapping a chip toggles that member’s paid flag', () => { - const { toggleBudgetMemberPaid } = withMembers() - - // The chip is rendered twice: the mobile stack under the name and the persons column. - const chips = screen.getAllByText('A') - fireEvent.click(chips[0]) - fireEvent.click(chips[1]) - - expect(toggleBudgetMemberPaid).toHaveBeenCalledTimes(2) - expect(toggleBudgetMemberPaid).toHaveBeenCalledWith(7, 1, 1, true) - }) - - it('FE-W4BCT-052: picking a member from either chip dropdown sets the item members', () => { - const { setBudgetItemMembers, container } = withMembers() - // The mobile stack under the name and the persons column both carry an editor. - // Assigned chips are buttons of their own, so the editors are the two that - // carry the picker icon. - const editors = Array.from(container.querySelectorAll('td button')) - .filter(b => b.querySelector('.lucide-pencil, .lucide-users')) - - fireEvent.click(editors[0]) - fireEvent.click(screen.getByText('bob')) - fireEvent.click(editors[0]) // picking an option leaves the dropdown open - fireEvent.click(editors[1]) - fireEvent.click(screen.getByText('bob')) - - expect(setBudgetItemMembers).toHaveBeenCalledTimes(2) - expect(setBudgetItemMembers).toHaveBeenCalledWith(7, 1, [1, 2]) - }) - - it('FE-W4BCT-053: read-only member chips expose no editing controls', () => { - setup( - { hasMultipleMembers: true, canEdit: false }, - [budgetItem({ members: [{ user_id: 1, username: 'ada', paid: 1 }] } as unknown as Partial)], - ) - - expect(screen.getAllByText('A')).toHaveLength(2) - expect(document.querySelectorAll('table button')).toHaveLength(0) - }) -}) diff --git a/client/src/components/Budget/BudgetPanelCategoryTable.tsx b/client/src/components/Budget/BudgetPanelCategoryTable.tsx deleted file mode 100644 index eac33f5ca..000000000 --- a/client/src/components/Budget/BudgetPanelCategoryTable.tsx +++ /dev/null @@ -1,273 +0,0 @@ -import { Fragment, type CSSProperties, type Dispatch, type SetStateAction } from 'react' -import { Trash2, Pencil, GripVertical } from 'lucide-react' -import type { BudgetItem } from '../../types' -import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions' -import { currencyDecimals } from '../../utils/formatters' -import { CustomDatePicker } from '../shared/CustomDateTimePicker' -import { calcPP, calcPD, calcPPD, hasCustomMemberSplit } from './BudgetPanel.helpers' -import InlineEditCell from './BudgetPanelInlineEditCell' -import AddItemRow from './BudgetPanelAddItemRow' -import BudgetMemberChips, { type TripMember } from './BudgetPanelMemberChips' -import type { EditingCat, AddItemData } from './useBudgetPanel' - -interface BudgetCategoryTableProps { - cat: string - grouped: Map - categoryColor: (cat: string) => string - canEdit: boolean - editingCat: EditingCat | null - setEditingCat: Dispatch> - dragCat: string | null - setDragCat: Dispatch> - dragOverCat: string | null - setDragOverCat: Dispatch> - dragItem: number | null - setDragItem: Dispatch> - dragOverItem: number | null - setDragOverItem: Dispatch> - dragItemCat: string | null - setDragItemCat: Dispatch> - categoryNames: string[] - reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise - reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise - handleRenameCategory: (oldName: string, newName: string) => Promise - handleDeleteCategory: (cat: string) => Promise - handleDeleteItem: (id: number) => Promise - handleUpdateField: (id: number, field: string, value: unknown) => Promise - handleAddItem: (category: string, data: AddItemData) => Promise - tripId: number - currency: string - locale: string - t: (key: string) => string - fmt: (v: number | null | undefined, cur: string) => string - hasMultipleMembers: boolean - tripMembers: TripMember[] - setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: unknown; item: unknown }> - toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise - th: CSSProperties - td: CSSProperties -} - -export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEdit, editingCat, setEditingCat, - dragCat, setDragCat, dragOverCat, setDragOverCat, dragItem, setDragItem, dragOverItem, setDragOverItem, - dragItemCat, setDragItemCat, categoryNames, reorderBudgetCategories, reorderBudgetItems, - handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleUpdateField, handleAddItem, - tripId, currency, locale, t, fmt, hasMultipleMembers, tripMembers, setBudgetItemMembers, toggleBudgetMemberPaid, th, td }: BudgetCategoryTableProps) { - const items = grouped.get(cat) || [] - const contribFor = usePluginViewContributions('costs', tripId) - const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0) - const color = categoryColor(cat) - return ( -
{ - if (!dragCat || dragCat === cat || dragItem) return - e.preventDefault(); e.dataTransfer.dropEffect = 'move' - setDragOverCat(cat) - }} - onDragLeave={e => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverCat(null) - }} - onDrop={e => { - e.preventDefault() - if (dragCat && dragCat !== cat) { - const newOrder = [...categoryNames] - const fromIdx = newOrder.indexOf(dragCat) - const toIdx = newOrder.indexOf(cat) - newOrder.splice(fromIdx, 1) - newOrder.splice(toIdx, 0, dragCat) - void reorderBudgetCategories(tripId, newOrder) - } - setDragCat(null); setDragOverCat(null) - }} - > - {dragOverCat === cat &&
} -
-
- {canEdit && ( -
{ e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/x-budget-cat', cat); setDragCat(cat) }} - onDragEnd={() => { setDragCat(null); setDragOverCat(null) }} - style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}> - -
- )} -
- {canEdit && editingCat?.name === cat ? ( - setEditingCat({ ...editingCat, value: e.target.value })} - onBlur={() => { void handleRenameCategory(cat, editingCat.value); setEditingCat(null) }} - onKeyDown={e => { if (e.key === 'Enter') { void handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }} - style={{ fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }} - /> - ) : ( - <> - {cat} - {canEdit && ( - - )} - - )} -
-
- {fmt(subtotal, currency)} - {canEdit && ( - - )} -
-
- -
{ if (dragCat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } }}> - - - - - - - - - - - - - - - - - {items.map(item => { - // A custom (uneven) split has no single per-person figure — the per-member - // amounts are shown via the member chips — so blank those columns (#1458). - const customSplit = hasCustomMemberSplit(item) - const pp = customSplit ? null : calcPP(item.total_price, item.persons) - const pd = calcPD(item.total_price, item.days) - const ppd = customSplit ? null : calcPPD(item.total_price, item.persons, item.days) - const hasMembers = (item.members?.length ?? 0) > 0 - const contributions = contribFor(item.id) - return ( - - { - if (dragCat && dragCat !== cat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; return } - if (dragItem && dragItemCat === cat && dragItem !== item.id) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverItem(item.id) } - }} - onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverItem(null) }} - onDrop={e => { - if (dragItem && dragItemCat === cat && dragItem !== item.id) { - e.preventDefault(); e.stopPropagation() - const ids = items.map(i => i.id) - const fromIdx = ids.indexOf(dragItem) - const toIdx = ids.indexOf(item.id) - ids.splice(fromIdx, 1) - ids.splice(toIdx, 0, dragItem) - void reorderBudgetItems(tripId, ids) - setDragItem(null); setDragOverItem(null); setDragItemCat(null) - } - }} - onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} - onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> - - - - - - - - - - - - {contributions.length > 0 && ( - - - - )} - - ) - })} - {canEdit && handleAddItem(cat, data)} t={t} />} - -
{t('budget.table.name')}{t('budget.table.total')}{t('budget.table.persons')}{t('budget.table.days')}{t('budget.table.perPerson')}{t('budget.table.perDay')}{t('budget.table.perPersonDay')}{t('budget.table.date')}{t('budget.table.note')}
-
- {canEdit && ( -
{ e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }} - onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }} - style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}> - -
- )} -
- handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} /> - {hasMultipleMembers && ( -
- setBudgetItemMembers(tripId, item.id, userIds)} - onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} - compact={false} - readOnly={!canEdit} - /> -
- )} -
-
-
- handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> - - {hasMultipleMembers ? ( - setBudgetItemMembers(tripId, item.id, userIds)} - onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} - readOnly={!canEdit} - /> - ) : ( - handleUpdateField(item.id, 'persons', v != null ? Number.parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> - )} - - handleUpdateField(item.id, 'days', v != null ? Number.parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> - {pp != null ? fmt(pp, currency) : '-'}{pd != null ? fmt(pd, currency) : '-'}{ppd != null ? fmt(ppd, currency) : '-'} - {canEdit ? ( -
- handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless /> -
- ) : ( - {item.expense_date || '—'} - )} -
handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> - {canEdit && ( - - )} -
- -
-
-
- ) -} diff --git a/client/src/components/Budget/BudgetPanelInlineEditCell.test.tsx b/client/src/components/Budget/BudgetPanelInlineEditCell.test.tsx deleted file mode 100644 index 8a191633c..000000000 --- a/client/src/components/Budget/BudgetPanelInlineEditCell.test.tsx +++ /dev/null @@ -1,166 +0,0 @@ -// FE-W4IEC-001 to FE-W4IEC-016 -import { describe, it, expect, vi } from 'vitest' -import { render, screen, fireEvent } from '../../../tests/helpers/render' -import InlineEditCell from './BudgetPanelInlineEditCell' - -function setup(props: Partial[0]> = {}) { - const onSave = vi.fn() - const utils = render() - return { onSave, ...utils } -} - -function paste(input: HTMLElement, text: string) { - fireEvent.paste(input, { clipboardData: { getData: () => text } }) -} - -describe('InlineEditCell — display', () => { - it('FE-W4IEC-001: shows the raw text value', () => { - setup() - expect(screen.getByText('Ferry')).toBeInTheDocument() - }) - - it('FE-W4IEC-002: formats a number with the given decimals and locale', () => { - setup({ value: 1234.5, type: 'number' }) - expect(screen.getByText('1,234.50')).toBeInTheDocument() - }) - - it('FE-W4IEC-003: honours a custom decimal count', () => { - setup({ value: 12, type: 'number', decimals: 0 }) - expect(screen.getByText('12')).toBeInTheDocument() - }) - - it('FE-W4IEC-004: falls back to the placeholder, then to a dash', () => { - const { unmount } = setup({ value: null, placeholder: 'Add note' }) - expect(screen.getByText('Add note')).toBeInTheDocument() - unmount() - - setup({ value: null }) - expect(screen.getByText('-')).toBeInTheDocument() - }) - - it('FE-W4IEC-005: exposes the edit tooltip and hover feedback when editable', () => { - const { container } = setup({ editTooltip: 'Click to edit' }) - const cell = container.firstElementChild as HTMLElement - - expect(cell).toHaveAttribute('title', 'Click to edit') - fireEvent.mouseEnter(cell) - expect(cell.style.background).toBe('var(--bg-hover)') - fireEvent.mouseLeave(cell) - expect(cell.style.background).toBe('transparent') - }) - - it('FE-W4IEC-006: a read-only cell has no tooltip, no hover and cannot be opened', () => { - const { container } = setup({ readOnly: true, editTooltip: 'Click to edit' }) - const cell = container.firstElementChild as HTMLElement - - expect(cell).not.toHaveAttribute('title') - fireEvent.mouseEnter(cell) - expect(cell.style.background).toBe('') - - fireEvent.click(cell) - expect(screen.queryByRole('textbox')).toBeNull() - }) - - it('FE-W4IEC-007: centres the content when the caller centres the text', () => { - const { container } = setup({ style: { textAlign: 'center' } }) - - expect((container.firstElementChild as HTMLElement).style.justifyContent).toBe('center') - }) -}) - -describe('InlineEditCell — editing', () => { - it('FE-W4IEC-008: clicking opens a focused, pre-selected input', () => { - const { container } = setup() - fireEvent.click(container.firstElementChild!) - - const input = screen.getByRole('textbox') as HTMLInputElement - expect(input).toHaveValue('Ferry') - expect(input).toHaveFocus() - }) - - it('FE-W4IEC-009: Enter saves the changed value', () => { - const { onSave, container } = setup() - fireEvent.click(container.firstElementChild!) - - const input = screen.getByRole('textbox') - fireEvent.change(input, { target: { value: 'Bus' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - - expect(onSave).toHaveBeenCalledWith('Bus') - expect(screen.queryByRole('textbox')).toBeNull() - }) - - it('FE-W4IEC-010: blur saves and an unchanged value does not fire onSave', () => { - const { onSave, container } = setup() - fireEvent.click(container.firstElementChild!) - - fireEvent.blur(screen.getByRole('textbox')) - - expect(onSave).not.toHaveBeenCalled() - }) - - it('FE-W4IEC-011: Escape restores the original value without saving', () => { - const { onSave, container } = setup() - fireEvent.click(container.firstElementChild!) - - const input = screen.getByRole('textbox') - fireEvent.change(input, { target: { value: 'Bus' } }) - fireEvent.keyDown(input, { key: 'Escape' }) - - expect(onSave).not.toHaveBeenCalled() - expect(screen.getByText('Ferry')).toBeInTheDocument() - }) - - it('FE-W4IEC-012: a numeric cell parses a comma decimal and uses a decimal keypad', () => { - const { onSave, container } = setup({ value: 10, type: 'number' }) - fireEvent.click(container.firstElementChild!) - - const input = screen.getByRole('textbox') - expect(input).toHaveAttribute('inputmode', 'decimal') - fireEvent.change(input, { target: { value: '12,50' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - - expect(onSave).toHaveBeenCalledWith(12.5) - }) - - it('FE-W4IEC-013: an unparseable numeric entry saves null', () => { - const { onSave, container } = setup({ value: 10, type: 'number' }) - fireEvent.click(container.firstElementChild!) - - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'abc' } }) - fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' }) - - expect(onSave).toHaveBeenCalledWith(null) - }) - - it('FE-W4IEC-014: pasting a formatted amount normalizes separators', () => { - const { container } = setup({ value: 0, type: 'number' }) - fireEvent.click(container.firstElementChild!) - const input = screen.getByRole('textbox') - - paste(input, '1.234,56 EUR') - expect(input).toHaveValue('1234.56') - - paste(input, '$2,345.67') - expect(input).toHaveValue('2345.67') - }) - - it('FE-W4IEC-015: pasting a separator-free amount keeps the digits', () => { - const { container } = setup({ value: 0, type: 'number' }) - fireEvent.click(container.firstElementChild!) - const input = screen.getByRole('textbox') - - paste(input, 'EUR 4200') - expect(input).toHaveValue('4200') - }) - - it('FE-W4IEC-016: a text cell leaves pasted content to the browser', () => { - const { container } = setup() - fireEvent.click(container.firstElementChild!) - const input = screen.getByRole('textbox') - - paste(input, '1.234,56') - - expect(input).toHaveValue('Ferry') - }) -}) diff --git a/client/src/components/Budget/BudgetPanelInlineEditCell.tsx b/client/src/components/Budget/BudgetPanelInlineEditCell.tsx deleted file mode 100644 index 6452f76dc..000000000 --- a/client/src/components/Budget/BudgetPanelInlineEditCell.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useState, useEffect, useRef } from 'react' -import { normalizePastedAmount } from './BudgetPanel.helpers' - -interface InlineEditCellProps { - value: string | number | null | undefined - onSave: (value: string | number | null) => void - type?: 'text' | 'number' - style?: React.CSSProperties - placeholder?: string - decimals?: number - locale: string - editTooltip?: string - readOnly?: boolean -} - -export default function InlineEditCell({ value, onSave, type = 'text', style = {} as React.CSSProperties, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }: InlineEditCellProps) { - const [editing, setEditing] = useState(false) - const [editValue, setEditValue] = useState(value ?? '') - const inputRef = useRef(null) - - useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing]) - - const save = () => { - setEditing(false) - let v: string | number | null = editValue - if (type === 'number') { const p = Number.parseFloat(String(editValue).replace(',', '.')); v = Number.isNaN(p) ? null : p } - if (v !== value) onSave(v) - } - - const handlePaste = (e: React.ClipboardEvent) => { - if (type !== 'number') return - e.preventDefault() - setEditValue(normalizePastedAmount(e.clipboardData.getData('text'))) - } - - if (editing) { - return setEditValue(e.target.value)} onBlur={save} onPaste={handlePaste} - onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }} - style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }} - placeholder={placeholder} /> - } - - const display = type === 'number' && value != null - ? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) - : (value || '') - - // Editable cells are real buttons so they can be tabbed to and pressed. A - // read-only cell renders no control at all rather than a disabled one: it is - // plain text, and a disabled button would be announced as an unavailable - // control that never becomes available. Same split the member chips use. - const cellStyle: React.CSSProperties = { - padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center', - justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s', - color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', - width: '100%', textAlign: 'start', ...style, - } - const content = display || placeholder || '-' - - if (readOnly) return
{content}
- - return ( - - ) -} diff --git a/client/src/components/Budget/BudgetPanelPerPersonInline.tsx b/client/src/components/Budget/BudgetPanelPerPersonInline.tsx deleted file mode 100644 index fa66ff8f2..000000000 --- a/client/src/components/Budget/BudgetPanelPerPersonInline.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useState, useEffect } from 'react' -import { budgetApi } from '../../api/client' -import type { BudgetItem } from '../../types' -import { fmtNum, colorForUserId, widgetTheme } from './BudgetPanel.helpers' -import RingAvatar from './BudgetPanelRingAvatar' - -interface PerPersonSummaryEntry { - user_id: number - username: string - avatar_url: string | null - total_assigned: number -} - -interface PerPersonInlineProps { - tripId: number - budgetItems: BudgetItem[] - currency: string - locale: string -} - -export default function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType }) { - const [data, setData] = useState(null) - const fmt = (v: number) => fmtNum(v, locale, currency) - - useEffect(() => { - budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {}) - }, [tripId, budgetItems]) - - if (!data || data.length === 0) return null - - const people = data.map(p => ({ ...p, color: colorForUserId(p.user_id) })) - - return ( - <> - {grandTotal > 0 && ( -
- {people.map(p => ( -
- ))} -
- )} - -
- {people.map(p => { - const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0 - return ( -
- -
-
{p.username}
-
{percent}%
-
-
{fmt(p.total_assigned)}
-
- ) - })} -
- - ) -} diff --git a/client/src/components/Budget/BudgetPanelPieChart.test.tsx b/client/src/components/Budget/BudgetPanelPieChart.test.tsx deleted file mode 100644 index 884287378..000000000 --- a/client/src/components/Budget/BudgetPanelPieChart.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -// FE-W4PIE-001 to FE-W4PIE-006 -import { describe, it, expect } from 'vitest' -import { render, screen } from '../../../tests/helpers/render' -import PieChart from './BudgetPanelPieChart' - -const SEGMENTS = [ - { label: 'Food', value: 300, color: '#ef4444' }, - { label: 'Hotels', value: 100, color: '#3b82f6' }, -] - -describe('BudgetPanelPieChart', () => { - it('FE-W4PIE-001: renders nothing without segments', () => { - const { container } = render() - expect(container).toBeEmptyDOMElement() - }) - - it('FE-W4PIE-002: renders nothing when every segment is zero', () => { - const { container } = render( - , - ) - expect(container).toBeEmptyDOMElement() - }) - - it('FE-W4PIE-003: turns the segment shares into consecutive conic-gradient stops', () => { - const { container } = render() - const pie = container.querySelector('.trek-pie-reveal') as HTMLElement - - expect(pie.style.background).toBe('conic-gradient(rgb(239, 68, 68) 0deg 270deg, rgb(59, 130, 246) 270deg 360deg)') - }) - - it('FE-W4PIE-004: shows the total label in the donut hole', () => { - render() - - expect(screen.getByText('400 €')).toBeInTheDocument() - }) - - it('FE-W4PIE-005: defaults to a 200px pie with a 55% hole', () => { - const { container } = render() - const root = container.firstElementChild as HTMLElement - const hole = screen.getByText('400 €').parentElement as HTMLElement - - expect(root.style.width).toBe('200px') - expect(Math.round(parseFloat(hole.style.width))).toBe(110) - }) - - it('FE-W4PIE-006: scales pie and hole from the size prop', () => { - const { container } = render() - const root = container.firstElementChild as HTMLElement - const hole = screen.getByText('400 €').parentElement as HTMLElement - - expect(root.style.height).toBe('120px') - expect(Math.round(parseFloat(hole.style.height))).toBe(66) - }) -}) diff --git a/client/src/components/Budget/BudgetPanelPieChart.tsx b/client/src/components/Budget/BudgetPanelPieChart.tsx deleted file mode 100644 index 3b2c53fe2..000000000 --- a/client/src/components/Budget/BudgetPanelPieChart.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Wallet } from 'lucide-react' - -interface PieSegment { - label: string - value: number - color: string -} - -// ── Pie Chart (pure CSS conic-gradient) ────────────────────────────────────── -interface PieChartProps { - segments: PieSegment[] - size?: number - totalLabel: string -} - -export default function PieChart({ segments, size = 200, totalLabel }: PieChartProps) { - if (!segments.length) return null - - const total = segments.reduce((s, x) => s + x.value, 0) - if (total === 0) return null - - let cumDeg = 0 - const stops = segments.map(seg => { - const start = cumDeg - const deg = (seg.value / total) * 360 - cumDeg += deg - return `${seg.color} ${start}deg ${start + deg}deg` - }).join(', ') - - return ( -
-
-
- - {totalLabel} -
-
- ) -} diff --git a/client/src/components/Budget/BudgetPanelRingAvatar.tsx b/client/src/components/Budget/BudgetPanelRingAvatar.tsx deleted file mode 100644 index 3c116d049..000000000 --- a/client/src/components/Budget/BudgetPanelRingAvatar.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { colorForUserId } from './BudgetPanel.helpers' - -export default function RingAvatar({ userId, username, avatarUrl, size = 34, innerBg = '#17171d', textColor = '#fff' }: { userId: number; username?: string; avatarUrl?: string | null; size?: number; innerBg?: string; textColor?: string }) { - const color = colorForUserId(userId) - return ( -
-
- {avatarUrl ? {username : username?.[0]?.toUpperCase()} -
-
- ) -} diff --git a/client/src/components/Budget/BudgetPanelSummary.tsx b/client/src/components/Budget/BudgetPanelSummary.tsx deleted file mode 100644 index fbf335123..000000000 --- a/client/src/components/Budget/BudgetPanelSummary.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import type { Dispatch, SetStateAction } from 'react' -import { Wallet, Info, ChevronDown, ChevronRight, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react' -import type { BudgetItem } from '../../types' -import { currencyDecimals } from '../../utils/formatters' -import { SYMBOLS } from './BudgetPanel.constants' -import { hexLighten, widgetTheme } from './BudgetPanel.helpers' -import RingAvatar from './BudgetPanelRingAvatar' -import PerPersonInline from './BudgetPanelPerPersonInline' -import type { SettlementData, PieSegment } from './useBudgetPanel' - -interface BudgetSummaryProps { - theme: ReturnType - currency: string - locale: string - grandTotal: number - hasMultipleMembers: boolean - budgetItems: BudgetItem[] - settlement: SettlementData | null - settlementOpen: boolean - setSettlementOpen: Dispatch> - pieSegments: PieSegment[] - isDark: boolean - tripId: number - t: (key: string) => string - fmt: (v: number | null | undefined, cur: string) => string -} - -export default function BudgetSummary({ theme, currency, locale, grandTotal, hasMultipleMembers, budgetItems, - settlement, settlementOpen, setSettlementOpen, pieSegments, isDark, tripId, t, fmt }: BudgetSummaryProps) { - return ( -
- -
-
-
- -
-
-
{t('budget.totalBudget')}
-
-
- - {(() => { - const decimals = currencyDecimals(currency) - const full = Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) - const sep = (0.1).toLocaleString(locale).replace(/\d/g, '') - const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, ''] - return ( -
- {/* One left-to-right box at the row's start: the amount is split into - spans, which a right-to-left flex row would reorder. */} - - {integerPart} - {decimalPart && {sep}{decimalPart}} - {SYMBOLS[currency] || currency} - -
- ) - })()} -
- {currency} -
- - {hasMultipleMembers && (budgetItems || []).some(i => (i.members?.length ?? 0) > 0) && ( - - )} - - {/* Settlement dropdown inside the total card */} - {hasMultipleMembers && settlement && settlement.flows.length > 0 && ( -
- - - {settlementOpen && ( -
- {settlement.flows.map((flow, i) => ( -
{ e.currentTarget.style.background = theme.flowHoverBg; e.currentTarget.style.borderColor = theme.flowHoverBorder }} - onMouseLeave={e => { e.currentTarget.style.background = theme.flowBg; e.currentTarget.style.borderColor = theme.flowBorder }} - > - -
- - {fmt(flow.amount, currency)} - -
-
-
-
- -
- ))} - - {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && ( -
-
- {t('budget.netBalances')} -
-
- {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => { - const positive = b.balance > 0 - const Trend = positive ? TrendingUp : TrendingDown - return ( -
- - - {b.username} - - - - {positive ? '+' : ''}{fmt(b.balance, currency)} - -
- ) - })} -
-
- )} -
- )} -
- )} -
- - {pieSegments.length > 0 && (() => { - const decimals = currencyDecimals(currency) - const total = pieSegments.reduce((s, x) => s + x.value, 0) - const totalFmt = Number(total).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) - const decimalSep = (0.1).toLocaleString(locale).replace(/\d/g, '') - const [totalInt, totalDec] = decimals > 0 ? totalFmt.split(decimalSep) : [totalFmt, ''] - const R = 80 - const CIRC = 2 * Math.PI * R - let dashOffset = 0 - return ( -
-
-
- -
-
-
{t('budget.byCategory')}
-
-
- -
- - - {pieSegments.map((seg, i) => { - const c2 = hexLighten(seg.color, 0.2) - return ( - - - - - ) - })} - - - {pieSegments.map((seg, i) => { - const segLen = total > 0 ? (seg.value / total) * CIRC : 0 - const circle = ( - - ) - dashOffset += segLen - return circle - })} - -
-
{t('budget.total')}
-
- {totalInt} - {totalDec && {decimalSep}{totalDec}} -
-
{currency}
-
-
- -
- {pieSegments.map((seg, i) => { - const pct = total > 0 ? (seg.value / total) * 100 : 0 - const pctLabel = pct.toFixed(1).replace('.', decimalSep) + '%' - const c2 = hexLighten(seg.color, 0.2) - const chipColor = isDark ? hexLighten(seg.color, 0.35) : seg.color - return ( -
e.currentTarget.style.background = theme.rowHover} - onMouseLeave={e => e.currentTarget.style.background = 'transparent'} - > -
-
-
{seg.name}
-
{fmt(seg.value, currency)}
-
- {pctLabel} -
- ) - })} -
-
- ) - })()} - -
- ) -} diff --git a/client/src/components/Budget/useBudgetPanel.ts b/client/src/components/Budget/useBudgetPanel.ts deleted file mode 100644 index e8105d573..000000000 --- a/client/src/components/Budget/useBudgetPanel.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { useState, useEffect, useRef, useMemo, useCallback } from 'react' -import type { CSSProperties } from 'react' -import { useTripStore } from '../../store/tripStore' -import { useCanDo } from '../../store/permissionsStore' -import { useToast } from '../shared/Toast' -import { useTranslation } from '../../i18n' -import { budgetApi } from '../../api/client' -import type { BudgetItem } from '../../types' -import { currencyDecimals } from '../../utils/formatters' -import { widgetTheme, fmtNum, calcPP, calcPD, calcPPD, hasCustomMemberSplit } from './BudgetPanel.helpers' -import { PIE_COLORS } from './BudgetPanel.constants' -import type { TripMember } from './BudgetPanelMemberChips' - -function useIsDark(): boolean { - const [dark, setDark] = useState(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')) - useEffect(() => { - if (typeof document === 'undefined') return - const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark'))) - mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) - return () => mo.disconnect() - }, []) - return dark -} - -export interface EditingCat { - name: string - value: string -} - -interface SettlementPerson { - user_id: number - username: string - avatar_url: string | null -} - -interface SettlementFlow { - from: SettlementPerson - to: SettlementPerson - amount: number -} - -interface SettlementBalance { - user_id: number - username: string - avatar_url: string | null - balance: number -} - -export interface SettlementData { - balances: SettlementBalance[] - flows: SettlementFlow[] -} - -export interface PieSegment { - name: string - value: number - color: string -} - -export interface AddItemData { - name: string - total_price: number - persons: number | null - days: number | null - note: string | null - expense_date: string | null -} - -export function useBudgetPanel(tripId: number, tripMembers: TripMember[]) { - const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore() - const can = useCanDo() - const toast = useToast() - const { t, locale } = useTranslation() - const isDark = useIsDark() - const theme = useMemo(() => widgetTheme(isDark), [isDark]) - const [newCategoryName, setNewCategoryName] = useState('') - const [editingCat, setEditingCat] = useState(null) // { name, value } - const [settlement, setSettlement] = useState(null) - const [settlementOpen, setSettlementOpen] = useState(false) - const currency = trip?.currency || 'EUR' - const canEdit = can('budget_edit', trip) - - const fmt = (v: number | null | undefined, cur: string) => fmtNum(v, locale, cur) - const hasMultipleMembers = tripMembers.length > 1 - - // Drag state for categories - const [dragCat, setDragCat] = useState(null) - const [dragOverCat, setDragOverCat] = useState(null) - // Drag state for items within a category - const [dragItem, setDragItem] = useState(null) - const [dragOverItem, setDragOverItem] = useState(null) - const [dragItemCat, setDragItemCat] = useState(null) - - // Load settlement data whenever budget items change - useEffect(() => { - if (!hasMultipleMembers) return - budgetApi.settlement(tripId).then(setSettlement).catch(() => {}) - }, [tripId, budgetItems, hasMultipleMembers]) - - const setCurrency = (cur: string) => { - if (tripId) updateTrip(tripId, { currency: cur }) - } - - useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId]) - - const grouped = useMemo(() => { - const map = new Map() - for (const item of (budgetItems || [])) { - const cat = item.category || 'Other' - if (!map.has(cat)) map.set(cat, []) - map.get(cat)!.push(item) - } - return map - }, [budgetItems]) - - const categoryNames = Array.from(grouped.keys()) - - // Stable color mapping: assign index-based colors once, never reassign on reorder - const colorMapRef = useRef(new Map()) - const categoryColor = useCallback((cat: string) => { - const map = colorMapRef.current - if (!map.has(cat)) { - map.set(cat, PIE_COLORS[map.size % PIE_COLORS.length]) - } - return map.get(cat)! - }, []) - const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0) - - const pieSegments = useMemo(() => - categoryNames.map((cat, i) => ({ - name: cat, - value: (grouped.get(cat) || []).reduce((s, x) => s + (x.total_price || 0), 0), - color: categoryColor(cat), - })).filter(s => s.value > 0) - , [grouped, categoryNames]) - - const handleAddItem = async (category: string, data: AddItemData) => { try { await addBudgetItem(tripId, { ...data, category }) } catch { toast.error(t('common.error')) } } - const handleUpdateField = async (id: number, field: string, value: unknown) => { try { await updateBudgetItem(tripId, id, { [field]: value } as Partial) } catch { toast.error(t('common.error')) } } - const handleDeleteItem = async (id: number) => { try { await deleteBudgetItem(tripId, id) } catch { toast.error(t('common.error')) } } - const handleDeleteCategory = async (cat: string) => { - const items = grouped.get(cat) || [] - try { for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id) } - catch { toast.error(t('common.error')) } - } - const handleRenameCategory = async (oldName: string, newName: string) => { - if (!newName.trim() || newName.trim() === oldName) return - const items = grouped.get(oldName) || [] - try { for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) } - catch { toast.error(t('common.error')) } - } - const handleAddCategory = () => { - if (!newCategoryName.trim()) return - Promise.resolve(addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })) - .catch(() => toast.error(t('common.error'))) - setNewCategoryName('') - } - - const handleExportCsv = () => { - const sep = ';' - const esc = (v: unknown) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s } - const d = currencyDecimals(currency) - const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : '' - - const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } - const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note'] - const rows = [header.join(sep)] - - for (const cat of categoryNames) { - for (const item of (grouped.get(cat) || [])) { - // A custom (uneven) split has no single per-person figure, so leave those columns blank (#1458). - const customSplit = hasCustomMemberSplit(item) - const pp = customSplit ? null : calcPP(item.total_price, item.persons) - const pd = calcPD(item.total_price, item.days) - const ppd = customSplit ? null : calcPPD(item.total_price, item.persons, item.days) - rows.push([ - esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')), - fmtPrice(item.total_price), item.persons ?? '', item.days ?? '', - fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd), - esc(item.note || ''), - ].join(sep)) - } - } - - const bom = '' - const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim() - a.download = `budget-${safeName}.csv` - a.click() - URL.revokeObjectURL(url) - } - - const th: CSSProperties = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' } - const td: CSSProperties = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' } - - return { - trip, budgetItems, - setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories, - t, locale, isDark, theme, - newCategoryName, setNewCategoryName, - editingCat, setEditingCat, - settlement, settlementOpen, setSettlementOpen, - currency, canEdit, fmt, hasMultipleMembers, - dragCat, setDragCat, dragOverCat, setDragOverCat, - dragItem, setDragItem, dragOverItem, setDragOverItem, dragItemCat, setDragItemCat, - setCurrency, - grouped, categoryNames, categoryColor, grandTotal, pieSegments, - handleAddItem, handleUpdateField, handleDeleteItem, handleDeleteCategory, handleRenameCategory, handleAddCategory, handleExportCsv, - th, td, - } -}