From 3dd6674d3f35644d617364f99b5832196bf68d4c Mon Sep 17 00:00:00 2001 From: RemEx <74838147+Rem7474@users.noreply.github.com> Date: Sat, 12 Sep 2026 06:59:24 +0000 Subject: [PATCH] feat(budget): let a recorded payment be edited from mobile Costs too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing/undoing a settle-up payment was desktop-only — the mobile ledger row rendered it read-only, with a comment explaining the gap was deliberate for now. Give it the same edit/undo affordance the expense row already has: edit reopens AddPaymentSheet pre-filled (now doubling as an edit form, same as desktop's SettlementModal), undo deletes it outright with no confirmation, mirroring CostsPanel.tsx's undoSettlement. --- .../mobile/screens/trip/tabs/MCostsTab.tsx | 93 +++++++++++++------ .../tests/unit/mobile/trip/MCostsTab.test.tsx | 29 ++++++ 2 files changed, 96 insertions(+), 26 deletions(-) diff --git a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx index 96d4be410..7e18803f6 100644 --- a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx @@ -1,19 +1,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AlertCircle, ArrowDown, ArrowLeftRight, ArrowRight, ArrowUp, Check, ChevronDown, ChevronUp, - Layers, Pencil, Plus, StickyNote, Trash2, Receipt, + Layers, Pencil, Plus, RotateCcw, StickyNote, Trash2, Receipt, } from 'lucide-react' import MDancingTrek from '../../../components/MDancingTrek' import { useAuthStore } from '../../../../store/authStore' import { useSettingsStore } from '../../../../store/settingsStore' import { useExchangeRates } from '../../../../hooks/useExchangeRates' import { useTranslation } from '../../../../i18n' -import { formatMoney } from '../../../../utils/formatters' +import { amountToInputString, formatMoney } from '../../../../utils/formatters' import { downloadBlob, openFile } from '../../../../utils/fileDownload' import { budgetApi } from '../../../../api/client' import MCostSheet from '../sheets/MCostSheet' import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal' -import { readUserNote } from '../../../../components/Budget/CostsPanel.helpers' +import { readUserNote, settlementDate } from '../../../../components/Budget/CostsPanel.helpers' import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories' import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker' import { localToday } from '../../../../components/Planner/today' @@ -78,6 +78,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { const [dayOpen, setDayOpen] = useState(false) const [settleOpen, setSettleOpen] = useState(true) const [addPaymentOpen, setAddPaymentOpen] = useState(false) + const [editingSettlement, setEditingSettlement] = useState(null) const [expenseModalOpen, setExpenseModalOpen] = useState(false) const [editingExpense, setEditingExpense] = useState(null) const [confirmDelete, setConfirmDelete] = useState(null) @@ -154,6 +155,17 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { } } + // Mirrors CostsPanel.tsx's undoSettlement: deleting the recorded transfer + // brings the suggested flow back, so it reads as "undo" rather than delete. + const handleUndoSettlement = async (id: number) => { + try { + await budgetApi.deleteSettlement(tripId, id) + loadSettlement() + } catch { + toast.error(t('common.unknownError')) + } + } + const handleTogglePaid = async (itemId: number, userId: number, paid: boolean) => { try { await planner.tripActions.toggleBudgetMemberPaid(tripId, itemId, userId, paid) @@ -470,6 +482,9 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { locale={locale} t={t} personName={personName} + canEdit={canEdit} + onEdit={() => setEditingSettlement(en.settlement)} + onUndo={() => handleUndoSettlement(en.settlement.id)} /> ))} @@ -505,8 +520,12 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { )} setAddPaymentOpen(false)} + open={addPaymentOpen || editingSettlement != null} + editing={editingSettlement} + onClose={() => { + setAddPaymentOpen(false) + setEditingSettlement(null) + }} tripId={tripId} base={base} people={tripMembers} @@ -515,6 +534,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { t={t} onSaved={() => { setAddPaymentOpen(false) + setEditingSettlement(null) loadSettlement() }} /> @@ -676,17 +696,21 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT /** * A recorded settle-up payment mixed into the day-grouped ledger (spec 03 - * §3.7 desktop parity — see `CostsPanel.tsx`'s `SettlementRow`). Read-only: - * editing/undoing a payment stays a desktop-only action for now, this row - * only closes the "it vanishes into thin air" gap on mobile. + * §3.7 desktop parity — see `CostsPanel.tsx`'s `SettlementRow`). Edit opens + * `AddPaymentSheet` pre-filled; undo deletes it outright (the recorded + * transfer is what closes the flow, so undoing it reopens the flow — same + * as desktop's `undoSettlement`, no confirmation prompt either). */ -function PaymentRow({ settlement, ctx, base, locale, t, personName }: { +function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onEdit, onUndo }: { settlement: CostsSettlement ctx: CostsCtx base: string locale: string t: TFn personName: (id: number) => string + canEdit: boolean + onEdit: () => void + onUndo: () => void }) { const cur = (settlement.currency || base).toUpperCase() const amount = ctx.convert(settlement.amount, cur) @@ -706,6 +730,17 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName }: { + + {canEdit && ( +
+ + +
+ )} ) } @@ -735,16 +770,18 @@ function MemberAvatar({ name, avatarUrl, isMe, variant, size, t }: { } /** - * "Add payment" — records a manual settle-up transfer (`budgetApi.createSettlement`). - * No pixel spec exists for this form (the demo only toasts "Demo: add payment", - * 03-trip-tabs.md §3.8) and no mobile/exported-desktop sheet covers it, so this - * is a small local sheet built from the trip form-sheet chrome, kept to the - * fields the settle-up card itself needs: from, to, amount in the display - * currency, and the day it happened (editable like an expense's, unlike the - * legacy created_at-only date — see `settlementDate` in CostsPanel.helpers.ts). + * "Add/edit payment" — records or updates a manual settle-up transfer + * (`budgetApi.createSettlement`/`updateSettlement`). No pixel spec exists for + * this form (the demo only toasts "Demo: add payment", 03-trip-tabs.md §3.8) + * and no mobile/exported-desktop sheet covers it, so this is a small local + * sheet built from the trip form-sheet chrome, kept to the fields the + * settle-up card itself needs: from, to, amount in the display currency, and + * the day it happened (editable like an expense's, unlike the legacy + * created_at-only date — see `settlementDate` in CostsPanel.helpers.ts). */ -function AddPaymentSheet({ open, onClose, tripId, base, people, me, toast, t, onSaved }: { +function AddPaymentSheet({ open, editing, onClose, tripId, base, people, me, toast, t, onSaved }: { open: boolean + editing: CostsSettlement | null onClose: () => void tripId: number base: string @@ -762,12 +799,12 @@ function AddPaymentSheet({ open, onClose, tripId, base, people, me, toast, t, on useEffect(() => { if (!open) return - setFromId(me) - setToId(people.find(p => p.id !== me)?.id ?? me) - setAmount('') - setDay(localToday()) + setFromId(editing?.from_user_id ?? me) + setToId(editing?.to_user_id ?? people.find(p => p.id !== me)?.id ?? me) + setAmount(editing ? amountToInputString(editing.amount, (editing.currency || base).toUpperCase()) : '') + setDay(editing ? settlementDate(editing) : localToday()) setSaving(false) - }, [open, me, people]) + }, [open, editing, me, base, people]) const amt = Number.parseFloat(amount.replace(',', '.')) || 0 const valid = amt > 0 && fromId !== toId @@ -775,8 +812,10 @@ function AddPaymentSheet({ open, onClose, tripId, base, people, me, toast, t, on const save = async () => { if (!valid || saving) return setSaving(true) + const data = { from_user_id: fromId, to_user_id: toId, amount: amt, currency: base, settled_at: day } try { - await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount: amt, currency: base, settled_at: day }) + if (editing) await budgetApi.updateSettlement(tripId, editing.id, data) + else await budgetApi.createSettlement(tripId, data) onSaved() } catch { toast.error(t('common.unknownError')) @@ -785,9 +824,11 @@ function AddPaymentSheet({ open, onClose, tripId, base, people, me, toast, t, on } } + const title = editing ? t('costs.editPayment') : t('costs.addPayment') + return ( - - + +
{t('costs.from')}
@@ -818,7 +859,7 @@ function AddPaymentSheet({ open, onClose, tripId, base, people, me, toast, t, on {t('costs.day')}
- + ) } diff --git a/client/tests/unit/mobile/trip/MCostsTab.test.tsx b/client/tests/unit/mobile/trip/MCostsTab.test.tsx index 228caefa8..bf90a7d57 100644 --- a/client/tests/unit/mobile/trip/MCostsTab.test.tsx +++ b/client/tests/unit/mobile/trip/MCostsTab.test.tsx @@ -597,4 +597,33 @@ describe('MCostsTab', () => { fireEvent.change(screen.getByLabelText('costs.searchPlaceholder'), { target: { value: 'ramen' } }) expect(screen.queryByText('costs.payment')).not.toBeInTheDocument() }) + + it('FE-MOB-COSTT-041: editing a recorded payment opens it pre-filled and saves the change', async () => { + const payment = { id: 501, from_user_id: 1, to_user_id: 2, amount: 25, currency: 'USD', settled_at: '2026-04-28', created_at: '2026-04-30T09:00:00Z' } + serveSettlement({ ...SETTLEMENT, settlements: [payment] }) + const update = vi.spyOn(budgetApi, 'updateSettlement').mockResolvedValue({}) + await renderTab() + + fireEvent.click(within(rowOf('costs.payment')).getByRole('button', { name: 'common.edit' })) + const dialog = screen.getByRole('dialog', { name: 'costs.editPayment' }) + expect(within(dialog).getByDisplayValue('25.00')).toBeInTheDocument() + + const submit = within(dialog).getByRole('button', { name: 'common.save' }) + fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '30' } }) + fireEvent.click(submit) + + expect(update).toHaveBeenCalledWith(7, 501, { from_user_id: 1, to_user_id: 2, amount: 30, currency: 'USD', settled_at: '2026-04-28' }) + await waitFor(() => expect(settlementBases).toHaveLength(2)) + }) + + it('FE-MOB-COSTT-042: undoing a payment deletes it outright, no confirmation', async () => { + const payment = { id: 502, from_user_id: 1, to_user_id: 2, amount: 25, currency: 'USD', created_at: '2026-04-30T09:00:00Z' } + serveSettlement({ ...SETTLEMENT, settlements: [payment] }) + const del = vi.spyOn(budgetApi, 'deleteSettlement').mockResolvedValue({ success: true }) + await renderTab() + + fireEvent.click(within(rowOf('costs.payment')).getByRole('button', { name: 'costs.undo' })) + expect(del).toHaveBeenCalledWith(7, 502) + await waitFor(() => expect(settlementBases).toHaveLength(2)) + }) })