diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 3d463041d..0c52841de 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -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 ? <> {t('costs.yourShare')}{fmt0(totals.myShare)} {t('costs.youPaid')}{fmt0(totals.myPaid)} - : {t('costs.expensesCount', { count: budgetItems.length })}} - {leftOutLabel && {leftOutLabel}}} /> + : {t('costs.expensesCount', { count: countedCount })}} + {/* Also the way back where the breakdown is not on show (the table view). */} + {leftOutLabel && ( + + + + )}} /> ) // Spend by category; a click leaves one out of the totals for a look without it (#2819). diff --git a/client/src/components/Budget/costsModel.test.ts b/client/src/components/Budget/costsModel.test.ts index 7000cbf57..183605c3f 100644 --- a/client/src/components/Budget/costsModel.test.ts +++ b/client/src/components/Budget/costsModel.test.ts @@ -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(['food']))).toBe(1); + }); }); diff --git a/client/src/components/Budget/costsModel.ts b/client/src/components/Budget/costsModel.ts index 4b615dd84..632d0d377 100644 --- a/client/src/components/Budget/costsModel.ts +++ b/client/src/components/Budget/costsModel.ts @@ -233,6 +233,11 @@ function counts(e: BudgetItem, leftOut: ReadonlySet): 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 = 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 diff --git a/client/src/components/Budget/receiptLinks.test.ts b/client/src/components/Budget/receiptLinks.test.ts index cfd4291aa..871f8ce25 100644 --- a/client/src/components/Budget/receiptLinks.test.ts +++ b/client/src/components/Budget/receiptLinks.test.ts @@ -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']); }); diff --git a/client/src/components/Budget/receiptLinks.ts b/client/src/components/Budget/receiptLinks.ts index 02a7905aa..8263b8aeb 100644 --- a/client/src/components/Budget/receiptLinks.ts +++ b/client/src/components/Budget/receiptLinks.ts @@ -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))]; } diff --git a/client/src/components/Budget/useCostsLedger.ts b/client/src/components/Budget/useCostsLedger.ts index 2bc1a62ca..e4731fbea 100644 --- a/client/src/components/Budget/useCostsLedger.ts +++ b/client/src/components/Budget/useCostsLedger.ts @@ -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, diff --git a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx index 6c623efe4..216e75d91 100644 --- a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx @@ -328,9 +328,8 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { )} - {leftOutLabel - ?

{leftOutLabel}

- : catBreakdown.length > 1 &&

{t('costs.breakdown.hint')}

} + {/* What is left out is said once, under the total above. */} + {!leftOutLabel && catBreakdown.length > 1 &&

{t('costs.breakdown.hint')}

} {catBreakdown.length === 0 ? (

{t('costs.noCategories')}

) : (