fix(costs): the count beside the total adds up what it does, and its left-out chip counts all again (#2819, #2839)

With a category left out the total card still counted every expense, and in
the table view the only way back was to switch views; the chip now brings
everything back. The phone says what is left out once, under the total.
Linking a receipt offers pictures and PDFs only, like the upload.
This commit is contained in:
Maurice
2026-11-13 10:49:28 +01:00
parent 43a8984e4c
commit fcbb538c24
7 changed files with 40 additions and 13 deletions
+8 -3
View File
@@ -76,7 +76,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, leftOutLabel, toggleLeftOutCat, countAllCats, exportCsv: handleExportCsv,
totals, filtered, filteredSettlements, catBreakdown, countedCount, leftOutLabel, toggleLeftOutCat, countAllCats, exportCsv: handleExportCsv,
deleteExpense: handleDelete, undoSettlement, settleFlow, settleAll, setBase, baseOptions,
} = useCostsLedger({
tripId, trip, budgetItems, memberCount: tripMembers.length, actions: { loadBudgetItems, deleteBudgetItem }, canEdit, t, toast,
@@ -242,8 +242,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
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>}
{leftOutLabel && <span className={`${CHIP} text-warning`}>{leftOutLabel}</span>}</>} />
</> : <span className={CHIP}>{t('costs.expensesCount', { count: countedCount })}</span>}
{/* Also the way back where the breakdown is not on show (the table view). */}
{leftOutLabel && (
<Tooltip label={t('costs.breakdown.countAll')} placement="top">
<button type="button" onClick={countAllCats} className={`${CHIP} text-warning hover:underline`}>{leftOutLabel}</button>
</Tooltip>
)}</>} />
)
// Spend by category; a click leaves one out of the totals for a look without it (#2819).
@@ -7,6 +7,7 @@ import {
buildCostsCsv,
categoryBreakdown,
computeTotals,
countedExpenses,
currencyOptions,
filterBudgetItems,
isPlainSplit,
@@ -203,4 +204,11 @@ describe('costsModel: categories left out of the totals (#2819)', () => {
['food', false, 100],
]);
});
it('FE-BUDGET-COSTSMODEL-016: the expense count beside the total counts what the total adds up', () => {
const food = expense({ category: 'food', total_price: 40 });
const hotel = expense({ category: 'accommodation', total_price: 100 });
expect(countedExpenses([food, hotel, food])).toBe(3);
expect(countedExpenses([food, hotel, food], new Set<CostCategory>(['food']))).toBe(1);
});
});
@@ -233,6 +233,11 @@ function counts(e: BudgetItem, leftOut: ReadonlySet<CostCategory>): boolean {
return leftOut.size === 0 || !leftOut.has(catMeta(e.category).key);
}
/** How many expenses the totals add up while `leftOut` categories are set aside (#2819). */
export function countedExpenses(items: BudgetItem[], leftOut: ReadonlySet<CostCategory> = ALL_COUNTED): number {
return items.filter((e) => counts(e, leftOut)).length;
}
/**
* 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
@@ -7,13 +7,14 @@ import { linkableReceiptFiles, receiptOfFile } from './receiptLinks';
const names = (files: { original_name: string }[]) => files.map((f) => f.original_name);
describe('receipt links (#2839)', () => {
const menu = buildTripFile({ id: 1, original_name: 'menu.jpg' });
const menu = buildTripFile({ id: 1, original_name: 'menu.jpg', mime_type: 'image/jpeg' });
const confirmation = buildTripFile({ id: 2, original_name: 'hotel-confirmation.pdf', reservation_id: 7 });
const linked = buildTripFile({ id: 3, original_name: 'hotel-invoice.pdf', linked_reservation_ids: [9, 7] });
const binned = buildTripFile({ id: 4, original_name: 'old.pdf', deleted_at: '2026-01-01 10:00:00' });
const track = buildTripFile({ id: 5, original_name: 'ridge.gpx', mime_type: 'application/gpx+xml' });
it('FE-BUDGET-RCPTLINK-001: offers the live files the expense does not carry yet', () => {
const files = linkableReceiptFiles([menu, confirmation, linked, binned], [receiptOfFile(linked)], null);
it('FE-BUDGET-RCPTLINK-001: offers the live pictures and PDFs the expense does not carry yet', () => {
const files = linkableReceiptFiles([menu, confirmation, linked, binned, track], [receiptOfFile(linked)], null);
expect(names(files)).toEqual(['menu.jpg', 'hotel-confirmation.pdf']);
});
+9 -4
View File
@@ -5,10 +5,15 @@ function onBooking(file: TripFile, reservationId: number): boolean {
return Number(file.reservation_id) === reservationId || (file.linked_reservation_ids || []).includes(reservationId);
}
/** What the receipt picker uploads, so linking offers the same: a picture or a PDF, not a GPX track. */
function isReceiptFile(file: TripFile): boolean {
return (file.mime_type || '').startsWith('image/') || file.mime_type === 'application/pdf';
}
/**
* The trip's files an expense could take as a receipt (#2839): the ones not in
* the bin and not on the expense yet. A booking's confirmation is so often its
* invoice as well that the files of the booking the expense belongs to lead.
* The trip's files an expense could take as a receipt (#2839): pictures and PDFs
* not in the bin and not on the expense yet. A booking's confirmation is so often
* its invoice as well that the files of the booking the expense belongs to lead.
*/
export function linkableReceiptFiles(
files: readonly TripFile[],
@@ -16,7 +21,7 @@ export function linkableReceiptFiles(
reservationId: number | null | undefined
): TripFile[] {
const taken = new Set(receipts.map((r) => r.id));
const live = files.filter((f) => !f.deleted_at && !taken.has(f.id));
const live = files.filter((f) => !f.deleted_at && !taken.has(f.id) && isReceiptFile(f));
if (!reservationId) return live;
return [...live.filter((f) => onBooking(f, reservationId)), ...live.filter((f) => !onBooking(f, reservationId))];
}
@@ -17,6 +17,7 @@ import {
buildCostsCsv,
categoryBreakdown,
computeTotals,
countedExpenses,
filterBudgetItems,
filterSettlements,
myShareOf,
@@ -237,6 +238,8 @@ export function useCostsLedger({
[splitting, settlement, search, activeSegment, catFilter, dayFilter, me]
);
const catBreakdown = useMemo(() => categoryBreakdown(budgetItems, ctx, leftOutCats), [budgetItems, ctx, leftOutCats]);
// The count beside the total adds up what the total does.
const countedCount = useMemo(() => countedExpenses(budgetItems, leftOutCats), [budgetItems, 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));
@@ -348,6 +351,7 @@ export function useCostsLedger({
filtered,
filteredSettlements,
catBreakdown,
countedCount,
leftOutLabel,
toggleLeftOutCat,
countAllCats,
@@ -328,9 +328,8 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
</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>}
{/* What is left out is said once, under the total above. */}
{!leftOutLabel && 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>
) : (