mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
refactor(budget): share the expense editor between desktop and phone
useExpenseForm holds the payers, the splits, the ticket items and the receipts of the expense editor in CostsPanel and MCostSheet. Flags keep the old differences. Only the desktop sends the people named on a ticket line as members (#1382). The phone ignores a second save while one is running and stays busy after a successful save. The rebalance effect uses useEffectEvent instead of an eslint-disable.
This commit is contained in:
@@ -8,9 +8,8 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { saveWithReceipts } from './receiptUploads'
|
||||
import { convertBooked, convertedLine, tripAmountOf, useExchangeRates, withFallbackFx } from '../../hooks/useExchangeRates'
|
||||
import { splitShareLabel, useExpenseFx } from './expenseFx'
|
||||
import { splitShareLabel } from './expenseFx'
|
||||
import { useFreezeMissingRates } from './useFreezeMissingRates'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
|
||||
@@ -21,9 +20,10 @@ import { localToday } from '../Planner/today'
|
||||
import { useReceiptScan } from './useReceiptScan'
|
||||
import { ReceiptScanModal } from './ReceiptScanModal'
|
||||
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
import { amountPattern, calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, NOTE_MAX, paidByUser, payersBalanced, readTicketItems, newExpenseSeed, readUserNote, rebalancePayers, settlementDate, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { finalBudgetFor, finalBudgetSources, NOTE_MAX, paidByUser, readUserNote, settlementDate, splitEqualShares } from './CostsPanel.helpers'
|
||||
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
import { usePercentSplit, type CustomSplitUnit } from './usePercentSplit'
|
||||
import { useExpenseForm } from './useExpenseForm'
|
||||
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
|
||||
import type { BudgetParticipantFinal, BudgetUnconverted, ReceiptLine } from '@trek/shared'
|
||||
import type { BudgetItem, BudgetItemReceipt } from '../../types'
|
||||
@@ -1369,320 +1369,23 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; prefill?: ExpensePrefill; onClose: () => void; onSaved: () => void
|
||||
}) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const isMobile = useIsMobile()
|
||||
const titleId = useId()
|
||||
const { addBudgetItem, updateBudgetItem } = useTripStore()
|
||||
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
||||
// A saved expense without a currency opens in the trip's own (#2525).
|
||||
const { tripCurrency: tripCur, editingCurrency, preview } = useExpenseFx(base, editing)
|
||||
|
||||
const [name, setName] = useState(editing?.name || prefill?.name || '')
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
||||
const [seed] = useState(() => newExpenseSeed(prefill, base, people.map(p => p.id), localToday()))
|
||||
const [currency, setCurrency] = useState(editing ? editingCurrency : seed.currency)
|
||||
const [day, setDay] = useState(editing ? (editing.expense_date || localToday()) : seed.day)
|
||||
const [note, setNote] = useState(() => readUserNote(editing))
|
||||
// Edit and prefill seeds are padded to the currency's decimals (#2175): the DB
|
||||
// returns numbers, so a saved 4,90 would otherwise reopen as "4,9" and a saved
|
||||
// 5,00 as "5".
|
||||
const [total, setTotal] = useState<string>(() => {
|
||||
if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : ''
|
||||
return seed.total
|
||||
})
|
||||
const [participants, setParticipants] = useState<Set<number>>(() =>
|
||||
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
||||
|
||||
// Payer state. An expense can be fronted by several people, each with their own
|
||||
// amount (budget_item_payers) — a shared card, or "I got this round, you get the
|
||||
// next". The single-payer dropdown stays the default path; multiPayer swaps in a
|
||||
// per-person amount editor. 0 represents "Nobody (planning entry)"; on an
|
||||
// existing expense a missing payer is a deliberate choice, so only a brand-new
|
||||
// one defaults to me. A negative payer (the recipient of a refund, #2176) is a
|
||||
// real payer — filtering on > 0 here would silently drop them on save.
|
||||
const initialPayers = (editing?.payers || []).filter(p => p.amount !== 0)
|
||||
|
||||
const [payerId, setPayerId] = useState<number>(() => {
|
||||
const existingPayer = initialPayers[0]
|
||||
if (existingPayer) return existingPayer.user_id
|
||||
return editing ? 0 : me
|
||||
})
|
||||
const [multiPayer, setMultiPayer] = useState(() => initialPayers.length > 1)
|
||||
const [payerIds, setPayerIds] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
|
||||
const [payerAmounts, setPayerAmounts] = useState<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {}
|
||||
for (const p of initialPayers) m[p.user_id] = amountToInputString(p.amount, currency)
|
||||
return m
|
||||
})
|
||||
// Payers the user typed an amount for: rebalance leaves these alone and makes
|
||||
// the others absorb the remainder.
|
||||
const [pinnedPayers, setPinnedPayers] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
|
||||
|
||||
const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => {
|
||||
if (hasTicketSplit(editing)) {
|
||||
return 'ticket'
|
||||
}
|
||||
if (editing && editing.members && editing.members.length > 0) {
|
||||
const hasCustom = editing.members.some(m => m.amount !== null && m.amount !== undefined)
|
||||
return hasCustom ? 'custom' : 'equally'
|
||||
}
|
||||
return 'equally'
|
||||
})
|
||||
|
||||
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => editing ? readTicketItems(editing) : seed.ticketItems)
|
||||
|
||||
const [customAmounts, setCustomAmounts] = useState<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {}
|
||||
if (editing && editing.members) {
|
||||
for (const member of editing.members) {
|
||||
if (member.amount !== null && member.amount !== undefined) {
|
||||
m[member.user_id] = amountToInputString(member.amount, currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
const [receipts, setReceipts] = useState<BudgetItemReceipt[]>(() => editing?.receipts || [])
|
||||
const [pendingReceiptFiles, setPendingReceiptFiles] = useState<File[]>(() => editing ? [] : seed.receiptFiles)
|
||||
const [uploadingReceipt, setUploadingReceipt] = useState(false)
|
||||
const [modalPreviewReceipts, setModalPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
|
||||
|
||||
const handleReceiptFileSelect = (files: FileList | File[] | null) => {
|
||||
if (!files || files.length === 0) return
|
||||
setPendingReceiptFiles(prev => [...prev, ...Array.from(files)])
|
||||
}
|
||||
|
||||
const handleRemoveReceipt = (receiptId: number) => {
|
||||
setReceipts(prev => prev.filter(r => r.id !== receiptId))
|
||||
}
|
||||
|
||||
const handleRemovePendingReceipt = (index: number) => {
|
||||
setPendingReceiptFiles(prev => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const isTicketMode = splitMode === 'ticket'
|
||||
|
||||
const ticketInfo = useMemo(() => {
|
||||
return calculateTicketShares(ticketItems)
|
||||
}, [ticketItems])
|
||||
|
||||
const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0)
|
||||
const fx = preview(totalNum, currency)
|
||||
const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
|
||||
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
|
||||
// How much is still to be handed out, read on the total's own side: on a refund
|
||||
// (#2176) the shares run negative, so a plain total minus sum flips under and
|
||||
// over around and sends the user the wrong way.
|
||||
const splitShortfall = totalNum < 0 ? splitSum - totalNum : totalNum - splitSum
|
||||
const each = participants.size > 0 ? totalNum / participants.size : 0
|
||||
const equalShares = useMemo(() => {
|
||||
return splitEqualShares(totalNum, [...participants].map(id => ({ user_id: id })), editing?.id || 0)
|
||||
}, [totalNum, participants, editing])
|
||||
const {
|
||||
cat, currency, customAmounts, customBalanced, day, disableMultiPayer, each, enableMultiPayer, equalShares, fx,
|
||||
handleAddEmptyItem, handleCustomAmountChange, handleReceiptFileSelect, handleRemoveItem, handleRemovePendingReceipt,
|
||||
handleRemoveReceipt, handleToggleItemParticipant, handleUpdateItemName, handleUpdateItemPrice, isTicketMode,
|
||||
multiPayer, name, nameOf, note, onPayerAmountChange, onTotalChange, participants, payerAmounts, payerId, payerIds,
|
||||
payersOk, pendingReceiptFiles, placeholderShares, previewReceipts: modalPreviewReceipts, receipts, save, saving,
|
||||
setCat, setCurrency, setCustomAmounts, setDay, setName, setNote, setPayerId,
|
||||
setPreviewReceipts: setModalPreviewReceipts, setSplitMode, splitMode, splitShortfall, splitSum, sym, ticketInfo,
|
||||
ticketItems, toggleParticipant, togglePayer, total, totalNum, tripCur, uploadingReceipt, valid,
|
||||
} = useExpenseForm({ tripId, base, people, me, editing, prefill, onSaved, ticketShareMembers: true })
|
||||
|
||||
// The custom split typed as percentages instead of amounts (#1709).
|
||||
const pct = usePercentSplit({ total: totalNum, participants, customAmounts, setCustomAmounts, currency })
|
||||
const inPercent = splitMode === 'custom' && pct.unit === 'percent'
|
||||
|
||||
const placeholderShares = useMemo(() => {
|
||||
const emptyParts = [...participants].filter(id => !customAmounts[id])
|
||||
if (emptyParts.length === 0) return {}
|
||||
|
||||
const enteredSum = [...participants]
|
||||
.filter(id => customAmounts[id])
|
||||
.reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
|
||||
// Clamped toward zero on the total's own side, so an over-entered positive
|
||||
// split never suggests negative leftovers — while a negative total (#2176)
|
||||
// still previews its negative equal shares.
|
||||
const rest = totalNum - enteredSum
|
||||
const remaining = totalNum >= 0 ? Math.max(0, rest) : Math.min(0, rest)
|
||||
|
||||
return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0)
|
||||
}, [totalNum, participants, customAmounts, editing])
|
||||
|
||||
const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (Number.parseFloat(item.price) || 0) > 0 && item.participants.size > 0)
|
||||
const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum))
|
||||
// A negative total is a valid entry (a refund, #2176); only zero has nothing to say.
|
||||
const valid = name.trim().length > 0 && payersOk && (
|
||||
isTicketMode
|
||||
? ticketValid
|
||||
: totalNum !== 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)
|
||||
)
|
||||
|
||||
const onTotalChange = (v: string) => {
|
||||
setTotal(v.replace(',', '.'))
|
||||
}
|
||||
|
||||
// Keep the payer amounts summing to the total as it changes — including in ticket
|
||||
// mode, where the total is derived from the ticket items rather than typed.
|
||||
useEffect(() => {
|
||||
if (!multiPayer) return
|
||||
setPayerAmounts(prev => rebalancePayers(prev, pinnedPayers, payerIds, totalNum))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [totalNum])
|
||||
|
||||
const enableMultiPayer = () => {
|
||||
const startPayers = payerIds.size > 0 ? new Set(payerIds) : new Set<number>([payerId > 0 ? payerId : me])
|
||||
const pinned = new Set<number>()
|
||||
setPayerIds(startPayers)
|
||||
setPinnedPayers(pinned)
|
||||
setPayerAmounts(prev => rebalancePayers(prev, pinned, startPayers, totalNum))
|
||||
setMultiPayer(true)
|
||||
}
|
||||
|
||||
const disableMultiPayer = () => {
|
||||
// Collapsing back keeps the first payer; their amount becomes the whole total.
|
||||
const [first] = [...payerIds]
|
||||
setPayerId(first ?? me)
|
||||
setMultiPayer(false)
|
||||
}
|
||||
|
||||
const togglePayer = (id: number) => {
|
||||
const nextIds = new Set(payerIds)
|
||||
const nextPinned = new Set(pinnedPayers)
|
||||
if (nextIds.has(id)) {
|
||||
nextIds.delete(id)
|
||||
nextPinned.delete(id)
|
||||
} else {
|
||||
nextIds.add(id)
|
||||
}
|
||||
setPayerIds(nextIds)
|
||||
setPinnedPayers(nextPinned)
|
||||
setPayerAmounts(prev => rebalancePayers(prev, nextPinned, nextIds, totalNum))
|
||||
}
|
||||
|
||||
const onPayerAmountChange = (id: number, v: string) => {
|
||||
const val = v.replace(',', '.')
|
||||
const nextPinned = new Set(pinnedPayers)
|
||||
nextPinned.add(id)
|
||||
setPinnedPayers(nextPinned)
|
||||
setPayerAmounts(prev => rebalancePayers({ ...prev, [id]: val }, nextPinned, payerIds, totalNum))
|
||||
}
|
||||
|
||||
const handleCustomAmountChange = (id: number, val: string) => {
|
||||
val = val.replace(',', '.')
|
||||
if (val === '' || amountPattern(currency, true).test(val)) {
|
||||
setCustomAmounts(prev => ({ ...prev, [id]: val }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddEmptyItem = () => {
|
||||
setTicketItems(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: String(Date.now() + Math.random()),
|
||||
name: '',
|
||||
price: '',
|
||||
participants: new Set(people.map(p => p.id))
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
const handleUpdateItemName = (id: string, name: string) => {
|
||||
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, name } : item))
|
||||
}
|
||||
|
||||
const handleUpdateItemPrice = (id: string, price: string) => {
|
||||
price = price.replace(',', '.')
|
||||
if (price === '' || amountPattern(currency, false).test(price)) {
|
||||
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveItem = (id: string) => {
|
||||
setTicketItems(prev => prev.filter(item => item.id !== id))
|
||||
}
|
||||
|
||||
const handleToggleItemParticipant = (itemId: string, userId: number) => {
|
||||
setTicketItems(prev => prev.map(item => {
|
||||
if (item.id === itemId) {
|
||||
const nextParts = new Set(item.participants)
|
||||
if (nextParts.has(userId)) nextParts.delete(userId)
|
||||
else nextParts.add(userId)
|
||||
return { ...item, participants: nextParts }
|
||||
}
|
||||
return item
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleParticipant = (id: number) => {
|
||||
const nextParts = new Set(participants)
|
||||
if (nextParts.has(id)) {
|
||||
nextParts.delete(id)
|
||||
setCustomAmounts(prev => {
|
||||
const copy = { ...prev }
|
||||
delete copy[id]
|
||||
return copy
|
||||
})
|
||||
} else {
|
||||
nextParts.add(id)
|
||||
}
|
||||
setParticipants(nextParts)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!valid) return
|
||||
setSaving(true)
|
||||
// A picked payer always goes out, even when nobody shares the expense: the
|
||||
// server re-derives total_price from the payer sum (CostsPanel.helpers), so
|
||||
// dropping the payer would store the entry with a total of 0.
|
||||
const payerList = multiPayer
|
||||
? [...payerIds]
|
||||
.map(id => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 0 }))
|
||||
.filter(p => p.amount !== 0)
|
||||
: payerId > 0 ? [{ user_id: payerId, amount: totalNum }] : []
|
||||
// A receipt line can name somebody who is not ticked as a participant. Sending
|
||||
// only the ticked set would drop their share, leaving the member sum short of
|
||||
// total_price and handing the settlement a difference it can never clear (#1382).
|
||||
const memberIds = splitMode === 'ticket'
|
||||
? [...new Set([...participants, ...Object.keys(ticketInfo.shares).map(Number)])].sort((a, b) => a - b)
|
||||
: [...participants]
|
||||
const memberList = memberIds.map(id => ({
|
||||
user_id: id,
|
||||
amount: splitMode === 'custom'
|
||||
? (Number.parseFloat(customAmounts[id]) || 0)
|
||||
: splitMode === 'ticket'
|
||||
? (ticketInfo.shares[id] || 0)
|
||||
: null
|
||||
}))
|
||||
const data = {
|
||||
name: name.trim(),
|
||||
category: cat,
|
||||
currency,
|
||||
payers: payerList,
|
||||
members: memberList,
|
||||
member_ids: memberIds,
|
||||
expense_date: day || null,
|
||||
total_price: totalNum,
|
||||
note: note.trim() || null,
|
||||
ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null,
|
||||
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
|
||||
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
|
||||
}
|
||||
try {
|
||||
setUploadingReceipt(pendingReceiptFiles.length > 0)
|
||||
await saveWithReceipts(tripId, pendingReceiptFiles, editing ? editing.id : null, ids => (
|
||||
editing
|
||||
? updateBudgetItem(tripId, editing.id, { ...data, receipt_file_ids: [...receipts.map(r => r.id), ...ids] })
|
||||
: addBudgetItem(tripId, { ...data, receipt_file_ids: ids })
|
||||
))
|
||||
// Only cleared once the save went through, so a retry after a failure
|
||||
// does not upload a second copy of every file.
|
||||
setPendingReceiptFiles([])
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
// A receipt the rollback could not remove is still on the trip, and the
|
||||
// user is the only one who can clear it out of the Files tab.
|
||||
const stuck = (err as { stuckReceiptIds?: number[] })?.stuckReceiptIds
|
||||
toast.error(stuck?.length ? t('costs.receiptLeftBehind', { count: stuck.length }) : t('common.unknownError'))
|
||||
} finally {
|
||||
setUploadingReceipt(false)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const catInfo = catMeta(cat)
|
||||
const money = (v: number) => formatMoney(v, currency, locale)
|
||||
// A person's row in the payer and split lists: avatar or initial, then the name.
|
||||
@@ -1697,7 +1400,6 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const AMOUNT_BOX = 'flex items-center gap-1 rounded-[8px] border border-edge bg-surface-input px-2.5'
|
||||
const AMOUNT_INPUT = 'w-full border-0 bg-transparent py-2 text-end font-semibold text-content outline-none dark:bg-transparent'
|
||||
|
||||
const nameOf = (p: TripMember) => (p.id === me ? t('costs.you') : p.username)
|
||||
// Who is in, shown as a real box to tick instead of a row that only fades.
|
||||
const tick = (on: boolean) => (
|
||||
<span aria-hidden className={`grid h-[18px] w-[18px] flex-none place-items-center rounded-[5px] border ${on ? 'border-transparent bg-accent text-accent-text' : 'border-edge bg-surface-card'}`}>
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildBudgetItem, buildTrip } from '../../../tests/helpers/factories';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { clearExchangeRateCache } from '../../hooks/useExchangeRates';
|
||||
import { useTripStore, type TripStoreState } from '../../store/tripStore';
|
||||
import type { BudgetItem } from '../../types';
|
||||
import { localToday } from '../Planner/today';
|
||||
import type { TripMember } from './BudgetPanelMemberChips';
|
||||
import type { ExpensePrefill } from './CostsPanel';
|
||||
import { saveWithReceipts } from './receiptUploads';
|
||||
import { useExpenseForm } from './useExpenseForm';
|
||||
|
||||
vi.mock('./receiptUploads', () => ({
|
||||
saveWithReceipts: vi.fn(
|
||||
async (_tripId: number, _files: File[], _id: number | null, save: (ids: number[]) => unknown) => ({
|
||||
result: await save([]),
|
||||
stuckIds: [],
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
// FE-BUDGET-EXPFORM-001 to FE-BUDGET-EXPFORM-020
|
||||
|
||||
const PEOPLE: TripMember[] = [
|
||||
{ id: 1, username: 'alice', avatar_url: null },
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
];
|
||||
const TODAY = localToday();
|
||||
|
||||
let addBudgetItem: ReturnType<typeof vi.fn>;
|
||||
let updateBudgetItem: ReturnType<typeof vi.fn>;
|
||||
let addToast: ReturnType<typeof vi.fn>;
|
||||
|
||||
interface Options {
|
||||
editing?: BudgetItem | null;
|
||||
prefill?: ExpensePrefill;
|
||||
ticketShareMembers?: boolean;
|
||||
oneSaveAtATime?: boolean;
|
||||
keepSavingOnSuccess?: boolean;
|
||||
}
|
||||
|
||||
function setup(options: Options = {}) {
|
||||
const onSaved = vi.fn();
|
||||
const rendered = renderHook(() =>
|
||||
useExpenseForm({
|
||||
tripId: 7,
|
||||
base: 'EUR',
|
||||
people: PEOPLE,
|
||||
me: 1,
|
||||
editing: options.editing ?? null,
|
||||
prefill: options.prefill,
|
||||
onSaved,
|
||||
ticketShareMembers: options.ticketShareMembers,
|
||||
oneSaveAtATime: options.oneSaveAtATime,
|
||||
keepSavingOnSuccess: options.keepSavingOnSuccess,
|
||||
})
|
||||
);
|
||||
return { ...rendered, onSaved };
|
||||
}
|
||||
|
||||
type Form = ReturnType<typeof setup>['result'];
|
||||
|
||||
function fill(result: Form, name: string, total: string) {
|
||||
act(() => {
|
||||
result.current.setName(name);
|
||||
result.current.onTotalChange(total);
|
||||
});
|
||||
}
|
||||
|
||||
async function save(result: Form) {
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
}
|
||||
|
||||
/** saveWithReceipts as it runs once the files are up: the save gets their ids. */
|
||||
const uploadsThenSave = (ids: number[]) =>
|
||||
(async (_tripId: number, _files: File[], _id: number | null, write: (uploaded: number[]) => Promise<unknown>) => ({
|
||||
result: await write(ids),
|
||||
stuckIds: [],
|
||||
})) as unknown as typeof saveWithReceipts;
|
||||
|
||||
const errorToasts = () => addToast.mock.calls.filter((c) => c[1] === 'error').map((c) => c[0]);
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
clearExchangeRateCache();
|
||||
// A fresh FX cache, so the hook never reaches the network.
|
||||
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.25 }, ts: Date.now() }));
|
||||
addBudgetItem = vi.fn(async () => buildBudgetItem({ id: 9 }));
|
||||
updateBudgetItem = vi.fn(async () => buildBudgetItem({ id: 9 }));
|
||||
useTripStore.setState({
|
||||
trip: buildTrip({ id: 7, currency: 'EUR' }),
|
||||
addBudgetItem,
|
||||
updateBudgetItem,
|
||||
} as unknown as Partial<TripStoreState>);
|
||||
addToast = vi.fn();
|
||||
(window as unknown as { __addToast: unknown }).__addToast = addToast;
|
||||
vi.mocked(saveWithReceipts).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { __addToast?: unknown }).__addToast;
|
||||
});
|
||||
|
||||
describe('useExpenseForm: where it starts', () => {
|
||||
it('FE-BUDGET-EXPFORM-001: a new expense is food, in the display currency, today, split equally by everyone, paid by me', () => {
|
||||
const { result } = setup();
|
||||
const f = result.current;
|
||||
expect(f.name).toBe('');
|
||||
expect(f.cat).toBe('food');
|
||||
expect(f.currency).toBe('EUR');
|
||||
expect(f.day).toBe(TODAY);
|
||||
expect(f.total).toBe('');
|
||||
expect([...f.participants]).toEqual([1, 2]);
|
||||
expect(f.payerId).toBe(1);
|
||||
expect(f.multiPayer).toBe(false);
|
||||
expect(f.splitMode).toBe('equally');
|
||||
expect(f.receipts).toEqual([]);
|
||||
expect(f.pendingReceiptFiles).toEqual([]);
|
||||
expect(f.valid).toBe(false);
|
||||
expect(f.saving).toBe(false);
|
||||
expect(f.nameOf(PEOPLE[0])).toBe('costs.you');
|
||||
expect(f.nameOf(PEOPLE[1])).toBe('bob');
|
||||
expect(f.sym('EUR')).toBe('€');
|
||||
expect(f.sym('XYZ')).toBe('XYZ ');
|
||||
});
|
||||
|
||||
it('FE-BUDGET-EXPFORM-002: a prefill brings its name, category, amount, currency, day, receipt lines and photos', () => {
|
||||
const photo = new File(['x'], 'receipt.jpg');
|
||||
const { result } = setup({
|
||||
prefill: {
|
||||
name: 'Cafe',
|
||||
category: 'food',
|
||||
amount: 12.5,
|
||||
currency: 'usd',
|
||||
date: '2026-05-01',
|
||||
lines: [{ name: 'Coffee', price: 4 }],
|
||||
receiptFiles: [photo],
|
||||
},
|
||||
});
|
||||
const f = result.current;
|
||||
expect(f.name).toBe('Cafe');
|
||||
expect(f.currency).toBe('USD');
|
||||
expect(f.total).toBe('12.50');
|
||||
expect(f.day).toBe('2026-05-01');
|
||||
expect(f.ticketItems.map((i) => [i.name, i.price, [...i.participants]])).toEqual([['Coffee', '4.00', [1, 2]]]);
|
||||
// The lines wait in the Ticket split; the split itself stays Equally.
|
||||
expect(f.splitMode).toBe('equally');
|
||||
expect(f.pendingReceiptFiles).toEqual([photo]);
|
||||
});
|
||||
|
||||
it('FE-BUDGET-EXPFORM-003: an edited expense reopens with its payer, its custom split, its note and receipts', () => {
|
||||
const receipt = { id: 3, filename: 'r.jpg', original_name: 'r.jpg', url: '/uploads/r.jpg' };
|
||||
const editing = buildBudgetItem({
|
||||
id: 9,
|
||||
name: 'Museum',
|
||||
category: 'activities',
|
||||
total_price: 30,
|
||||
currency: 'USD',
|
||||
note: 'Adult tickets',
|
||||
expense_date: '2026-04-30',
|
||||
members: [
|
||||
{ user_id: 1, paid: 0, username: 'alice', amount: 10 },
|
||||
{ user_id: 2, paid: 0, username: 'bob', amount: 20 },
|
||||
],
|
||||
payers: [{ user_id: 2, amount: 30 }],
|
||||
receipts: [receipt],
|
||||
} as Partial<BudgetItem>);
|
||||
const f = setup({ editing }).result.current;
|
||||
expect(f.name).toBe('Museum');
|
||||
expect(f.cat).toBe('activities');
|
||||
expect(f.currency).toBe('USD');
|
||||
expect(f.total).toBe('30.00');
|
||||
expect(f.day).toBe('2026-04-30');
|
||||
expect(f.note).toBe('Adult tickets');
|
||||
expect(f.payerId).toBe(2);
|
||||
expect(f.payerAmounts).toEqual({ 2: '30.00' });
|
||||
expect(f.splitMode).toBe('custom');
|
||||
expect(f.customAmounts).toEqual({ 1: '10.00', 2: '20.00' });
|
||||
expect(f.receipts).toEqual([receipt]);
|
||||
expect(f.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-BUDGET-EXPFORM-004: an edited expense nobody paid stays unpaid; several payers open the payer editor', () => {
|
||||
const unpaid = setup({ editing: buildBudgetItem({ total_price: 20, payers: [], members: [] }) }).result.current;
|
||||
expect(unpaid.payerId).toBe(0);
|
||||
expect(unpaid.splitMode).toBe('equally');
|
||||
const shared = setup({
|
||||
editing: buildBudgetItem({
|
||||
total_price: 50,
|
||||
currency: 'EUR',
|
||||
payers: [
|
||||
{ user_id: 1, amount: 20 },
|
||||
{ user_id: 2, amount: 30 },
|
||||
],
|
||||
members: [{ user_id: 1, paid: 0, username: 'alice', amount: null }],
|
||||
} as Partial<BudgetItem>),
|
||||
}).result.current;
|
||||
expect(shared.multiPayer).toBe(true);
|
||||
expect([...shared.payerIds]).toEqual([1, 2]);
|
||||
expect(shared.payerAmounts).toEqual({ 1: '20.00', 2: '30.00' });
|
||||
expect(shared.splitMode).toBe('equally');
|
||||
const ticket = setup({
|
||||
editing: buildBudgetItem({
|
||||
ticket_json: JSON.stringify({ items: [{ name: 'Pizza', price: '12', parts: [1] }] }),
|
||||
}),
|
||||
}).result.current;
|
||||
expect(ticket.splitMode).toBe('ticket');
|
||||
expect(ticket.ticketItems.map((i) => i.name)).toEqual(['Pizza']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useExpenseForm: what it adds up', () => {
|
||||
it('FE-BUDGET-EXPFORM-005: equal shares, the custom sum and what is still to hand out', () => {
|
||||
const { result } = setup();
|
||||
fill(result, 'Dinner', '40,5');
|
||||
expect(result.current.total).toBe('40.5');
|
||||
expect(result.current.totalNum).toBe(40.5);
|
||||
expect(result.current.each).toBe(20.25);
|
||||
expect(result.current.equalShares).toEqual({ 1: 20.25, 2: 20.25 });
|
||||
act(() => {
|
||||
result.current.setSplitMode('custom');
|
||||
result.current.handleCustomAmountChange(1, '10,5');
|
||||
});
|
||||
expect(result.current.customAmounts).toEqual({ 1: '10.5' });
|
||||
expect(result.current.splitSum).toBe(10.5);
|
||||
expect(result.current.splitShortfall).toBe(30);
|
||||
expect(result.current.customBalanced).toBe(false);
|
||||
expect(result.current.placeholderShares).toEqual({ 2: 30 });
|
||||
expect(result.current.valid).toBe(false);
|
||||
act(() => result.current.handleCustomAmountChange(2, '30'));
|
||||
expect(result.current.customBalanced).toBe(true);
|
||||
expect(result.current.placeholderShares).toEqual({});
|
||||
expect(result.current.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-BUDGET-EXPFORM-006: a refund splits negative, and an over-entered split suggests nothing below zero', () => {
|
||||
const { result } = setup();
|
||||
fill(result, 'Refund', '-20');
|
||||
act(() => result.current.setSplitMode('custom'));
|
||||
expect(result.current.placeholderShares).toEqual({ 1: -10, 2: -10 });
|
||||
act(() => result.current.handleCustomAmountChange(1, '-5'));
|
||||
// Read on the refund's own side: 15 is still to go out, not 15 too much.
|
||||
expect(result.current.splitShortfall).toBe(15);
|
||||
const over = setup();
|
||||
fill(over.result, 'Taxi', '10');
|
||||
act(() => {
|
||||
over.result.current.setSplitMode('custom');
|
||||
over.result.current.handleCustomAmountChange(1, '25');
|
||||
});
|
||||
expect(over.result.current.placeholderShares).toEqual({ 2: 0 });
|
||||
});
|
||||
|
||||
it('FE-BUDGET-EXPFORM-007: an entry needs a name and an amount that is not zero, a refund is fine', () => {
|
||||
const { result } = setup();
|
||||
fill(result, ' ', '10');
|
||||
expect(result.current.valid).toBe(false);
|
||||
fill(result, 'Taxi', '0');
|
||||
expect(result.current.valid).toBe(false);
|
||||
fill(result, 'Taxi', '-10');
|
||||
expect(result.current.valid).toBe(true);
|
||||
// With nobody in the split there is nothing to balance.
|
||||
act(() => result.current.setSplitMode('custom'));
|
||||
act(() => result.current.toggleParticipant(1));
|
||||
act(() => result.current.toggleParticipant(2));
|
||||
expect(result.current.participants.size).toBe(0);
|
||||
expect(result.current.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-BUDGET-EXPFORM-008: amounts are refused beyond the currency decimals and ticket prices cannot be negative', () => {
|
||||
const { result } = setup();
|
||||
act(() => {
|
||||
result.current.handleCustomAmountChange(1, '1.234');
|
||||
result.current.handleCustomAmountChange(2, 'abc');
|
||||
});
|
||||
expect(result.current.customAmounts).toEqual({});
|
||||
act(() => result.current.handleCustomAmountChange(1, '-2'));
|
||||
expect(result.current.customAmounts).toEqual({ 1: '-2' });
|
||||
act(() => result.current.handleCustomAmountChange(1, ''));
|
||||
expect(result.current.customAmounts).toEqual({ 1: '' });
|
||||
act(() => result.current.handleAddEmptyItem());
|
||||
const id = result.current.ticketItems[0].id;
|
||||
act(() => result.current.handleUpdateItemPrice(id, '-3'));
|
||||
expect(result.current.ticketItems[0].price).toBe('');
|
||||
act(() => result.current.handleUpdateItemPrice(id, '3,5'));
|
||||
expect(result.current.ticketItems[0].price).toBe('3.5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useExpenseForm: payers', () => {
|
||||
it('FE-BUDGET-EXPFORM-009: several payers start from the one picked and absorb what nobody typed', () => {
|
||||
const { result } = setup();
|
||||
fill(result, 'Hotel', '30');
|
||||
act(() => result.current.enableMultiPayer());
|
||||
expect(result.current.multiPayer).toBe(true);
|
||||
expect([...result.current.payerIds]).toEqual([1]);
|
||||
expect(result.current.payerAmounts).toEqual({ 1: '30.00' });
|
||||
act(() => result.current.togglePayer(2));
|
||||
expect(result.current.payerAmounts).toEqual({ 1: '15.00', 2: '15.00' });
|
||||
act(() => result.current.onPayerAmountChange(1, '10'));
|
||||
expect(result.current.payerAmounts).toEqual({ 1: '10', 2: '20.00' });
|
||||
expect(result.current.payersOk).toBe(true);
|
||||
// A new total moves only the payer nobody typed for.
|
||||
act(() => result.current.onTotalChange('50'));
|
||||
expect(result.current.payerAmounts).toEqual({ 1: '10', 2: '40.00' });
|
||||
act(() => result.current.togglePayer(2));
|
||||
expect([...result.current.payerIds]).toEqual([1]);
|
||||
expect(result.current.payerAmounts[1]).toBe('10');
|
||||
expect(result.current.payersOk).toBe(false);
|
||||
expect(result.current.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-BUDGET-EXPFORM-010: back to one payer keeps the first; a single payer is left alone by a new total', () => {
|
||||
const { result } = setup();
|
||||
fill(result, 'Hotel', '30');
|
||||
act(() => result.current.setPayerId(0));
|
||||
act(() => result.current.enableMultiPayer());
|
||||
// "Nobody" picked: the editor starts on me.
|
||||
expect([...result.current.payerIds]).toEqual([1]);
|
||||
act(() => result.current.togglePayer(2));
|
||||
act(() => result.current.togglePayer(1));
|
||||
act(() => result.current.disableMultiPayer());
|
||||
expect(result.current.multiPayer).toBe(false);
|
||||
expect(result.current.payerId).toBe(2);
|
||||
const amounts = result.current.payerAmounts;
|
||||
act(() => result.current.onTotalChange('80'));
|
||||
expect(result.current.payerAmounts).toBe(amounts);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useExpenseForm: splits, ticket lines and receipts', () => {
|
||||
it('FE-BUDGET-EXPFORM-011: ticket lines are added, named, priced, shared and removed', () => {
|
||||
const { result } = setup();
|
||||
act(() => {
|
||||
result.current.setSplitMode('ticket');
|
||||
result.current.handleAddEmptyItem();
|
||||
});
|
||||
expect(result.current.isTicketMode).toBe(true);
|
||||
const [line] = result.current.ticketItems;
|
||||
expect(line).toMatchObject({ name: '', price: '' });
|
||||
expect([...line.participants]).toEqual([1, 2]);
|
||||
act(() => {
|
||||
result.current.handleUpdateItemName(line.id, 'Pizza');
|
||||
result.current.handleUpdateItemPrice(line.id, '12');
|
||||
result.current.handleToggleItemParticipant(line.id, 2);
|
||||
});
|
||||
expect(result.current.ticketInfo).toEqual({ shares: { 1: 12 }, total: 12 });
|
||||
expect(result.current.totalNum).toBe(12);
|
||||
act(() => result.current.setName('Lunch'));
|
||||
expect(result.current.valid).toBe(true);
|
||||
act(() => result.current.handleToggleItemParticipant(line.id, 2));
|
||||
expect([...result.current.ticketItems[0].participants]).toEqual([1, 2]);
|
||||
act(() => result.current.handleRemoveItem(line.id));
|
||||
expect(result.current.ticketItems).toEqual([]);
|
||||
expect(result.current.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-BUDGET-EXPFORM-012: leaving the split drops the custom amount, joining it again adds the person back', () => {
|
||||
const { result } = setup();
|
||||
act(() => {
|
||||
result.current.handleCustomAmountChange(2, '5');
|
||||
result.current.toggleParticipant(2);
|
||||
});
|
||||
expect([...result.current.participants]).toEqual([1]);
|
||||
expect(result.current.customAmounts).toEqual({});
|
||||
act(() => result.current.toggleParticipant(2));
|
||||
expect([...result.current.participants]).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('FE-BUDGET-EXPFORM-013: picked files wait to upload; saved and waiting receipts can both be taken off', () => {
|
||||
const receipt = { id: 3, filename: 'r.jpg', original_name: 'r.jpg', url: '/uploads/r.jpg' };
|
||||
const { result } = setup({ editing: buildBudgetItem({ receipts: [receipt] }) });
|
||||
const a = new File(['a'], 'a.pdf');
|
||||
const b = new File(['b'], 'b.pdf');
|
||||
act(() => result.current.handleReceiptFileSelect(null));
|
||||
act(() => result.current.handleReceiptFileSelect([]));
|
||||
expect(result.current.pendingReceiptFiles).toEqual([]);
|
||||
act(() => result.current.handleReceiptFileSelect([a, b]));
|
||||
expect(result.current.pendingReceiptFiles).toEqual([a, b]);
|
||||
act(() => result.current.handleRemovePendingReceipt(0));
|
||||
expect(result.current.pendingReceiptFiles).toEqual([b]);
|
||||
act(() => result.current.handleRemoveReceipt(3));
|
||||
expect(result.current.receipts).toEqual([]);
|
||||
act(() => result.current.setPreviewReceipts({ receipts: [receipt], initialIndex: 0 }));
|
||||
expect(result.current.previewReceipts).toEqual({ receipts: [receipt], initialIndex: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useExpenseForm: saving', () => {
|
||||
it('FE-BUDGET-EXPFORM-014: a new equal split goes out with me as payer and everyone in it', async () => {
|
||||
const { result, onSaved } = setup();
|
||||
fill(result, ' Dinner ', '40');
|
||||
act(() => result.current.setNote(' with a view '));
|
||||
await save(result);
|
||||
expect(addBudgetItem).toHaveBeenCalledWith(7, {
|
||||
name: 'Dinner',
|
||||
category: 'food',
|
||||
currency: 'EUR',
|
||||
payers: [{ user_id: 1, amount: 40 }],
|
||||
members: [
|
||||
{ user_id: 1, amount: null },
|
||||
{ user_id: 2, amount: null },
|
||||
],
|
||||
member_ids: [1, 2],
|
||||
expense_date: TODAY,
|
||||
total_price: 40,
|
||||
note: 'with a view',
|
||||
ticket_json: null,
|
||||
receipt_file_ids: [],
|
||||
});
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.saving).toBe(false);
|
||||
expect(result.current.uploadingReceipt).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-BUDGET-EXPFORM-015: nobody paid sends no payer; a booking or a place links only a new expense', async () => {
|
||||
const created = setup({ prefill: { name: 'Flight', amount: 100, reservationId: 4, placeId: 5 } });
|
||||
act(() => {
|
||||
created.result.current.setPayerId(0);
|
||||
created.result.current.setSplitMode('custom');
|
||||
created.result.current.handleCustomAmountChange(1, '60');
|
||||
created.result.current.handleCustomAmountChange(2, '40');
|
||||
});
|
||||
await save(created.result);
|
||||
expect(addBudgetItem).toHaveBeenCalledWith(
|
||||
7,
|
||||
expect.objectContaining({
|
||||
payers: [],
|
||||
members: [
|
||||
{ user_id: 1, amount: 60 },
|
||||
{ user_id: 2, amount: 40 },
|
||||
],
|
||||
reservation_id: 4,
|
||||
place_id: 5,
|
||||
})
|
||||
);
|
||||
|
||||
const editing = buildBudgetItem({ id: 9, name: 'Flight', total_price: 100, payers: [{ user_id: 1, amount: 100 }] });
|
||||
const edited = setup({ editing, prefill: { reservationId: 4, placeId: 5 } });
|
||||
await save(edited.result);
|
||||
const sent = updateBudgetItem.mock.calls[0][2];
|
||||
expect(updateBudgetItem).toHaveBeenCalledWith(
|
||||
7,
|
||||
9,
|
||||
expect.objectContaining({ payers: [{ user_id: 1, amount: 100 }] })
|
||||
);
|
||||
expect(sent).not.toHaveProperty('reservation_id');
|
||||
expect(sent).not.toHaveProperty('place_id');
|
||||
});
|
||||
|
||||
it('FE-BUDGET-EXPFORM-016: an edit keeps the saved receipts and adds the uploaded ones', async () => {
|
||||
vi.mocked(saveWithReceipts).mockImplementationOnce(uploadsThenSave([41]));
|
||||
const receipt = { id: 3, filename: 'r.jpg', original_name: 'r.jpg', url: '/uploads/r.jpg' };
|
||||
const editing = buildBudgetItem({ id: 9, name: 'Museum', total_price: 30, receipts: [receipt] });
|
||||
const photo = new File(['x'], 'new.jpg');
|
||||
const { result } = setup({ editing });
|
||||
act(() => result.current.handleReceiptFileSelect([photo]));
|
||||
await save(result);
|
||||
expect(saveWithReceipts).toHaveBeenCalledWith(7, [photo], 9, expect.any(Function));
|
||||
expect(updateBudgetItem).toHaveBeenCalledWith(7, 9, expect.objectContaining({ receipt_file_ids: [3, 41] }));
|
||||
expect(result.current.pendingReceiptFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-BUDGET-EXPFORM-017: a ticket split sends everyone a line names on the desktop, only the ticked on the phone (#1382)', async () => {
|
||||
const run = async (ticketShareMembers: boolean) => {
|
||||
const { result } = setup({ ticketShareMembers });
|
||||
act(() => {
|
||||
result.current.setName('Lunch');
|
||||
result.current.setSplitMode('ticket');
|
||||
result.current.handleAddEmptyItem();
|
||||
});
|
||||
const id = result.current.ticketItems[0].id;
|
||||
act(() => {
|
||||
result.current.handleUpdateItemName(id, 'Pizza');
|
||||
result.current.handleUpdateItemPrice(id, '12');
|
||||
// Bob eats from the line but is not ticked for the expense.
|
||||
result.current.toggleParticipant(2);
|
||||
});
|
||||
await save(result);
|
||||
return addBudgetItem.mock.calls[addBudgetItem.mock.calls.length - 1][1];
|
||||
};
|
||||
const desktop = await run(true);
|
||||
expect(desktop).toMatchObject({
|
||||
members: [
|
||||
{ user_id: 1, amount: 6 },
|
||||
{ user_id: 2, amount: 6 },
|
||||
],
|
||||
member_ids: [1, 2],
|
||||
total_price: 12,
|
||||
ticket_json: JSON.stringify({ items: [{ name: 'Pizza', price: '12', parts: [1, 2] }] }),
|
||||
});
|
||||
const phone = await run(false);
|
||||
expect(phone).toMatchObject({ members: [{ user_id: 1, amount: 6 }], member_ids: [1] });
|
||||
});
|
||||
|
||||
it('FE-BUDGET-EXPFORM-018: a failed save says why; a receipt it could not take back is counted', async () => {
|
||||
const { result, onSaved } = setup();
|
||||
fill(result, 'Dinner', '40');
|
||||
vi.mocked(saveWithReceipts).mockRejectedValueOnce(new Error('offline'));
|
||||
await save(result);
|
||||
vi.mocked(saveWithReceipts).mockRejectedValueOnce(Object.assign(new Error('x'), { stuckReceiptIds: [5, 6] }));
|
||||
await save(result);
|
||||
expect(errorToasts()).toEqual(['common.unknownError', 'costs.receiptLeftBehind']);
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-BUDGET-EXPFORM-019: the phone stays busy after a save that went through and frees itself after a failure', async () => {
|
||||
const { result } = setup({ oneSaveAtATime: true, keepSavingOnSuccess: true });
|
||||
fill(result, 'Dinner', '40');
|
||||
vi.mocked(saveWithReceipts).mockRejectedValueOnce(new Error('offline'));
|
||||
await save(result);
|
||||
expect(result.current.saving).toBe(false);
|
||||
await save(result);
|
||||
expect(addBudgetItem).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.saving).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-BUDGET-EXPFORM-020: a save while one is out is dropped on the phone and sent again on the desktop; an invalid one never', async () => {
|
||||
const pending = (() => new Promise(() => {})) as unknown as typeof saveWithReceipts;
|
||||
vi.mocked(saveWithReceipts)
|
||||
.mockImplementationOnce(pending)
|
||||
.mockImplementationOnce(pending)
|
||||
.mockImplementationOnce(pending);
|
||||
const phone = setup({ oneSaveAtATime: true });
|
||||
fill(phone.result, 'Dinner', '40');
|
||||
act(() => {
|
||||
void phone.result.current.save();
|
||||
});
|
||||
act(() => {
|
||||
void phone.result.current.save();
|
||||
});
|
||||
expect(saveWithReceipts).toHaveBeenCalledTimes(1);
|
||||
|
||||
const desktop = setup();
|
||||
fill(desktop.result, 'Dinner', '40');
|
||||
act(() => {
|
||||
void desktop.result.current.save();
|
||||
});
|
||||
act(() => {
|
||||
void desktop.result.current.save();
|
||||
});
|
||||
expect(saveWithReceipts).toHaveBeenCalledTimes(3);
|
||||
|
||||
const invalid = setup();
|
||||
await save(invalid.result);
|
||||
expect(saveWithReceipts).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
import { useEffect, useEffectEvent, useMemo, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { BudgetItem, BudgetItemReceipt } from '../../types';
|
||||
import { amountToInputString } from '../../utils/formatters';
|
||||
import { localToday } from '../Planner/today';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { SYMBOLS } from './BudgetPanel.constants';
|
||||
import type { TripMember } from './BudgetPanelMemberChips';
|
||||
import type { ExpensePrefill } from './CostsPanel';
|
||||
import {
|
||||
amountPattern,
|
||||
calculateTicketShares,
|
||||
hasTicketSplit,
|
||||
newExpenseSeed,
|
||||
payersBalanced,
|
||||
readTicketItems,
|
||||
readUserNote,
|
||||
rebalancePayers,
|
||||
splitEqualShares,
|
||||
writeTicketItems,
|
||||
type TicketItem,
|
||||
} from './CostsPanel.helpers';
|
||||
import { catMeta } from './costsCategories';
|
||||
import { useExpenseFx } from './expenseFx';
|
||||
import { saveWithReceipts } from './receiptUploads';
|
||||
|
||||
export type ExpenseSplitMode = 'equally' | 'custom' | 'ticket';
|
||||
|
||||
/** Keeps the local ids of ticket lines added in the same millisecond apart. */
|
||||
let ticketItemSeq = 0;
|
||||
|
||||
/**
|
||||
* The expense editor behind the desktop ExpenseModal and the phone MCostSheet:
|
||||
* name, category, amount and currency, payers, the Equally / Custom / Ticket
|
||||
* splits, the note and the receipts, what makes the entry valid, and the save
|
||||
* through the trip store. Both surfaces lay it out their own way.
|
||||
*
|
||||
* Where the two save differently today, the surface says which way:
|
||||
* `ticketShareMembers` (desktop) also sends everyone a ticket line names (#1382);
|
||||
* `oneSaveAtATime` (phone) ignores a save while one is out; `keepSavingOnSuccess`
|
||||
* (phone) leaves the form busy after a save that went through, as the sheet closes.
|
||||
*/
|
||||
export function useExpenseForm({
|
||||
tripId,
|
||||
base,
|
||||
people,
|
||||
me,
|
||||
editing,
|
||||
prefill,
|
||||
onSaved,
|
||||
ticketShareMembers = false,
|
||||
oneSaveAtATime = false,
|
||||
keepSavingOnSuccess = false,
|
||||
}: {
|
||||
tripId: number;
|
||||
base: string;
|
||||
people: TripMember[];
|
||||
me: number;
|
||||
editing: BudgetItem | null;
|
||||
prefill?: ExpensePrefill;
|
||||
onSaved: () => void;
|
||||
ticketShareMembers?: boolean;
|
||||
oneSaveAtATime?: boolean;
|
||||
keepSavingOnSuccess?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const addBudgetItem = useTripStore((s) => s.addBudgetItem);
|
||||
const updateBudgetItem = useTripStore((s) => s.updateBudgetItem);
|
||||
const sym = (c: string) => SYMBOLS[c] || c + ' ';
|
||||
const nameOf = (p: TripMember) => (p.id === me ? t('costs.you') : p.username);
|
||||
// A saved expense without a currency opens in the trip's own (#2525).
|
||||
const { tripCurrency: tripCur, editingCurrency, preview } = useExpenseFx(base, editing);
|
||||
|
||||
const [name, setName] = useState(editing?.name || prefill?.name || '');
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : prefill?.category || 'food');
|
||||
const [seed] = useState(() =>
|
||||
newExpenseSeed(
|
||||
prefill,
|
||||
base,
|
||||
people.map((p) => p.id),
|
||||
localToday()
|
||||
)
|
||||
);
|
||||
const [currency, setCurrency] = useState(editing ? editingCurrency : seed.currency);
|
||||
const [day, setDay] = useState(editing ? editing.expense_date || localToday() : seed.day);
|
||||
const [note, setNote] = useState(() => readUserNote(editing));
|
||||
// Edit and prefill seeds are padded to the currency's decimals (#2175): the DB
|
||||
// returns numbers, so a saved 4,90 would otherwise reopen as "4,9" and a saved
|
||||
// 5,00 as "5".
|
||||
const [total, setTotal] = useState<string>(() => {
|
||||
if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : '';
|
||||
return seed.total;
|
||||
});
|
||||
const [participants, setParticipants] = useState<Set<number>>(() =>
|
||||
editing ? new Set((editing.members || []).map((m) => m.user_id)) : new Set(people.map((p) => p.id))
|
||||
);
|
||||
|
||||
// Payer state. An expense can be fronted by several people, each with their own
|
||||
// amount (budget_item_payers): a shared card, or "I got this round, you get the
|
||||
// next". The single-payer choice stays the default path; multiPayer swaps in a
|
||||
// per-person amount editor. 0 represents "Nobody (planning entry)"; on an
|
||||
// existing expense a missing payer is a deliberate choice, so only a brand-new
|
||||
// one defaults to me. A negative payer (the recipient of a refund, #2176) is a
|
||||
// real payer: filtering on > 0 here would silently drop them on save.
|
||||
const initialPayers = (editing?.payers || []).filter((p) => p.amount !== 0);
|
||||
|
||||
const [payerId, setPayerId] = useState<number>(() => {
|
||||
const existingPayer = initialPayers[0];
|
||||
if (existingPayer) return existingPayer.user_id;
|
||||
return editing ? 0 : me;
|
||||
});
|
||||
const [multiPayer, setMultiPayer] = useState(() => initialPayers.length > 1);
|
||||
const [payerIds, setPayerIds] = useState<Set<number>>(() => new Set(initialPayers.map((p) => p.user_id)));
|
||||
const [payerAmounts, setPayerAmounts] = useState<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {};
|
||||
for (const p of initialPayers) m[p.user_id] = amountToInputString(p.amount, currency);
|
||||
return m;
|
||||
});
|
||||
// Payers the user typed an amount for: rebalance leaves these alone and makes
|
||||
// the others absorb the remainder.
|
||||
const [pinnedPayers, setPinnedPayers] = useState<Set<number>>(() => new Set(initialPayers.map((p) => p.user_id)));
|
||||
|
||||
const [splitMode, setSplitMode] = useState<ExpenseSplitMode>(() => {
|
||||
if (hasTicketSplit(editing)) return 'ticket';
|
||||
if (editing && editing.members && editing.members.length > 0) {
|
||||
const hasCustom = editing.members.some((m) => m.amount !== null && m.amount !== undefined);
|
||||
return hasCustom ? 'custom' : 'equally';
|
||||
}
|
||||
return 'equally';
|
||||
});
|
||||
|
||||
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() =>
|
||||
editing ? readTicketItems(editing) : seed.ticketItems
|
||||
);
|
||||
|
||||
const [customAmounts, setCustomAmounts] = useState<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {};
|
||||
if (editing && editing.members) {
|
||||
for (const member of editing.members) {
|
||||
if (member.amount !== null && member.amount !== undefined) {
|
||||
m[member.user_id] = amountToInputString(member.amount, currency);
|
||||
}
|
||||
}
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
const [receipts, setReceipts] = useState<BudgetItemReceipt[]>(() => editing?.receipts || []);
|
||||
const [pendingReceiptFiles, setPendingReceiptFiles] = useState<File[]>(() => (editing ? [] : seed.receiptFiles));
|
||||
const [uploadingReceipt, setUploadingReceipt] = useState(false);
|
||||
const [previewReceipts, setPreviewReceipts] = useState<{
|
||||
receipts: BudgetItemReceipt[];
|
||||
initialIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleReceiptFileSelect = (files: FileList | File[] | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
setPendingReceiptFiles((prev) => [...prev, ...Array.from(files)]);
|
||||
};
|
||||
|
||||
const handleRemoveReceipt = (receiptId: number) => {
|
||||
setReceipts((prev) => prev.filter((r) => r.id !== receiptId));
|
||||
};
|
||||
|
||||
const handleRemovePendingReceipt = (index: number) => {
|
||||
setPendingReceiptFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const isTicketMode = splitMode === 'ticket';
|
||||
const ticketInfo = useMemo(() => calculateTicketShares(ticketItems), [ticketItems]);
|
||||
|
||||
const totalNum = isTicketMode ? ticketInfo.total : Number.parseFloat(total) || 0;
|
||||
const fx = preview(totalNum, currency);
|
||||
const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0);
|
||||
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100);
|
||||
// How much is still to be handed out, read on the total's own side: on a refund
|
||||
// (#2176) the shares run negative, so a plain total minus sum flips under and
|
||||
// over around and sends the user the wrong way.
|
||||
const splitShortfall = totalNum < 0 ? splitSum - totalNum : totalNum - splitSum;
|
||||
const each = participants.size > 0 ? totalNum / participants.size : 0;
|
||||
const equalShares = useMemo(
|
||||
() =>
|
||||
splitEqualShares(
|
||||
totalNum,
|
||||
[...participants].map((id) => ({ user_id: id })),
|
||||
editing?.id || 0
|
||||
),
|
||||
[totalNum, participants, editing]
|
||||
);
|
||||
|
||||
const placeholderShares = useMemo(() => {
|
||||
const emptyParts = [...participants].filter((id) => !customAmounts[id]);
|
||||
if (emptyParts.length === 0) return {};
|
||||
const enteredSum = [...participants]
|
||||
.filter((id) => customAmounts[id])
|
||||
.reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0);
|
||||
// Clamped toward zero on the total's own side, so an over-entered positive
|
||||
// split never suggests negative leftovers, while a negative total (#2176)
|
||||
// still previews its negative equal shares.
|
||||
const rest = totalNum - enteredSum;
|
||||
const remaining = totalNum >= 0 ? Math.max(0, rest) : Math.min(0, rest);
|
||||
return splitEqualShares(
|
||||
remaining,
|
||||
emptyParts.map((id) => ({ user_id: id })),
|
||||
editing?.id || 0
|
||||
);
|
||||
}, [totalNum, participants, customAmounts, editing]);
|
||||
|
||||
const ticketValid =
|
||||
ticketItems.length > 0 &&
|
||||
ticketItems.every(
|
||||
(item) => item.name.trim().length > 0 && (Number.parseFloat(item.price) || 0) > 0 && item.participants.size > 0
|
||||
);
|
||||
const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum));
|
||||
// A negative total is a valid entry (a refund, #2176); only zero has nothing to say.
|
||||
const valid =
|
||||
name.trim().length > 0 &&
|
||||
payersOk &&
|
||||
(isTicketMode
|
||||
? ticketValid
|
||||
: totalNum !== 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced));
|
||||
|
||||
const onTotalChange = (v: string) => setTotal(v.replace(',', '.'));
|
||||
|
||||
// Keep the payer amounts summing to the total as it changes, including in ticket
|
||||
// mode, where the total is derived from the ticket items rather than typed. Only
|
||||
// a new total triggers it; the payer state is read as it stands then.
|
||||
const rebalanceToTotal = useEffectEvent(() => {
|
||||
if (!multiPayer) return;
|
||||
setPayerAmounts((prev) => rebalancePayers(prev, pinnedPayers, payerIds, totalNum));
|
||||
});
|
||||
useEffect(() => {
|
||||
rebalanceToTotal();
|
||||
}, [totalNum]);
|
||||
|
||||
const enableMultiPayer = () => {
|
||||
const startPayers = payerIds.size > 0 ? new Set(payerIds) : new Set<number>([payerId > 0 ? payerId : me]);
|
||||
const pinned = new Set<number>();
|
||||
setPayerIds(startPayers);
|
||||
setPinnedPayers(pinned);
|
||||
setPayerAmounts((prev) => rebalancePayers(prev, pinned, startPayers, totalNum));
|
||||
setMultiPayer(true);
|
||||
};
|
||||
|
||||
const disableMultiPayer = () => {
|
||||
// Collapsing back keeps the first payer; their amount becomes the whole total.
|
||||
const [first] = [...payerIds];
|
||||
setPayerId(first ?? me);
|
||||
setMultiPayer(false);
|
||||
};
|
||||
|
||||
const togglePayer = (id: number) => {
|
||||
const nextIds = new Set(payerIds);
|
||||
const nextPinned = new Set(pinnedPayers);
|
||||
if (nextIds.has(id)) {
|
||||
nextIds.delete(id);
|
||||
nextPinned.delete(id);
|
||||
} else {
|
||||
nextIds.add(id);
|
||||
}
|
||||
setPayerIds(nextIds);
|
||||
setPinnedPayers(nextPinned);
|
||||
setPayerAmounts((prev) => rebalancePayers(prev, nextPinned, nextIds, totalNum));
|
||||
};
|
||||
|
||||
const onPayerAmountChange = (id: number, v: string) => {
|
||||
const val = v.replace(',', '.');
|
||||
const nextPinned = new Set(pinnedPayers);
|
||||
nextPinned.add(id);
|
||||
setPinnedPayers(nextPinned);
|
||||
setPayerAmounts((prev) => rebalancePayers({ ...prev, [id]: val }, nextPinned, payerIds, totalNum));
|
||||
};
|
||||
|
||||
const handleCustomAmountChange = (id: number, raw: string) => {
|
||||
const val = raw.replace(',', '.');
|
||||
if (val === '' || amountPattern(currency, true).test(val)) {
|
||||
setCustomAmounts((prev) => ({ ...prev, [id]: val }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddEmptyItem = () => {
|
||||
setTicketItems((prev) => [
|
||||
...prev,
|
||||
{ id: `${Date.now()}-${++ticketItemSeq}`, name: '', price: '', participants: new Set(people.map((p) => p.id)) },
|
||||
]);
|
||||
};
|
||||
|
||||
const handleUpdateItemName = (id: string, itemName: string) => {
|
||||
setTicketItems((prev) => prev.map((item) => (item.id === id ? { ...item, name: itemName } : item)));
|
||||
};
|
||||
|
||||
const handleUpdateItemPrice = (id: string, raw: string) => {
|
||||
const price = raw.replace(',', '.');
|
||||
if (price === '' || amountPattern(currency, false).test(price)) {
|
||||
setTicketItems((prev) => prev.map((item) => (item.id === id ? { ...item, price } : item)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveItem = (id: string) => {
|
||||
setTicketItems((prev) => prev.filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const handleToggleItemParticipant = (itemId: string, userId: number) => {
|
||||
setTicketItems((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id !== itemId) return item;
|
||||
const nextParts = new Set(item.participants);
|
||||
if (nextParts.has(userId)) nextParts.delete(userId);
|
||||
else nextParts.add(userId);
|
||||
return { ...item, participants: nextParts };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const toggleParticipant = (id: number) => {
|
||||
const nextParts = new Set(participants);
|
||||
if (nextParts.has(id)) {
|
||||
nextParts.delete(id);
|
||||
setCustomAmounts((prev) => {
|
||||
const copy = { ...prev };
|
||||
delete copy[id];
|
||||
return copy;
|
||||
});
|
||||
} else {
|
||||
nextParts.add(id);
|
||||
}
|
||||
setParticipants(nextParts);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!valid || (oneSaveAtATime && saving)) return;
|
||||
setSaving(true);
|
||||
// A picked payer always goes out, even when nobody shares the expense: the
|
||||
// server re-derives total_price from the payer sum (CostsPanel.helpers), so
|
||||
// dropping the payer would store the entry with a total of 0.
|
||||
const payerList = multiPayer
|
||||
? [...payerIds]
|
||||
.map((id) => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 0 }))
|
||||
.filter((p) => p.amount !== 0)
|
||||
: payerId > 0
|
||||
? [{ user_id: payerId, amount: totalNum }]
|
||||
: [];
|
||||
// A receipt line can name somebody who is not ticked as a participant. Sending
|
||||
// only the ticked set would drop their share, leaving the member sum short of
|
||||
// total_price and handing the settlement a difference it can never clear (#1382).
|
||||
const memberIds =
|
||||
splitMode === 'ticket' && ticketShareMembers
|
||||
? [...new Set([...participants, ...Object.keys(ticketInfo.shares).map(Number)])].sort((a, b) => a - b)
|
||||
: [...participants];
|
||||
const memberList = memberIds.map((id) => ({
|
||||
user_id: id,
|
||||
amount:
|
||||
splitMode === 'custom'
|
||||
? Number.parseFloat(customAmounts[id]) || 0
|
||||
: splitMode === 'ticket'
|
||||
? ticketInfo.shares[id] || 0
|
||||
: null,
|
||||
}));
|
||||
const data = {
|
||||
name: name.trim(),
|
||||
category: cat,
|
||||
currency,
|
||||
payers: payerList,
|
||||
members: memberList,
|
||||
member_ids: memberIds,
|
||||
expense_date: day || null,
|
||||
total_price: totalNum,
|
||||
note: note.trim() || null,
|
||||
ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null,
|
||||
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
|
||||
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
|
||||
};
|
||||
try {
|
||||
setUploadingReceipt(pendingReceiptFiles.length > 0);
|
||||
await saveWithReceipts(tripId, pendingReceiptFiles, editing ? editing.id : null, (ids) =>
|
||||
editing
|
||||
? updateBudgetItem(tripId, editing.id, { ...data, receipt_file_ids: [...receipts.map((r) => r.id), ...ids] })
|
||||
: addBudgetItem(tripId, { ...data, receipt_file_ids: ids })
|
||||
);
|
||||
// Only cleared once the save went through, so a retry after a failure
|
||||
// does not upload a second copy of every file.
|
||||
setPendingReceiptFiles([]);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
// A receipt the rollback could not remove is still on the trip, and the
|
||||
// user is the only one who can clear it out of the Files tab.
|
||||
const stuck = (err as { stuckReceiptIds?: number[] })?.stuckReceiptIds;
|
||||
toast.error(stuck?.length ? t('costs.receiptLeftBehind', { count: stuck.length }) : t('common.unknownError'));
|
||||
if (keepSavingOnSuccess) setSaving(false);
|
||||
} finally {
|
||||
setUploadingReceipt(false);
|
||||
if (!keepSavingOnSuccess) setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
sym,
|
||||
nameOf,
|
||||
tripCur,
|
||||
name,
|
||||
setName,
|
||||
cat,
|
||||
setCat,
|
||||
currency,
|
||||
setCurrency,
|
||||
day,
|
||||
setDay,
|
||||
note,
|
||||
setNote,
|
||||
total,
|
||||
onTotalChange,
|
||||
totalNum,
|
||||
fx,
|
||||
participants,
|
||||
toggleParticipant,
|
||||
payerId,
|
||||
setPayerId,
|
||||
multiPayer,
|
||||
enableMultiPayer,
|
||||
disableMultiPayer,
|
||||
payerIds,
|
||||
payerAmounts,
|
||||
togglePayer,
|
||||
onPayerAmountChange,
|
||||
payersOk,
|
||||
splitMode,
|
||||
setSplitMode,
|
||||
isTicketMode,
|
||||
ticketItems,
|
||||
ticketInfo,
|
||||
handleAddEmptyItem,
|
||||
handleUpdateItemName,
|
||||
handleUpdateItemPrice,
|
||||
handleRemoveItem,
|
||||
handleToggleItemParticipant,
|
||||
customAmounts,
|
||||
setCustomAmounts,
|
||||
handleCustomAmountChange,
|
||||
splitSum,
|
||||
customBalanced,
|
||||
splitShortfall,
|
||||
each,
|
||||
equalShares,
|
||||
placeholderShares,
|
||||
receipts,
|
||||
pendingReceiptFiles,
|
||||
uploadingReceipt,
|
||||
handleReceiptFileSelect,
|
||||
handleRemoveReceipt,
|
||||
handleRemovePendingReceipt,
|
||||
previewReceipts,
|
||||
setPreviewReceipts,
|
||||
valid,
|
||||
saving,
|
||||
save,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, Plus, Trash2, Wallet, Receipt, Paperclip } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import CustomSelect from '../../../../components/shared/CustomSelect'
|
||||
@@ -8,20 +8,18 @@ import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader }
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { useToast } from '../../../../components/shared/Toast'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { formatMoney, localizeAmountInput, amountToInputString } from '../../../../utils/formatters'
|
||||
import { formatMoney, localizeAmountInput } from '../../../../utils/formatters'
|
||||
import { openFile } from '../../../../utils/fileDownload'
|
||||
import { saveWithReceipts } from '../../../../components/Budget/receiptUploads'
|
||||
import { splitShareLabel, useExpenseFx } from '../../../../components/Budget/expenseFx'
|
||||
import { splitShareLabel } from '../../../../components/Budget/expenseFx'
|
||||
import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants'
|
||||
import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories'
|
||||
import { localToday } from '../../../../components/Planner/today'
|
||||
import { amountPattern, calculateTicketShares, hasTicketSplit, newExpenseSeed, NOTE_MAX, readTicketItems, readUserNote, splitEqualShares, writeTicketItems, type TicketItem } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import { NOTE_MAX } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import type { ExpensePrefill } from '../../../../components/Budget/CostsPanel'
|
||||
import { payersBalanced, rebalancePayers } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import { useExpenseForm } from '../../../../components/Budget/useExpenseForm'
|
||||
import GuestBadge from '../../../../components/shared/GuestBadge'
|
||||
import type { TripMember } from '../../../../components/Budget/BudgetPanelMemberChips'
|
||||
import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal'
|
||||
import type { BudgetItem, BudgetItemReceipt } from '../../../../types'
|
||||
import type { BudgetItem } from '../../../../types'
|
||||
|
||||
export interface MCostSheetProps {
|
||||
tripId: number
|
||||
@@ -57,10 +55,19 @@ const SPLIT_MODES = [
|
||||
export default function MCostSheet({ tripId, base, people, me, editing, prefill, onClose, onSaved }: MCostSheetProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const { addBudgetItem, updateBudgetItem, deleteBudgetItem } = useTripStore()
|
||||
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
||||
// A saved expense without a currency opens in the trip's own (#2525), as on desktop.
|
||||
const { tripCurrency: tripCur, editingCurrency, preview } = useExpenseFx(base, editing)
|
||||
const { deleteBudgetItem } = useTripStore()
|
||||
|
||||
// The same editor as the desktop modal, a saved expense without a currency
|
||||
// opening in the trip's own (#2525).
|
||||
const {
|
||||
sym, nameOf, tripCur, name, setName, cat, setCat, currency, setCurrency, day, setDay, note, setNote, total, onTotalChange,
|
||||
totalNum, fx, participants, toggleParticipant, payerId, setPayerId, multiPayer, enableMultiPayer, disableMultiPayer,
|
||||
payerIds, payerAmounts, togglePayer, onPayerAmountChange, payersOk, splitMode, setSplitMode, isTicketMode, ticketItems,
|
||||
ticketInfo, handleAddEmptyItem, handleUpdateItemName, handleUpdateItemPrice, handleRemoveItem, handleToggleItemParticipant,
|
||||
customAmounts, handleCustomAmountChange, splitSum, customBalanced, each, equalShares, placeholderShares,
|
||||
receipts, pendingReceiptFiles, uploadingReceipt, handleReceiptFileSelect, handleRemoveReceipt, handleRemovePendingReceipt,
|
||||
previewReceipts, setPreviewReceipts, valid, saving, save,
|
||||
} = useExpenseForm({ tripId, base, people, me, editing, prefill, onSaved, oneSaveAtATime: true, keepSavingOnSuccess: true })
|
||||
|
||||
// Internal open flag so the exit animation still plays even though the parent
|
||||
// unmounts us on close.
|
||||
@@ -72,243 +79,9 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
closeTimer.current = window.setTimeout(onClose, 280)
|
||||
}
|
||||
|
||||
const [name, setName] = useState(editing?.name || prefill?.name || '')
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
||||
const [catOpen, setCatOpen] = useState(false)
|
||||
const [note, setNote] = useState(() => readUserNote(editing))
|
||||
const [seed] = useState(() => newExpenseSeed(prefill, base, people.map(p => p.id), localToday()))
|
||||
const [currency, setCurrency] = useState(editing ? editingCurrency : seed.currency)
|
||||
const [day, setDay] = useState(editing ? (editing.expense_date || localToday()) : seed.day)
|
||||
// Edit and prefill seeds are padded to the currency's decimals (#2175), same
|
||||
// as the desktop modal: a saved 4,90 must reopen as "4,90", not "4,9".
|
||||
const [total, setTotal] = useState<string>(() => {
|
||||
if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : ''
|
||||
return seed.total
|
||||
})
|
||||
const [participants, setParticipants] = useState<Set<number>>(() =>
|
||||
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
||||
|
||||
// Payer state — same model as the desktop modal. 0 = "Nobody (planning entry)".
|
||||
// A negative payer (the recipient of a refund, #2176) must survive the reopen.
|
||||
const initialPayers = (editing?.payers || []).filter(p => p.amount !== 0)
|
||||
const [payerId, setPayerId] = useState<number>(() => {
|
||||
const existingPayer = initialPayers[0]
|
||||
if (existingPayer) return existingPayer.user_id
|
||||
return editing ? 0 : me
|
||||
})
|
||||
const [multiPayer, setMultiPayer] = useState(() => initialPayers.length > 1)
|
||||
const [payerIds, setPayerIds] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
|
||||
const [payerAmounts, setPayerAmounts] = useState<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {}
|
||||
for (const p of initialPayers) m[p.user_id] = amountToInputString(p.amount, currency)
|
||||
return m
|
||||
})
|
||||
const [pinnedPayers, setPinnedPayers] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
|
||||
|
||||
const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => {
|
||||
if (hasTicketSplit(editing)) return 'ticket'
|
||||
if (editing && editing.members && editing.members.length > 0) {
|
||||
const hasCustom = editing.members.some(m => m.amount !== null && m.amount !== undefined)
|
||||
return hasCustom ? 'custom' : 'equally'
|
||||
}
|
||||
return 'equally'
|
||||
})
|
||||
|
||||
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => editing ? readTicketItems(editing) : seed.ticketItems)
|
||||
|
||||
const [customAmounts, setCustomAmounts] = useState<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {}
|
||||
if (editing && editing.members) {
|
||||
for (const member of editing.members) {
|
||||
if (member.amount !== null && member.amount !== undefined) m[member.user_id] = amountToInputString(member.amount, currency)
|
||||
}
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
const [receipts, setReceipts] = useState<BudgetItemReceipt[]>(() => editing?.receipts || [])
|
||||
const [pendingReceiptFiles, setPendingReceiptFiles] = useState<File[]>(() => editing ? [] : seed.receiptFiles)
|
||||
const [uploadingReceipt, setUploadingReceipt] = useState(false)
|
||||
const [previewReceipts, setPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
|
||||
|
||||
const handleReceiptFileSelect = (files: FileList | File[] | null) => {
|
||||
if (!files || files.length === 0) return
|
||||
setPendingReceiptFiles(prev => [...prev, ...Array.from(files)])
|
||||
}
|
||||
|
||||
const handleRemoveReceipt = (receiptId: number) => {
|
||||
setReceipts(prev => prev.filter(r => r.id !== receiptId))
|
||||
}
|
||||
|
||||
const handleRemovePendingReceipt = (index: number) => {
|
||||
setPendingReceiptFiles(prev => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleteArmed, setDeleteArmed] = useState(false)
|
||||
|
||||
const isTicketMode = splitMode === 'ticket'
|
||||
const ticketInfo = useMemo(() => calculateTicketShares(ticketItems), [ticketItems])
|
||||
|
||||
const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0)
|
||||
const fx = preview(totalNum, currency)
|
||||
const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
|
||||
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
|
||||
const each = participants.size > 0 ? totalNum / participants.size : 0
|
||||
const equalShares = useMemo(
|
||||
() => splitEqualShares(totalNum, [...participants].map(id => ({ user_id: id })), editing?.id || 0),
|
||||
[totalNum, participants, editing],
|
||||
)
|
||||
|
||||
const placeholderShares = useMemo(() => {
|
||||
const emptyParts = [...participants].filter(id => !customAmounts[id])
|
||||
if (emptyParts.length === 0) return {}
|
||||
const enteredSum = [...participants]
|
||||
.filter(id => customAmounts[id])
|
||||
.reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
|
||||
// Clamped toward zero on the total's own side so a negative total (#2176)
|
||||
// still previews its negative equal shares — same as the desktop modal.
|
||||
const rest = totalNum - enteredSum
|
||||
const remaining = totalNum >= 0 ? Math.max(0, rest) : Math.min(0, rest)
|
||||
return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0)
|
||||
}, [totalNum, participants, customAmounts, editing])
|
||||
|
||||
const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (Number.parseFloat(item.price) || 0) > 0 && item.participants.size > 0)
|
||||
const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum))
|
||||
// A negative total is a valid entry (a refund, #2176); only zero has nothing to say.
|
||||
const valid = name.trim().length > 0 && payersOk && (
|
||||
isTicketMode
|
||||
? ticketValid
|
||||
: totalNum !== 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)
|
||||
)
|
||||
|
||||
const onTotalChange = (v: string) => setTotal(v.replace(',', '.'))
|
||||
|
||||
// Keep payer amounts summing to the total as it changes (also in ticket mode).
|
||||
useEffect(() => {
|
||||
if (!multiPayer) return
|
||||
setPayerAmounts(prev => rebalancePayers(prev, pinnedPayers, payerIds, totalNum))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [totalNum])
|
||||
|
||||
const enableMultiPayer = () => {
|
||||
const startPayers = payerIds.size > 0 ? new Set(payerIds) : new Set<number>([payerId > 0 ? payerId : me])
|
||||
const pinned = new Set<number>()
|
||||
setPayerIds(startPayers)
|
||||
setPinnedPayers(pinned)
|
||||
setPayerAmounts(prev => rebalancePayers(prev, pinned, startPayers, totalNum))
|
||||
setMultiPayer(true)
|
||||
}
|
||||
|
||||
const disableMultiPayer = () => {
|
||||
const [first] = [...payerIds]
|
||||
setPayerId(first ?? me)
|
||||
setMultiPayer(false)
|
||||
}
|
||||
|
||||
const togglePayer = (id: number) => {
|
||||
const nextIds = new Set(payerIds)
|
||||
const nextPinned = new Set(pinnedPayers)
|
||||
if (nextIds.has(id)) { nextIds.delete(id); nextPinned.delete(id) } else { nextIds.add(id) }
|
||||
setPayerIds(nextIds)
|
||||
setPinnedPayers(nextPinned)
|
||||
setPayerAmounts(prev => rebalancePayers(prev, nextPinned, nextIds, totalNum))
|
||||
}
|
||||
|
||||
const onPayerAmountChange = (id: number, v: string) => {
|
||||
const val = v.replace(',', '.')
|
||||
const nextPinned = new Set(pinnedPayers)
|
||||
nextPinned.add(id)
|
||||
setPinnedPayers(nextPinned)
|
||||
setPayerAmounts(prev => rebalancePayers({ ...prev, [id]: val }, nextPinned, payerIds, totalNum))
|
||||
}
|
||||
|
||||
const handleCustomAmountChange = (id: number, val: string) => {
|
||||
val = val.replace(',', '.')
|
||||
if (val === '' || amountPattern(currency, true).test(val)) setCustomAmounts(prev => ({ ...prev, [id]: val }))
|
||||
}
|
||||
|
||||
const handleAddEmptyItem = () => {
|
||||
setTicketItems(prev => [
|
||||
...prev,
|
||||
{ id: String(Date.now() + Math.random()), name: '', price: '', participants: new Set(people.map(p => p.id)) },
|
||||
])
|
||||
}
|
||||
const handleUpdateItemName = (id: string, itemName: string) => setTicketItems(prev => prev.map(item => item.id === id ? { ...item, name: itemName } : item))
|
||||
const handleUpdateItemPrice = (id: string, price: string) => {
|
||||
price = price.replace(',', '.')
|
||||
if (price === '' || amountPattern(currency, false).test(price)) setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item))
|
||||
}
|
||||
const handleRemoveItem = (id: string) => setTicketItems(prev => prev.filter(item => item.id !== id))
|
||||
const handleToggleItemParticipant = (itemId: string, userId: number) => {
|
||||
setTicketItems(prev => prev.map(item => {
|
||||
if (item.id !== itemId) return item
|
||||
const nextParts = new Set(item.participants)
|
||||
if (nextParts.has(userId)) nextParts.delete(userId); else nextParts.add(userId)
|
||||
return { ...item, participants: nextParts }
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleParticipant = (id: number) => {
|
||||
const nextParts = new Set(participants)
|
||||
if (nextParts.has(id)) {
|
||||
nextParts.delete(id)
|
||||
setCustomAmounts(prev => { const copy = { ...prev }; delete copy[id]; return copy })
|
||||
} else {
|
||||
nextParts.add(id)
|
||||
}
|
||||
setParticipants(nextParts)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!valid || saving) return
|
||||
setSaving(true)
|
||||
// A picked payer always goes out, even when nobody shares the expense: the
|
||||
// server re-derives total_price from the payer sum (CostsPanel.helpers), so
|
||||
// dropping the payer would store the entry with a total of 0.
|
||||
const payerList = multiPayer
|
||||
? [...payerIds].map(id => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 0 })).filter(p => p.amount !== 0)
|
||||
: payerId > 0 ? [{ user_id: payerId, amount: totalNum }] : []
|
||||
const memberList = [...participants].map(id => ({
|
||||
user_id: id,
|
||||
amount: splitMode === 'custom'
|
||||
? (Number.parseFloat(customAmounts[id]) || 0)
|
||||
: splitMode === 'ticket'
|
||||
? (ticketInfo.shares[id] || 0)
|
||||
: null,
|
||||
}))
|
||||
const data = {
|
||||
name: name.trim(),
|
||||
category: cat,
|
||||
currency,
|
||||
payers: payerList,
|
||||
members: memberList,
|
||||
member_ids: [...participants],
|
||||
expense_date: day || null,
|
||||
total_price: totalNum,
|
||||
note: note.trim() || null,
|
||||
ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null,
|
||||
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
|
||||
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
|
||||
}
|
||||
try {
|
||||
setUploadingReceipt(pendingReceiptFiles.length > 0)
|
||||
await saveWithReceipts(tripId, pendingReceiptFiles, editing ? editing.id : null, ids => (
|
||||
editing
|
||||
? updateBudgetItem(tripId, editing.id, { ...data, receipt_file_ids: [...receipts.map(r => r.id), ...ids] })
|
||||
: addBudgetItem(tripId, { ...data, receipt_file_ids: ids })
|
||||
))
|
||||
setPendingReceiptFiles([])
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
const stuck = (err as { stuckReceiptIds?: number[] })?.stuckReceiptIds
|
||||
toast.error(stuck?.length ? t('costs.receiptLeftBehind', { count: stuck.length }) : t('common.unknownError'))
|
||||
setSaving(false)
|
||||
} finally {
|
||||
setUploadingReceipt(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!editing) return
|
||||
if (!deleteArmed) {
|
||||
@@ -326,7 +99,6 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
}
|
||||
|
||||
const initialOf = (p: TripMember) => (p.id === me ? t('costs.youShort') : (p.username || '?').charAt(0)).toUpperCase()
|
||||
const nameOf = (p: TripMember) => (p.id === me ? t('costs.you') : p.username)
|
||||
|
||||
const Avatar = ({ p, idx, size = 22, dim = false }: { p: TripMember; idx: number; size?: number; dim?: boolean }) =>
|
||||
p.avatar_url
|
||||
|
||||
Reference in New Issue
Block a user