diff --git a/apps/pwa/src/components/MobileShell.tsx b/apps/pwa/src/components/MobileShell.tsx index b32c1c4..9c27679 100644 --- a/apps/pwa/src/components/MobileShell.tsx +++ b/apps/pwa/src/components/MobileShell.tsx @@ -42,17 +42,31 @@ export const MobileShell = ({ title, children }: MobileShellProps) => { theme.zIndex.appBar, + }} > - + {title} - + {children} @@ -63,6 +77,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..5a1fbad 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,35 @@ 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); + + if (isInflowExpense(expense)) { + return { text: `+${base}`, color: 'success.main' }; + } + + if (expense.kind === 'transfer_internal') { + return { text: base, color: 'text.secondary' }; + } + + return { text: base, color: 'text.primary' }; +}; + export const ExpensesPage = () => { const queryClient = useQueryClient(); const [open, setOpen] = useState(false); @@ -261,192 +287,178 @@ 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) => + sum + + (isInflowExpense(expense) + ? 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), + }); + }; + + 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 ( + - {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 ? ( + + + + + ) : null} + {canShowReimbursement && + outstandingMinor === 0 && + expense.reimbursementStatus === 'written_off' ? ( + - - - ) : expense.reimbursementStatus === 'written_off' ? ( - - ) : null} - - ) : null} - - - - - {pounds(expense.money.amountMinor, expense.money.currency)} - - - - ); - })} - - - ))} - - )} - - - + Reopen + + ) : null} + + + + + {amountView.text} + + + + ); + })} + + + ))} + + )}