feat(costs): let a trip keep its costs as totals only (#1714)

Travellers who track what a trip costs but never split it saw an
Unfinished warning on every expense and a settle-up they had no use for.
A trip now has a costs mode (appended migration, trips.costs_mode, split
by default); totals leaves payers, shares and settling up out of Costs on
desktop and phone through the same gate as a trip of one, and saves new
expenses without a payer. PUT /api/trips/:id and update_trip take it on
budget_edit. The split chips move into their own component.
This commit is contained in:
Maurice
2026-11-13 09:58:17 +01:00
parent c9c419b4ad
commit a70eea5bf2
62 changed files with 778 additions and 104 deletions
@@ -0,0 +1,51 @@
import { useId } from 'react';
import { useTranslation } from '../../i18n';
import type { Trip } from '../../types';
import ToggleSwitch from '../Settings/ToggleSwitch';
import { fs } from '../shared/DialogShell';
import { useCostsMode } from './useCostsMode';
/**
* The switch between splitting a trip's costs and keeping the totals only
* (#1714), at the foot of the Costs sidebar. Nothing is shown to someone who may
* not change it, or on a trip of one.
*/
export function CostsModeCard({
trip,
memberCount,
canEdit,
className,
}: {
trip: Trip | null | undefined;
memberCount: number;
canEdit: boolean;
className: string;
}) {
const { t } = useTranslation();
const hintId = useId();
const { splitting, setSplitting, saving, offered } = useCostsMode(trip, memberCount, canEdit);
if (!offered) return null;
return (
<section aria-label={t('costs.mode.label')} className={className}>
<div className="flex items-start gap-3 px-4 py-4">
<div className="min-w-0 flex-1">
<div className="font-semibold text-content" style={fs(13, 'body')}>
{t('costs.mode.label')}
</div>
<p id={hintId} className="m-0 mt-1 text-content-faint" style={fs(12, 'body')}>
{splitting ? t('costs.mode.hintSplit') : t('costs.mode.hintTotals')}
</p>
</div>
<ToggleSwitch
on={splitting}
onToggle={() => void setSplitting(!splitting)}
label={t('costs.mode.label')}
describedBy={hintId}
disabled={saving}
/>
</div>
</section>
);
}
@@ -0,0 +1,104 @@
// FE-COSTS-TOTALS-001 to FE-COSTS-TOTALS-003: a trip that keeps its costs as totals only (#1714)
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildBudgetItem, buildTrip, buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore, type TripStoreState } from '../../store/tripStore';
import type { BudgetItem } from '../../types';
import CostsPanel from './CostsPanel';
const tripMembers = [
{ id: 1, username: 'alice', avatar_url: null },
{ id: 2, username: 'bob', avatar_url: null },
];
// Paid by Bob and shared by both: a split trip owes him half.
const dinner = {
...buildBudgetItem({ trip_id: 1 }),
id: 801,
name: 'Dinner',
category: 'food',
total_price: 60,
expense_date: '2025-06-15',
payers: [{ user_id: 2, amount: 60 }],
members: [
{ user_id: 1, username: 'alice' },
{ user_id: 2, username: 'bob' },
],
} as unknown as BudgetItem;
let updateTrip: ReturnType<typeof vi.fn>;
function mount(costsMode: 'split' | 'totals') {
updateTrip = vi.fn(async () => buildTrip({ id: 1 }));
useTripStore.setState({
trip: buildTrip({ id: 1, currency: 'EUR', costs_mode: costsMode }),
updateTrip,
} as unknown as Partial<TripStoreState>);
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [dinner] })),
http.get('/api/trips/1/budget/settlement', () =>
HttpResponse.json({
balances: [],
flows: [{ from: { user_id: 1, username: 'alice' }, to: { user_id: 2, username: 'bob' }, amount: 30 }],
settlements: [],
})
)
);
return render(<CostsPanel tripId={1} tripMembers={tripMembers} />);
}
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true });
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } });
});
describe('CostsPanel keeping totals only (#1714)', () => {
it('FE-COSTS-TOTALS-001: what is owed, the payers and the shares stay out, the totals stay', async () => {
mount('totals');
await screen.findByText('Dinner');
expect(screen.getByRole('region', { name: 'Total trip spend' })).toHaveTextContent('1 expense');
for (const name of ['You owe', 'Settle up', 'Balances', 'Final budget']) {
expect(screen.queryByRole('region', { name })).toBeNull();
}
expect(screen.queryByText(/you lent|you borrowed/)).toBeNull();
expect(screen.queryAllByTestId('split-chip')).toHaveLength(0);
expect(screen.getByRole('region', { name: 'By category' })).toBeInTheDocument();
});
it('FE-COSTS-TOTALS-002: the switch at the foot of the sidebar turns splitting back on', async () => {
const user = userEvent.setup();
mount('totals');
await screen.findByText('Dinner');
const toggle = screen.getByRole('button', { name: 'Split costs between travelers' });
expect(toggle).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByText(/^Totals only:/)).toBeInTheDocument();
await user.click(toggle);
expect(updateTrip).toHaveBeenCalledWith(1, { costs_mode: 'split' });
});
it('FE-COSTS-TOTALS-003: a split trip shows the switch on, and a new expense asks for no payer or split once off', async () => {
const user = userEvent.setup();
const split = mount('split');
expect(await screen.findByRole('region', { name: 'Settle up' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Split costs between travelers' })).toHaveAttribute(
'aria-pressed',
'true'
);
split.unmount();
mount('totals');
await screen.findByText('Dinner');
await user.click(screen.getByRole('button', { name: 'Add expense' }));
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(screen.queryByText('Who paid?')).toBeNull();
expect(screen.queryByRole('button', { name: 'Equally' })).toBeNull();
});
});
+6 -37
View File
@@ -29,6 +29,8 @@ import { ReceiptPreviewModal } from './ReceiptPreviewModal'
import { ExpenseReceiptsSection } from './ExpenseReceiptsSection'
import { useExpenseDeleteConfirm } from './useExpenseDeleteConfirm'
import { FlowPills } from './FlowPills'
import { CostsSplitChips } from './CostsSplitChips'
import { CostsModeCard } from './CostsModeCard'
import type { BudgetParticipantFinal, ReceiptLine } from '@trek/shared'
import type { BudgetItem, BudgetItemReceipt } from '../../types'
import type { TripMember } from './BudgetPanelMemberChips'
@@ -43,9 +45,6 @@ import CostsToolbar, { type CostsView } from './CostsToolbar'
import CostsTable, { CostsTableSummary } from './CostsTable'
import SummaryCard from './CostsSummaryCard'
/** Split chips a row shows before the rest fold into "+N" (#1763). */
const SPLIT_CHIP_MAX = 6
interface CostsPanelProps {
tripId: number
tripMembers?: TripMember[]
@@ -381,6 +380,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div className="px-4 py-4">{CategoryBreakdown()}</div>
</section>
)}
<CostsModeCard trip={trip} memberCount={people.length} canEdit={canEdit} className={SIDE_CARD} />
</div>
</div>
</div>)}
@@ -608,6 +608,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
{CategoryBreakdown()}
</div>
<CostsModeCard trip={trip} memberCount={people.length} canEdit={canEdit} className={`${cardCls} rounded-[18px]`} />
</div>
)
}
@@ -738,7 +739,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
))}
</div>
)}
{splitting && SplitChips({ e })}
{splitting && <CostsSplitChips e={e} avatar={(id, size) => <Avatar id={id} size={size} />}
personName={personName} shareOf={shareOf} fmt={v => fmt(v)} />}
</div>
{/* The note, marked by a rule in the category colour instead of a box, so
@@ -762,39 +764,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
)
}
// Who an expense is split between and each one's part (#1763), so checking that
// the right people are in no longer means opening every expense. Quieter than
// the payer chips above, and capped so a large group cannot crowd the row.
function SplitChips({ e }: { e: BudgetItem }) {
const members = e.members || []
if (members.length === 0) return null
const shown = members.length > SPLIT_CHIP_MAX ? members.slice(0, SPLIT_CHIP_MAX - 1) : members
const rest = members.slice(shown.length)
const chip = 'inline-flex items-center gap-[5px] rounded-full bg-surface-tertiary text-content-secondary'
return (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 5, marginTop: 6 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', marginInlineEnd: 2 }}>{t('costs.split')}</span>
{shown.map(m => {
const share = fmt(shareOf(e, m.user_id))
return (
<Tooltip key={m.user_id} label={t('costs.splitChipLabel', { name: personName(m.user_id), amount: share })}>
<span className={chip} data-testid="split-chip" style={{ paddingBlock: 2, paddingInline: '2px 8px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
<Avatar id={m.user_id} size={16} />
<span style={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{share}</span>
{m.paid ? <Check size={11} strokeWidth={2.6} className="text-success" aria-label={t('costs.paid')} /> : null}
</span>
</Tooltip>
)
})}
{rest.length > 0 && (
<Tooltip label={rest.map(m => t('costs.splitChipLabel', { name: personName(m.user_id), amount: fmt(shareOf(e, m.user_id)) })).join(', ')}>
<span className={chip} style={{ padding: '2px 8px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>+{rest.length}</span>
</Tooltip>
)}
</div>
)
}
// A row's note as a pill that opens in place; an empty one keeps the column.
function NoteCell({ note, noteKey, color }: { note: string; noteKey: string; color: string }) {
if (!note) return <span />
@@ -0,0 +1,81 @@
import { Check } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import type { BudgetItem } from '../../types';
import { Tooltip } from '../shared/Tooltip';
/** Split chips a row shows before the rest fold into "+N" (#1763). */
const SPLIT_CHIP_MAX = 6;
const CHIP = 'inline-flex items-center gap-[5px] rounded-full bg-surface-tertiary text-content-secondary';
/**
* Who an expense is split between and each one's part (#1763), so checking that
* the right people are in no longer means opening every expense. Quieter than
* the payer chips above it in the ledger row, and capped so a large group
* cannot crowd the row.
*/
export function CostsSplitChips({
e,
avatar,
personName,
shareOf,
fmt,
}: {
e: BudgetItem;
/** A member's avatar at the given size, drawn the way the rest of the ledger draws it. */
avatar: (userId: number, size: number) => ReactNode;
personName: (id: number) => string;
/** A member's part of the expense in the display currency. */
shareOf: (e: BudgetItem, userId: number) => number;
fmt: (amount: number) => string;
}) {
const { t } = useTranslation();
const members = e.members || [];
if (members.length === 0) return null;
const shown = members.length > SPLIT_CHIP_MAX ? members.slice(0, SPLIT_CHIP_MAX - 1) : members;
const rest = members.slice(shown.length);
const label = (userId: number) =>
t('costs.splitChipLabel', { name: personName(userId), amount: fmt(shareOf(e, userId)) });
return (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 5, marginTop: 6 }}>
<span
className="text-content-faint"
style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
marginInlineEnd: 2,
}}
>
{t('costs.split')}
</span>
{shown.map((m) => (
<Tooltip key={m.user_id} label={label(m.user_id)}>
<span
className={CHIP}
data-testid="split-chip"
style={{ paddingBlock: 2, paddingInline: '2px 8px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}
>
{avatar(m.user_id, 16)}
<span style={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{fmt(shareOf(e, m.user_id))}</span>
{m.paid ? (
<Check size={11} strokeWidth={2.6} className="text-success" aria-label={t('costs.paid')} />
) : null}
</span>
</Tooltip>
))}
{rest.length > 0 && (
<Tooltip label={rest.map((m) => label(m.user_id)).join(', ')}>
<span
className={CHIP}
style={{ padding: '2px 8px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700 }}
>
+{rest.length}
</span>
</Tooltip>
)}
</div>
);
}
@@ -18,7 +18,7 @@ import {
type CostsSettlement,
} from './costsModel';
// FE-BUDGET-COSTSMODEL-001 to FE-BUDGET-COSTSMODEL-013
// FE-BUDGET-COSTSMODEL-001 to FE-BUDGET-COSTSMODEL-014
// The pieces both ledgers share since the desktop panel runs on this model too;
// the rest of the model is pinned in tests/unit/mobile/trip/costsModel.test.ts.
@@ -148,20 +148,20 @@ describe('costsModel: the currency picker options', () => {
});
});
describe('costsModel: when Costs shows who paid and the shares (#2689)', () => {
describe('costsModel: when Costs shows who paid and the shares (#2689, #1714)', () => {
const settled = { flows: [], balances: [] };
it('FE-BUDGET-COSTSMODEL-012: a trip of one has nothing to split, unless the ledger still names someone else', () => {
const mine = expense({ payers: [payer(1, 20)], members: [member(1)] });
expect(showsSplitting(2, [], null, 1)).toBe(true);
expect(showsSplitting(1, [mine], settled, 1)).toBe(false);
expect(showsSplitting(0, [], null, 1)).toBe(false);
expect(showsSplitting('split', 2, [], null, 1)).toBe(true);
expect(showsSplitting(undefined, 1, [mine], settled, 1)).toBe(false);
expect(showsSplitting('split', 0, [], null, 1)).toBe(false);
// Someone who left: named on an expense, owed money, or still in a transfer.
expect(showsSplitting(1, [expense({ members: [member(1), member(9)] })], settled, 1)).toBe(true);
expect(showsSplitting('split', 1, [expense({ members: [member(1), member(9)] })], settled, 1)).toBe(true);
const owed = { flows: [], balances: [{ user_id: 9, username: 'u9', avatar_url: null, balance: 5 }] };
expect(showsSplitting(1, [mine], owed, 1)).toBe(true);
expect(showsSplitting('split', 1, [mine], owed, 1)).toBe(true);
const flow = { from: { user_id: 9, username: 'u9' }, to: { user_id: 1, username: 'u1' }, amount: 5 };
expect(showsSplitting(1, [mine], { flows: [flow], balances: [] }, 1)).toBe(true);
expect(showsSplitting('split', 1, [mine], { flows: [flow], balances: [] }, 1)).toBe(true);
});
it('FE-BUDGET-COSTSMODEL-013: a split is plain with one payer, equal shares, no receipt lines and only current members', () => {
@@ -172,4 +172,11 @@ describe('costsModel: when Costs shows who paid and the shares (#2689)', () => {
expect(isPlainSplit(expense({ ticket_json: '{"items":[]}', members: [member(1)] }), [1])).toBe(false);
expect(isPlainSplit(expense({ payers: [payer(9, 20)], members: [] }), [1])).toBe(false);
});
it('FE-BUDGET-COSTSMODEL-014: a trip that keeps totals only splits nothing, however many share it (#1714)', () => {
const shared = expense({ payers: [payer(1, 20)], members: [member(1), member(2)] });
const flow = { from: { user_id: 2, username: 'u2' }, to: { user_id: 1, username: 'u1' }, amount: 10 };
expect(showsSplitting('totals', 3, [shared], { flows: [flow], balances: [] }, 1)).toBe(false);
expect(showsSplitting('split', 3, [shared], { flows: [flow], balances: [] }, 1)).toBe(true);
});
});
+8 -5
View File
@@ -1,4 +1,4 @@
import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared';
import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory, TripCostsMode } from '@trek/shared';
import { readDateFormat } from '../../hooks/useDateFormat';
import { convertBooked, convertedLine, inDisplayCurrency, tripAmountOf } from '../../hooks/useExchangeRates';
import type { BudgetItem } from '../../types';
@@ -174,17 +174,20 @@ export interface CostsSettlementResponse {
/**
* Whether Costs shows the splitting side: who paid, the shares, the balances
* and settling up. A trip of one has nothing to split, since every share is the
* whole amount and nobody can owe anybody (#2689), so it is left out there,
* unless the ledger still names someone else: a traveller who left the trip
* with money open, or members that have not loaded yet.
* and settling up. A trip that keeps its costs as totals only leaves it out
* (#1714), whatever is stored. A trip of one has nothing to split, since every
* share is the whole amount and nobody can owe anybody (#2689), so it is left
* out there too, unless the ledger still names someone else: a traveller who
* left the trip with money open, or members that have not loaded yet.
*/
export function showsSplitting(
costsMode: TripCostsMode | undefined,
memberCount: number,
items: BudgetItem[],
settlement: Pick<CostsSettlementResponse, 'flows' | 'balances'> | null,
me: number
): boolean {
if (costsMode === 'totals') return false;
if (memberCount > 1) return true;
const namesOthers = items.some((e) => [...(e.payers || []), ...(e.members || [])].some((p) => p.user_id !== me));
const moneyOpen = (settlement?.balances || []).some((b) => Math.abs(b.balance) >= 0.005);
@@ -16,7 +16,7 @@ import type { CostsSettlementResponse } from './costsModel';
import { useCostsLedger } from './useCostsLedger';
import { useFreezeMissingRates } from './useFreezeMissingRates';
// FE-BUDGET-LEDGER-001 to FE-BUDGET-LEDGER-014
// FE-BUDGET-LEDGER-001 to FE-BUDGET-LEDGER-015
const fx = vi.hoisted(() => ({
// One stable converter, the way useExchangeRates hands out a memoised one: USD is half a euro.
@@ -263,6 +263,16 @@ describe('useCostsLedger: a trip of one (#2689)', () => {
});
});
describe('useCostsLedger: a trip that keeps totals only (#1714)', () => {
it('FE-BUDGET-LEDGER-015: nothing is split, though the trip has several members and money open', async () => {
const { result } = setup({ trip: buildTrip({ id: 7, currency: 'EUR', costs_mode: 'totals' }) });
await waitFor(() => expect(result.current.settlement).toEqual(SETTLEMENT));
expect(result.current.splitting).toBe(false);
expect(result.current.filteredSettlements).toEqual([]);
expect(result.current.filtered).toHaveLength(2);
});
});
describe('useCostsLedger: writes', () => {
it('FE-BUDGET-LEDGER-009: deleting an expense reads the settlement again, a failure toasts', async () => {
const { result } = setup();
@@ -53,8 +53,9 @@ interface Toaster {
* breakdown, the CSV export and the ledger writes. Each surface lays it out
* and keeps its own dialogs or sheets.
*
* `splitting` says whether the ledger has anything to split at all (#2689);
* without it the owner filter and the recorded payments are left out too.
* `splitting` says whether the ledger splits anything at all: not on a trip
* that keeps totals only (#1714) or on a trip of one (#2689). Without it the
* owner filter and the recorded payments are left out too.
*
* Where the two differ today, the surface says which way: the desktop also
* reads the settlement when the trip changes (`settlementOnTripChange`), leaves
@@ -187,8 +188,8 @@ export function useCostsLedger({
const shareOf = skipUnfinishedShares ? settledShareOf : myShareOf;
const flows = useMemo(() => settlement?.flows || [], [settlement]);
const splitting = useMemo(
() => showsSplitting(memberCount, budgetItems, settlement, me),
[memberCount, budgetItems, settlement, me]
() => showsSplitting(trip?.costs_mode, memberCount, budgetItems, settlement, me),
[trip?.costs_mode, memberCount, budgetItems, settlement, me]
);
// The owner filter is hidden while nothing is split, so it cannot hold the list back.
const activeSegment = splitting ? segment : 'all';
@@ -0,0 +1,56 @@
// FE-BUDGET-COSTSMODE-001 to FE-BUDGET-COSTSMODE-003
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildTrip } from '../../../tests/helpers/factories';
import { resetAllStores } from '../../../tests/helpers/store';
import { useTripStore, type TripStoreState } from '../../store/tripStore';
import { useCostsMode } from './useCostsMode';
let updateTrip: ReturnType<typeof vi.fn>;
let addToast: ReturnType<typeof vi.fn>;
const trip = (costsMode?: 'split' | 'totals') => buildTrip({ id: 7, ...(costsMode ? { costs_mode: costsMode } : {}) });
beforeEach(() => {
resetAllStores();
updateTrip = vi.fn(async () => buildTrip({ id: 7 }));
useTripStore.setState({ updateTrip } as unknown as Partial<TripStoreState>);
addToast = vi.fn();
(window as unknown as { __addToast: unknown }).__addToast = addToast;
});
afterEach(() => {
delete (window as unknown as { __addToast?: unknown }).__addToast;
});
describe('useCostsMode (#1714)', () => {
it('FE-BUDGET-COSTSMODE-001: a trip splits its costs until it says otherwise, and keeps totals only when it does', () => {
expect(renderHook(() => useCostsMode(trip(), 2, true)).result.current.splitting).toBe(true);
expect(renderHook(() => useCostsMode(trip('split'), 2, true)).result.current.splitting).toBe(true);
expect(renderHook(() => useCostsMode(trip('totals'), 2, true)).result.current.splitting).toBe(false);
});
it('FE-BUDGET-COSTSMODE-002: the switch is offered to whoever may edit the costs, on a trip of more than one', () => {
expect(renderHook(() => useCostsMode(trip(), 2, true)).result.current.offered).toBe(true);
expect(renderHook(() => useCostsMode(trip(), 1, true)).result.current.offered).toBe(false);
expect(renderHook(() => useCostsMode(trip(), 3, false)).result.current.offered).toBe(false);
});
it('FE-BUDGET-COSTSMODE-003: switching saves the mode on the trip, and a failure says so', async () => {
const { result } = renderHook(() => useCostsMode(trip(), 2, true));
await act(async () => {
await result.current.setSplitting(false);
});
expect(updateTrip).toHaveBeenCalledWith(7, { costs_mode: 'totals' });
expect(result.current.saving).toBe(false);
updateTrip.mockRejectedValueOnce(new Error('offline'));
await act(async () => {
await result.current.setSplitting(true);
});
expect(updateTrip).toHaveBeenLastCalledWith(7, { costs_mode: 'split' });
expect(addToast.mock.calls.map((c) => c[1])).toContain('error');
expect(result.current.saving).toBe(false);
});
});
@@ -0,0 +1,36 @@
import { useState } from 'react';
import { useTranslation } from '../../i18n';
import { useTripStore } from '../../store/tripStore';
import type { Trip } from '../../types';
import { useToast } from '../shared/Toast';
/**
* Whether a trip splits its costs between the travellers or keeps the totals
* only (#1714), and the switch between the two, for the desktop Costs panel and
* the phone tab alike. The mode is the trip's own (`trips.costs_mode`), so every
* member sees the same Costs; it is saved through the trip, as `budget_edit`.
*
* The switch is offered to whoever may edit the costs, and only on a trip with
* more than one member: a trip of one has nothing to split either way (#2689).
*/
export function useCostsMode(trip: Trip | null | undefined, memberCount: number, canEdit: boolean) {
const { t } = useTranslation();
const toast = useToast();
const updateTrip = useTripStore((s) => s.updateTrip);
const [saving, setSaving] = useState(false);
const setSplitting = async (on: boolean) => {
if (!trip) return;
setSaving(true);
try {
await updateTrip(trip.id, { costs_mode: on ? 'split' : 'totals' });
} catch {
toast.error(t('common.unknownError'));
} finally {
setSaving(false);
}
};
return { splitting: trip?.costs_mode !== 'totals', setSplitting, saving, offered: canEdit && memberCount > 1 };
}
@@ -21,7 +21,7 @@ vi.mock('./receiptUploads', () => ({
),
}));
// FE-BUDGET-EXPFORM-001 to FE-BUDGET-EXPFORM-027
// FE-BUDGET-EXPFORM-001 to FE-BUDGET-EXPFORM-028
const PEOPLE: TripMember[] = [
{ id: 1, username: 'alice', avatar_url: null },
@@ -516,6 +516,40 @@ describe('useExpenseForm: saving', () => {
);
});
it('FE-BUDGET-EXPFORM-028: on a totals-only trip a new expense names nobody, an edit keeps who it named (#1714)', async () => {
useTripStore.setState({
trip: buildTrip({ id: 7, currency: 'EUR', costs_mode: 'totals' }),
} as Partial<TripStoreState>);
const created = setup();
expect(created.result.current.showSplit).toBe(false);
fill(created.result, 'Museum', '30');
await save(created.result);
expect(addBudgetItem).toHaveBeenCalledWith(
7,
expect.objectContaining({ payers: [], members: [], member_ids: [], total_price: 30 })
);
const editing = buildBudgetItem({
id: 9,
name: 'Dinner',
total_price: 40,
payers: [{ user_id: 2, amount: 40 }],
members: [
{ user_id: 1, paid: 0, username: 'alice', amount: null },
{ user_id: 2, paid: 0, username: 'bob', amount: null },
],
} as Partial<BudgetItem>);
const edited = setup({ editing });
expect(edited.result.current.showSplit).toBe(false);
act(() => edited.result.current.onTotalChange('50'));
await save(edited.result);
expect(updateBudgetItem).toHaveBeenCalledWith(
7,
9,
expect.objectContaining({ payers: [{ user_id: 2, amount: 50 }], member_ids: [1, 2], total_price: 50 })
);
});
it('FE-BUDGET-EXPFORM-017: a ticket split sends everyone a line names, ticked or not (#1382)', async () => {
const { result } = setup();
act(() => {
+10 -5
View File
@@ -73,6 +73,7 @@ export function useExpenseForm({
const addBudgetItem = useTripStore((s) => s.addBudgetItem);
const updateBudgetItem = useTripStore((s) => s.updateBudgetItem);
const tripFiles = useTripStore((s) => s.files);
const totalsOnly = useTripStore((s) => s.trip?.costs_mode) === 'totals';
const sym = (c: string) => SYMBOLS[c] || c + ' ';
const nameOf = (p: TripMember) => (p.id === me ? t('costs.you') : p.username);
// A saved expense without a currency opens in the trip's own (#2525).
@@ -103,8 +104,11 @@ export function useExpenseForm({
if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : '';
return seed.total;
});
// A new expense on a totals-only trip (#1714) names nobody: no payer, and no
// split that would turn into debts if the trip ever splits its costs again.
const unsplit = totalsOnly && !editing;
const [participants, setParticipants] = useState<Set<number>>(() =>
editing ? new Set((editing.members || []).map((m) => m.user_id)) : new Set(people.map((p) => p.id))
editing ? new Set((editing.members || []).map((m) => m.user_id)) : new Set(unsplit ? [] : people.map((p) => p.id))
);
// Payer state. An expense can be fronted by several people, each with their own
@@ -119,7 +123,7 @@ export function useExpenseForm({
const [payerId, setPayerId] = useState<number>(() => {
const existingPayer = initialPayers[0];
if (existingPayer) return existingPayer.user_id;
return editing ? 0 : me;
return editing || unsplit ? 0 : me;
});
const [multiPayer, setMultiPayer] = useState(() => initialPayers.length > 1);
const [payerIds, setPayerIds] = useState<Set<number>>(() => new Set(initialPayers.map((p) => p.user_id)));
@@ -132,15 +136,16 @@ export function useExpenseForm({
// the others absorb the remainder.
const [pinnedPayers, setPinnedPayers] = useState<Set<number>>(() => new Set(initialPayers.map((p) => p.user_id)));
// A trip of one has nothing to split (#2689): who paid and the split stay out of
// sight, unless the expense being edited carries more than that one person.
// Who paid and the split stay out of sight on a trip that keeps totals only
// (#1714) and on a trip of one, which has nothing to split (#2689), unless the
// expense being edited carries more than that, so nothing on it is hidden.
const [plainSplit] = useState(() =>
isPlainSplit(
editing,
people.map((p) => p.id)
)
);
const showSplit = people.length > 1 || !plainSplit;
const showSplit = (!totalsOnly && people.length > 1) || !plainSplit;
const [splitMode, setSplitMode] = useState<ExpenseSplitMode>(() => {
if (hasTicketSplit(editing)) return 'ticket';
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'
import {
AlertCircle, ArrowDown, ArrowLeftRight, ArrowRight, ArrowUp, Check, ChevronDown, ChevronUp,
Layers, Pencil, Plus, RotateCcw, StickyNote, Trash2, Receipt,
@@ -12,6 +12,8 @@ import MCostSheet from '../sheets/MCostSheet'
import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal'
import { useCostsLedger } from '../../../../components/Budget/useCostsLedger'
import { useExpenseLinks } from '../../../../components/Budget/useExpenseLinks'
import { useCostsMode } from '../../../../components/Budget/useCostsMode'
import ToggleSwitch from '../../../../components/Settings/ToggleSwitch'
import { finalBudgetFor, finalBudgetSources, formerParticipantRows, paidByUser, readUserNote } from '../../../../components/Budget/CostsPanel.helpers'
import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories'
import MConfirmSheet from '../../settings/MConfirmSheet'
@@ -51,6 +53,9 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
deleteExpense, undoSettlement: handleUndoSettlement,
} = useCostsLedger({ tripId, trip, budgetItems, memberCount: tripMembers.length, actions: planner.tripActions, canEdit, t, toast })
// Split between the travellers or totals only (#1714), switched at the foot of the summary.
const costsMode = useCostsMode(trip, tripMembers.length, canEdit)
const modeHintId = useId()
const [catOpen, setCatOpen] = useState(false)
const [dayOpen, setDayOpen] = useState(false)
const [settleOpen, setSettleOpen] = useState(true)
@@ -328,6 +333,23 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
)}
</div>
{costsMode.offered && (
<div className="mt-2 flex items-start gap-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-m-card p-[13px]">
<div className="min-w-0 flex-1">
<div className="text-body font-bold text-m-ink">{t('costs.mode.label')}</div>
<p id={modeHintId} className="m-0 mt-[2px] text-caption text-m-faint">
{costsMode.splitting ? t('costs.mode.hintSplit') : t('costs.mode.hintTotals')}
</p>
</div>
<ToggleSwitch
on={costsMode.splitting}
onToggle={() => void costsMode.setSplitting(!costsMode.splitting)}
label={t('costs.mode.label')}
describedBy={modeHintId}
disabled={costsMode.saving}
/>
</div>
)}
</>
)