mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(assistant): expense, payment and settle-up cards, and expense links into Costs
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { PersonFace } from '../parts/CardPeople';
|
||||
import { CardMore, CardRow, CardRows } from '../parts/CardRows';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { useBudgetPeople } from './budgetHooks';
|
||||
import { costsTarget, latestOf } from './budgetModel';
|
||||
|
||||
/**
|
||||
* What the trip's expenses come to for each traveller, largest share first:
|
||||
* the share at the end, a bar against the largest one under the name, how
|
||||
* many expenses it is made of and how much of it is marked paid.
|
||||
*/
|
||||
export default function CostPerPersonCard({ cards }: { cards: readonly CardEntry[] }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const { nameOf } = useBudgetPeople();
|
||||
const card = latestOf(cards, 'costPerPerson');
|
||||
if (!card) return null;
|
||||
const title = t('budget.perPerson');
|
||||
const largest = Math.max(...card.rows.map((row) => Math.abs(row.share)));
|
||||
const money = (amount: number) => format.money({ amount, currency: card.currency });
|
||||
return (
|
||||
<CardFrame label={title}>
|
||||
<CardHeader
|
||||
icon={Users}
|
||||
tone="info"
|
||||
title={title}
|
||||
count={card.rows.length + card.more}
|
||||
actions={<CardOpenButton target={costsTarget()} labelKey="assistant.card.budget.open" />}
|
||||
/>
|
||||
<CardRows>
|
||||
{card.rows.map((row) => (
|
||||
<CardRow
|
||||
key={row.person.id}
|
||||
lead={<PersonFace person={row.person} size={24} />}
|
||||
title={<bdi>{nameOf(row.person)}</bdi>}
|
||||
aside={<span className="text-body font-semibold tabular-nums text-content">{money(row.share)}</span>}
|
||||
facts={
|
||||
<span className="mt-1 block">
|
||||
<span className="block h-[5px] overflow-hidden rounded-full bg-surface-secondary">
|
||||
<span
|
||||
className="block h-full rounded-full bg-accent"
|
||||
style={{ width: `${largest > 0 ? (Math.abs(row.share) / largest) * 100 : 0}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<span className="text-caption font-normal text-content-faint">
|
||||
{t('costs.expensesCount', { count: row.count })}
|
||||
</span>
|
||||
{row.paid > 0 && (
|
||||
<SoftPill tone="success">
|
||||
{t('assistant.card.budget.markedPaid', { amount: money(row.paid) })}
|
||||
</SoftPill>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</CardRows>
|
||||
<CardMore count={card.more} target={costsTarget()} />
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { CardExpense, ExpensesCard } from '@trek/shared';
|
||||
import { CalendarDays, MapPin, Receipt, Tag } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { catMeta } from '../../../Budget/costsCategories';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import { Fact, FactChange } from '../parts/CardFacts';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { SPLIT_LABEL, togetherOf } from './budgetModel';
|
||||
import { Amount, NotePill, SplitLine } from './budgetParts';
|
||||
|
||||
/**
|
||||
* An expense inside a change row, waiting or decided: its amount at the end
|
||||
* of the subject line (the one an update replaces struck through before it),
|
||||
* its category, day and place on the faint line with what an update changes,
|
||||
* and under it the whole split, the part the user decides on. A run of new
|
||||
* expenses in one currency says what they add up to.
|
||||
*/
|
||||
|
||||
interface SlotProps {
|
||||
card: ExpensesCard;
|
||||
item: ChangeItem;
|
||||
}
|
||||
|
||||
/** Whether the row is struck: an expense that goes, or a change that will not happen. */
|
||||
function struckOut(card: ExpensesCard, item: ChangeItem): boolean {
|
||||
return card.rows[0]?.change === 'willDelete' || item.state === 'discarded' || item.state === 'expired';
|
||||
}
|
||||
|
||||
export function ExpenseAside({ card, item }: SlotProps) {
|
||||
const row = card.rows[0];
|
||||
if (!row) return null;
|
||||
return <Amount value={row.amount} before={row.before?.amount ?? null} struck={struckOut(card, item)} />;
|
||||
}
|
||||
|
||||
/** The category, or the one it was struck through before the new one. */
|
||||
function CategoryFact({ row }: { row: CardExpense }) {
|
||||
const { t } = useTranslation();
|
||||
const nameOf = (category: string | null) => (category === null ? null : t(catMeta(category).labelKey));
|
||||
const now = nameOf(row.category);
|
||||
const was = row.before?.category ?? null;
|
||||
if (was !== null) return <FactChange icon={Tag} before={nameOf(was)} after={now ?? ''} tone="info" />;
|
||||
return now ? <Fact icon={Tag} label={now} /> : null;
|
||||
}
|
||||
|
||||
/** The day, or the one it was struck through before the new one. */
|
||||
function DateFact({ row }: { row: CardExpense }) {
|
||||
const format = useCardFormat();
|
||||
const was = row.before?.date ?? null;
|
||||
const now = row.date === null ? '' : format.date(row.date);
|
||||
if (was !== null) return <FactChange icon={CalendarDays} before={format.date(was)} after={now} tone="info" />;
|
||||
return now ? <Fact icon={CalendarDays} label={now} /> : null;
|
||||
}
|
||||
|
||||
/** The place the expense belongs to; shown on the map where the chat can show it. */
|
||||
function PlaceFact({ place }: { place: NonNullable<CardExpense['place']> }) {
|
||||
const { onOpen } = useAssistantView();
|
||||
const fact = <Fact icon={MapPin} label={place.name} />;
|
||||
if (!onOpen) return fact;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen({ kind: 'map', placeId: place.id, dayId: null })}
|
||||
className="inline-flex max-w-full rounded-full"
|
||||
>
|
||||
{fact}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExpenseFacts({ card }: SlotProps) {
|
||||
const { t } = useTranslation();
|
||||
const row = card.rows[0];
|
||||
if (!row) return null;
|
||||
const split = row.before?.split ?? null;
|
||||
return (
|
||||
<>
|
||||
<CategoryFact row={row} />
|
||||
<DateFact row={row} />
|
||||
{row.place && <PlaceFact place={row.place} />}
|
||||
{split && (
|
||||
<FactChange
|
||||
icon={Tag}
|
||||
before={t(SPLIT_LABEL[split].labelKey)}
|
||||
after={t(SPLIT_LABEL[row.split].labelKey)}
|
||||
tone="info"
|
||||
/>
|
||||
)}
|
||||
{row.receipts > 0 && <Fact icon={Receipt} label={t('assistant.card.budget.receipts', { count: row.receipts })} />}
|
||||
{row.note && <NotePill note={row.note} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExpenseBody({ card }: SlotProps) {
|
||||
const row = card.rows[0];
|
||||
return row ? <SplitLine row={row} /> : null;
|
||||
}
|
||||
|
||||
/** Under a run of waiting expenses: what the included ones add up to, when they are two or more in one currency. */
|
||||
export function ExpensesTogether({
|
||||
items,
|
||||
excluded,
|
||||
place,
|
||||
}: {
|
||||
items: readonly ChangeItem[];
|
||||
excluded: ReadonlySet<string>;
|
||||
place: 'head' | 'foot';
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const together = place === 'foot' ? togetherOf(items, excluded) : null;
|
||||
if (!together) return null;
|
||||
return (
|
||||
<p className="m-0 pt-1 text-end text-caption tabular-nums text-content-secondary">
|
||||
{t('assistant.card.budget.together', { amount: format.money(together) })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* FE-AI-CARD-BUD-007 to FE-AI-CARD-BUD-014
|
||||
*
|
||||
* An expense in the chat, as the Costs page draws it: the list in an answer
|
||||
* (category tile, amount and what a foreign one comes to, day, payer and
|
||||
* split, receipts, the foot with what is left out and the total, the way
|
||||
* into the Costs tab), and an expense inside its change row (the amount and
|
||||
* the one it replaces, the facts an update changes, the whole split with
|
||||
* paid and highlighted parts, what a run of new expenses adds up to).
|
||||
*/
|
||||
import type { AssistantMessage, TrekCard } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ANA, BEN, CLEO, changeItem, expense, expenses } from '../../../../../tests/helpers/assistantBudget';
|
||||
import { buildUser } from '../../../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import { useAuthStore } from '../../../../store/authStore';
|
||||
import { formatMoney } from '../../../../utils/formatters';
|
||||
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
|
||||
import { ExpenseAside, ExpenseBody, ExpenseFacts, ExpensesTogether } from './ExpenseChange';
|
||||
import ExpensesCard from './ExpensesCard';
|
||||
|
||||
const AUTH = useAuthStore.getState();
|
||||
const MESSAGE: AssistantMessage = {
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
createdAt: '2026-10-22T08:00:00.000Z',
|
||||
parts: [],
|
||||
};
|
||||
const money = (amount: number, currency = 'EUR') => formatMoney(amount, currency, 'en');
|
||||
|
||||
function inView(ui: ReactNode, view: Partial<AssistantView> = {}) {
|
||||
const value: AssistantView = { tripId: 4, variant: 'window', ...view };
|
||||
return render(<AssistantViewContext.Provider value={value}>{ui}</AssistantViewContext.Provider>);
|
||||
}
|
||||
|
||||
const answer = (card: TrekCard) => <ExpensesCard cards={[{ toolCallId: 'r1', card }]} message={MESSAGE} />;
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ user: buildUser({ id: ANA.id }) });
|
||||
});
|
||||
afterEach(() => {
|
||||
useAuthStore.setState(AUTH, true);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('the expense list', () => {
|
||||
it('FE-AI-CARD-BUD-007: lists each expense with its category, amount, day, receipts and the trip total', () => {
|
||||
const museum = expense({
|
||||
id: 32,
|
||||
name: 'Museum',
|
||||
category: 'activities',
|
||||
amount: { amount: 50, currency: 'USD' },
|
||||
tripAmount: { amount: 40, currency: 'EUR' },
|
||||
receipts: 2,
|
||||
});
|
||||
const card = expenses([expense(), museum], { more: 1, total: { amount: 1204.5, currency: 'EUR' }, unconverted: 1 });
|
||||
inView(answer(card), { onOpen: vi.fn() });
|
||||
expect(screen.getByRole('region', { name: 'Expenses' })).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'Food & drink' })).toBeInTheDocument();
|
||||
expect(screen.getByText(money(84))).toBeInTheDocument();
|
||||
expect(screen.getByText(money(50, 'USD'))).toBeInTheDocument();
|
||||
expect(screen.getByText(`≈ ${money(40)}`)).toBeInTheDocument();
|
||||
expect(screen.getByText('2 receipts')).toBeInTheDocument();
|
||||
expect(screen.getByText(money(1204.5))).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: /1 foreign expense is left out/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-008: an expense opens itself in the Costs tab, the head and the count the tab', () => {
|
||||
const onOpen = vi.fn();
|
||||
inView(answer(expenses([expense()], { more: 4 })), { onOpen });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Dinner at Lokanta/ }));
|
||||
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'tab', tab: 'finanzplan', params: { expense: '31' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in Costs' }));
|
||||
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'tab', tab: 'finanzplan' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '4 more' }));
|
||||
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'tab', tab: 'finanzplan' });
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-009: the viewer reads as "You", a planned expense says so, and an unpaid one warns', () => {
|
||||
const planned = expense({ id: 40, name: 'Hotel', split: 'none', shares: [], payers: [] });
|
||||
const unpaid = expense({ id: 41, name: 'Taxi', payers: [] });
|
||||
const shared = expense({
|
||||
id: 42,
|
||||
name: 'Boat',
|
||||
payers: [
|
||||
{ person: BEN, amount: 40 },
|
||||
{ person: CLEO, amount: 44 },
|
||||
],
|
||||
});
|
||||
inView(answer(expenses([expense(), planned, unpaid, shared])));
|
||||
expect(screen.getAllByRole('img', { name: 'You' }).length).toBeGreaterThan(0);
|
||||
// Who paid is said in words, not only in a tooltip.
|
||||
expect(screen.getByText('You paid')).toBeInTheDocument();
|
||||
expect(screen.getByText('Multiple people paid')).toBeInTheDocument();
|
||||
expect(screen.getByText('Planning only, not split')).toBeInTheDocument();
|
||||
expect(screen.getByText('No one paid yet')).toBeInTheDocument();
|
||||
// Nothing can open it here, so no row is a button.
|
||||
expect(screen.queryByRole('button', { name: /Hotel/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-010: a waiting new expense is no button, and an answer without expenses draws nothing', () => {
|
||||
inView(answer(expenses([expense({ id: null, name: 'Gelato' })])), { onOpen: vi.fn() });
|
||||
expect(screen.getByText('Gelato')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Gelato/ })).toBeNull();
|
||||
const { container } = render(<ExpensesCard cards={[]} message={MESSAGE} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe('an expense in a change row', () => {
|
||||
it('FE-AI-CARD-BUD-011: the amount it replaces is struck through, and an expense that goes is struck itself', () => {
|
||||
const update = expense({
|
||||
amount: { amount: 96, currency: 'EUR' },
|
||||
before: { name: null, amount: { amount: 84, currency: 'EUR' }, category: null, date: null, split: null },
|
||||
});
|
||||
const first = inView(<ExpenseAside card={expenses([update])} item={changeItem('a', null)} />);
|
||||
expect(screen.getByText(money(84))).toHaveClass('line-through');
|
||||
expect(screen.getByText(money(96))).not.toHaveClass('line-through');
|
||||
first.unmount();
|
||||
inView(<ExpenseAside card={expenses([expense({ change: 'willDelete' })])} item={changeItem('b', null)} />);
|
||||
expect(screen.getByText(money(84))).toHaveClass('line-through');
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-012: the facts name the category, day and place, and what an update changes', () => {
|
||||
const onOpen = vi.fn();
|
||||
const update = expense({
|
||||
category: 'transport',
|
||||
date: '2026-06-05',
|
||||
place: { id: 12, name: 'Lokanta' },
|
||||
receipts: 1,
|
||||
note: 'Window table',
|
||||
split: 'equal',
|
||||
before: { name: null, amount: null, category: 'food', date: '2026-06-03', split: 'custom' },
|
||||
});
|
||||
inView(<ExpenseFacts card={expenses([update])} item={changeItem('a', null)} />, { onOpen });
|
||||
expect(screen.getByText('Food & drink')).toHaveClass('line-through');
|
||||
expect(screen.getByText('Transport')).toBeInTheDocument();
|
||||
expect(screen.getByText('Split unevenly')).toHaveClass('line-through');
|
||||
expect(screen.getByText('Split evenly')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 receipt')).toBeInTheDocument();
|
||||
expect(screen.getByText('Window table')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Lokanta/ }));
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 12, dayId: null });
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-013: the split shows who paid and each part, paid ones green, the ones it is about in the accent', () => {
|
||||
const split = expense({
|
||||
payers: [{ person: BEN, amount: 84 }],
|
||||
shares: [
|
||||
{ person: ANA, amount: 28, paid: false },
|
||||
{ person: BEN, amount: 28, paid: true },
|
||||
{ person: CLEO, amount: 28, paid: false },
|
||||
],
|
||||
highlight: [CLEO.id],
|
||||
});
|
||||
const first = inView(<ExpenseBody card={expenses([split])} item={changeItem('a', null)} />);
|
||||
expect(screen.getByText('Ben paid')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(`You: ${money(28)}`)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(`Ben: ${money(28)}, Paid`).firstElementChild).toHaveClass('text-success');
|
||||
// The one the change is about stands out, and says whether the tick is set or taken off.
|
||||
const cleo = screen.getByRole('img', { name: `Cleo: ${money(28)}, Not paid` });
|
||||
expect(cleo.firstElementChild).toHaveClass('bg-accent');
|
||||
expect(cleo.querySelector('.lucide-check')).toBeNull();
|
||||
first.unmount();
|
||||
|
||||
const ticked = expense({ shares: split.shares.map((share) => ({ ...share, paid: true })), highlight: [CLEO.id] });
|
||||
const tick = inView(<ExpenseBody card={expenses([ticked])} item={changeItem('t', null)} />);
|
||||
const paid = screen.getByRole('img', { name: `Cleo: ${money(28)}, Paid` });
|
||||
expect(paid.firstElementChild).toHaveClass('bg-accent');
|
||||
expect(paid.querySelector('.lucide-check')).not.toBeNull();
|
||||
tick.unmount();
|
||||
|
||||
// An even split of many reads as a stack and the part per person.
|
||||
const crowd = Array.from({ length: 6 }, (_, i) => ({ person: { ...CLEO, id: 100 + i }, amount: 14, paid: false }));
|
||||
const second = inView(<ExpenseBody card={expenses([expense({ shares: crowd })])} item={changeItem('b', null)} />);
|
||||
expect(screen.getByText('You paid')).toBeInTheDocument();
|
||||
expect(screen.getByText(`${money(14)} per person`)).toBeInTheDocument();
|
||||
second.unmount();
|
||||
|
||||
// Unless the change is about one of them: then each part is a badge, so that person shows.
|
||||
const about = expense({ shares: crowd, highlight: [crowd[2]?.person.id ?? 0] });
|
||||
const third = inView(<ExpenseBody card={expenses([about])} item={changeItem('d', null)} />);
|
||||
expect(screen.queryByText(`${money(14)} per person`)).toBeNull();
|
||||
const standsOut = screen.getByRole('img', { name: `Cleo: ${money(14)}, Not paid` });
|
||||
expect(standsOut.firstElementChild).toHaveClass('bg-accent');
|
||||
third.unmount();
|
||||
|
||||
inView(<ExpenseBody card={expenses([expense({ split: 'none', shares: [] })])} item={changeItem('c', null)} />);
|
||||
expect(screen.getByText('Planning only, not split')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-014: under a run of new expenses, what the included ones add up to', () => {
|
||||
const items = [
|
||||
changeItem('a', expenses([expense({ id: null, change: 'new' })])),
|
||||
changeItem('b', expenses([expense({ id: null, change: 'new', amount: { amount: 12.1, currency: 'EUR' } })])),
|
||||
];
|
||||
const foot = inView(<ExpensesTogether items={items} excluded={new Set()} place="foot" />);
|
||||
expect(screen.getByText(`Together ${money(96.1)}`)).toBeInTheDocument();
|
||||
foot.unmount();
|
||||
const { container } = inView(<ExpensesTogether items={items} excluded={new Set()} place="head" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { CardExpense, ExpensesCard as ExpensesCardData } from '@trek/shared';
|
||||
import { CalendarDays, Receipt, TriangleAlert, Wallet } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import { Fact } from '../parts/CardFacts';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { PeopleStack, PersonFace } from '../parts/CardPeople';
|
||||
import { CardMore, CardRow, CardRows } from '../parts/CardRows';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { useBudgetPeople } from './budgetHooks';
|
||||
import { costsTarget, mergeExpenses, stackLimit } from './budgetModel';
|
||||
import { CategoryTile } from './budgetParts';
|
||||
|
||||
/**
|
||||
* The expense list in an answer, as the Costs page lists it: per expense its
|
||||
* category tile, the name, the amount (and what a foreign one comes to in the
|
||||
* trip currency), the day, who paid and who shares it, its receipts. An
|
||||
* expense opens itself in the Costs tab; the ones the card leaves out are
|
||||
* counted there too, beside the trip's total.
|
||||
*/
|
||||
|
||||
/** Who paid, in words as the Costs page says it: "You paid", "Ana paid", or that several did. */
|
||||
function PayerBadge({ payers }: { payers: CardExpense['payers'] }) {
|
||||
const { t } = useTranslation();
|
||||
const { paidBy } = useBudgetPeople();
|
||||
const [payer] = payers;
|
||||
if (!payer) return null;
|
||||
if (payers.length > 1) return <WhiteBadge>{t('costs.multiplePayers')}</WhiteBadge>;
|
||||
return <WhiteBadge icon={<PersonFace person={payer.person} size={14} />}>{paidBy(payer.person)}</WhiteBadge>;
|
||||
}
|
||||
|
||||
/** The line under an expense's name: its day, who paid, who shares it, its receipts. */
|
||||
function ExpenseFacts({ row }: { row: CardExpense }) {
|
||||
const { t } = useTranslation();
|
||||
const { density } = useAssistantView();
|
||||
const format = useCardFormat();
|
||||
const payer = row.payers[0];
|
||||
return (
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{row.date && <Fact icon={CalendarDays} label={format.date(row.date)} />}
|
||||
<PayerBadge payers={row.payers} />
|
||||
{row.split === 'none' ? (
|
||||
<SoftPill>{t('assistant.card.budget.split.none')}</SoftPill>
|
||||
) : (
|
||||
<>
|
||||
{!payer && <SoftPill tone="warning">{t('costs.noOnePaid')}</SoftPill>}
|
||||
<PeopleStack people={row.shares.map((share) => share.person)} max={stackLimit(density)} />
|
||||
</>
|
||||
)}
|
||||
{row.receipts > 0 && <Fact icon={Receipt} label={t('assistant.card.budget.receipts', { count: row.receipts })} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One expense of the list: a button into the Costs tab where something can open it. */
|
||||
function ExpenseRow({ row }: { row: CardExpense }) {
|
||||
const { onOpen } = useAssistantView();
|
||||
const format = useCardFormat();
|
||||
const amount = format.money(row.amount);
|
||||
const id = row.id;
|
||||
return (
|
||||
<CardRow
|
||||
lead={<CategoryTile category={row.category} />}
|
||||
title={
|
||||
<bdi title={row.name} className="line-clamp-2">
|
||||
{row.name}
|
||||
</bdi>
|
||||
}
|
||||
aside={
|
||||
<span className="flex flex-col items-end">
|
||||
<span className="whitespace-nowrap text-body font-semibold tabular-nums text-content">{amount}</span>
|
||||
{row.tripAmount && <span className="whitespace-nowrap tabular-nums">≈ {format.money(row.tripAmount)}</span>}
|
||||
</span>
|
||||
}
|
||||
facts={<ExpenseFacts row={row} />}
|
||||
onActivate={id !== null && onOpen ? () => onOpen(costsTarget(id)) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The foot of the list: the expenses left out, and the trip's total (marked when it leaves some out). */
|
||||
function ExpensesFoot({ card }: { card: ExpensesCardData }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
if (card.more === 0 && !card.total) return null;
|
||||
const leftOut = t('assistant.card.budget.leftOut', { count: card.unconverted });
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 border-t border-edge-faint pt-2 text-caption">
|
||||
<CardMore count={card.more} target={costsTarget()} />
|
||||
{card.total && (
|
||||
<span className="ms-auto flex items-center gap-1 text-content-secondary">
|
||||
{card.unconverted > 0 && (
|
||||
<Tooltip label={leftOut}>
|
||||
<span role="img" aria-label={leftOut} className="inline-flex text-warning">
|
||||
<TriangleAlert size={12} aria-hidden="true" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{t('budget.total')}
|
||||
<span className="font-semibold tabular-nums text-content">{format.money(card.total)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The expense cards of one answer, drawn as one list. */
|
||||
export default function ExpensesCard({ cards }: { cards: readonly CardEntry[] }) {
|
||||
const { t } = useTranslation();
|
||||
const card = mergeExpenses(cards);
|
||||
if (!card) return null;
|
||||
const title = t('costs.expenses');
|
||||
return (
|
||||
<CardFrame label={title}>
|
||||
<CardHeader
|
||||
icon={Wallet}
|
||||
tone="info"
|
||||
title={title}
|
||||
count={card.rows.length + card.more}
|
||||
actions={<CardOpenButton target={costsTarget()} labelKey="assistant.card.budget.open" />}
|
||||
/>
|
||||
<CardRows>
|
||||
{card.rows.map((row, i) => <ExpenseRow key={row.id ?? `new-${i}`} row={row} />)}
|
||||
</CardRows>
|
||||
<ExpensesFoot card={card} />
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* FE-AI-CARD-BUD-015 to FE-AI-CARD-BUD-017
|
||||
*
|
||||
* Settle-up payments in the chat: the history in an answer (who paid whom,
|
||||
* face to face, how much, the day and the note), and a payment inside its
|
||||
* change row (the amount it replaces struck through, the people it no longer
|
||||
* moves money between, the two faces under the row).
|
||||
*/
|
||||
import type { AssistantMessage } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ANA, BEN, CLEO, changeItem, payment, payments } from '../../../../../tests/helpers/assistantBudget';
|
||||
import { buildUser } from '../../../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import { useAuthStore } from '../../../../store/authStore';
|
||||
import { formatMoney } from '../../../../utils/formatters';
|
||||
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
|
||||
import PaymentsCard, { PaymentAside, PaymentBody, PaymentChangeFacts } from './PaymentsCard';
|
||||
|
||||
const AUTH = useAuthStore.getState();
|
||||
const MESSAGE: AssistantMessage = {
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
createdAt: '2026-10-22T08:00:00.000Z',
|
||||
parts: [],
|
||||
};
|
||||
const money = (amount: number, currency = 'EUR') => formatMoney(amount, currency, 'en');
|
||||
|
||||
function inView(ui: ReactNode, view: Partial<AssistantView> = {}) {
|
||||
const value: AssistantView = { tripId: 4, variant: 'window', ...view };
|
||||
return render(<AssistantViewContext.Provider value={value}>{ui}</AssistantViewContext.Provider>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ user: buildUser({ id: ANA.id }) });
|
||||
});
|
||||
afterEach(() => {
|
||||
useAuthStore.setState(AUTH, true);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('payments', () => {
|
||||
it('FE-AI-CARD-BUD-015: the history names who paid whom, how much, when and why, and opens the Costs tab', () => {
|
||||
const onOpen = vi.fn();
|
||||
const card = payments([payment({ note: 'Bank transfer' }), payment({ id: 6, from: CLEO, to: BEN, date: null })], 2);
|
||||
inView(<PaymentsCard cards={[{ toolCallId: 'r1', card }]} message={MESSAGE} />, { onOpen });
|
||||
expect(screen.getByRole('region', { name: 'Settle history' })).toBeInTheDocument();
|
||||
expect(screen.getByText('4')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(money(42.5))).toHaveLength(2);
|
||||
expect(screen.getByText('Bank transfer')).toBeInTheDocument();
|
||||
// The viewer reads as "You", in words and on the face.
|
||||
expect(screen.getAllByText('You').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Cleo')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '2 more' }));
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'finanzplan' });
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-016: a changed payment strikes through the amount and the people it replaces', () => {
|
||||
const changed = payment({
|
||||
from: CLEO,
|
||||
amount: { amount: 50, currency: 'EUR' },
|
||||
before: { from: BEN, to: null, amount: { amount: 42.5, currency: 'EUR' } },
|
||||
change: 'changed',
|
||||
});
|
||||
const card = payments([changed]);
|
||||
const aside = inView(<PaymentAside card={card} item={changeItem('a', card)} />);
|
||||
expect(screen.getByText(money(42.5))).toHaveClass('line-through');
|
||||
expect(screen.getByText(money(50))).not.toHaveClass('line-through');
|
||||
aside.unmount();
|
||||
const facts = inView(<PaymentChangeFacts card={card} item={changeItem('a', card)} />);
|
||||
// The names stay apart, so the arrow between them turns with a right-to-left language.
|
||||
expect(screen.getByText('Ben').closest('.line-through')).not.toBeNull();
|
||||
expect(screen.getByText('Cleo').closest('.line-through')).toBeNull();
|
||||
expect(screen.getAllByText('You')).toHaveLength(2);
|
||||
facts.unmount();
|
||||
inView(<PaymentBody card={card} item={changeItem('a', card)} />);
|
||||
expect(screen.getByRole('img', { name: 'Cleo' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-017: a payment that goes, or a change that will not happen, is struck through', () => {
|
||||
const gone = payments([payment({ change: 'willDelete' })]);
|
||||
const first = inView(<PaymentAside card={gone} item={changeItem('a', gone)} />);
|
||||
expect(screen.getByText(money(42.5))).toHaveClass('line-through');
|
||||
first.unmount();
|
||||
const kept = payments([payment()]);
|
||||
inView(<PaymentAside card={kept} item={changeItem('b', kept, 'discarded')} />);
|
||||
expect(screen.getByText(money(42.5))).toHaveClass('line-through');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { CardPayment, CardPerson, PaymentsCard as PaymentsCardData } from '@trek/shared';
|
||||
import { ArrowLeftRight, ArrowRight, CalendarDays } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import { Fact } from '../parts/CardFacts';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { CardMore, CardRow, CardRows } from '../parts/CardRows';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { costsTarget, mergePayments } from './budgetModel';
|
||||
import { Amount, NotePill, PaymentFaces, PaymentNames } from './budgetParts';
|
||||
|
||||
/**
|
||||
* Settle-up payments as the Costs page's history lists them: who paid whom,
|
||||
* face to face, how much, on which day and with what note. The same pieces
|
||||
* fill a change row of a payment recorded, changed or removed, where a change
|
||||
* strikes through the people and the amount it replaces.
|
||||
*/
|
||||
|
||||
/** The day of a payment and its note. */
|
||||
function PaymentFacts({ row }: { row: CardPayment }) {
|
||||
const format = useCardFormat();
|
||||
return (
|
||||
<>
|
||||
{row.date && <Fact icon={CalendarDays} label={format.date(row.date)} />}
|
||||
{row.note && <NotePill note={row.note} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One payment of the history. */
|
||||
function PaymentRow({ row }: { row: CardPayment }) {
|
||||
const format = useCardFormat();
|
||||
return (
|
||||
<CardRow
|
||||
lead={<PaymentFaces from={row.from} to={row.to} />}
|
||||
title={<PaymentNames from={row.from} to={row.to} />}
|
||||
aside={<span className="text-body font-semibold tabular-nums text-content">{format.money(row.amount)}</span>}
|
||||
facts={
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<PaymentFacts row={row} />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The payment cards of one answer, as one history. */
|
||||
export default function PaymentsCard({ cards }: { cards: readonly CardEntry[] }) {
|
||||
const { t } = useTranslation();
|
||||
const card = mergePayments(cards);
|
||||
if (!card) return null;
|
||||
const title = t('costs.settleHistory');
|
||||
return (
|
||||
<CardFrame label={title}>
|
||||
<CardHeader
|
||||
icon={ArrowLeftRight}
|
||||
tone="info"
|
||||
title={title}
|
||||
count={card.rows.length + card.more}
|
||||
actions={<CardOpenButton target={costsTarget()} labelKey="assistant.card.budget.open" />}
|
||||
/>
|
||||
<CardRows>
|
||||
{card.rows.map((row, i) => <PaymentRow key={row.id ?? `new-${i}`} row={row} />)}
|
||||
</CardRows>
|
||||
<CardMore count={card.more} target={costsTarget()} />
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
interface SlotProps {
|
||||
card: PaymentsCardData;
|
||||
item: ChangeItem;
|
||||
}
|
||||
|
||||
/** The amount at the end of a payment's change row, the one it replaces struck through before it. */
|
||||
export function PaymentAside({ card, item }: SlotProps) {
|
||||
const row = card.rows[0];
|
||||
if (!row) return null;
|
||||
const struck = row.change === 'willDelete' || item.state === 'discarded' || item.state === 'expired';
|
||||
return <Amount value={row.amount} before={row.before?.amount ?? null} struck={struck} />;
|
||||
}
|
||||
|
||||
interface PaymentPeople {
|
||||
from: CardPerson;
|
||||
to: CardPerson;
|
||||
}
|
||||
|
||||
/** The people a change no longer moves money between, struck through, before the ones it does. */
|
||||
function PeopleChange({ was, now }: { was: PaymentPeople; now: PaymentPeople }) {
|
||||
return (
|
||||
<SoftPill
|
||||
tone="info"
|
||||
className="max-w-full"
|
||||
icon={<ArrowLeftRight size={10} strokeWidth={2.2} aria-hidden="true" className="flex-none" />}
|
||||
>
|
||||
<span className="inline-flex min-w-0 text-content-faint line-through">
|
||||
<PaymentNames from={was.from} to={was.to} wrap={false} />
|
||||
</span>
|
||||
<ArrowRight size={9} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />
|
||||
<PaymentNames from={now.from} to={now.to} wrap={false} />
|
||||
</SoftPill>
|
||||
);
|
||||
}
|
||||
|
||||
/** The day and note of a payment's change, and the people it no longer moves money between. */
|
||||
export function PaymentChangeFacts({ card }: SlotProps) {
|
||||
const row = card.rows[0];
|
||||
if (!row) return null;
|
||||
const before = row.before;
|
||||
const was = before && (before.from || before.to) ? { from: before.from ?? row.from, to: before.to ?? row.to } : null;
|
||||
return (
|
||||
<>
|
||||
{was && <PeopleChange was={was} now={row} />}
|
||||
<PaymentFacts row={row} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Under a payment's change row: the two people, face to face. */
|
||||
export function PaymentBody({ card }: SlotProps) {
|
||||
const row = card.rows[0];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<span className="flex items-center gap-2 text-caption text-content-secondary">
|
||||
<PaymentFaces from={row.from} to={row.to} size={20} />
|
||||
<PaymentNames from={row.from} to={row.to} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* FE-AI-CARD-BUD-018 to FE-AI-CARD-BUD-021
|
||||
*
|
||||
* Settle up and the share per traveller in the chat: who pays whom or the
|
||||
* square trip, the balances on their bars (green owed, red owing, from the
|
||||
* middle), the entries no rate converts, the payments already recorded, and
|
||||
* each traveller's share with how much of it is marked paid.
|
||||
*/
|
||||
import type {
|
||||
AssistantMessage,
|
||||
CostPerPersonCard as CostPerPersonCardData,
|
||||
SettlementCard as SettlementData,
|
||||
} from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ANA, BEN, CLEO } from '../../../../../tests/helpers/assistantBudget';
|
||||
import { buildUser } from '../../../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import { useAuthStore } from '../../../../store/authStore';
|
||||
import { formatMoney } from '../../../../utils/formatters';
|
||||
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
|
||||
import CostPerPersonCard from './CostPerPersonCard';
|
||||
import SettlementCard from './SettlementCard';
|
||||
|
||||
const AUTH = useAuthStore.getState();
|
||||
const MESSAGE: AssistantMessage = {
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
createdAt: '2026-10-22T08:00:00.000Z',
|
||||
parts: [],
|
||||
};
|
||||
const money = (amount: number) => formatMoney(amount, 'EUR', 'en');
|
||||
|
||||
function inView(ui: ReactNode, view: Partial<AssistantView> = {}) {
|
||||
const value: AssistantView = { tripId: 4, variant: 'window', ...view };
|
||||
return render(<AssistantViewContext.Provider value={value}>{ui}</AssistantViewContext.Provider>);
|
||||
}
|
||||
|
||||
function settle(overrides: Partial<SettlementData> = {}): SettlementData {
|
||||
return {
|
||||
kind: 'settlement',
|
||||
currency: 'EUR',
|
||||
flows: [
|
||||
{ from: BEN, to: ANA, amount: 42.5 },
|
||||
{ from: CLEO, to: ANA, amount: 18 },
|
||||
],
|
||||
moreFlows: 0,
|
||||
balances: [
|
||||
{ person: ANA, balance: 60.5 },
|
||||
{ person: CLEO, balance: -18 },
|
||||
{ person: BEN, balance: -42.5 },
|
||||
],
|
||||
moreBalances: 0,
|
||||
recorded: 3,
|
||||
unconverted: { entries: 2, currencies: ['USD'] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ user: buildUser({ id: ANA.id }) });
|
||||
});
|
||||
afterEach(() => {
|
||||
useAuthStore.setState(AUTH, true);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('settle up', () => {
|
||||
it('FE-AI-CARD-BUD-018: names who pays whom, the balances on their bars and the payments already recorded', () => {
|
||||
const onOpen = vi.fn();
|
||||
inView(<SettlementCard cards={[{ toolCallId: 'r1', card: settle() }]} message={MESSAGE} />, { onOpen });
|
||||
expect(screen.getByRole('region', { name: 'Settle up' })).toBeInTheDocument();
|
||||
expect(screen.getByText(money(42.5))).toBeInTheDocument();
|
||||
expect(screen.getByText(money(18))).toBeInTheDocument();
|
||||
expect(screen.getByText('Balances')).toBeInTheDocument();
|
||||
expect(screen.getByText(`+${money(60.5)}`)).toHaveClass('text-success');
|
||||
expect(screen.getByText(money(-42.5))).toHaveClass('text-danger');
|
||||
expect(screen.getByText('3 payments settled')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 entries in USD are not counted: no exchange rate yet.')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in Costs' }));
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'finanzplan' });
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-019: a square trip says so and counts nothing it does not have', () => {
|
||||
const square = settle({
|
||||
flows: [],
|
||||
balances: [{ person: ANA, balance: 0 }],
|
||||
recorded: 0,
|
||||
unconverted: { entries: 0, currencies: [] },
|
||||
});
|
||||
inView(<SettlementCard cards={[{ toolCallId: 'r1', card: square }]} message={MESSAGE} />);
|
||||
expect(screen.getByText("Everyone's square")).toBeInTheDocument();
|
||||
expect(screen.getByText('No payments outstanding right now.')).toBeInTheDocument();
|
||||
expect(screen.getByText(money(0))).toHaveClass('text-content-faint');
|
||||
expect(screen.queryByText(/payments settled/)).toBeNull();
|
||||
expect(screen.queryByText(/not counted/)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-020: a long settle up counts the payments and the balances it leaves out', () => {
|
||||
const card = settle({ moreFlows: 3, moreBalances: 7 });
|
||||
inView(<SettlementCard cards={[{ toolCallId: 'r1', card }]} message={MESSAGE} />);
|
||||
expect(screen.getByText('3 more')).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
expect(screen.getByText('7 more')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the share per traveller', () => {
|
||||
it('FE-AI-CARD-BUD-021: lists each share, largest first, with its expenses and what is marked paid', () => {
|
||||
const card: CostPerPersonCardData = {
|
||||
kind: 'costPerPerson',
|
||||
currency: 'EUR',
|
||||
rows: [
|
||||
{ person: ANA, share: 69, paid: 45, count: 2 },
|
||||
{ person: BEN, share: 61, paid: 0, count: 1 },
|
||||
],
|
||||
more: 1,
|
||||
};
|
||||
inView(<CostPerPersonCard cards={[{ toolCallId: 'r1', card }]} message={MESSAGE} />, { onOpen: vi.fn() });
|
||||
expect(screen.getByRole('region', { name: 'Per Person' })).toBeInTheDocument();
|
||||
expect(screen.getByText(money(69))).toBeInTheDocument();
|
||||
expect(screen.getByText('2 expenses')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 expense')).toBeInTheDocument();
|
||||
expect(screen.getByText(`${money(45)} marked paid`)).toBeInTheDocument();
|
||||
expect(screen.queryByText(`${money(0)} marked paid`)).toBeNull();
|
||||
expect(screen.getByText('You')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '1 more' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { SettlementCard as SettlementCardData } from '@trek/shared';
|
||||
import { Check, Scale, TriangleAlert } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { Eyebrow } from '../../../Planner/bookings/bookingParts';
|
||||
import { TourNotice } from '../../../Tours/tourParts';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { PersonFace } from '../parts/CardPeople';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { useBudgetPeople } from './budgetHooks';
|
||||
import { balanceShare, costsTarget, latestOf } from './budgetModel';
|
||||
import { PaymentFaces, PaymentNames } from './budgetParts';
|
||||
|
||||
/**
|
||||
* Settle up as the Costs page shows it: the payments that square the trip,
|
||||
* face to face, or a green "everyone's square"; each traveller's balance on
|
||||
* a bar from the middle (green to one side, red to the other, mirrored in a
|
||||
* right-to-left language); the entries no rate converts yet, and how many
|
||||
* payments were already recorded.
|
||||
*/
|
||||
|
||||
/** Who pays whom, or the square trip. */
|
||||
function Flows({ card }: { card: SettlementCardData }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
if (card.flows.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 py-2">
|
||||
<span className="flex h-7 w-7 flex-none items-center justify-center rounded-full bg-success-soft text-success">
|
||||
<Check size={14} strokeWidth={2.4} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-body font-semibold text-content">{t('costs.everyoneSquare')}</span>
|
||||
<span className="block text-caption text-content-faint">{t('costs.nothingOutstanding')}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul className="m-0 list-none divide-y divide-edge-faint p-0">
|
||||
{card.flows.map((flow) => (
|
||||
<li key={`${flow.from.id}-${flow.to.id}`} className="flex items-center gap-2 py-2">
|
||||
<PaymentFaces from={flow.from} to={flow.to} />
|
||||
<span className="min-w-0 flex-1 text-caption text-content-secondary">
|
||||
<PaymentNames from={flow.from} to={flow.to} />
|
||||
</span>
|
||||
<span className="flex-none text-body font-semibold tabular-nums text-content">
|
||||
{format.money({ amount: flow.amount, currency: card.currency })}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{card.moreFlows > 0 && (
|
||||
<li className="py-1 text-caption text-content-faint">
|
||||
{t('assistant.card.common.more', { count: card.moreFlows })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** Each traveller's balance: positive is owed money, negative owes it. */
|
||||
function Balances({ card }: { card: SettlementCardData }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const { nameOf } = useBudgetPeople();
|
||||
if (card.balances.length === 0) return null;
|
||||
const largest = Math.max(...card.balances.map((row) => Math.abs(row.balance)));
|
||||
return (
|
||||
<section className="mt-2">
|
||||
<Eyebrow className="mb-1.5">{t('costs.balances')}</Eyebrow>
|
||||
<ul className="m-0 flex list-none flex-col gap-2 p-0">
|
||||
{card.balances.map((row) => {
|
||||
const owed = row.balance >= 0.005;
|
||||
const owes = row.balance <= -0.005;
|
||||
const reach = { width: `${balanceShare(row.balance, largest)}%` };
|
||||
const money = format.money({ amount: row.balance, currency: card.currency });
|
||||
const tone = owed ? 'text-success' : owes ? 'text-danger' : 'text-content-faint';
|
||||
return (
|
||||
<li key={row.person.id} className="grid grid-cols-[20px_minmax(0,1fr)_auto] items-center gap-2.5">
|
||||
<PersonFace person={row.person} size={20} />
|
||||
<span className="min-w-0">
|
||||
<bdi className="block truncate text-caption font-semibold text-content">{nameOf(row.person)}</bdi>
|
||||
<span className="relative mt-1 block h-[5px] overflow-hidden rounded-full bg-surface-secondary">
|
||||
<span className="absolute inset-y-0 start-1/2 w-px bg-edge" />
|
||||
{owed && <span className="absolute inset-y-0 start-1/2 rounded-full bg-success" style={reach} />}
|
||||
{owes && <span className="absolute inset-y-0 end-1/2 rounded-full bg-danger" style={reach} />}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`text-caption font-semibold tabular-nums ${tone}`}>{owed ? `+${money}` : money}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{card.moreBalances > 0 && (
|
||||
<li className="text-caption text-content-faint">
|
||||
{t('assistant.card.common.more', { count: card.moreBalances })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** The latest settle up of an answer. */
|
||||
export default function SettlementCard({ cards }: { cards: readonly CardEntry[] }) {
|
||||
const { t } = useTranslation();
|
||||
const card = latestOf(cards, 'settlement');
|
||||
if (!card) return null;
|
||||
const title = t('costs.settleUp');
|
||||
const { entries, currencies } = card.unconverted;
|
||||
return (
|
||||
<CardFrame label={title}>
|
||||
<CardHeader
|
||||
icon={Scale}
|
||||
tone="info"
|
||||
title={title}
|
||||
count={card.flows.length + card.moreFlows}
|
||||
actions={<CardOpenButton target={costsTarget()} labelKey="assistant.card.budget.open" />}
|
||||
/>
|
||||
<Flows card={card} />
|
||||
<Balances card={card} />
|
||||
{entries > 0 && (
|
||||
<div className="mt-2">
|
||||
<TourNotice tone="warning" icon={<TriangleAlert size={12} aria-hidden="true" />}>
|
||||
{t('assistant.card.budget.unconverted', { count: entries, currencies: currencies.join(', ') })}
|
||||
</TourNotice>
|
||||
</div>
|
||||
)}
|
||||
{card.recorded > 0 && (
|
||||
<p className="m-0 pt-2 text-caption text-content-faint">
|
||||
{t('costs.paymentsSettled', { count: card.recorded })}
|
||||
</p>
|
||||
)}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { CardPerson } from '@trek/shared';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { useViewerId } from '../parts/useViewerId';
|
||||
|
||||
/**
|
||||
* The words the costs' cards say about people, as the Costs page says them:
|
||||
* the signed-in user is "You", and "You paid" rather than "Ana paid".
|
||||
*/
|
||||
export function useBudgetPeople() {
|
||||
const { t } = useTranslation();
|
||||
const viewerId = useViewerId();
|
||||
const isViewer = (person: CardPerson) => person.id === viewerId;
|
||||
return {
|
||||
nameOf: (person: CardPerson) => (isViewer(person) ? t('costs.you') : person.name),
|
||||
paidBy: (person: CardPerson) =>
|
||||
isViewer(person) ? t('costs.youPaid') : t('assistant.card.budget.paidBy', { name: person.name }),
|
||||
};
|
||||
}
|
||||
@@ -1,25 +1,72 @@
|
||||
import { Wallet } from 'lucide-react';
|
||||
import type { AssistantApplied, TrekCard } from '@trek/shared';
|
||||
import { ArrowLeftRight, Wallet } from 'lucide-react';
|
||||
|
||||
import type { PackLook } from '../cardTypes';
|
||||
import { costsTarget } from './budgetModel';
|
||||
|
||||
/**
|
||||
* What the costs bring to the chat's pure look: the verbs, tool names and steps
|
||||
* they add, what an applied change of theirs opens, and the look of each of
|
||||
* their kinds. An applied expense has no way to open yet, as before. A look
|
||||
* stays pure, icons and literals only: the chat's main chunk reads every look,
|
||||
* so whatever a look imports ships with it.
|
||||
* their kinds. An applied expense opens itself in the Costs tab, a removed
|
||||
* one and a payment the tab where they were listed. A look stays pure, icons
|
||||
* and literals only: the chat's main chunk reads every look, so whatever a
|
||||
* look imports ships with it.
|
||||
*/
|
||||
|
||||
/** The expense an applied change leaves to open: none once it was removed. */
|
||||
function expenseLeft(applied: AssistantApplied, card: TrekCard | null): number | null {
|
||||
const removed = card?.kind === 'expenses' && card.rows[0]?.change === 'willDelete';
|
||||
return removed ? null : (applied.ids[0] ?? null);
|
||||
}
|
||||
|
||||
/** Whether a card is one of the costs' change cards, which a lookup of the expenses only served. */
|
||||
function isBudgetChange(change: TrekCard): boolean {
|
||||
return change.kind === 'expenses' || change.kind === 'payments';
|
||||
}
|
||||
|
||||
export const BUDGET_LOOK = {
|
||||
verbs: {},
|
||||
tools: {},
|
||||
steps: {},
|
||||
verbs: {
|
||||
addPayment: { Icon: ArrowLeftRight, tone: 'success', labelKey: 'assistant.change.verb.addPayment' },
|
||||
updatePayment: { Icon: ArrowLeftRight, tone: 'info', labelKey: 'assistant.change.verb.updatePayment' },
|
||||
},
|
||||
tools: {
|
||||
create_budget_item_with_members: { labelKey: 'assistant.tool.create_budget_item_with_members' },
|
||||
set_budget_item_members: { labelKey: 'assistant.tool.set_budget_item_members' },
|
||||
toggle_budget_member_paid: { labelKey: 'assistant.tool.toggle_budget_member_paid' },
|
||||
create_settlement: { labelKey: 'assistant.tool.create_settlement' },
|
||||
update_settlement: { labelKey: 'assistant.tool.update_settlement' },
|
||||
delete_settlement: { labelKey: 'assistant.tool.delete_settlement' },
|
||||
freeze_budget_rates: { labelKey: 'assistant.tool.freeze_budget_rates' },
|
||||
},
|
||||
steps: {
|
||||
list_settlements: { stepKey: 'assistant.step.costs' },
|
||||
},
|
||||
entities: {
|
||||
budget: { Icon: Wallet },
|
||||
budget: {
|
||||
Icon: Wallet,
|
||||
openLabelKey: 'assistant.card.budget.open',
|
||||
open: (applied: AssistantApplied, card: TrekCard | null) => costsTarget(expenseLeft(applied, card)),
|
||||
},
|
||||
settlement: {
|
||||
Icon: ArrowLeftRight,
|
||||
openLabelKey: 'assistant.card.budget.open',
|
||||
open: () => costsTarget(),
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
expenses: {},
|
||||
payments: {},
|
||||
settlement: {},
|
||||
expenses: {
|
||||
entity: () => 'budget',
|
||||
followUps: [{ id: 'whoOwes', labelKey: 'assistant.followUp.whoOwes' }],
|
||||
// The list read to find an expense gives way to the change it was read for.
|
||||
yieldsToChange: (_answer, change) => isBudgetChange(change),
|
||||
},
|
||||
payments: {
|
||||
entity: () => 'settlement',
|
||||
yieldsToChange: (_answer, change) => change.kind === 'payments',
|
||||
},
|
||||
settlement: {
|
||||
followUps: [{ id: 'recordPayments', labelKey: 'assistant.followUp.recordPayments', edits: true }],
|
||||
},
|
||||
costPerPerson: {},
|
||||
},
|
||||
} satisfies PackLook<'expenses' | 'payments' | 'settlement' | 'costPerPerson'>;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* FE-AI-CARD-BUD-001 to FE-AI-CARD-BUD-006
|
||||
*
|
||||
* The pure side of the costs' cards: pages of the expense list read as one
|
||||
* list where they meet, the latest settle up wins, an even split of many reads as a
|
||||
* stack, what a run of new expenses adds up to, how far a balance reaches,
|
||||
* and what each card and receipt opens.
|
||||
*/
|
||||
import type { AssistantApplied, CostPerPersonCard, SettlementCard, TrekCard } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ANA, changeItem, expense, expenses, payment, payments } from '../../../../../tests/helpers/assistantBudget';
|
||||
import { CARD_RULES, ENTITY_LOOK } from '../cardLooks';
|
||||
import {
|
||||
balanceShare,
|
||||
chipLimit,
|
||||
costsTarget,
|
||||
latestOf,
|
||||
mergeExpenses,
|
||||
mergePayments,
|
||||
sharesAsChips,
|
||||
stackLimit,
|
||||
togetherOf,
|
||||
} from './budgetModel';
|
||||
|
||||
const entry = (toolCallId: string, card: TrekCard) => ({ toolCallId, card });
|
||||
|
||||
const settlement = (recorded: number): SettlementCard => ({
|
||||
kind: 'settlement',
|
||||
currency: 'EUR',
|
||||
flows: [],
|
||||
moreFlows: 0,
|
||||
balances: [],
|
||||
moreBalances: 0,
|
||||
recorded,
|
||||
unconverted: { entries: 0, currencies: [] },
|
||||
});
|
||||
|
||||
describe('budgetModel', () => {
|
||||
it('FE-AI-CARD-BUD-001: pages of the expense list read as one list where they meet, each expense once', () => {
|
||||
const total = { amount: 90, currency: 'EUR' };
|
||||
const first = expenses([expense({ id: 1 }), expense({ id: 2 })], { more: 2, total });
|
||||
const second = expenses([expense({ id: 2 }), expense({ id: 3 })], { more: 2 });
|
||||
const merged = mergeExpenses([entry('a', first), entry('b', second), entry('c', settlement(0))]);
|
||||
expect(merged?.rows.map((row) => row.id)).toEqual([1, 2, 3]);
|
||||
// Four in all: the pages show three of them.
|
||||
expect(merged?.more).toBe(1);
|
||||
expect(merged?.total).toEqual(total);
|
||||
expect(mergeExpenses([entry('c', settlement(0))])).toBeNull();
|
||||
// A page read first that comes later in the list still follows the one before it.
|
||||
const later = expenses([expense({ id: 3 })], { offset: 2, more: 3 });
|
||||
const start = expenses([expense({ id: 1 }), expense({ id: 2 })], { more: 2 });
|
||||
expect(mergeExpenses([entry('a', later), entry('b', start)])?.rows.map((row) => row.id)).toEqual([1, 2, 3]);
|
||||
// A page past a gap is counted with the rest instead of read as if it came next.
|
||||
const head = expenses([expense({ id: 1 }), expense({ id: 2 })], { more: 19 });
|
||||
const past = expenses([expense({ id: 21 })], { offset: 20, more: 20 });
|
||||
const gapped = mergeExpenses([entry('a', head), entry('b', past)]);
|
||||
expect(gapped?.rows.map((row) => row.id)).toEqual([1, 2]);
|
||||
expect(gapped?.more).toBe(19);
|
||||
const twice = mergePayments([entry('a', payments([payment()], 3)), entry('b', payments([payment()], 3))]);
|
||||
expect(twice?.rows).toHaveLength(1);
|
||||
expect(twice?.more).toBe(3);
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-002: settle up and the shares answer once, with the latest card', () => {
|
||||
const shares: CostPerPersonCard = {
|
||||
kind: 'costPerPerson',
|
||||
currency: 'EUR',
|
||||
rows: [{ person: ANA, share: 1, paid: 0, count: 1 }],
|
||||
more: 0,
|
||||
};
|
||||
expect(latestOf([entry('a', settlement(1)), entry('b', settlement(4))], 'settlement')?.recorded).toBe(4);
|
||||
expect(latestOf([entry('a', shares)], 'costPerPerson')).toBe(shares);
|
||||
expect(latestOf([entry('a', shares)], 'settlement')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-003: an uneven split is a badge per person, an even one of many a stack', () => {
|
||||
const many = { split: 'equal' as const, shares: expense().shares, moreShares: 3, highlight: [] };
|
||||
expect(sharesAsChips(many, chipLimit('compact'))).toBe(false);
|
||||
expect(sharesAsChips({ ...many, moreShares: 0 }, chipLimit('compact'))).toBe(true);
|
||||
expect(sharesAsChips({ ...many, split: 'custom' }, chipLimit('compact'))).toBe(true);
|
||||
expect(sharesAsChips(many, chipLimit('roomy'))).toBe(true);
|
||||
expect([chipLimit(undefined), stackLimit(undefined), stackLimit('roomy')]).toEqual([4, 3, 5]);
|
||||
// The person a change is about must stand out, so their split is never a stack.
|
||||
expect(sharesAsChips({ ...many, highlight: [ANA.id] }, chipLimit('compact'))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-004: new expenses add up while two or more are included, in one currency', () => {
|
||||
const fresh = (overrides: Parameters<typeof expense>[0] = {}) => expense({ id: null, change: 'new', ...overrides });
|
||||
const dinner = changeItem('a', expenses([fresh()]));
|
||||
const taxi = changeItem('b', expenses([fresh({ amount: { amount: 12.1, currency: 'EUR' } })]));
|
||||
expect(togetherOf([dinner, taxi], new Set())).toEqual({ amount: 96.1, currency: 'EUR' });
|
||||
expect(togetherOf([dinner, taxi], new Set(['b']))).toBeNull();
|
||||
const dollars = changeItem('c', expenses([fresh({ amount: { amount: 5, currency: 'USD' } })]));
|
||||
expect(togetherOf([dinner, dollars], new Set())).toBeNull();
|
||||
const gone = changeItem('d', expenses([expense({ change: 'willDelete' })]));
|
||||
expect(togetherOf([dinner, gone], new Set())).toBeNull();
|
||||
const applied = changeItem('e', expenses([fresh()]), 'applied');
|
||||
expect(togetherOf([dinner, applied], new Set())).toBeNull();
|
||||
// A paid tick or an update to an expense already there adds no money.
|
||||
const ticks = [
|
||||
changeItem('f', expenses([expense({ change: 'changed' })])),
|
||||
changeItem('g', expenses([expense({ id: 32, change: 'changed' })])),
|
||||
];
|
||||
expect(togetherOf(ticks, new Set())).toBeNull();
|
||||
expect(togetherOf([dinner, taxi, ...ticks], new Set())).toEqual({ amount: 96.1, currency: 'EUR' });
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-005: a balance reaches half the bar at most, against the largest one', () => {
|
||||
expect(balanceShare(42.5, 42.5)).toBe(50);
|
||||
expect(balanceShare(-21.25, 42.5)).toBe(25);
|
||||
expect(balanceShare(0, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-006: a receipt opens the Costs tab on its expense, a payment the tab, and a lookup gives way', () => {
|
||||
const applied = (entity: AssistantApplied['entity'], ids: number[]): AssistantApplied => ({
|
||||
toolCallId: 't',
|
||||
entity,
|
||||
ids,
|
||||
dayId: null,
|
||||
undoUntil: null,
|
||||
});
|
||||
expect(ENTITY_LOOK.budget.open?.(applied('budget', [31]), null)).toEqual(costsTarget(31));
|
||||
expect(costsTarget(31)).toEqual({ kind: 'tab', tab: 'finanzplan', params: { expense: '31' } });
|
||||
expect(ENTITY_LOOK.budget.open?.(applied('budget', []), null)).toEqual({ kind: 'tab', tab: 'finanzplan' });
|
||||
// A removed expense is no longer there to open: its receipt opens the tab.
|
||||
const removed = expenses([expense({ change: 'willDelete' })]);
|
||||
expect(ENTITY_LOOK.budget.open?.(applied('budget', [31]), removed)).toEqual(costsTarget());
|
||||
expect(ENTITY_LOOK.settlement.open?.(applied('settlement', [5]), null)).toEqual(costsTarget());
|
||||
expect(ENTITY_LOOK.budget.onMap).toBeUndefined();
|
||||
const list = expenses([expense()]);
|
||||
expect(CARD_RULES.yieldsToChange(list, expenses([expense({ id: null })]))).toBe(true);
|
||||
expect(CARD_RULES.yieldsToChange(list, payments([payment()]))).toBe(true);
|
||||
expect(CARD_RULES.yieldsToChange(payments([payment()]), list)).toBe(false);
|
||||
expect(CARD_RULES.yieldsToChange(list, settlement(0))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type {
|
||||
CardExpense,
|
||||
CardMoney,
|
||||
CostPerPersonCard,
|
||||
ExpensesCard,
|
||||
ExpenseSplit,
|
||||
PaymentsCard,
|
||||
SettlementCard,
|
||||
TrekCardKind,
|
||||
TrekCardOf,
|
||||
} from '@trek/shared';
|
||||
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import type { CardEntry, EntityTarget } from '../cardTypes';
|
||||
|
||||
/**
|
||||
* The pure side of the costs' cards: how the cards of one answer come
|
||||
* together (pages of the expense list read as one list where they meet, the
|
||||
* last settle up wins), how a split is drawn (a badge per person, or a stack and the
|
||||
* share per person for an even split of many), what a run of new expenses
|
||||
* adds up to, and where each card opens.
|
||||
*/
|
||||
|
||||
/** The Costs tab, on one expense when there is one. */
|
||||
export function costsTarget(expenseId: number | null = null): EntityTarget {
|
||||
return expenseId === null
|
||||
? { kind: 'tab', tab: 'finanzplan' }
|
||||
: { kind: 'tab', tab: 'finanzplan', params: { expense: String(expenseId) } };
|
||||
}
|
||||
|
||||
function cardsOf<K extends TrekCardKind>(entries: readonly CardEntry[], kind: K): TrekCardOf<K>[] {
|
||||
return entries.flatMap((entry) => (entry.card.kind === kind ? [entry.card as TrekCardOf<K>] : []));
|
||||
}
|
||||
|
||||
/**
|
||||
* The expense cards of one answer as one list: the pages in the list's order,
|
||||
* each expense once, and only as far as they follow on from each other. A
|
||||
* page past a gap would read as if its expenses came next, so it and the
|
||||
* ones after it are counted with the rest instead.
|
||||
*/
|
||||
export function mergeExpenses(entries: readonly CardEntry[]): ExpensesCard | null {
|
||||
const cards = cardsOf(entries, 'expenses').sort((a, b) => a.offset - b.offset);
|
||||
const first = cards[0];
|
||||
if (!first) return null;
|
||||
const seen = new Set<number>();
|
||||
const rows: CardExpense[] = [];
|
||||
let reach = first.offset;
|
||||
for (const card of cards) {
|
||||
if (card.offset > reach) break;
|
||||
for (const row of card.rows) {
|
||||
if (row.id !== null && seen.has(row.id)) continue;
|
||||
if (row.id !== null) seen.add(row.id);
|
||||
rows.push(row);
|
||||
}
|
||||
reach = Math.max(reach, card.offset + card.rows.length);
|
||||
}
|
||||
// Each page counts the whole list: what is left out is that, less what the pages show.
|
||||
const all = Math.max(...cards.map((card) => card.rows.length + card.more));
|
||||
return { ...first, rows, more: Math.max(0, all - rows.length) };
|
||||
}
|
||||
|
||||
/** The payment cards of one answer as one list, each payment once. */
|
||||
export function mergePayments(entries: readonly CardEntry[]): PaymentsCard | null {
|
||||
const cards = cardsOf(entries, 'payments');
|
||||
const first = cards[0];
|
||||
if (!first) return null;
|
||||
const seen = new Set<number>();
|
||||
const rows = cards
|
||||
.flatMap((card) => card.rows)
|
||||
.filter((row) => {
|
||||
if (row.id === null) return true;
|
||||
if (seen.has(row.id)) return false;
|
||||
seen.add(row.id);
|
||||
return true;
|
||||
});
|
||||
return { ...first, rows, more: Math.max(...cards.map((card) => card.more)) };
|
||||
}
|
||||
|
||||
/** Settle up and the share per traveller answer once: the latest card of the answer is the one shown. */
|
||||
export function latestOf(entries: readonly CardEntry[], kind: 'settlement'): SettlementCard | null;
|
||||
export function latestOf(entries: readonly CardEntry[], kind: 'costPerPerson'): CostPerPersonCard | null;
|
||||
export function latestOf(entries: readonly CardEntry[], kind: 'settlement' | 'costPerPerson') {
|
||||
return cardsOf(entries, kind).at(-1) ?? null;
|
||||
}
|
||||
|
||||
/** How many people of a split get a badge of their own before an even one reads as a stack. */
|
||||
export function chipLimit(density: 'compact' | 'roomy' | undefined): number {
|
||||
return density === 'roomy' ? 6 : 4;
|
||||
}
|
||||
|
||||
/** How many faces a stack shows. */
|
||||
export function stackLimit(density: 'compact' | 'roomy' | undefined): number {
|
||||
return density === 'roomy' ? 5 : 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a split is drawn as a badge per person: an uneven one always, one
|
||||
* a change is about someone in (a paid tick, someone joining) too, so that
|
||||
* person stands out, and an even one while it is small.
|
||||
*/
|
||||
export function sharesAsChips(
|
||||
row: Pick<CardExpense, 'split' | 'shares' | 'moreShares' | 'highlight'>,
|
||||
limit: number
|
||||
): boolean {
|
||||
return row.split === 'custom' || row.highlight.length > 0 || row.shares.length + row.moreShares <= limit;
|
||||
}
|
||||
|
||||
/** The words of each way a split can go. A table, so each key is named where it is read. */
|
||||
export const SPLIT_LABEL: Readonly<Record<ExpenseSplit, { labelKey: string }>> = {
|
||||
equal: { labelKey: 'assistant.card.budget.split.equal' },
|
||||
custom: { labelKey: 'assistant.card.budget.split.custom' },
|
||||
none: { labelKey: 'assistant.card.budget.split.none' },
|
||||
};
|
||||
|
||||
/**
|
||||
* What the new expenses of a run add up to, the ones still waiting that the
|
||||
* user includes: two or more, all in one currency. A change to an expense
|
||||
* already there (an update, a paid tick, who shares it) adds no money, nor
|
||||
* does one going away, so neither counts.
|
||||
*/
|
||||
export function togetherOf(items: readonly ChangeItem[], excluded: ReadonlySet<string>): CardMoney | null {
|
||||
const amounts = items.flatMap((item) => {
|
||||
const card = item.card;
|
||||
if (item.state !== 'pending' || excluded.has(item.toolCallId) || card?.kind !== 'expenses') return [];
|
||||
return card.rows.filter((row) => row.change === 'new').map((row) => row.amount);
|
||||
});
|
||||
const currency = amounts[0]?.currency;
|
||||
if (amounts.length < 2 || !currency || amounts.some((amount) => amount.currency !== currency)) return null;
|
||||
const cents = amounts.reduce((sum, amount) => sum + Math.round(amount.amount * 100), 0);
|
||||
return { amount: cents / 100, currency };
|
||||
}
|
||||
|
||||
/** How far a balance reaches on its bar, against the largest one; half the bar is the most. */
|
||||
export function balanceShare(balance: number, largest: number): number {
|
||||
return largest <= 0 ? 0 : Math.min(50, (Math.abs(balance) / largest) * 50);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { CardExpense, CardMoney, CardPerson } from '@trek/shared';
|
||||
import { ArrowRight, Check, StickyNote } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { catMeta } from '../../../Budget/costsCategories';
|
||||
import { SoftPill, tintOf, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import { PeopleStack, PersonFace } from '../parts/CardPeople';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { useBudgetPeople } from './budgetHooks';
|
||||
import { chipLimit, sharesAsChips, stackLimit } from './budgetModel';
|
||||
|
||||
/**
|
||||
* The pieces the costs' cards share, drawn as the Costs page draws them: an
|
||||
* expense's category tile, an amount (struck through where a change replaces
|
||||
* it), the people of a split as small badges with their part, and the line
|
||||
* that says who paid and who shares it.
|
||||
*/
|
||||
|
||||
/** The category's tile: its icon in its own colour on a tint of it, its name for a screen reader. */
|
||||
export function CategoryTile({ category }: { category: string | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { Icon, color, labelKey } = catMeta(category);
|
||||
const label = t(labelKey);
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="flex h-7 w-7 flex-none items-center justify-center rounded-[9px]"
|
||||
style={{ background: tintOf(color, 14), color }}
|
||||
>
|
||||
<Icon size={14} strokeWidth={2.1} aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** An amount at the end of a row; the one a change replaces struck through before it. */
|
||||
export function Amount({
|
||||
value,
|
||||
before = null,
|
||||
struck = false,
|
||||
}: {
|
||||
value: CardMoney;
|
||||
before?: CardMoney | null;
|
||||
struck?: boolean;
|
||||
}) {
|
||||
const format = useCardFormat();
|
||||
return (
|
||||
<span className="flex flex-none flex-wrap items-center justify-end gap-1 whitespace-nowrap text-body font-semibold tabular-nums text-content">
|
||||
{before && (
|
||||
<>
|
||||
<bdi className="font-normal text-content-faint line-through">{format.money(before)}</bdi>
|
||||
<ArrowRight size={9} aria-hidden="true" className="text-content-faint rtl:-scale-x-100" />
|
||||
</>
|
||||
)}
|
||||
<bdi className={struck ? 'text-content-faint line-through' : ''}>{format.money(value)}</bdi>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One person's part of a split: a white badge, green with a tick once paid,
|
||||
* in the accent when the change is about them, the tick there too when they
|
||||
* have paid, so a paid tick set and one taken off read apart. Everything it
|
||||
* shows is in its label, for a screen reader and a phone without a tooltip.
|
||||
*/
|
||||
function ShareChip({
|
||||
person,
|
||||
amount,
|
||||
paid = false,
|
||||
highlighted = false,
|
||||
}: {
|
||||
person: CardPerson;
|
||||
amount: CardMoney;
|
||||
paid?: boolean;
|
||||
highlighted?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const { nameOf } = useBudgetPeople();
|
||||
const money = format.money(amount);
|
||||
const said = t('costs.splitChipLabel', { name: nameOf(person), amount: money });
|
||||
const state = paid ? t('costs.table.paid') : highlighted ? t('assistant.card.budget.notPaid') : null;
|
||||
const label = state ? `${said}, ${state}` : said;
|
||||
const face = <PersonFace person={person} size={14} />;
|
||||
const pill = highlighted ? (
|
||||
<SoftPill tone="accent" icon={face} className="tabular-nums">
|
||||
{money}
|
||||
{paid && <Check size={9} strokeWidth={3} aria-hidden="true" />}
|
||||
</SoftPill>
|
||||
) : paid ? (
|
||||
<SoftPill tone="success" icon={face} className="tabular-nums">
|
||||
{money}
|
||||
<Check size={9} strokeWidth={3} aria-hidden="true" />
|
||||
</SoftPill>
|
||||
) : (
|
||||
<WhiteBadge icon={face}>{money}</WhiteBadge>
|
||||
);
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<span role="img" className="inline-flex" aria-label={label}>
|
||||
{pill}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** Who paid: one payer by name ("You paid"), several each with what they paid; a warning when nobody did. */
|
||||
function Payers({ row }: { row: Pick<CardExpense, 'payers' | 'amount'> }) {
|
||||
const { t } = useTranslation();
|
||||
const { paidBy } = useBudgetPeople();
|
||||
const [only] = row.payers;
|
||||
if (!only) return <SoftPill tone="warning">{t('costs.noOnePaid')}</SoftPill>;
|
||||
if (row.payers.length === 1) {
|
||||
return <WhiteBadge icon={<PersonFace person={only.person} size={14} />}>{paidBy(only.person)}</WhiteBadge>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{row.payers.map((payer) => (
|
||||
<ShareChip
|
||||
key={payer.person.id}
|
||||
person={payer.person}
|
||||
amount={{ amount: payer.amount, currency: row.amount.currency }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The split of an expense in a change row: who paid, an arrow, then each
|
||||
* part as a badge, or a stack and the part per person for an even split of
|
||||
* many. A planning-only expense says so instead.
|
||||
*/
|
||||
export function SplitLine({ row }: { row: CardExpense }) {
|
||||
const { t } = useTranslation();
|
||||
const { density } = useAssistantView();
|
||||
const format = useCardFormat();
|
||||
if (row.split === 'none') {
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
<SoftPill>{t('assistant.card.budget.split.none')}</SoftPill>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const currency = row.amount.currency;
|
||||
const highlighted = new Set(row.highlight);
|
||||
const firstShare = row.shares[0];
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
<Payers row={row} />
|
||||
<ArrowRight size={10} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />
|
||||
{sharesAsChips(row, chipLimit(density)) || !firstShare ? (
|
||||
row.shares.map((share) => (
|
||||
<ShareChip
|
||||
key={share.person.id}
|
||||
person={share.person}
|
||||
amount={{ amount: share.amount, currency }}
|
||||
paid={share.paid}
|
||||
highlighted={highlighted.has(share.person.id)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<PeopleStack people={row.shares.map((share) => share.person)} max={stackLimit(density)} />
|
||||
<span className="text-caption tabular-nums text-content-secondary">
|
||||
{t('costs.perPerson', { amount: format.money({ amount: firstShare.amount, currency }) })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{row.moreShares > 0 && (
|
||||
<span className="text-caption text-content-faint">
|
||||
{t('assistant.card.common.more', { count: row.moreShares })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Two people of a payment, face to face: who paid, an arrow, who got it. */
|
||||
export function PaymentFaces({ from, to, size = 24 }: { from: CardPerson; to: CardPerson; size?: 20 | 24 }) {
|
||||
return (
|
||||
<span className="flex flex-none items-center gap-1">
|
||||
<PersonFace person={from} size={size} />
|
||||
<ArrowRight size={13} aria-hidden="true" className="text-content-faint rtl:-scale-x-100" />
|
||||
<PersonFace person={to} size={size} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Ben → Ana" in words, each name kept whole in either reading direction and
|
||||
* the arrow turned for a right-to-left one. Inside a badge it stays on one line.
|
||||
*/
|
||||
export function PaymentNames({ from, to, wrap = true }: { from: CardPerson; to: CardPerson; wrap?: boolean }) {
|
||||
const { nameOf } = useBudgetPeople();
|
||||
return (
|
||||
<span className={`inline-flex min-w-0 items-center gap-1 ${wrap ? 'flex-wrap' : 'flex-nowrap'}`}>
|
||||
<bdi>{nameOf(from)}</bdi>
|
||||
<ArrowRight size={11} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />
|
||||
<bdi>{nameOf(to)}</bdi>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A note in the white of a badge, wrapping over up to three lines rather
|
||||
* than cut on one, so a phone without a tooltip reads it too. The card
|
||||
* carries a note already cut to a sentence or two; the tooltip still holds
|
||||
* all of it where three lines are not enough.
|
||||
*/
|
||||
export function NotePill({ note }: { note: string }) {
|
||||
return (
|
||||
<span
|
||||
title={note}
|
||||
className="inline-flex min-w-0 max-w-full items-start gap-1 rounded-[10px] bg-surface-card px-2 py-[3px] text-caption leading-snug text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint"
|
||||
>
|
||||
<StickyNote size={10} aria-hidden="true" className="mt-[3px] flex-none text-content-faint" />
|
||||
<bdi className="line-clamp-3 min-w-0 break-words">{note}</bdi>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,34 @@
|
||||
import { type CardView, PENDING_VIEW } from '../cardTypes';
|
||||
import type { CardView } from '../cardTypes';
|
||||
import CostPerPersonCard from './CostPerPersonCard';
|
||||
import { ExpenseAside, ExpenseBody, ExpenseFacts, ExpensesTogether } from './ExpenseChange';
|
||||
import ExpensesCard from './ExpensesCard';
|
||||
import PaymentsCard, { PaymentAside, PaymentBody, PaymentChangeFacts } from './PaymentsCard';
|
||||
import SettlementCard from './SettlementCard';
|
||||
|
||||
/**
|
||||
* How the kinds of the costs draw in the chat; each one draws nothing until its
|
||||
* cards are built. This file only maps each kind to its view: the components
|
||||
* live in sibling files, since a file that exports anything besides components
|
||||
* breaks fast refresh, and lint:warnings holds
|
||||
* react-refresh/only-export-components at zero for a new file.
|
||||
* How the kinds of the costs draw in the chat: the expense list, the payment
|
||||
* history, settle up and the share per traveller as answer cards, and an
|
||||
* expense or a payment inside its change row (the amount at the end, the
|
||||
* facts on the faint line, the split or the two people under it). This file
|
||||
* only maps each kind to its view: the components live in sibling files,
|
||||
* since a file that exports anything besides components breaks fast refresh,
|
||||
* and lint:warnings holds react-refresh/only-export-components at zero for a
|
||||
* new file.
|
||||
*/
|
||||
export const BUDGET_VIEWS = {
|
||||
expenses: PENDING_VIEW,
|
||||
payments: PENDING_VIEW,
|
||||
settlement: PENDING_VIEW,
|
||||
costPerPerson: PENDING_VIEW,
|
||||
expenses: {
|
||||
Block: ExpensesCard,
|
||||
Aside: ExpenseAside,
|
||||
Facts: ExpenseFacts,
|
||||
Body: ExpenseBody,
|
||||
Group: ExpensesTogether,
|
||||
},
|
||||
payments: {
|
||||
Block: PaymentsCard,
|
||||
Aside: PaymentAside,
|
||||
Facts: PaymentChangeFacts,
|
||||
Body: PaymentBody,
|
||||
},
|
||||
settlement: { Block: SettlementCard },
|
||||
costPerPerson: { Block: CostPerPersonCard },
|
||||
} satisfies { [K in 'expenses' | 'payments' | 'settlement' | 'costPerPerson']: CardView<K> };
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Fragment, useState, useEffect, useMemo, useCallback, useId, type ReactNode } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { Fragment, useState, useMemo, useCallback, useId, type ReactNode } from 'react'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown, Receipt, Paperclip, ScanLine, Users } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
type CostsSettlement as Settlement, type CostsSettlementResponse,
|
||||
} from './costsModel'
|
||||
import { useCostsLedger } from './useCostsLedger'
|
||||
import { useExpenseLinks } from './useExpenseLinks'
|
||||
import { useSettlementForm } from './useSettlementForm'
|
||||
import { usePercentSplit, type CustomSplitUnit } from './usePercentSplit'
|
||||
import { useExpenseForm } from './useExpenseForm'
|
||||
@@ -119,14 +119,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const fmt = useCallback((v: number, c = base) => formatMoney(v, c, locale), [base, locale])
|
||||
const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale])
|
||||
|
||||
// The bottom-nav "+" on the Costs tab opens the add-expense modal via ?create=expense.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
useEffect(() => {
|
||||
if (searchParams.get('create') === 'expense') {
|
||||
setEditing(null); setModalOpen(true)
|
||||
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
|
||||
}
|
||||
}, [searchParams])
|
||||
// The bottom-nav "+" (?create=expense) and a link to one expense (?expense=<id>) open the expense modal.
|
||||
useExpenseLinks(tripId, budgetItems, e => { setEditing(e); setModalOpen(true) }, { create: true })
|
||||
|
||||
// ── derived expense maths (everything converted to the base currency) ────
|
||||
// Booked, not live: an expense entered in a foreign currency keeps the rate it was
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* FE-COSTS-LINK-001 to FE-COSTS-LINK-006
|
||||
*
|
||||
* The links into the Costs tab that open the expense dialog: one expense
|
||||
* (`?expense=<id>`, the assistant's "Open in Costs"), reading the expenses
|
||||
* once more when the store does not hold it yet, dropped once taken or when
|
||||
* that read does not bring it either; and the bottom bar's "+"
|
||||
* (`?create=expense`) where the view asks for it.
|
||||
*/
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
|
||||
import { buildBudgetItem } from '../../../tests/helpers/factories';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { BudgetItem } from '../../types';
|
||||
import { useExpenseLinks } from './useExpenseLinks';
|
||||
|
||||
let reload: Mock<(tripId: number | string) => Promise<void>>;
|
||||
|
||||
function Probe({
|
||||
items,
|
||||
open,
|
||||
create = false,
|
||||
}: {
|
||||
items: BudgetItem[];
|
||||
open: (expense: BudgetItem | null) => void;
|
||||
create?: boolean;
|
||||
}) {
|
||||
useExpenseLinks(4, items, open, { create });
|
||||
const [params] = useSearchParams();
|
||||
return <output aria-label="search">{params.toString()}</output>;
|
||||
}
|
||||
|
||||
const search = () => screen.getByRole('status', { name: 'search' }).textContent;
|
||||
|
||||
describe('useExpenseLinks', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
reload = vi.fn<(tripId: number | string) => Promise<void>>(async () => undefined);
|
||||
useTripStore.setState({ loadBudgetItems: reload });
|
||||
});
|
||||
|
||||
it('FE-COSTS-LINK-001: opens the expense a link names and drops only its own part of the address', () => {
|
||||
const dinner = buildBudgetItem({ id: 31 });
|
||||
const open = vi.fn();
|
||||
render(<Probe items={[buildBudgetItem({ id: 30 }), dinner]} open={open} />, {
|
||||
initialEntries: ['/trips/4?tab=finanzplan&expense=31'],
|
||||
});
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(open).toHaveBeenCalledWith(dinner);
|
||||
expect(search()).toBe('tab=finanzplan');
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COSTS-LINK-002: an expense not in the store yet reads the expenses again and opens once it arrives', async () => {
|
||||
let arrive: () => void = () => undefined;
|
||||
reload.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
arrive = resolve;
|
||||
})
|
||||
);
|
||||
const open = vi.fn();
|
||||
const first = render(<Probe items={[buildBudgetItem({ id: 30 })]} open={open} />, {
|
||||
initialEntries: ['/trips/4?expense=31'],
|
||||
});
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
expect(reload).toHaveBeenCalledWith(4);
|
||||
expect(search()).toBe('expense=31');
|
||||
const dinner = buildBudgetItem({ id: 31 });
|
||||
first.rerender(<Probe items={[buildBudgetItem({ id: 30 }), dinner]} open={open} />);
|
||||
expect(open).toHaveBeenCalledWith(dinner);
|
||||
expect(search()).toBe('');
|
||||
// The read that was still under way changes nothing once it lands.
|
||||
arrive();
|
||||
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
|
||||
expect(search()).toBe('');
|
||||
});
|
||||
|
||||
it('FE-COSTS-LINK-003: a malformed id is dropped at once, expenses or not', () => {
|
||||
const open = vi.fn();
|
||||
render(<Probe items={[]} open={open} />, { initialEntries: ['/trips/4?expense=dinner'] });
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(search()).toBe('');
|
||||
});
|
||||
|
||||
it('FE-COSTS-LINK-004: the "+" opens an empty dialog where the view asks for it, and is left alone elsewhere', () => {
|
||||
const open = vi.fn();
|
||||
const first = render(<Probe items={[]} open={open} create />, { initialEntries: ['/trips/4?create=expense'] });
|
||||
expect(open).toHaveBeenCalledWith(null);
|
||||
expect(search()).toBe('');
|
||||
first.unmount();
|
||||
|
||||
const phone = vi.fn();
|
||||
render(<Probe items={[]} open={phone} />, { initialEntries: ['/trips/4?create=expense'] });
|
||||
expect(phone).not.toHaveBeenCalled();
|
||||
expect(search()).toBe('create=expense');
|
||||
});
|
||||
|
||||
it('FE-COSTS-LINK-005: an id the second read does not bring either is dropped without opening anything', async () => {
|
||||
const open = vi.fn();
|
||||
render(<Probe items={[buildBudgetItem({ id: 5 })]} open={open} />, { initialEntries: ['/trips/4?expense=99'] });
|
||||
expect(search()).toBe('expense=99');
|
||||
await waitFor(() => expect(search()).toBe(''));
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COSTS-LINK-006: on a trip without any expense the link is still dropped after the read', async () => {
|
||||
const open = vi.fn();
|
||||
render(<Probe items={[]} open={open} />, { initialEntries: ['/trips/4?tab=finanzplan&expense=31'] });
|
||||
await waitFor(() => expect(search()).toBe('tab=finanzplan'));
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useEffectEvent, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { BudgetItem } from '../../types';
|
||||
|
||||
/**
|
||||
* The links into the Costs tab that open the expense dialog, taken once and
|
||||
* then dropped from the URL so a reload or the way back does not repeat them.
|
||||
* `?expense=<id>` opens that expense the way a click on its row does: the
|
||||
* assistant's "Open in Costs" on an expense lands here, on the desktop and on
|
||||
* the phone alike. An id the store does not hold yet (an expense the
|
||||
* assistant just created, before its broadcast arrived) reads the expenses
|
||||
* once more; an id that read does not bring either is dropped without a word,
|
||||
* on a trip without any expense too. What opening means is the view's: the
|
||||
* phone opens its edit sheet only for an editor, as its rows do. With
|
||||
* `create`, the bottom bar's "+" opens an empty dialog, as on the desktop.
|
||||
*/
|
||||
export function useExpenseLinks(
|
||||
tripId: number | string,
|
||||
budgetItems: readonly BudgetItem[],
|
||||
open: (expense: BudgetItem | null) => void,
|
||||
{ create = false }: { create?: boolean } = {}
|
||||
): void {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const loadBudgetItems = useTripStore((state) => state.loadBudgetItems);
|
||||
// The link whose expenses were read again for it: after that read it is done, found or not.
|
||||
const [checked, setChecked] = useState<string | null>(null);
|
||||
const creating = create && searchParams.get('create') === 'expense';
|
||||
const asked = searchParams.get('expense');
|
||||
const id = asked === null ? null : Number(asked);
|
||||
const malformed = id !== null && (!Number.isInteger(id) || id <= 0);
|
||||
const found = id === null || malformed ? undefined : budgetItems.find((item) => item.id === id);
|
||||
const settled = asked !== null && (found !== undefined || malformed || checked === asked);
|
||||
const openExpense = useEffectEvent(open);
|
||||
const readAgain = useEffectEvent(() => Promise.resolve(loadBudgetItems(tripId)));
|
||||
|
||||
useEffect(() => {
|
||||
if (asked === null || settled) return;
|
||||
let live = true;
|
||||
void readAgain().finally(() => {
|
||||
if (live) setChecked(asked);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [asked, settled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!creating && !settled) return;
|
||||
if (creating) openExpense(null);
|
||||
else if (found) openExpense(found);
|
||||
setSearchParams(
|
||||
(params) => {
|
||||
if (creating) params.delete('create');
|
||||
if (settled) params.delete('expense');
|
||||
return params;
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
}, [creating, settled, found, setSearchParams]);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { openFile } from '../../../../utils/fileDownload'
|
||||
import MCostSheet from '../sheets/MCostSheet'
|
||||
import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal'
|
||||
import { useCostsLedger } from '../../../../components/Budget/useCostsLedger'
|
||||
import { useExpenseLinks } from '../../../../components/Budget/useExpenseLinks'
|
||||
import { useSettlementForm } from '../../../../components/Budget/useSettlementForm'
|
||||
import { finalBudgetFor, finalBudgetSources, formerParticipantRows, NOTE_MAX, paidByUser, readUserNote } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories'
|
||||
@@ -65,6 +66,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
|
||||
const [editingExpense, setEditingExpense] = useState<BudgetItem | null>(null)
|
||||
const [confirmDelete, setConfirmDelete] = useState<BudgetItem | null>(null)
|
||||
const [previewReceipts, setPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
|
||||
useExpenseLinks(tripId, budgetItems, (e) => { if (canEdit) { setEditingExpense(e); setExpenseModalOpen(true) } })
|
||||
|
||||
const groups = useMemo(() => groupLedgerByDay(filtered, filteredSettlements), [filtered, filteredSettlements])
|
||||
const catKeys = useMemo(() => categoryFilterKeys(budgetItems), [budgetItems])
|
||||
|
||||
Reference in New Issue
Block a user