feat(costs): leave a category out of the totals for a look (#2819)

Seeing what a trip cost without, say, food meant adding it up by hand. A
click (or tap) on a category in the breakdown now leaves it out of the
total, your share and what you paid, dimmed in place with a line naming it
and Count all to undo. View only: nothing is saved, and what is owed, open
or settled stays whole. The desktop breakdown moves into its own component.
This commit is contained in:
Maurice
2026-11-13 10:04:57 +01:00
parent a70eea5bf2
commit 72fc61a016
40 changed files with 480 additions and 63 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
"src/components/Budget/CostsPanel.test.tsx": 2730,
"src/components/Budget/CostsPanel.tsx": 1633,
"src/components/Budget/CostsPanel.tsx": 1607,
"src/components/Map/MapView.tsx": 1200,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2126,
@@ -0,0 +1,62 @@
// FE-COSTS-CATBARS-001 to FE-COSTS-CATBARS-003
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import { CostsCategoryBreakdown } from './CostsCategoryBreakdown';
import type { CostsCategoryBar } from './costsModel';
const fmt = (v: number) => `${v} €`;
const rows: CostsCategoryBar[] = [
{ key: 'accommodation', amount: 300, widthPct: 100, leftOut: false },
{ key: 'food', amount: 120, widthPct: 40, leftOut: false },
];
function setup(over: Partial<Parameters<typeof CostsCategoryBreakdown>[0]> = {}) {
const onToggle = vi.fn();
const onCountAll = vi.fn();
render(
<CostsCategoryBreakdown
rows={rows}
leftOutLabel=""
fmt={fmt}
onToggle={onToggle}
onCountAll={onCountAll}
{...over}
/>
);
return { onToggle, onCountAll };
}
describe('CostsCategoryBreakdown (#2819)', () => {
it('FE-COSTS-CATBARS-001: a category is clicked out of the totals, with a hint on how', async () => {
const user = userEvent.setup();
const { onToggle } = setup();
expect(screen.getByText('Select a category to leave it out of the totals.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Leave Food & drink out of the totals' }));
expect(onToggle).toHaveBeenCalledWith('food');
});
it('FE-COSTS-CATBARS-002: a category left out stays dimmed in place and says what is left out', async () => {
const user = userEvent.setup();
const { onToggle, onCountAll } = setup({
rows: [rows[0], { ...rows[1], leftOut: true, widthPct: 0 }],
leftOutLabel: 'Left out of the totals: Food & drink',
});
const food = screen.getByRole('button', { name: 'Count Food & drink in the totals again' });
expect(food.className).toContain('opacity-45');
expect(screen.getByText('120 €').className).toContain('line-through');
expect(screen.getByText('Left out of the totals: Food & drink')).toBeInTheDocument();
expect(screen.queryByText('Select a category to leave it out of the totals.')).toBeNull();
await user.click(food);
expect(onToggle).toHaveBeenCalledWith('food');
await user.click(screen.getByRole('button', { name: 'Count all' }));
expect(onCountAll).toHaveBeenCalledTimes(1);
});
it('FE-COSTS-CATBARS-003: without spend there is nothing to break down', () => {
setup({ rows: [] });
expect(screen.getByText('No expenses yet.')).toBeInTheDocument();
expect(screen.queryByRole('button')).toBeNull();
});
});
@@ -0,0 +1,101 @@
import type { CostCategory } from '@trek/shared';
import { RotateCcw } from 'lucide-react';
import { useTranslation } from '../../i18n';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { COST_CAT_META } from './costsCategories';
import type { CostsCategoryBar } from './costsModel';
/**
* What each category cost, as a ranked list of bars (spec §3.5), on the
* desktop Costs panel. Categories net refunds against spend (#2176): a category
* that nets negative keeps its row without a bar. Bars are scaled against the
* most expensive category counted, not the trip total, so the ranking reads.
*
* A row is a switch that leaves its category out of the totals for a look
* without it (#2819). It stays in place, dimmed and struck through, and the
* line on top says what is left out, with a way to count everything again.
*/
export function CostsCategoryBreakdown({
rows,
leftOutLabel,
fmt,
onToggle,
onCountAll,
}: {
rows: CostsCategoryBar[];
/** What the totals leave out, as the line on top says it; empty while everything counts. */
leftOutLabel: string;
fmt: (amount: number) => string;
onToggle: (key: CostCategory) => void;
onCountAll: () => void;
}) {
const { t } = useTranslation();
if (rows.length === 0) {
return (
<div className="text-content-faint" style={fs(12.5, 'body')}>
{t('costs.noCategories')}
</div>
);
}
return (
<div className="flex flex-col gap-3">
{leftOutLabel ? (
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="min-w-0 text-content-muted" style={fs(12, 'body')}>
{leftOutLabel}
</span>
<button
type="button"
onClick={onCountAll}
className="inline-flex flex-none items-center gap-1 rounded-full bg-surface-card px-2.5 py-1 font-semibold text-content-muted shadow-sm hover:text-content"
style={fs(11.5)}
>
<RotateCcw size={12} /> {t('costs.breakdown.countAll')}
</button>
</div>
) : (
rows.length > 1 && (
<p className="m-0 text-content-faint" style={fs(11.5)}>
{t('costs.breakdown.hint')}
</p>
)
)}
{rows.map((row) => {
const c = COST_CAT_META[row.key];
const name = t(c.labelKey);
const action = row.leftOut
? t('costs.breakdown.countIn', { category: name })
: t('costs.breakdown.leaveOut', { category: name });
return (
<Tooltip key={row.key} label={action}>
<button
type="button"
aria-label={action}
onClick={() => onToggle(row.key)}
className={`grid w-full grid-cols-[auto_1fr_auto] items-center gap-x-2.5 gap-y-1 rounded-lg text-start transition-opacity ${row.leftOut ? 'opacity-45' : ''}`}
>
<span className="h-2.5 w-2.5 rounded-[3px]" style={{ background: c.color }} />
<span className="text-content" style={fs(13, 'body')}>
{name}
</span>
<span
className={`font-semibold text-content-muted ${row.leftOut ? 'line-through' : ''}`}
style={fs(13, 'body')}
>
{fmt(row.amount)}
</span>
<span className="col-span-full h-[5px] overflow-hidden rounded-[3px] bg-surface-secondary">
<span
className="block h-full rounded-[3px]"
style={{ width: `${row.widthPct}%`, background: c.color }}
/>
</span>
</button>
</Tooltip>
);
})}
</div>
);
}
+12 -33
View File
@@ -14,7 +14,7 @@ import { useReceiptScan } from './useReceiptScan'
import { ReceiptScanModal } from './ReceiptScanModal'
import { SPLIT_COLORS } from './BudgetPanel.constants'
import { finalBudgetFor, finalBudgetSources, formerParticipantRows, NOTE_MAX, paidByUser, readUserNote, settlementDate } from './CostsPanel.helpers'
import { COST_CAT_META, COST_CATEGORY_LIST, catMeta } from './costsCategories'
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
import {
baseTotal as baseTotalOf, booked as bookedAmount, isUnfinished as isUnfinishedModel, lineOf as lineOfModel, memberShareOf,
myPaidOf as myPaidOfModel, paymentAmount, paymentLineOf, settledShareOf,
@@ -31,6 +31,7 @@ import { useExpenseDeleteConfirm } from './useExpenseDeleteConfirm'
import { FlowPills } from './FlowPills'
import { CostsSplitChips } from './CostsSplitChips'
import { CostsModeCard } from './CostsModeCard'
import { CostsCategoryBreakdown } from './CostsCategoryBreakdown'
import type { BudgetParticipantFinal, ReceiptLine } from '@trek/shared'
import type { BudgetItem, BudgetItemReceipt } from '../../types'
import type { TripMember } from './BudgetPanelMemberChips'
@@ -74,7 +75,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const {
me, base, tripCurrency, ctx, settlement, settlementError, loadSettlement, knownNames, splitting,
search, setSearch, segment: filter, setSegment: setFilter, catFilter, setCatFilter, dayFilter, setDayFilter,
totals, filtered, filteredSettlements, catBreakdown, exportCsv: handleExportCsv,
totals, filtered, filteredSettlements, catBreakdown, leftOutLabel, toggleLeftOutCat, countAllCats, exportCsv: handleExportCsv,
deleteExpense: handleDelete, undoSettlement, settleFlow, settleAll,
} = useCostsLedger({
tripId, trip, budgetItems, memberCount: tripMembers.length, actions: { loadBudgetItems, deleteBudgetItem }, canEdit, t, toast,
@@ -237,12 +238,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const totalCard = (
<SummaryCard label={t('costs.totalSpend')} sub={t('costs.totalSpendSub')} amount={totals.totalSpend} currency={base} locale={locale}
icon={<BarChart3 size={18} />} tone="total"
foot={splitting ? <>
foot={<>{splitting ? <>
<span className={CHIP}>{t('costs.yourShare')}<b className="text-content">{fmt0(totals.myShare)}</b></span>
<span className={CHIP}>{t('costs.youPaid')}<b className="text-content">{fmt0(totals.myPaid)}</b></span>
</> : <span className={CHIP}>{t('costs.expensesCount', { count: budgetItems.length })}</span>} />
</> : <span className={CHIP}>{t('costs.expensesCount', { count: budgetItems.length })}</span>}
{leftOutLabel && <span className={`${CHIP} text-warning`}>{leftOutLabel}</span>}</>} />
)
// Spend by category; a click leaves one out of the totals for a look without it (#2819).
const categoryBars = <CostsCategoryBreakdown rows={catBreakdown} leftOutLabel={leftOutLabel} fmt={v => fmt0(v)}
onToggle={toggleLeftOutCat} onCountAll={countAllCats} />
// ── category + day filter controls (shared by both layouts) ──────────────
// A prominent summary shown when a single day is selected: the day + its total.
const dayFilterTotal = dayFilter ? filtered.reduce((a, e) => a + baseTotal(e), 0) : 0
@@ -377,7 +383,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
) : (
<section aria-label={t('costs.byCategory')} className={SIDE_CARD}>
{sideHead(t('costs.byCategory'))}
<div className="px-4 py-4">{CategoryBreakdown()}</div>
<div className="px-4 py-4">{categoryBars}</div>
</section>
)}
<CostsModeCard trip={trip} memberCount={people.length} canEdit={canEdit} className={SIDE_CARD} />
@@ -606,7 +612,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{/* By category */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
{CategoryBreakdown()}
{categoryBars}
</div>
<CostsModeCard trip={trip} memberCount={people.length} canEdit={canEdit} className={`${cardCls} rounded-[18px]`} />
</div>
@@ -978,33 +984,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
)
}
function CategoryBreakdown() {
// Categories net refunds against spend (#2176): a negative entry lowers its
// category's sum, and a category that nets negative keeps its own row,
// just without a bar, since the bars rank positive spend. Bars are scaled
// relative to the most expensive category (the top row fills the bar), not
// to the trip grand total, which makes the relative ranking readable.
if (catBreakdown.length === 0) return <div className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>{t('costs.noCategories')}</div>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{catBreakdown.map(row => {
const c = COST_CAT_META[row.key]
const v = row.amount; const pct = row.widthPct
return (
<div key={row.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{t(c.labelKey)}</span>
<span className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{fmt0(v)}</span>
<div className="bg-surface-secondary" style={{ gridColumn: '1 / -1', height: 5, borderRadius: 3, overflow: 'hidden', marginTop: -2 }}>
<span style={{ display: 'block', height: '100%', width: pct + '%', background: c.color, borderRadius: 3 }} />
</div>
</div>
)
})}
</div>
)
}
}
// ── Add / edit expense modal ───────────────────────────────────────────────
@@ -1,9 +1,11 @@
import type { CostCategory } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { buildBudgetItem } from '../../../tests/helpers/factories';
import type { BudgetItem } from '../../types';
import {
buildCostsCsv,
categoryBreakdown,
computeTotals,
currencyOptions,
filterBudgetItems,
@@ -18,7 +20,7 @@ import {
type CostsSettlement,
} from './costsModel';
// FE-BUDGET-COSTSMODEL-001 to FE-BUDGET-COSTSMODEL-014
// FE-BUDGET-COSTSMODEL-001 to FE-BUDGET-COSTSMODEL-015
// 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.
@@ -180,3 +182,25 @@ describe('costsModel: when Costs shows who paid and the shares (#2689, #1714)',
expect(showsSplitting('split', 3, [shared], { flows: [flow], balances: [] }, 1)).toBe(true);
});
});
describe('costsModel: categories left out of the totals (#2819)', () => {
it('FE-BUDGET-COSTSMODEL-015: they leave what was spent, paid and shared, not what is owed or open, and keep their row', () => {
const food = expense({
category: 'food',
total_price: 40,
payers: [payer(1, 40)],
members: [member(1), member(2)],
});
const hotel = expense({ category: 'accommodation', total_price: 100, payers: [], members: [member(1)] });
const flow = { from: { user_id: 2, username: 'u2' }, to: { user_id: 1, username: 'u1' }, amount: 20 };
const without = computeTotals([food, hotel], [flow], ctx, myShareOf, new Set<CostCategory>(['food']));
expect(without).toMatchObject({ totalSpend: 100, myPaid: 0, myShare: 100, owed: 20, outstanding: 100 });
expect(computeTotals([food, hotel], [flow], ctx).totalSpend).toBe(140);
const bars = categoryBreakdown([food, hotel], ctx, new Set<CostCategory>(['accommodation']));
expect(bars.map((b) => [b.key, b.leftOut, b.widthPct])).toEqual([
['accommodation', true, 0],
['food', false, 100],
]);
});
});
+35 -9
View File
@@ -225,15 +225,30 @@ export interface CostsTotals {
outstandingCount: number;
}
/** No category left out of the totals. */
const ALL_COUNTED: ReadonlySet<CostCategory> = new Set();
/** Whether an expense counts in the totals while `leftOut` categories are set aside. */
function counts(e: BudgetItem, leftOut: ReadonlySet<CostCategory>): boolean {
return leftOut.size === 0 || !leftOut.has(catMeta(e.category).key);
}
/**
* The hero and tile figures. Categories left out for a look without them
* (#2819) leave what was spent, paid and shared; what is owed and still open
* stays whole, since leaving a category out of the view settles nothing.
*/
export function computeTotals(
items: BudgetItem[],
flows: CostsSettlementFlow[],
ctx: CostsCtx,
shareOf: ShareOf = myShareOf
shareOf: ShareOf = myShareOf,
leftOut: ReadonlySet<CostCategory> = ALL_COUNTED
): CostsTotals {
const totalSpend = items.reduce((a, e) => a + baseTotal(e, ctx), 0);
const myPaid = items.reduce((a, e) => a + myPaidOf(e, ctx), 0);
const myShare = items.reduce((a, e) => a + shareOf(e, ctx), 0);
const counted = items.filter((e) => counts(e, leftOut));
const totalSpend = counted.reduce((a, e) => a + baseTotal(e, ctx), 0);
const myPaid = counted.reduce((a, e) => a + myPaidOf(e, ctx), 0);
const myShare = counted.reduce((a, e) => a + shareOf(e, ctx), 0);
const owe = flows.filter((f) => f.from.user_id === ctx.me).reduce((a, f) => a + f.amount, 0);
const owed = flows.filter((f) => f.to.user_id === ctx.me).reduce((a, f) => a + f.amount, 0);
const outstandingItems = items.filter((e) => isUnfinished(e, ctx));
@@ -366,11 +381,17 @@ export function dayFilterKeys(items: BudgetItem[]): string[] {
export interface CostsCategoryBar {
key: CostCategory;
amount: number;
/** 0-100, relative to the largest category (not the grand total) — spec §3.5. */
/** 0-100, relative to the largest category counted (not the grand total) — spec §3.5. */
widthPct: number;
/** Left out of the totals for now (#2819): the row stays, without a bar. */
leftOut: boolean;
}
export function categoryBreakdown(items: BudgetItem[], ctx: CostsCtx): CostsCategoryBar[] {
export function categoryBreakdown(
items: BudgetItem[],
ctx: CostsCtx,
leftOut: ReadonlySet<CostCategory> = ALL_COUNTED
): CostsCategoryBar[] {
// Categories net refunds against spend (#2176): a negative entry lowers its
// category's sum. A category that nets negative keeps its own row at the
// bottom, with widthPct 0 — the bars rank positive spend, and a negative
@@ -380,11 +401,16 @@ export function categoryBreakdown(items: BudgetItem[], ctx: CostsCtx): CostsCate
const key = catMeta(e.category).key;
totals.set(key, (totals.get(key) || 0) + baseTotal(e, ctx));
}
const rows = COST_CATEGORY_LIST.map((c) => ({ key: c.key, amount: totals.get(c.key) || 0 }))
// A row left out keeps its place, so it does not jump while it is toggled.
const rows = COST_CATEGORY_LIST.map((c) => ({
key: c.key,
amount: totals.get(c.key) || 0,
leftOut: leftOut.has(c.key),
}))
.filter((r) => r.amount !== 0)
.sort((a, b) => b.amount - a.amount);
const max = Math.max(0, ...rows.map((r) => r.amount));
return rows.map((r) => ({ ...r, widthPct: max > 0 && r.amount > 0 ? (r.amount / max) * 100 : 0 }));
const max = Math.max(0, ...rows.filter((r) => !r.leftOut).map((r) => r.amount));
return rows.map((r) => ({ ...r, widthPct: !r.leftOut && max > 0 && r.amount > 0 ? (r.amount / max) * 100 : 0 }));
}
// ── presentation helpers ─────────────────────────────────────────────────
@@ -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-015
// FE-BUDGET-LEDGER-001 to FE-BUDGET-LEDGER-016
const fx = vi.hoisted(() => ({
// One stable converter, the way useExchangeRates hands out a memoised one: USD is half a euro.
@@ -273,6 +273,24 @@ describe('useCostsLedger: a trip that keeps totals only (#1714)', () => {
});
});
describe('useCostsLedger: categories left out (#2819)', () => {
it('FE-BUDGET-LEDGER-016: a category left out leaves the total and is named, and counting all brings it back', async () => {
const { result } = setup();
await waitFor(() => expect(result.current.settlement).toEqual(SETTLEMENT));
const whole = result.current.totals.totalSpend;
const [first] = result.current.catBreakdown;
act(() => result.current.toggleLeftOutCat(first.key));
expect(result.current.totals.totalSpend).toBeCloseTo(whole - first.amount, 6);
expect(result.current.catBreakdown.find((r) => r.key === first.key)?.leftOut).toBe(true);
expect(result.current.leftOutLabel).toBe('costs.breakdown.leftOut');
// What is owed stays whole: the flows are the server's.
expect(result.current.totals.owe).toBe(15);
act(() => result.current.countAllCats());
expect(result.current.totals.totalSpend).toBe(whole);
expect(result.current.leftOutLabel).toBe('');
});
});
describe('useCostsLedger: writes', () => {
it('FE-BUDGET-LEDGER-009: deleting an expense reads the settlement again, a failure toasts', async () => {
const { result } = setup();
+27 -2
View File
@@ -1,3 +1,4 @@
import type { CostCategory } from '@trek/shared';
import { liveQuery } from 'dexie';
import { useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react';
@@ -12,6 +13,7 @@ import { useServerPing } from '../../sync/useServerPing';
import type { BudgetItem, Trip } from '../../types';
import { downloadBlob } from '../../utils/fileDownload';
import { localToday } from '../Planner/today';
import { COST_CAT_META } from './costsCategories';
import {
buildCostsCsv,
categoryBreakdown,
@@ -184,6 +186,18 @@ export function useCostsLedger({
const [segment, setSegment] = useState<CostsSegment>('all');
const [catFilter, setCatFilter] = useState(''); // '' = all categories
const [dayFilter, setDayFilter] = useState(''); // '' = all days, else YYYY-MM-DD
// Categories left out of the totals for a look without them (#2819): only this
// view, nothing is saved and nothing owed changes.
const [leftOutCats, setLeftOutCats] = useState<ReadonlySet<CostCategory>>(() => new Set());
const toggleLeftOutCat = useCallback((key: CostCategory) => {
setLeftOutCats((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, []);
const countAllCats = useCallback(() => setLeftOutCats(new Set()), []);
const shareOf = skipUnfinishedShares ? settledShareOf : myShareOf;
const flows = useMemo(() => settlement?.flows || [], [settlement]);
@@ -197,7 +211,10 @@ export function useCostsLedger({
const knownNames = useMemo(() => knownPersonNames(budgetItems, settlement), [budgetItems, settlement]);
// `ctx` carries the rates. They can land after the expenses, and without it in the
// deps the cards kept the sums they were first added up with while the rows moved on.
const totals = useMemo(() => computeTotals(budgetItems, flows, ctx, shareOf), [budgetItems, flows, ctx, shareOf]);
const totals = useMemo(
() => computeTotals(budgetItems, flows, ctx, shareOf, leftOutCats),
[budgetItems, flows, ctx, shareOf, leftOutCats]
);
const filtered = useMemo(
() =>
filterBudgetItems(
@@ -219,7 +236,12 @@ export function useCostsLedger({
: [],
[splitting, settlement, search, activeSegment, catFilter, dayFilter, me]
);
const catBreakdown = useMemo(() => categoryBreakdown(budgetItems, ctx), [budgetItems, ctx]);
const catBreakdown = useMemo(() => categoryBreakdown(budgetItems, ctx, leftOutCats), [budgetItems, ctx, leftOutCats]);
// What the totals leave out, named in the breakdown's order, for the line that says so.
const leftOutLabel = useMemo(() => {
const names = catBreakdown.filter((r) => r.leftOut).map((r) => t(COST_CAT_META[r.key].labelKey));
return names.length > 0 ? t('costs.breakdown.leftOut', { categories: names.join(', ') }) : '';
}, [catBreakdown, t]);
// CSV export of all expenses, the wiki-documented export that got lost in the
// Costs rework (#1500). One row per expense, oldest first.
@@ -324,6 +346,9 @@ export function useCostsLedger({
filtered,
filteredSettlements,
catBreakdown,
leftOutLabel,
toggleLeftOutCat,
countAllCats,
exportCsv,
deleteExpense,
undoSettlement,
@@ -49,7 +49,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
const {
me, base, tripCurrency, ctx, settlement, settlementError, loadSettlement, flows, splitting, knownNames,
search, setSearch, segment, setSegment, catFilter, setCatFilter, dayFilter, setDayFilter,
totals, filtered, filteredSettlements, catBreakdown, exportCsv: handleExportCsv,
totals, filtered, filteredSettlements, catBreakdown, leftOutLabel, toggleLeftOutCat, countAllCats, exportCsv: handleExportCsv,
deleteExpense, undoSettlement: handleUndoSettlement,
} = useCostsLedger({ tripId, trip, budgetItems, memberCount: tripMembers.length, actions: planner.tripActions, canEdit, t, toast })
@@ -142,6 +142,8 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
<div className="mt-1 font-geist text-[1.875rem] font-extrabold tabular-nums tracking-[-0.02em]">
{formatMoney(totals.totalSpend, base, locale)}
</div>
{/* Categories left out below (#2819) leave this total too, which it says. */}
{leftOutLabel && <div className="mt-[2px] text-caption opacity-70">{leftOutLabel}</div>}
{/* Each figure under its own label, so the two pairs never read as one run.
Only with something to split (#2689), like everything owed below. */}
{splitting && <div className="mt-[8px] flex flex-wrap gap-x-[22px] gap-y-[6px] text-[0.6875rem]" style={{ color: 'rgba(245,245,247,.7)' }}>
@@ -309,25 +311,49 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
</>}
{/* By category (spec §3.5) */}
{/* By category (spec §3.5). A tap leaves a category out of the totals
for a look without it (#2819); the row stays, dimmed. */}
<div className="mt-2 rounded-2xl border border-[color:var(--m-rowbr)] bg-m-card p-[13px]">
<div className="font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">{t('costs.byCategory')}</div>
<div className="flex items-center gap-2">
<div className="font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">{t('costs.byCategory')}</div>
{leftOutLabel && (
<button
type="button"
onClick={countAllCats}
className="ms-auto flex flex-none items-center gap-1 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[9px] py-[3px] text-caption font-semibold text-m-muted"
>
<RotateCcw size={11} strokeWidth={2.2} />
{t('costs.breakdown.countAll')}
</button>
)}
</div>
{leftOutLabel
? <p className="mt-1 text-caption text-m-muted">{leftOutLabel}</p>
: catBreakdown.length > 1 && <p className="mt-1 text-caption text-m-faint">{t('costs.breakdown.hint')}</p>}
{catBreakdown.length === 0 ? (
<p className="mt-2 font-geist text-[0.71875rem] text-m-faint">{t('costs.noCategories')}</p>
) : (
catBreakdown.map(c => {
const meta = COST_CAT_META[c.key]
const name = t(meta.labelKey)
const action = c.leftOut ? t('costs.breakdown.countIn', { category: name }) : t('costs.breakdown.leaveOut', { category: name })
return (
<div key={c.key} className="pb-[2px] pt-[6px]">
<div className="flex items-center gap-[7px]">
<button
key={c.key}
type="button"
aria-label={action}
onClick={() => toggleLeftOutCat(c.key)}
className={`block w-full pb-[2px] pt-[6px] text-start ${c.leftOut ? 'opacity-45' : ''}`}
>
<span className="flex items-center gap-[7px]">
<span className="h-2 w-2 flex-none rounded-full" style={{ background: meta.color }} />
<span className="min-w-0 flex-1 truncate font-geist text-[0.65625rem] font-semibold text-m-muted">{t(meta.labelKey)}</span>
<span dir="ltr" className="ms-auto flex-none font-geist text-[0.65625rem] font-bold tabular-nums text-m-ink">{formatMoney(c.amount, base, locale)}</span>
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-[color:var(--m-ic)]">
<span className="min-w-0 flex-1 truncate font-geist text-[0.65625rem] font-semibold text-m-muted">{name}</span>
<span dir="ltr" className={`ms-auto flex-none font-geist text-[0.65625rem] font-bold tabular-nums text-m-ink ${c.leftOut ? 'line-through' : ''}`}>{formatMoney(c.amount, base, locale)}</span>
</span>
<span className="mt-1 block h-1 overflow-hidden rounded-full bg-[color:var(--m-ic)]">
<span className="block h-full rounded-full" style={{ width: `${c.widthPct}%`, background: meta.color }} />
</div>
</div>
</span>
</button>
)
})
)}
@@ -18,7 +18,7 @@ import { server } from '../../../helpers/msw/server'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
import { setSizeClass } from '../../../helpers/sizeClass'
// FE-MOB-COSTT-001 to FE-MOB-COSTT-057
// FE-MOB-COSTT-001 to FE-MOB-COSTT-058
// The add/edit expense sheet is the shared desktop-sized form; the panel only
// owns when it opens and what happens on save, so it is stubbed here.
@@ -997,4 +997,18 @@ describe('MCostsTab', () => {
fireEvent.click(toggle)
await waitFor(() => expect(updateTrip).toHaveBeenCalledWith(7, { costs_mode: 'split' }))
})
it('FE-MOB-COSTT-058: a tapped category leaves the total and is named, and Count all brings it back (#2819)', async () => {
await renderTab()
const total = () => up(screen.getByText('costs.totalSpend'), 1)
const before = total().textContent
expect(screen.queryByText('costs.breakdown.countAll')).not.toBeInTheDocument()
fireEvent.click(screen.getAllByRole('button', { name: /^costs\.breakdown\.leaveOut:/ })[0])
expect(total().textContent).not.toBe(before)
expect(within(total()).getByText(/^costs\.breakdown\.leftOut:/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^costs\.breakdown\.countIn:/ })).toHaveClass('opacity-45')
fireEvent.click(screen.getByText('costs.breakdown.countAll'))
expect(total().textContent).toBe(before)
expect(screen.queryByRole('button', { name: /^costs\.breakdown\.countIn:/ })).not.toBeInTheDocument()
})
})
@@ -438,7 +438,7 @@ describe('costsModel — breakdown and presentation', () => {
const bars = categoryBreakdown(items, ctx);
expect(bars.map(b => b.key)).toEqual(['accommodation', 'food']);
expect(bars[0]).toEqual({ key: 'accommodation', amount: 300, widthPct: 100 });
expect(bars[0]).toEqual({ key: 'accommodation', amount: 300, widthPct: 100, leftOut: false });
expect(bars[1].amount).toBe(200);
expect(bars[1].widthPct).toBeCloseTo(66.667, 3);
});
@@ -460,14 +460,14 @@ describe('costsModel — breakdown and presentation', () => {
// category keeps its own row at the bottom, without a bar — a negative CSS
// width would be dropped and paint a full one.
expect(bars).toEqual([
{ key: 'food', amount: 60, widthPct: 100 },
{ key: 'activities', amount: -20, widthPct: 0 },
{ key: 'food', amount: 60, widthPct: 100, leftOut: false },
{ key: 'activities', amount: -20, widthPct: 0, leftOut: false },
]);
});
it('FE-MOB-CMOD-032: an all-negative breakdown renders rows but no bars (#2176)', () => {
const bars = categoryBreakdown([expense({ category: 'food', total_price: -20 })], ctx);
expect(bars).toEqual([{ key: 'food', amount: -20, widthPct: 0 }]);
expect(bars).toEqual([{ key: 'food', amount: -20, widthPct: 0, leftOut: false }]);
});
it('FE-MOB-CMOD-021: tint turns a hex colour into rgba, with or without the leading hash', () => {
+5
View File
@@ -205,6 +205,11 @@ const budget: TranslationStrings = {
'costs.mode.label': 'تقسيم التكاليف بين المسافرين',
'costs.mode.hintSplit': 'من دفع ومن يتشارك كل مصروف، والتسوية في النهاية.',
'costs.mode.hintTotals': 'الإجماليات فقط: بلا دافعين ولا حصص ولا تسوية. ما قُسِّم من قبل يبقى محفوظًا.',
'costs.breakdown.hint': 'اختر فئة لاستبعادها من الإجماليات.',
'costs.breakdown.leaveOut': 'استبعاد {category} من الإجماليات',
'costs.breakdown.countIn': 'احتساب {category} في الإجماليات مجددًا',
'costs.breakdown.leftOut': 'مستبعد من الإجماليات: {categories}',
'costs.breakdown.countAll': 'احتساب الكل',
'costs.splitHint.ticket': 'اكتب ما ورد في الإيصال وحدّد من أخذ كل عنصر.',
'costs.filter.allCategories': 'كل الفئات',
'costs.filter.allDays': 'كل الأيام',
+5
View File
@@ -167,6 +167,11 @@ const budget: TranslationStrings = {
'costs.mode.label': 'Xərcləri səyahətçilər arasında bölmək',
'costs.mode.hintSplit': 'Hər xərci kimin ödədiyi və kimlərin bölüşdüyü, sonda isə hesablaşma.',
'costs.mode.hintTotals': 'Yalnız yekunlar: ödəyən, pay və hesablaşma yoxdur. Əvvəl bölünənlər saxlanılır.',
'costs.breakdown.hint': 'Kateqoriyanı yekunlardan çıxarmaq üçün onu seçin.',
'costs.breakdown.leaveOut': '{category} yekunlardan çıxarılsın',
'costs.breakdown.countIn': '{category} yenidən yekunlara daxil edilsin',
'costs.breakdown.leftOut': 'Yekunlardan çıxarılıb: {categories}',
'costs.breakdown.countAll': 'Hamısını say',
'costs.splitHint.ticket': 'Qəbzdə olanları sadalayın və hər elementi kimin götürdüyünü işarələyin.',
'costs.pickSomeone': 'Bölüşdürmək üçün ən azı bir nəfər seçin.',
'costs.splitSummary': '{count} nəfər arasında bölünüb · hərəsinə {amount}',
+5
View File
@@ -177,6 +177,11 @@ const budget: TranslationStrings = {
'costs.mode.label': 'Dividir os custos entre os viajantes',
'costs.mode.hintSplit': 'Quem pagou e quem divide cada despesa, e o acerto no final.',
'costs.mode.hintTotals': 'Só os totais: sem pagadores, partes ou acerto. O que foi dividido continua salvo.',
'costs.breakdown.hint': 'Selecione uma categoria para deixá-la fora dos totais.',
'costs.breakdown.leaveOut': 'Deixar {category} fora dos totais',
'costs.breakdown.countIn': 'Contar {category} nos totais de novo',
'costs.breakdown.leftOut': 'Fora dos totais: {categories}',
'costs.breakdown.countAll': 'Contar tudo',
'costs.splitHint.ticket': 'Liste o que estava no comprovante e marque quem consumiu cada item.',
'costs.filter.allCategories': 'Todas as categorias',
'costs.filter.allDays': 'Todos os dias',
+5
View File
@@ -142,6 +142,11 @@ const budget: TranslationStrings = {
'costs.mode.label': 'Repartir les despeses entre els viatgers',
'costs.mode.hintSplit': 'Qui ha pagat i qui comparteix cada despesa, i el saldo final.',
'costs.mode.hintTotals': 'Només els totals: sense pagadors, parts ni saldo. El que s’havia repartit es manté desat.',
'costs.breakdown.hint': 'Tria una categoria per deixar-la fora dels totals.',
'costs.breakdown.leaveOut': 'Deixa {category} fora dels totals',
'costs.breakdown.countIn': 'Torna a comptar {category} als totals',
'costs.breakdown.leftOut': 'Fora dels totals: {categories}',
'costs.breakdown.countAll': 'Comptar-ho tot',
'costs.splitHint.ticket': 'Llista el que hi havia al rebut i marca qui ha pres cada article.',
'costs.pickSomeone': 'Tria almenys una persona amb qui dividir-ho.',
'costs.splitSummary': 'Dividit entre {count} persones · {amount} cadascú',
+5
View File
@@ -184,6 +184,11 @@ const budget: TranslationStrings = {
'costs.mode.label': 'Dělit náklady mezi cestující',
'costs.mode.hintSplit': 'Kdo platil a kdo se na každém výdaji podílí, a vyrovnání na konci.',
'costs.mode.hintTotals': 'Jen součty: bez plátců, podílů a vyrovnání. Co už bylo rozděleno, zůstává uložené.',
'costs.breakdown.hint': 'Vyberte kategorii a vynechte ji ze součtů.',
'costs.breakdown.leaveOut': 'Vynechat {category} ze součtů',
'costs.breakdown.countIn': 'Znovu započítat {category} do součtů',
'costs.breakdown.leftOut': 'Vynecháno ze součtů: {categories}',
'costs.breakdown.countAll': 'Započítat vše',
'costs.splitHint.ticket': 'Vypište, co bylo na účtence, a zaškrtněte, kdo měl kterou položku.',
'costs.filter.allCategories': 'Všechny kategorie',
'costs.filter.allDays': 'Všechny dny',
+5
View File
@@ -182,6 +182,11 @@ const budget: TranslationStrings = {
'costs.mode.hintSplit': 'Wer bezahlt hat und wer sich jede Ausgabe teilt, und am Ende das Ausgleichen.',
'costs.mode.hintTotals':
'Nur Summen: keine Zahler, Anteile oder Ausgleich. Was schon aufgeteilt war, bleibt gespeichert.',
'costs.breakdown.hint': 'Wähle eine Kategorie, um sie aus den Summen herauszunehmen.',
'costs.breakdown.leaveOut': '{category} aus den Summen herausnehmen',
'costs.breakdown.countIn': '{category} wieder in die Summen aufnehmen',
'costs.breakdown.leftOut': 'Nicht in den Summen: {categories}',
'costs.breakdown.countAll': 'Alle zählen',
'costs.splitHint.ticket': 'Liste auf, was auf dem Beleg steht, und hak ab, wer was hatte.',
'costs.ticketItemName': 'Artikelname',
'costs.ticketSplitting': 'Aufteilen auf:',
+5
View File
@@ -151,6 +151,11 @@ const budget: TranslationStrings = {
'costs.mode.label': 'Split costs between travelers',
'costs.mode.hintSplit': 'Who paid and who shares each expense, and settling up at the end.',
'costs.mode.hintTotals': 'Totals only: no payers, shares or settling up. What was split stays saved.',
'costs.breakdown.hint': 'Select a category to leave it out of the totals.',
'costs.breakdown.leaveOut': 'Leave {category} out of the totals',
'costs.breakdown.countIn': 'Count {category} in the totals again',
'costs.breakdown.leftOut': 'Left out of the totals: {categories}',
'costs.breakdown.countAll': 'Count all',
'costs.splitHint.ticket': 'List what was on the receipt and tick who had each item.',
'costs.pickSomeone': 'Pick at least one person to split with.',
'costs.splitSummary': 'Split {count} ways · {amount} each',
+5
View File
@@ -178,6 +178,11 @@ const budget: TranslationStrings = {
'costs.mode.hintSplit': 'Quién pagó y quién comparte cada gasto, y el ajuste de cuentas al final.',
'costs.mode.hintTotals':
'Solo totales: sin pagadores, partes ni ajuste de cuentas. Lo que ya estaba repartido se conserva.',
'costs.breakdown.hint': 'Elige una categoría para dejarla fuera de los totales.',
'costs.breakdown.leaveOut': 'Dejar {category} fuera de los totales',
'costs.breakdown.countIn': 'Volver a contar {category} en los totales',
'costs.breakdown.leftOut': 'Fuera de los totales: {categories}',
'costs.breakdown.countAll': 'Contar todo',
'costs.splitHint.ticket': 'Enumera lo que había en el recibo y marca quién tomó cada artículo.',
'costs.filter.allCategories': 'Todas las categorías',
'costs.filter.allDays': 'Todos los días',
+5
View File
@@ -151,6 +151,11 @@ const budget: TranslationStrings = {
'costs.mode.label': 'Jaga kulud reisijate vahel',
'costs.mode.hintSplit': 'Kes maksis ja kes iga kulu jagab, ning lõpus arveldamine.',
'costs.mode.hintTotals': 'Ainult summad: maksjaid, osasid ega arveldamist pole. Varem jagatu jääb salvestatuks.',
'costs.breakdown.hint': 'Vali kategooria, et see summadest välja jätta.',
'costs.breakdown.leaveOut': 'Jäta {category} summadest välja',
'costs.breakdown.countIn': 'Arvesta {category} jälle summadesse',
'costs.breakdown.leftOut': 'Summadest välja jäetud: {categories}',
'costs.breakdown.countAll': 'Arvesta kõik',
'costs.splitHint.ticket': 'Loetle, mis tšekil oli, ja märgi iga kirje juurde, kellele see kuulus.',
'costs.pickSomeone': 'Vali jagamiseks vähemalt üks inimene.',
'costs.splitSummary': 'Jaotatud {count} osaks · igaühele {amount}',
+5
View File
@@ -179,6 +179,11 @@ const budget: TranslationStrings = {
'costs.mode.hintSplit': 'Qui a payé et qui partage chaque dépense, puis le règlement à la fin.',
'costs.mode.hintTotals':
'Totaux seulement : ni payeurs, ni parts, ni règlement. Ce qui était partagé reste enregistré.',
'costs.breakdown.hint': 'Choisissez une catégorie pour l’exclure des totaux.',
'costs.breakdown.leaveOut': 'Exclure {category} des totaux',
'costs.breakdown.countIn': 'Compter de nouveau {category} dans les totaux',
'costs.breakdown.leftOut': 'Exclu des totaux : {categories}',
'costs.breakdown.countAll': 'Tout compter',
'costs.splitHint.ticket': 'Listez ce qui figurait sur le reçu et cochez qui a pris chaque article.',
'costs.filter.allCategories': 'Toutes les catégories',
'costs.filter.allDays': 'Tous les jours',
+5
View File
@@ -179,6 +179,11 @@ const budget: TranslationStrings = {
'costs.mode.label': 'Μοιρασιά των εξόδων μεταξύ των ταξιδιωτών',
'costs.mode.hintSplit': 'Ποιος πλήρωσε και ποιοι μοιράζονται κάθε έξοδο, και η εκκαθάριση στο τέλος.',
'costs.mode.hintTotals': 'Μόνο σύνολα: χωρίς πληρωτές, μερίδια ή εκκαθάριση. Ό,τι είχε μοιραστεί μένει αποθηκευμένο.',
'costs.breakdown.hint': 'Επιλέξτε μια κατηγορία για να την αφήσετε έξω από τα σύνολα.',
'costs.breakdown.leaveOut': 'Εξαίρεση του {category} από τα σύνολα',
'costs.breakdown.countIn': 'Επαναφορά του {category} στα σύνολα',
'costs.breakdown.leftOut': 'Εκτός συνόλων: {categories}',
'costs.breakdown.countAll': 'Μέτρηση όλων',
'costs.splitHint.ticket': 'Καταγράψτε τι είχε η απόδειξη και σημειώστε ποιος πήρε κάθε είδος.',
'costs.filter.allCategories': 'Όλες οι κατηγορίες',
'costs.filter.allDays': 'Όλες οι ημέρες',
+5
View File
@@ -155,6 +155,11 @@ const budget: TranslationStrings = {
'costs.mode.label': 'חלוקת ההוצאות בין המטיילים',
'costs.mode.hintSplit': 'מי שילם ומי מתחלק בכל הוצאה, והתחשבנות בסוף.',
'costs.mode.hintTotals': 'סכומים בלבד: בלי משלמים, חלקים או התחשבנות. מה שחולק כבר נשאר שמור.',
'costs.breakdown.hint': 'בחרו קטגוריה כדי להוציא אותה מהסכומים.',
'costs.breakdown.leaveOut': 'להוציא את {category} מהסכומים',
'costs.breakdown.countIn': 'לכלול שוב את {category} בסכומים',
'costs.breakdown.leftOut': 'לא נכלל בסכומים: {categories}',
'costs.breakdown.countAll': 'לכלול הכול',
'costs.splitHint.ticket': 'רשמו מה היה בקבלה וסמנו מי לקח כל פריט.',
'costs.pickSomeone': 'בחרו לפחות אדם אחד לחלוקה.',
'costs.splitSummary': 'חלוקה ל-{count} · {amount} לכל אחד',
+5
View File
@@ -180,6 +180,11 @@ const budget: TranslationStrings = {
'costs.mode.hintSplit': 'Ki fizetett és kik osztoznak az egyes kiadásokon, a végén pedig az elszámolás.',
'costs.mode.hintTotals':
'Csak összesítés: nincs fizető, részesedés vagy elszámolás. Ami már fel volt osztva, megmarad.',
'costs.breakdown.hint': 'Válassz egy kategóriát, hogy kimaradjon az összesítésből.',
'costs.breakdown.leaveOut': '{category} kihagyása az összesítésből',
'costs.breakdown.countIn': '{category} visszavétele az összesítésbe',
'costs.breakdown.leftOut': 'Kimarad az összesítésből: {categories}',
'costs.breakdown.countAll': 'Minden beszámítása',
'costs.splitHint.ticket': 'Sorold fel, mi szerepelt a nyugtán, és minden tételnél jelöld be, kié volt.',
'costs.filter.allCategories': 'Összes kategória',
'costs.filter.allDays': 'Összes nap',
+5
View File
@@ -171,6 +171,11 @@ const budget: TranslationStrings = {
'costs.mode.hintSplit':
'Siapa yang membayar dan siapa yang ikut menanggung tiap pengeluaran, lalu pelunasan di akhir.',
'costs.mode.hintTotals': 'Hanya total: tanpa pembayar, bagian, atau pelunasan. Yang sudah dibagi tetap tersimpan.',
'costs.breakdown.hint': 'Pilih kategori untuk mengecualikannya dari total.',
'costs.breakdown.leaveOut': 'Kecualikan {category} dari total',
'costs.breakdown.countIn': 'Hitung {category} lagi dalam total',
'costs.breakdown.leftOut': 'Dikecualikan dari total: {categories}',
'costs.breakdown.countAll': 'Hitung semua',
'costs.splitHint.ticket': 'Tuliskan isi kuitansi dan centang siapa yang mengambil tiap item.',
'costs.filter.allCategories': 'Semua kategori',
'costs.filter.allDays': 'Semua hari',
+5
View File
@@ -177,6 +177,11 @@ const budget: TranslationStrings = {
'costs.mode.label': 'Dividere le spese tra i viaggiatori',
'costs.mode.hintSplit': 'Chi ha pagato e chi condivide ogni spesa, e il saldo alla fine.',
'costs.mode.hintTotals': 'Solo totali: niente pagatori, quote o saldi. Ciò che era già diviso resta salvato.',
'costs.breakdown.hint': 'Seleziona una categoria per lasciarla fuori dai totali.',
'costs.breakdown.leaveOut': 'Escludi {category} dai totali',
'costs.breakdown.countIn': 'Conta di nuovo {category} nei totali',
'costs.breakdown.leftOut': 'Esclusi dai totali: {categories}',
'costs.breakdown.countAll': 'Conta tutto',
'costs.splitHint.ticket': "Elenca cosa c'era sulla ricevuta e spunta chi ha preso ciascun articolo.",
'costs.filter.allCategories': 'Tutte le categorie',
'costs.filter.allDays': 'Tutti i giorni',
+5
View File
@@ -169,6 +169,11 @@ const budget: TranslationStrings = {
'costs.mode.label': '旅行者の間で費用を分ける',
'costs.mode.hintSplit': '誰が支払い、誰が各支出を分担するか、そして最後に精算します。',
'costs.mode.hintTotals': '合計のみ:支払者・分担・精算はありません。分担済みの内容は保存されたままです。',
'costs.breakdown.hint': 'カテゴリを選ぶと合計から除外できます。',
'costs.breakdown.leaveOut': '{category}を合計から除外',
'costs.breakdown.countIn': '{category}を合計に戻す',
'costs.breakdown.leftOut': '合計から除外中:{categories}',
'costs.breakdown.countAll': 'すべて含める',
'costs.splitHint.ticket': 'レシートの品目を書き出し、それぞれ誰の分かにチェックを入れます。',
'costs.filter.allCategories': 'すべてのカテゴリー',
'costs.filter.allDays': 'すべての日',
+5
View File
@@ -169,6 +169,11 @@ const budget: TranslationStrings = {
'costs.mode.label': '여행자끼리 비용 나누기',
'costs.mode.hintSplit': '누가 냈고 누가 각 지출을 나누는지, 그리고 마지막 정산까지.',
'costs.mode.hintTotals': '합계만: 결제자, 분담, 정산이 없습니다. 이미 나눈 내용은 그대로 저장됩니다.',
'costs.breakdown.hint': '카테고리를 선택하면 합계에서 뺄 수 있습니다.',
'costs.breakdown.leaveOut': '{category}을(를) 합계에서 빼기',
'costs.breakdown.countIn': '{category}을(를) 다시 합계에 넣기',
'costs.breakdown.leftOut': '합계에서 제외됨: {categories}',
'costs.breakdown.countAll': '모두 포함',
'costs.splitHint.ticket': '영수증에 있던 항목을 적고, 항목마다 누구 몫인지 체크하세요.',
'costs.filter.allCategories': '모든 카테고리',
'costs.filter.allDays': '모든 날짜',
+5
View File
@@ -179,6 +179,11 @@ const budget: TranslationStrings = {
'costs.mode.hintSplit': 'Wie betaalde en wie elke uitgave deelt, en aan het eind de verrekening.',
'costs.mode.hintTotals':
'Alleen totalen: geen betalers, aandelen of verrekening. Wat al verdeeld was, blijft bewaard.',
'costs.breakdown.hint': 'Kies een categorie om die buiten de totalen te laten.',
'costs.breakdown.leaveOut': '{category} buiten de totalen laten',
'costs.breakdown.countIn': '{category} weer meetellen in de totalen',
'costs.breakdown.leftOut': 'Buiten de totalen: {categories}',
'costs.breakdown.countAll': 'Alles meetellen',
'costs.splitHint.ticket': 'Zet op een rij wat er op de bon stond en vink aan wie welk item had.',
'costs.filter.allCategories': 'Alle categorieën',
'costs.filter.allDays': 'Alle dagen',
+5
View File
@@ -192,6 +192,11 @@ const budget: TranslationStrings = {
'costs.mode.hintSplit': 'Kto zapłacił i kto dzieli każdy wydatek, a na koniec rozliczenie.',
'costs.mode.hintTotals':
'Tylko sumy: bez płacących, udziałów i rozliczeń. To, co już podzielono, pozostaje zapisane.',
'costs.breakdown.hint': 'Wybierz kategorię, aby pominąć ją w sumach.',
'costs.breakdown.leaveOut': 'Pomiń {category} w sumach',
'costs.breakdown.countIn': 'Uwzględnij {category} w sumach ponownie',
'costs.breakdown.leftOut': 'Pominięte w sumach: {categories}',
'costs.breakdown.countAll': 'Uwzględnij wszystko',
'costs.splitHint.ticket': 'Wypisz, co było na paragonie, i zaznacz, kto miał którą pozycję.',
'costs.filter.allCategories': 'Wszystkie kategorie',
'costs.filter.allDays': 'Wszystkie dni',
+5
View File
@@ -191,6 +191,11 @@ const budget: TranslationStrings = {
'costs.mode.hintSplit': 'Кто заплатил и кто делит каждый расход, а в конце взаиморасчёт.',
'costs.mode.hintTotals':
'Только итоги: без плательщиков, долей и взаиморасчётов. Уже разделённое остаётся сохранённым.',
'costs.breakdown.hint': 'Выберите категорию, чтобы не учитывать её в итогах.',
'costs.breakdown.leaveOut': 'Не учитывать {category} в итогах',
'costs.breakdown.countIn': 'Снова учитывать {category} в итогах',
'costs.breakdown.leftOut': 'Не учтено в итогах: {categories}',
'costs.breakdown.countAll': 'Учитывать всё',
'costs.splitHint.ticket': 'Перечислите, что было в чеке, и отметьте, кто что брал.',
'costs.filter.allCategories': 'Все категории',
'costs.filter.allDays': 'Все дни',
+5
View File
@@ -156,6 +156,11 @@ const budget: TranslationStrings = {
'costs.mode.label': 'Deliť náklady medzi cestujúcich',
'costs.mode.hintSplit': 'Kto platil a kto sa na každom výdavku podieľa, a na konci vyrovnanie.',
'costs.mode.hintTotals': 'Len súčty: bez platiteľov, podielov a vyrovnania. Čo už bolo rozdelené, zostáva uložené.',
'costs.breakdown.hint': 'Vyberte kategóriu a vynechajte ju zo súčtov.',
'costs.breakdown.leaveOut': 'Vynechať {category} zo súčtov',
'costs.breakdown.countIn': 'Znova započítať {category} do súčtov',
'costs.breakdown.leftOut': 'Vynechané zo súčtov: {categories}',
'costs.breakdown.countAll': 'Započítať všetko',
'costs.splitHint.ticket': 'Vypíšte, čo bolo na účtenke, a označte, kto mal ktorú položku.',
'costs.pickSomeone': 'Vyberte aspoň jednu osobu na rozdelenie.',
'costs.splitSummary': 'Rozdelené na {count} dielov · {amount} na osobu',
+5
View File
@@ -177,6 +177,11 @@ const budget: TranslationStrings = {
'costs.mode.label': 'Dela kostnaderna mellan resenärerna',
'costs.mode.hintSplit': 'Vem som betalade och vilka som delar varje utgift, och avräkning på slutet.',
'costs.mode.hintTotals': 'Bara summor: inga betalare, andelar eller avräkning. Det som redan delats sparas.',
'costs.breakdown.hint': 'Välj en kategori för att lämna den utanför summorna.',
'costs.breakdown.leaveOut': 'Lämna {category} utanför summorna',
'costs.breakdown.countIn': 'Räkna med {category} i summorna igen',
'costs.breakdown.leftOut': 'Utanför summorna: {categories}',
'costs.breakdown.countAll': 'Räkna allt',
'costs.splitHint.ticket': 'Lista det som stod på kvittot och bocka för vem som hade varje artikel.',
'costs.filter.allCategories': 'Alla kategorier',
'costs.filter.allDays': 'Alla dagar',
+5
View File
@@ -141,6 +141,11 @@ const budget: TranslationStrings = {
'costs.mode.label': 'แบ่งค่าใช้จ่ายระหว่างผู้ร่วมเดินทาง',
'costs.mode.hintSplit': 'ใครจ่ายและใครร่วมรับผิดชอบค่าใช้จ่ายแต่ละรายการ แล้วเคลียร์ยอดตอนท้าย',
'costs.mode.hintTotals': 'เฉพาะยอดรวม: ไม่มีผู้จ่าย ส่วนแบ่ง หรือการเคลียร์ยอด สิ่งที่แบ่งไว้แล้วยังคงบันทึกอยู่',
'costs.breakdown.hint': 'เลือกหมวดหมู่เพื่อไม่นับรวมในยอดรวม',
'costs.breakdown.leaveOut': 'ไม่นับ {category} ในยอดรวม',
'costs.breakdown.countIn': 'นับ {category} ในยอดรวมอีกครั้ง',
'costs.breakdown.leftOut': 'ไม่นับในยอดรวม: {categories}',
'costs.breakdown.countAll': 'นับทั้งหมด',
'costs.splitHint.ticket': 'ใส่รายการที่อยู่ในใบเสร็จ แล้วติ๊กว่าใครสั่งรายการไหน',
'costs.pickSomeone': 'เลือกอย่างน้อยหนึ่งคนที่จะแยกด้วย',
'costs.splitSummary': '{count} คน · คนละ {amount}',
+5
View File
@@ -177,6 +177,11 @@ const budget: TranslationStrings = {
'costs.mode.label': 'Masrafları yolcular arasında böl',
'costs.mode.hintSplit': 'Her harcamayı kimin ödediği ve kimlerin paylaştığı, sonunda da hesaplaşma.',
'costs.mode.hintTotals': 'Yalnızca toplamlar: ödeyen, pay veya hesaplaşma yok. Önceden bölünenler kayıtlı kalır.',
'costs.breakdown.hint': 'Toplamların dışında bırakmak için bir kategori seçin.',
'costs.breakdown.leaveOut': '{category} toplamların dışında bırakılsın',
'costs.breakdown.countIn': '{category} yeniden toplamlara katılsın',
'costs.breakdown.leftOut': 'Toplamların dışında: {categories}',
'costs.breakdown.countAll': 'Hepsini say',
'costs.splitHint.ticket': 'Fişte neler olduğunu listeleyin ve her kalemi kimin aldığını işaretleyin.',
'costs.filter.allCategories': 'Tüm kategoriler',
'costs.filter.allDays': 'Tüm günler',
+5
View File
@@ -191,6 +191,11 @@ const budget: TranslationStrings = {
'costs.mode.label': 'Ділити витрати між мандрівниками',
'costs.mode.hintSplit': 'Хто заплатив і хто ділить кожну витрату, а наприкінці розрахунок.',
'costs.mode.hintTotals': 'Лише підсумки: без платників, часток і розрахунків. Уже поділене залишається збереженим.',
'costs.breakdown.hint': 'Виберіть категорію, щоб не враховувати її в підсумках.',
'costs.breakdown.leaveOut': 'Не враховувати {category} в підсумках',
'costs.breakdown.countIn': 'Знову враховувати {category} в підсумках',
'costs.breakdown.leftOut': 'Не враховано в підсумках: {categories}',
'costs.breakdown.countAll': 'Враховувати все',
'costs.splitHint.ticket': 'Перелічіть, що було в чеку, і позначте, хто брав кожну позицію.',
'costs.filter.allCategories': 'Усі категорії',
'costs.filter.allDays': 'Усі дні',
+5
View File
@@ -170,6 +170,11 @@ const budget: TranslationStrings = {
'costs.mode.hintSplit': 'Ai đã trả và ai cùng chia từng khoản chi, rồi thanh toán bù trừ ở cuối.',
'costs.mode.hintTotals':
'Chỉ tổng cộng: không có người trả, phần chia hay thanh toán bù trừ. Những gì đã chia vẫn được lưu.',
'costs.breakdown.hint': 'Chọn một danh mục để loại nó khỏi tổng.',
'costs.breakdown.leaveOut': 'Loại {category} khỏi tổng',
'costs.breakdown.countIn': 'Tính lại {category} vào tổng',
'costs.breakdown.leftOut': 'Đã loại khỏi tổng: {categories}',
'costs.breakdown.countAll': 'Tính tất cả',
'costs.splitHint.ticket': 'Liệt kê các món trên hóa đơn và đánh dấu ai đã dùng từng món.',
'costs.filter.allCategories': 'Tất cả danh mục',
'costs.filter.allDays': 'Tất cả các ngày',
+5
View File
@@ -168,6 +168,11 @@ const budget: TranslationStrings = {
'costs.mode.label': '在旅伴之間分攤費用',
'costs.mode.hintSplit': '記錄誰付款、誰分攤每筆支出,最後結算。',
'costs.mode.hintTotals': '僅統計總額:沒有付款人、分攤或結算。已分攤的內容會保留。',
'costs.breakdown.hint': '選擇一個類別即可將其排除在合計之外。',
'costs.breakdown.leaveOut': '將{category}排除在合計之外',
'costs.breakdown.countIn': '將{category}重新計入合計',
'costs.breakdown.leftOut': '未計入合計:{categories}',
'costs.breakdown.countAll': '全部計入',
'costs.splitHint.ticket': '列出收據上的項目,並勾選每一項是誰的。',
'costs.filter.allCategories': '所有類別',
'costs.filter.allDays': '所有日期',
+5
View File
@@ -168,6 +168,11 @@ const budget: TranslationStrings = {
'costs.mode.label': '在旅伴之间分摊费用',
'costs.mode.hintSplit': '记录谁付款、谁分摊每笔支出,最后结算。',
'costs.mode.hintTotals': '仅统计总额:没有付款人、分摊或结算。已分摊的内容会保留。',
'costs.breakdown.hint': '选择一个类别即可将其排除在合计之外。',
'costs.breakdown.leaveOut': '将{category}排除在合计之外',
'costs.breakdown.countIn': '将{category}重新计入合计',
'costs.breakdown.leftOut': '未计入合计:{categories}',
'costs.breakdown.countAll': '全部计入',
'costs.splitHint.ticket': '列出收据上的项目,并勾选每一项是谁的。',
'costs.filter.allCategories': '所有类别',
'costs.filter.allDays': '所有日期',
+2
View File
@@ -202,6 +202,8 @@ Four cards sit above the ledger:
The right-hand column ends with **By category**: spending per category as a ranked list of bars in the category colours. Only categories with spend on them appear, sorted by amount, and the bars are scaled against the biggest category rather than the trip total, so the ranking stays readable.
A click on a category in **By category** leaves it out of the totals for a look without it, say to see what the trip cost without food. The row stays where it was, dimmed and struck through, and the other bars rescale. **Total trip spend**, **Your share** and **You paid** drop that category, and a line on the total card and above the bars names what is left out; **Count all**, or a second click on the row, brings it back. Nothing is saved and nothing owed changes: **You owe**, **You're owed**, the outstanding amount, the settle-up and the balances stay whole, and the choice is gone when you leave the tab. On the phone, a tap on the category does the same.
### Costs in the day plan
With Costs on, the cost pills on the days of the plan and **Total Cost** at its foot (and the same figures in the trip PDF) add up the expenses from Costs, each one once: on the day its booking starts, otherwise the first day its place is planned, otherwise its own date; one without any day still counts in the total. A foreign expense counts at its frozen rate and a refund nets against its day, so **Total Cost** matches **Total trip spend**, and deleting an expense lowers both. With Costs off, a place's own price counts instead, once per place.