mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
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:
@@ -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']);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user