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, - } -}