From 9098e29cbe839c972f93edfb99acc9505873b7ec Mon Sep 17 00:00:00 2001 From: LogenNineFingersIsAlive Date: Wed, 25 Feb 2026 23:35:26 +0000 Subject: [PATCH 1/3] feat(pwa): polish expenses mobile list UX and safe-area behavior --- apps/pwa/src/components/MobileShell.tsx | 15 +- apps/pwa/src/pages/ExpensesPage.tsx | 372 +++++++++++------------- 2 files changed, 178 insertions(+), 209 deletions(-) diff --git a/apps/pwa/src/components/MobileShell.tsx b/apps/pwa/src/components/MobileShell.tsx index b32c1c4..b303c8d 100644 --- a/apps/pwa/src/components/MobileShell.tsx +++ b/apps/pwa/src/components/MobileShell.tsx @@ -42,17 +42,22 @@ export const MobileShell = ({ title, children }: MobileShellProps) => { theme.zIndex.appBar, + }} > - + {title} - + {children} @@ -63,6 +68,8 @@ export const MobileShell = ({ title, children }: MobileShellProps) => { bottom: 0, left: 0, right: 0, + zIndex: (theme) => theme.zIndex.appBar + 2, + pb: 'env(safe-area-inset-bottom, 0px)', borderTopLeftRadius: 18, borderTopRightRadius: 18, overflow: 'hidden', diff --git a/apps/pwa/src/pages/ExpensesPage.tsx b/apps/pwa/src/pages/ExpensesPage.tsx index 0168de1..d86ca37 100644 --- a/apps/pwa/src/pages/ExpensesPage.tsx +++ b/apps/pwa/src/pages/ExpensesPage.tsx @@ -4,9 +4,6 @@ import { Avatar, Box, Button, - Card, - CardContent, - Chip, CircularProgress, Dialog, DialogActions, @@ -126,6 +123,30 @@ const reimbursementChipLabel = (status?: string): string | null => { return null; }; +const expenseAmountPresentation = (expense: { + kind?: string; + transferDirection?: 'in' | 'out' | null; + money: { amountMinor: number; currency: string }; +}): { text: string; color: string } => { + const base = pounds(expense.money.amountMinor, expense.money.currency); + const isInflow = + expense.kind === 'income' || + expense.kind === 'transfer_external' || + expense.kind === 'transfer_internal' + ? expense.transferDirection === 'in' || expense.kind === 'income' + : false; + + if (isInflow) { + return { text: `+${base}`, color: '#2E7D32' }; + } + + if (expense.kind === 'transfer_internal') { + return { text: base, color: '#5F6368' }; + } + + return { text: base, color: '#111827' }; +}; + export const ExpensesPage = () => { const queryClient = useQueryClient(); const [open, setOpen] = useState(false); @@ -187,24 +208,6 @@ export const ExpensesPage = () => { }, }); - const closeReimbursement = useMutation({ - mutationFn: (payload: { - expenseOutId: string; - closeOutstandingMinor?: number; - reason?: string | null; - }) => - api.reimbursements.close(payload.expenseOutId, { - closeOutstandingMinor: payload.closeOutstandingMinor, - reason: payload.reason ?? undefined, - }), - onSuccess: async () => { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['expenses'] }), - queryClient.invalidateQueries({ queryKey: ['report', 'monthlyLedger'] }), - ]); - }, - }); - const reopenReimbursement = useMutation({ mutationFn: (expenseOutId: string) => api.reimbursements.reopen(expenseOutId), onSuccess: async () => { @@ -261,192 +264,151 @@ export const ExpensesPage = () => { return ( - - - - - Latest Expenses - - {expenses.length === 0 ? ( - - No expenses logged yet. - - ) : ( - - {groupedExpenses.map(([label, items]) => ( - - + No expenses logged yet. + + ) : ( + + {groupedExpenses.map(([label, items]) => ( + + `1px solid ${theme.palette.divider}`, + }} + > + + {label} + + + {pounds( + items.reduce((sum, expense) => { + const isInflow = + expense.kind === 'income' || + ((expense.kind === 'transfer_external' || + expense.kind === 'transfer_internal') && + expense.transferDirection === 'in'); + return sum + (isInflow ? expense.money.amountMinor : -expense.money.amountMinor); + }, 0), + 'GBP', + )} + + + + {items.map((expense) => { + const merchant = expense.merchantName?.trim() || 'Card payment'; + const categoryMeta = categoryById.get(expense.categoryId); + const categoryName = categoryMeta?.name ?? expense.categoryId; + const kindLabel = semanticKindLabel(expense.kind, expense.transferDirection); + const reimbursementLabel = reimbursementChipLabel(expense.reimbursementStatus); + const canShowReimbursement = + expense.kind === 'expense' && + expense.reimbursementStatus && + expense.reimbursementStatus !== 'none'; + const outstandingMinor = expense.outstandingMinor ?? 0; + const amountView = expenseAmountPresentation(expense); + + const subtitle = canShowReimbursement + ? `${reimbursementLabel ?? 'Reimbursable'} · Outstanding ${pounds( + outstandingMinor, + expense.money.currency, + )}` + : kindLabel || categoryName; + + const handleLinkRepayment = () => { + const expenseInId = window.prompt( + 'Inbound transaction ID to link as reimbursement', + ); + if (!expenseInId) return; + const amountText = window.prompt( + 'Allocation amount (GBP)', + (outstandingMinor / 100).toFixed(2), + ); + if (!amountText) return; + const parsed = Number(amountText); + if (!Number.isFinite(parsed) || parsed <= 0) return; + linkReimbursement.mutate({ + expenseOutId: expense.id, + expenseInId: expenseInId.trim(), + amountMinor: Math.round(parsed * 100), + }); + }; + + return ( + - {label} - - - {items.map((expense) => { - const merchant = expense.merchantName?.trim() || 'Card payment'; - const categoryMeta = categoryById.get(expense.categoryId); - const categoryName = categoryMeta?.name ?? expense.categoryId; - const categoryColor = categoryMeta?.color ?? '#607D8B'; - const kindChip = semanticKindLabel(expense.kind, expense.transferDirection); - const reimbursementLabel = reimbursementChipLabel( - expense.reimbursementStatus, - ); - const canShowReimbursement = - expense.kind === 'expense' && - expense.reimbursementStatus && - expense.reimbursementStatus !== 'none'; - const recoverableMinor = expense.recoverableMinor ?? 0; - const recoveredMinor = expense.recoveredMinor ?? 0; - const outstandingMinor = expense.outstandingMinor ?? 0; - - const handleLinkRepayment = () => { - const expenseInId = window.prompt( - 'Inbound transaction ID to link as reimbursement', - ); - if (!expenseInId) return; - const amountText = window.prompt( - 'Allocation amount (GBP)', - (outstandingMinor / 100).toFixed(2), - ); - if (!amountText) return; - const parsed = Number(amountText); - if (!Number.isFinite(parsed) || parsed <= 0) return; - linkReimbursement.mutate({ - expenseOutId: expense.id, - expenseInId: expenseInId.trim(), - amountMinor: Math.round(parsed * 100), - }); - }; - - const handleCloseRemainder = () => { - const amountText = window.prompt( - 'Write-off outstanding amount (GBP)', - (outstandingMinor / 100).toFixed(2), - ); - if (!amountText) return; - const parsed = Number(amountText); - if (!Number.isFinite(parsed) || parsed < 0) return; - const reason = window.prompt('Reason (optional)') ?? undefined; - closeReimbursement.mutate({ - expenseOutId: expense.id, - closeOutstandingMinor: Math.round(parsed * 100), - reason, - }); - }; - - return ( - + + + + + + {merchant} + + + {subtitle} + + {canShowReimbursement && outstandingMinor > 0 ? ( + - - - ) : expense.reimbursementStatus === 'written_off' ? ( - - ) : null} - - ) : null} - - - - - {pounds(expense.money.amountMinor, expense.money.currency)} - - - - ); - })} - - - ))} - - )} - - - + Link repayment + + ) : null} + {canShowReimbursement && + outstandingMinor === 0 && + expense.reimbursementStatus === 'written_off' ? ( + + ) : null} + + + + + {amountView.text} + + + + ); + })} + + + ))} + + )} Date: Wed, 25 Feb 2026 23:37:56 +0000 Subject: [PATCH 2/3] style(pwa): format mobile shell and expenses page --- apps/pwa/src/components/MobileShell.tsx | 11 ++++++++++- apps/pwa/src/pages/ExpensesPage.tsx | 4 +++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/pwa/src/components/MobileShell.tsx b/apps/pwa/src/components/MobileShell.tsx index b303c8d..9c27679 100644 --- a/apps/pwa/src/components/MobileShell.tsx +++ b/apps/pwa/src/components/MobileShell.tsx @@ -57,7 +57,16 @@ export const MobileShell = ({ title, children }: MobileShellProps) => { - + {children} diff --git a/apps/pwa/src/pages/ExpensesPage.tsx b/apps/pwa/src/pages/ExpensesPage.tsx index d86ca37..300aae7 100644 --- a/apps/pwa/src/pages/ExpensesPage.tsx +++ b/apps/pwa/src/pages/ExpensesPage.tsx @@ -298,7 +298,9 @@ export const ExpensesPage = () => { ((expense.kind === 'transfer_external' || expense.kind === 'transfer_internal') && expense.transferDirection === 'in'); - return sum + (isInflow ? expense.money.amountMinor : -expense.money.amountMinor); + return ( + sum + (isInflow ? expense.money.amountMinor : -expense.money.amountMinor) + ); }, 0), 'GBP', )} From 9d59eeab4ccedd010fa08c6ddf406d7a8dacba83 Mon Sep 17 00:00:00 2001 From: LogenNineFingersIsAlive Date: Wed, 25 Feb 2026 23:51:13 +0000 Subject: [PATCH 3/3] fix(expenses): address PR feedback on inflow logic and reimbursement actions --- apps/pwa/src/pages/ExpensesPage.tsx | 110 ++++++++++++++++++++-------- 1 file changed, 79 insertions(+), 31 deletions(-) diff --git a/apps/pwa/src/pages/ExpensesPage.tsx b/apps/pwa/src/pages/ExpensesPage.tsx index 300aae7..5a1fbad 100644 --- a/apps/pwa/src/pages/ExpensesPage.tsx +++ b/apps/pwa/src/pages/ExpensesPage.tsx @@ -123,28 +123,33 @@ const reimbursementChipLabel = (status?: string): string | null => { return null; }; +const isInflowExpense = (expense: { + kind?: string; + transferDirection?: 'in' | 'out' | null; +}): boolean => { + if (expense.kind === 'income') return true; + if (expense.kind === 'transfer_external' || expense.kind === 'transfer_internal') { + return expense.transferDirection === 'in'; + } + return false; +}; + const expenseAmountPresentation = (expense: { kind?: string; transferDirection?: 'in' | 'out' | null; money: { amountMinor: number; currency: string }; }): { text: string; color: string } => { const base = pounds(expense.money.amountMinor, expense.money.currency); - const isInflow = - expense.kind === 'income' || - expense.kind === 'transfer_external' || - expense.kind === 'transfer_internal' - ? expense.transferDirection === 'in' || expense.kind === 'income' - : false; - - if (isInflow) { - return { text: `+${base}`, color: '#2E7D32' }; + + if (isInflowExpense(expense)) { + return { text: `+${base}`, color: 'success.main' }; } if (expense.kind === 'transfer_internal') { - return { text: base, color: '#5F6368' }; + return { text: base, color: 'text.secondary' }; } - return { text: base, color: '#111827' }; + return { text: base, color: 'text.primary' }; }; export const ExpensesPage = () => { @@ -208,6 +213,24 @@ export const ExpensesPage = () => { }, }); + const closeReimbursement = useMutation({ + mutationFn: (payload: { + expenseOutId: string; + closeOutstandingMinor?: number; + reason?: string | null; + }) => + api.reimbursements.close(payload.expenseOutId, { + closeOutstandingMinor: payload.closeOutstandingMinor, + reason: payload.reason ?? undefined, + }), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['expenses'] }), + queryClient.invalidateQueries({ queryKey: ['report', 'monthlyLedger'] }), + ]); + }, + }); + const reopenReimbursement = useMutation({ mutationFn: (expenseOutId: string) => api.reimbursements.reopen(expenseOutId), onSuccess: async () => { @@ -292,16 +315,14 @@ export const ExpensesPage = () => { {pounds( - items.reduce((sum, expense) => { - const isInflow = - expense.kind === 'income' || - ((expense.kind === 'transfer_external' || - expense.kind === 'transfer_internal') && - expense.transferDirection === 'in'); - return ( - sum + (isInflow ? expense.money.amountMinor : -expense.money.amountMinor) - ); - }, 0), + items.reduce( + (sum, expense) => + sum + + (isInflowExpense(expense) + ? expense.money.amountMinor + : -expense.money.amountMinor), + 0, + ), 'GBP', )} @@ -346,6 +367,22 @@ export const ExpensesPage = () => { }); }; + const handleCloseRemainder = () => { + const amountText = window.prompt( + 'Write-off outstanding amount (GBP)', + (outstandingMinor / 100).toFixed(2), + ); + if (!amountText) return; + const parsed = Number(amountText); + if (!Number.isFinite(parsed) || parsed < 0) return; + const reason = window.prompt('Reason (optional)') ?? undefined; + closeReimbursement.mutate({ + expenseOutId: expense.id, + closeOutstandingMinor: Math.round(parsed * 100), + reason, + }); + }; + return ( { {subtitle} {canShowReimbursement && outstandingMinor > 0 ? ( - + + + + ) : null} {canShowReimbursement && outstandingMinor === 0 && @@ -388,7 +436,7 @@ export const ExpensesPage = () => { variant="text" onClick={() => reopenReimbursement.mutate(expense.id)} disabled={reopenReimbursement.isPending} - sx={{ mt: 0.1, minHeight: 22, px: 0 }} + sx={{ mt: 0.25, minHeight: 36, px: 0.5 }} > Reopen