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' ? (
+
- );
- })}
-
-
- ))}
-
- )}
-
-
-
+ Reopen
+
+ ) : null}
+
+
+
+
+ {amountView.text}
+
+
+
+ );
+ })}
+
+
+ ))}
+
+ )}