mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 08:11:03 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user