= 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')}
) : (