diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 01a15e1b..dab6ba43 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -595,6 +595,7 @@ export const budgetApi = { perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data), settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data), createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data), + updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data), deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data), reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data), reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data), diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx new file mode 100644 index 00000000..1d3fe503 --- /dev/null +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -0,0 +1,138 @@ +// FE-COMP-COSTS: settlements surfaced inline in the Costs ledger (issue #1241) +import { render, screen, waitFor } from '../../../tests/helpers/render' +import { http, HttpResponse } from 'msw' +import { server } from '../../../tests/helpers/msw/server' +import { useAuthStore } from '../../store/authStore' +import { useTripStore } from '../../store/tripStore' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' +import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories' +import CostsPanel from './CostsPanel' + +const tripMembers = [ + { id: 1, username: 'alice', avatar_url: null }, + { id: 2, username: 'bob', avatar_url: null }, +] + +beforeEach(() => { + resetAllStores() + seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }) + seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) }) +}) + +describe('CostsPanel — settlements in the ledger', () => { + it('renders a settle-up payment as a ledger row with an undo action', async () => { + const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' } + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), + http.get('/api/trips/1/budget/settlement', () => + HttpResponse.json({ + balances: [], + flows: [], + settlements: [ + { id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' }, + ], + }) + ), + ) + render() + + // The expense and the settlement (payment) both appear in the unified ledger. + await screen.findByText('Dinner') + await screen.findByText('Payment') + // The payment row exposes an inline undo (no need to open a separate History modal). + expect(screen.getByTitle('Undo')).toBeInTheDocument() + }) + + it('records a manual payment via the Add payment button', async () => { + let posted: Record | null = null + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })), + http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })), + http.post('/api/trips/1/budget/settlements', async ({ request }) => { + posted = await request.json() as Record + return HttpResponse.json({ settlement: { id: 1, ...posted } }) + }), + ) + const { default: userEvent } = await import('@testing-library/user-event') + const user = userEvent.setup() + render() + + await user.click(await screen.findByRole('button', { name: 'Add payment' })) + await user.type(await screen.findByPlaceholderText('0.00'), '25') + // The footer submit is the second "Add payment" control once the modal is open. + const addButtons = screen.getAllByRole('button', { name: 'Add payment' }) + const submit = addButtons[addButtons.length - 1] + await user.click(submit) + await waitFor(() => expect(posted).toMatchObject({ amount: 25 })) + }) + + it('hides payment rows while a text search is active', async () => { + const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' } + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), + http.get('/api/trips/1/budget/settlement', () => + HttpResponse.json({ + balances: [], + flows: [], + settlements: [ + { id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' }, + ], + }) + ), + ) + const { default: userEvent } = await import('@testing-library/user-event') + const user = userEvent.setup() + render() + + await screen.findByText('Payment') + await user.type(screen.getByPlaceholderText('Search expenses…'), 'Dinner') + // Payment rows have no name, so a search hides them while the matching expense stays. + expect(screen.queryByText('Payment')).not.toBeInTheDocument() + expect(screen.getByText('Dinner')).toBeInTheDocument() + }) + + it('auto-splits the total across participants and rebalances a pinned amount on save', async () => { + let posted: Record | null = null + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })), + http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })), + http.post('/api/trips/1/budget', async ({ request }) => { + posted = await request.json() as Record + return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 5 } }) + }), + ) + const { default: userEvent } = await import('@testing-library/user-event') + const user = userEvent.setup() + render() + + await user.click(await screen.findByRole('button', { name: 'Add expense' })) + await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner') + const nums = () => screen.getAllByRole('spinbutton') as HTMLInputElement[] + await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants + await waitFor(() => expect(nums()[1].value).toBe('50')) + expect(nums()[2].value).toBe('50') + // Pin the first participant to 30 → the other non-pinned field rebalances to 70. + await user.clear(nums()[1]); await user.type(nums()[1], '30') + await waitFor(() => expect(nums()[2].value).toBe('70')) + + const addBtns = screen.getAllByRole('button', { name: 'Add expense' }) + await user.click(addBtns[addBtns.length - 1]) // footer submit + await waitFor(() => expect(posted).toBeTruthy()) + expect(posted!.total_price).toBe(100) + expect(posted!.payers).toEqual(expect.arrayContaining([ + expect.objectContaining({ user_id: 1, amount: 30 }), + expect.objectContaining({ user_id: 2, amount: 70 }), + ])) + }) + + it('marks an expense with no payer as Unfinished', async () => { + const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] } + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), + http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })), + ) + render() + await screen.findByText('Hotel') + expect(screen.getByText('Unfinished')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index bc3f778c..9541275a 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' -import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, Check, RotateCcw, History, Pencil, Trash2 } from 'lucide-react' +import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2 } from 'lucide-react' import { useTripStore } from '../../store/tripStore' import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' @@ -39,6 +39,12 @@ interface SettlementData { settlements: Settlement[] } +// One row in the unified Costs ledger — either an expense or a settle-up payment, +// carrying the date used to group it by day. +type LedgerEntry = + | { kind: 'expense'; date: string; e: BudgetItem } + | { kind: 'payment'; date: string; s: Settlement } + const round2 = (n: number) => Math.round(n * 100) / 100 const FIELD_H = 40 // shared height for the amount / currency / day row in the modal @@ -62,9 +68,10 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const [settlement, setSettlement] = useState(null) const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all') const [search, setSearch] = useState('') - const [histOpen, setHistOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false) const [editing, setEditing] = useState(null) + const [editingSettlement, setEditingSettlement] = useState(null) + const [addingPayment, setAddingPayment] = useState(false) const people = tripMembers const personById = useCallback((id: number) => people.find(p => p.id === id), [people]) @@ -122,21 +129,37 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps return list }, [budgetItems, filter, search, me]) + // Settlements ("payments") shown inline in the ledger. They have no name, so a + // text search hides them; they're excluded from the "owed" expense filter and, + // under "mine", only show transfers I'm part of. + const filteredSettlements = useMemo(() => { + if (search.trim()) return [] + if (filter === 'owed') return [] + let list = settlement?.settlements || [] + if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me) + return list + }, [settlement, filter, search, me]) + const dayGroups = useMemo(() => { - const groups: { day: string; items: BudgetItem[] }[] = [] - const labelOf = (e: BudgetItem) => { - if (!e.expense_date) return t('costs.noDate') - try { return new Date(e.expense_date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return e.expense_date } + const entries: LedgerEntry[] = [ + ...filtered.map(e => ({ kind: 'expense' as const, date: e.expense_date || '', e })), + ...filteredSettlements.map(s => ({ kind: 'payment' as const, date: (s.created_at || '').slice(0, 10), s })), + ] + const labelOf = (date: string) => { + if (!date) return t('costs.noDate') + try { return new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return date } } - const sorted = filtered.slice().sort((a, b) => (b.expense_date || '').localeCompare(a.expense_date || '')) - for (const e of sorted) { - const day = labelOf(e) + // Newest day first; within a day, expenses before payments (insertion order). + const sorted = entries.slice().sort((a, b) => (b.date || '').localeCompare(a.date || '')) + const groups: { day: string; entries: LedgerEntry[] }[] = [] + for (const en of sorted) { + const day = labelOf(en.date) let g = groups.find(x => x.day === day) - if (!g) { g = { day, items: [] }; groups.push(g) } - g.items.push(e) + if (!g) { g = { day, entries: [] }; groups.push(g) } + g.entries.push(en) } return groups - }, [filtered, locale, t]) + }, [filtered, filteredSettlements, locale, t]) // ── settle actions ────────────────────────────────────────────────────── const settleFlow = async (fromId: number, toId: number, amount: number) => { @@ -280,14 +303,16 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {search ? t('costs.noMatch') : t('costs.emptyText')} ) : dayGroups.map(g => { - const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0) + const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0) return (
{g.day}{t('costs.spent', { amount: fmt(dtot) })}
- {g.items.map(e => )} + {g.entries.map(en => en.kind === 'expense' + ? + : )}
) @@ -300,11 +325,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{t('costs.settleUp')} · {(settlement?.flows || []).length}
- + {canEdit && ( + + )}
@@ -330,9 +357,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} /> )} - setHistOpen(false)} title={t('costs.settleHistory')} size="md"> - - + {(editingSettlement || addingPayment) && ( + { setEditingSettlement(null); setAddingPayment(false) }} + onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} /> + )}