Files
HOA_Financial_Platform/frontend/src/pages/accounts/AccountsPage.tsx
olsch01 c92eb1b57b RBAC: Enforce read-only viewer role across backend and frontend
- Add global WriteAccessGuard that blocks POST/PUT/PATCH/DELETE for viewer role
- Add @AllowViewer() decorator for endpoints viewers need (switch-org, intro-seen, AI recommendations)
- Add useIsReadOnly hook to auth store for frontend role checks
- Hide write UI (add/edit/delete/import buttons, inline editors) in all 13 data pages for viewers
- Disable inline NumberInputs on Budgets and Monthly Actuals pages for viewers
- Skip onboarding wizard for viewer role users

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:18:32 -05:00

1199 lines
44 KiB
TypeScript

import { useState } from 'react';
import {
Title,
Table,
Badge,
Group,
Button,
TextInput,
Select,
Modal,
Stack,
NumberInput,
Switch,
Text,
Card,
ActionIcon,
Tabs,
Loader,
Center,
Tooltip,
SimpleGrid,
Alert,
Divider,
Textarea,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconPlus,
IconEdit,
IconSearch,
IconArchive,
IconArchiveOff,
IconStar,
IconStarFilled,
IconAdjustments,
IconInfoCircle,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage'];
const isInvestmentType = (t: string) => INVESTMENT_TYPES.includes(t);
// Only show account types that represent real cash / financial positions.
// Income, expense, and equity accounts are internal bookkeeping — managed via
// budget import and system operations, not manually on this page.
const ACCOUNT_TYPE_OPTIONS = [
{ value: 'asset', label: 'Asset (Bank Account)' },
{ value: 'liability', label: 'Liability' },
{ value: '__divider', label: '── Investment Accounts ──', disabled: true },
{ value: 'inv_cd', label: 'Investment — CD' },
{ value: 'inv_money_market', label: 'Investment — Money Market' },
{ value: 'inv_treasury', label: 'Investment — Treasury' },
{ value: 'inv_savings', label: 'Investment — Savings' },
{ value: 'inv_brokerage', label: 'Investment — Brokerage' },
];
interface Account {
id: string;
account_number: string;
name: string;
description: string;
account_type: string;
fund_type: string;
is_1099_reportable: boolean;
is_active: boolean;
is_system: boolean;
is_primary: boolean;
balance: string;
interest_rate: string | null;
}
interface Investment {
id: string;
name: string;
institution: string;
account_number_last4: string;
investment_type: string;
fund_type: string;
principal: string;
interest_rate: string;
maturity_date: string;
purchase_date: string;
current_value: string;
is_active: boolean;
interest_earned: string | null;
maturity_value: string | null;
days_remaining: number | null;
}
interface TrialBalanceEntry {
id: string;
account_number: string;
name: string;
account_type: string;
fund_type: string;
total_debits: string;
total_credits: string;
balance: string;
}
const accountTypeColors: Record<string, string> = {
asset: 'green',
'asset (investments)': 'teal',
liability: 'red',
equity: 'violet',
income: 'blue',
expense: 'orange',
};
const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
export function AccountsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [adjustOpened, { open: openAdjust, close: closeAdjust }] = useDisclosure(false);
const [invEditOpened, { open: openInvEdit, close: closeInvEdit }] = useDisclosure(false);
const [editing, setEditing] = useState<Account | null>(null);
const [editingInvestment, setEditingInvestment] = useState<Investment | null>(null);
const [adjustingAccount, setAdjustingAccount] = useState<Account | null>(null);
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
// ── Accounts query ──
const { data: accounts = [], isLoading } = useQuery<Account[]>({
queryKey: ['accounts', showArchived],
queryFn: async () => {
const params = showArchived ? '?includeArchived=true' : '';
const { data } = await api.get(`/accounts${params}`);
return data;
},
});
// ── Investments query ──
const { data: investments = [], isLoading: investmentsLoading } = useQuery<Investment[]>({
queryKey: ['investments'],
queryFn: async () => {
const { data } = await api.get('/investment-accounts');
return data;
},
});
// ── Trial balance query (for balance adjustment) ──
const { data: trialBalance = [] } = useQuery<TrialBalanceEntry[]>({
queryKey: ['trial-balance'],
queryFn: async () => {
const { data } = await api.get('/accounts/trial-balance');
return data;
},
});
// ── Create / Edit form ──
const form = useForm({
initialValues: {
accountNumber: '',
name: '',
description: '',
accountType: 'asset',
fundType: 'operating',
is1099Reportable: false,
initialBalance: 0,
// Investment fields
institution: '',
accountNumberLast4: '',
principal: 0,
interestRate: 0,
maturityDate: null as Date | null,
purchaseDate: null as Date | null,
investmentNotes: '',
withdrawFromPrimary: true,
},
validate: {
accountNumber: (v, values) => isInvestmentType(values.accountType) ? null : (v.trim().length > 0 ? null : 'Required'),
name: (v) => (v.length > 0 ? null : 'Required'),
principal: (v, values) => isInvestmentType(values.accountType) ? (v > 0 ? null : 'Required') : null,
},
});
// ── Balance adjustment form ──
const adjustForm = useForm({
initialValues: {
targetBalance: 0,
asOfDate: new Date() as Date | null,
memo: '',
},
validate: {
targetBalance: (v) => (v !== undefined && v !== null ? null : 'Required'),
asOfDate: (v) => (v ? null : 'Required'),
},
});
// ── Mutations ──
const createMutation = useMutation({
mutationFn: (values: any) => {
if (editing) {
return api.put(`/accounts/${editing.id}`, values);
}
// Investment account creation
if (isInvestmentType(values.accountType)) {
const investmentTypeMap: Record<string, string> = {
inv_cd: 'cd', inv_money_market: 'money_market', inv_treasury: 'treasury',
inv_savings: 'savings', inv_brokerage: 'other',
};
return api.post('/investment-accounts', {
name: values.name,
institution: values.institution || null,
account_number_last4: values.accountNumberLast4 || null,
investment_type: investmentTypeMap[values.accountType] || 'other',
fund_type: values.fundType,
principal: values.principal,
interest_rate: values.interestRate || 0,
maturity_date: values.maturityDate ? values.maturityDate.toISOString().split('T')[0] : null,
purchase_date: values.purchaseDate ? values.purchaseDate.toISOString().split('T')[0] : null,
current_value: values.principal,
notes: values.investmentNotes || null,
withdraw_from_primary: values.withdrawFromPrimary,
});
}
return api.post('/accounts', values);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
queryClient.invalidateQueries({ queryKey: ['investments'] });
const isInv = isInvestmentType(form.values.accountType);
notifications.show({ message: editing ? 'Account updated' : (isInv ? 'Investment created' : 'Account created'), color: 'green' });
close();
setEditing(null);
form.reset();
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' });
},
});
const archiveMutation = useMutation({
mutationFn: (account: Account) =>
api.put(`/accounts/${account.id}`, { isActive: !account.is_active }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
notifications.show({ message: 'Account status updated', color: 'green' });
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' });
},
});
const setPrimaryMutation = useMutation({
mutationFn: (accountId: string) => api.put(`/accounts/${accountId}/set-primary`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
notifications.show({ message: 'Primary account updated', color: 'green' });
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' });
},
});
const adjustBalanceMutation = useMutation({
mutationFn: (values: { accountId: string; targetBalance: number; asOfDate: string; memo: string }) =>
api.post(`/accounts/${values.accountId}/adjust-balance`, {
targetBalance: values.targetBalance,
asOfDate: values.asOfDate,
memo: values.memo,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
queryClient.invalidateQueries({ queryKey: ['trial-balance'] });
notifications.show({ message: 'Balance adjusted successfully', color: 'green' });
closeAdjust();
setAdjustingAccount(null);
adjustForm.reset();
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Error adjusting balance', color: 'red' });
},
});
// ── Investment edit form ──
const invForm = useForm({
initialValues: {
name: '',
institution: '',
accountNumberLast4: '',
investmentType: 'cd',
fundType: 'operating',
principal: 0,
interestRate: 0,
maturityDate: null as Date | null,
purchaseDate: null as Date | null,
currentValue: 0,
notes: '',
},
validate: {
name: (v) => (v.length > 0 ? null : 'Required'),
principal: (v) => (v > 0 ? null : 'Required'),
},
});
const updateInvestmentMutation = useMutation({
mutationFn: (values: any) =>
api.put(`/investment-accounts/${editingInvestment!.id}`, {
name: values.name,
institution: values.institution || null,
account_number_last4: values.accountNumberLast4 || null,
investment_type: values.investmentType,
fund_type: values.fundType,
principal: values.principal,
interest_rate: values.interestRate || 0,
maturity_date: values.maturityDate ? values.maturityDate.toISOString().split('T')[0] : null,
purchase_date: values.purchaseDate ? values.purchaseDate.toISOString().split('T')[0] : null,
current_value: values.currentValue || values.principal,
notes: values.notes || null,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['investments'] });
notifications.show({ message: 'Investment updated', color: 'green' });
closeInvEdit();
setEditingInvestment(null);
invForm.reset();
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Error updating investment', color: 'red' });
},
});
// ── Handlers ──
const handleEditInvestment = (inv: Investment) => {
setEditingInvestment(inv);
invForm.setValues({
name: inv.name,
institution: inv.institution || '',
accountNumberLast4: inv.account_number_last4 || '',
investmentType: inv.investment_type,
fundType: inv.fund_type,
principal: parseFloat(inv.principal || '0'),
interestRate: parseFloat(inv.interest_rate || '0'),
maturityDate: inv.maturity_date ? new Date(inv.maturity_date) : null,
purchaseDate: inv.purchase_date ? new Date(inv.purchase_date) : null,
currentValue: parseFloat(inv.current_value || inv.principal || '0'),
notes: '',
});
openInvEdit();
};
const handleEdit = (account: Account) => {
setEditing(account);
form.setValues({
accountNumber: account.account_number,
name: account.name,
description: account.description || '',
accountType: account.account_type,
fundType: account.fund_type,
is1099Reportable: account.is_1099_reportable,
initialBalance: 0,
interestRate: parseFloat(account.interest_rate || '0'),
});
open();
};
const handleNew = () => {
setEditing(null);
form.reset();
open();
};
const handleAdjustBalance = (account: Account) => {
setAdjustingAccount(account);
const tbEntry = trialBalance.find((tb) => tb.id === account.id);
adjustForm.setValues({
targetBalance: parseFloat(tbEntry?.balance || account.balance || '0'),
asOfDate: new Date(),
memo: '',
});
openAdjust();
};
const handleAdjustSubmit = (values: { targetBalance: number; asOfDate: Date | null; memo: string }) => {
if (!adjustingAccount || !values.asOfDate) return;
adjustBalanceMutation.mutate({
accountId: adjustingAccount.id,
targetBalance: values.targetBalance,
asOfDate: values.asOfDate.toISOString().split('T')[0],
memo: values.memo,
});
};
// ── Filtering ──
// Only show asset and liability accounts — these represent real cash positions.
// Income, expense, and equity accounts are internal bookkeeping managed via
// budget import, transactions, and system operations.
const VISIBLE_ACCOUNT_TYPES = ['asset', 'liability'];
const filtered = accounts.filter((a) => {
if (a.is_system) return false;
if (!VISIBLE_ACCOUNT_TYPES.includes(a.account_type)) return false;
if (search && !a.name.toLowerCase().includes(search.toLowerCase()) && !String(a.account_number).includes(search)) return false;
if (filterType && a.account_type !== filterType) return false;
return true;
});
const activeAccounts = filtered.filter((a) => a.is_active);
const archivedAccounts = filtered.filter((a) => !a.is_active);
// ── Investments split by fund type ──
const operatingInvestments = investments.filter((i) => i.fund_type === 'operating' && i.is_active);
const reserveInvestments = investments.filter((i) => i.fund_type === 'reserve' && i.is_active);
// ── Summary cards ──
// Only show asset and liability totals — these are real cash positions.
// Income/expense/equity are internal bookkeeping and don't belong here.
const totalsByType = accounts.reduce(
(acc, a) => {
if (a.is_active && !a.is_system && VISIBLE_ACCOUNT_TYPES.includes(a.account_type)) {
acc[a.account_type] = (acc[a.account_type] || 0) + parseFloat(a.balance || '0');
}
return acc;
},
{} as Record<string, number>,
);
// Include investment current value in a separate summary card
const investmentTotal = investments
.filter((i) => i.is_active)
.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
if (investmentTotal > 0) {
totalsByType['investments'] = investmentTotal;
}
// Net position = assets + investments - liabilities
const netPosition = (totalsByType['asset'] || 0) + investmentTotal - (totalsByType['liability'] || 0);
// ── Estimated monthly interest across all accounts + investments with rates ──
const acctMonthlyInterest = accounts
.filter((a) => a.is_active && !a.is_system && a.interest_rate && parseFloat(a.interest_rate) > 0)
.reduce((sum, a) => {
const bal = parseFloat(a.balance || '0');
const rate = parseFloat(a.interest_rate || '0');
return sum + (bal * (rate / 100) / 12);
}, 0);
const invMonthlyInterest = investments
.filter((i) => i.is_active && parseFloat(i.interest_rate || '0') > 0)
.reduce((sum, i) => {
const val = parseFloat(i.current_value || i.principal || '0');
const rate = parseFloat(i.interest_rate || '0');
return sum + (val * (rate / 100) / 12);
}, 0);
const estMonthlyInterest = acctMonthlyInterest + invMonthlyInterest;
// ── Per-fund cash and interest breakdowns ──
const operatingCash = accounts
.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset' && a.fund_type === 'operating')
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
const reserveCash = accounts
.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset' && a.fund_type === 'reserve')
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
const opInvTotal = operatingInvestments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
const resInvTotal = reserveInvestments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
const opMonthlyInterest = accounts
.filter((a) => a.is_active && !a.is_system && a.fund_type === 'operating' && parseFloat(a.interest_rate || '0') > 0)
.reduce((sum, a) => sum + (parseFloat(a.balance || '0') * (parseFloat(a.interest_rate || '0') / 100) / 12), 0)
+ operatingInvestments
.filter((i) => parseFloat(i.interest_rate || '0') > 0)
.reduce((sum, i) => sum + (parseFloat(i.current_value || i.principal || '0') * (parseFloat(i.interest_rate || '0') / 100) / 12), 0);
const resMonthlyInterest = accounts
.filter((a) => a.is_active && !a.is_system && a.fund_type === 'reserve' && parseFloat(a.interest_rate || '0') > 0)
.reduce((sum, a) => sum + (parseFloat(a.balance || '0') * (parseFloat(a.interest_rate || '0') / 100) / 12), 0)
+ reserveInvestments
.filter((i) => parseFloat(i.interest_rate || '0') > 0)
.reduce((sum, i) => sum + (parseFloat(i.current_value || i.principal || '0') * (parseFloat(i.interest_rate || '0') / 100) / 12), 0);
// ── Adjust modal: current balance from trial balance ──
const adjustCurrentBalance = adjustingAccount
? parseFloat(
trialBalance.find((tb) => tb.id === adjustingAccount.id)?.balance ||
adjustingAccount.balance ||
'0',
)
: 0;
const adjustmentAmount = (adjustForm.values.targetBalance || 0) - adjustCurrentBalance;
if (isLoading) {
return (
<Center h={300}>
<Loader />
</Center>
);
}
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Accounts</Title>
<Group>
<Switch
label="Show Archived"
checked={showArchived}
onChange={(e) => setShowArchived(e.currentTarget.checked)}
size="sm"
/>
{!isReadOnly && (
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Account
</Button>
)}
</Group>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }}>
<Card withBorder p="xs">
<Text size="xs" c="dimmed">Operating Fund</Text>
<Text fw={700} size="sm" c="green">{fmt(operatingCash)}</Text>
{opInvTotal > 0 && <Text size="xs" c="teal">Investments: {fmt(opInvTotal)}</Text>}
</Card>
<Card withBorder p="xs">
<Text size="xs" c="dimmed">Reserve Fund</Text>
<Text fw={700} size="sm" c="violet">{fmt(reserveCash)}</Text>
{resInvTotal > 0 && <Text size="xs" c="teal">Investments: {fmt(resInvTotal)}</Text>}
</Card>
<Card withBorder p="xs">
<Text size="xs" c="dimmed">Total All Funds</Text>
<Text fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</Text>
<Text size="xs" c="dimmed">Op: {fmt(operatingCash + opInvTotal)} | Res: {fmt(reserveCash + resInvTotal)}</Text>
</Card>
{estMonthlyInterest > 0 && (
<Card withBorder p="xs">
<Text size="xs" c="dimmed">Est. Monthly Interest</Text>
<Text fw={700} size="sm" c="blue">{fmt(estMonthlyInterest)}</Text>
<Text size="xs" c="dimmed">Op: {fmt(opMonthlyInterest)} | Res: {fmt(resMonthlyInterest)}</Text>
</Card>
)}
</SimpleGrid>
<Group>
<TextInput
placeholder="Search accounts..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Select
placeholder="Type"
clearable
data={[
{ value: 'asset', label: 'Asset' },
{ value: 'liability', label: 'Liability' },
]}
value={filterType}
onChange={setFilterType}
w={150}
/>
</Group>
<Tabs defaultValue="all">
<Tabs.List>
<Tabs.Tab value="all">All ({activeAccounts.length + investments.filter(i => i.is_active).length})</Tabs.Tab>
<Tabs.Tab value="operating">
Operating ({activeAccounts.filter(a => a.fund_type === 'operating').length + operatingInvestments.length})
</Tabs.Tab>
<Tabs.Tab value="reserve">
Reserve ({activeAccounts.filter(a => a.fund_type === 'reserve').length + reserveInvestments.length})
</Tabs.Tab>
{showArchived && archivedAccounts.length > 0 && (
<Tabs.Tab value="archived" color="gray">
Archived ({archivedAccounts.length})
</Tabs.Tab>
)}
</Tabs.List>
<Tabs.Panel value="all" pt="sm">
<Stack>
<AccountTable
accounts={activeAccounts}
onEdit={handleEdit}
onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance}
isReadOnly={isReadOnly}
/>
{investments.filter(i => i.is_active).length > 0 && (
<>
<Divider label="Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} />
</>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="operating" pt="sm">
<Stack>
<AccountTable
accounts={activeAccounts.filter((a) => a.fund_type === 'operating')}
onEdit={handleEdit}
onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance}
isReadOnly={isReadOnly}
/>
{operatingInvestments.length > 0 && (
<>
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} />
</>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="reserve" pt="sm">
<Stack>
<AccountTable
accounts={activeAccounts.filter((a) => a.fund_type === 'reserve')}
onEdit={handleEdit}
onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance}
isReadOnly={isReadOnly}
/>
{reserveInvestments.length > 0 && (
<>
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} />
</>
)}
</Stack>
</Tabs.Panel>
{showArchived && (
<Tabs.Panel value="archived" pt="sm">
<AccountTable
accounts={archivedAccounts}
onEdit={handleEdit}
onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance}
isReadOnly={isReadOnly}
isArchivedView
/>
</Tabs.Panel>
)}
</Tabs>
{/* Create / Edit Account Modal */}
<Modal
opened={opened}
onClose={close}
title={editing ? 'Edit Account' : isInvestmentType(form.values.accountType) ? 'New Investment Account' : 'New Account'}
size="md"
closeOnClickOutside={false}
>
<form onSubmit={form.onSubmit((values) => createMutation.mutate(values))}>
<Stack>
{!editing && (
<Select
label="Account Type"
required
data={ACCOUNT_TYPE_OPTIONS}
{...form.getInputProps('accountType')}
/>
)}
<TextInput
label={isInvestmentType(form.values.accountType) ? 'Investment Name' : 'Account Name'}
placeholder={isInvestmentType(form.values.accountType) ? 'e.g. Reserve CD - 12 Month' : 'e.g. Operating Cash'}
required
{...form.getInputProps('name')}
/>
{!isInvestmentType(form.values.accountType) && (
<>
<TextInput label="Account Number" placeholder="e.g. 4000 or 40-4007-0000" required {...form.getInputProps('accountNumber')} />
<TextInput label="Description" {...form.getInputProps('description')} />
{editing && (
<Select
label="Account Type"
required
data={[
{ value: 'asset', label: 'Asset (Bank Account)' },
{ value: 'liability', label: 'Liability' },
]}
{...form.getInputProps('accountType')}
/>
)}
</>
)}
<Select
label="Fund Type"
required
data={[
{ value: 'operating', label: 'Operating' },
{ value: 'reserve', label: 'Reserve' },
]}
{...form.getInputProps('fundType')}
/>
{/* Investment-specific fields */}
{!editing && isInvestmentType(form.values.accountType) && (
<>
<Divider label="Investment Details" labelPosition="center" />
<TextInput
label="Institution"
placeholder="e.g. First National Bank"
{...form.getInputProps('institution')}
/>
<Group grow>
<NumberInput
label="Principal Amount"
required
prefix="$"
decimalScale={2}
thousandSeparator=","
min={0}
{...form.getInputProps('principal')}
/>
<NumberInput
label="Interest Rate (%)"
decimalScale={4}
suffix="%"
min={0}
max={100}
{...form.getInputProps('interestRate')}
/>
</Group>
<Group grow>
<DateInput
label="Purchase Date"
clearable
{...form.getInputProps('purchaseDate')}
/>
<DateInput
label="Maturity Date"
clearable
{...form.getInputProps('maturityDate')}
/>
</Group>
<TextInput
label="Account # (last 4)"
placeholder="1234"
maxLength={4}
{...form.getInputProps('accountNumberLast4')}
/>
<Textarea
label="Notes"
autosize
minRows={2}
maxRows={4}
{...form.getInputProps('investmentNotes')}
/>
<Divider my="xs" />
{(() => {
const primaryAcct = accounts.find(
(a) => a.is_primary && a.fund_type === form.values.fundType && !a.is_system,
);
return (
<Switch
label={`Withdraw from primary account${primaryAcct ? ` (${primaryAcct.name})` : ''}`}
description="Creates a journal entry to deduct the principal from your primary account"
{...form.getInputProps('withdrawFromPrimary', { type: 'checkbox' })}
disabled={!primaryAcct}
/>
);
})()}
</>
)}
{/* Regular account fields */}
{!isInvestmentType(form.values.accountType) && (
<>
<NumberInput
label="Interest Rate (%)"
description="Annual interest rate for this account"
decimalScale={4}
suffix="%"
min={0}
max={100}
{...form.getInputProps('interestRate')}
/>
<Switch label="1099 Reportable" {...form.getInputProps('is1099Reportable', { type: 'checkbox' })} />
{!editing && (
<NumberInput
label="Initial Balance"
description="Opening balance (creates a journal entry)"
prefix="$"
decimalScale={2}
{...form.getInputProps('initialBalance')}
/>
)}
</>
)}
<Button type="submit" loading={createMutation.isPending}>
{editing ? 'Update' : isInvestmentType(form.values.accountType) ? 'Create Investment' : 'Create Account'}
</Button>
</Stack>
</form>
</Modal>
{/* Balance Adjustment Modal */}
<Modal opened={adjustOpened} onClose={closeAdjust} title="Adjust Balance" size="md" closeOnClickOutside={false}>
{adjustingAccount && (
<form onSubmit={adjustForm.onSubmit(handleAdjustSubmit)}>
<Stack>
<Text size="sm" c="dimmed">
Account: <strong>{adjustingAccount.account_number} - {adjustingAccount.name}</strong>
</Text>
<TextInput
label="Current Balance"
value={fmt(adjustCurrentBalance)}
readOnly
variant="filled"
/>
<NumberInput
label="Target Balance"
required
prefix="$"
decimalScale={2}
thousandSeparator=","
{...adjustForm.getInputProps('targetBalance')}
/>
<DateInput
label="As-of Date"
required
clearable
{...adjustForm.getInputProps('asOfDate')}
/>
<TextInput
label="Memo"
placeholder="Optional memo for this adjustment"
{...adjustForm.getInputProps('memo')}
/>
<Alert icon={<IconInfoCircle size={16} />} color={adjustmentAmount >= 0 ? 'blue' : 'orange'} variant="light">
<Text size="sm">
Adjustment amount: <strong>{fmt(adjustmentAmount)}</strong>
{adjustmentAmount > 0 && ' (increase)'}
{adjustmentAmount < 0 && ' (decrease)'}
{adjustmentAmount === 0 && ' (no change)'}
</Text>
</Alert>
<Button type="submit" loading={adjustBalanceMutation.isPending}>
Apply Adjustment
</Button>
</Stack>
</form>
)}
</Modal>
{/* Investment Edit Modal */}
<Modal opened={invEditOpened} onClose={closeInvEdit} title="Edit Investment Account" size="md" closeOnClickOutside={false}>
{editingInvestment && (
<form onSubmit={invForm.onSubmit((values) => updateInvestmentMutation.mutate(values))}>
<Stack>
<TextInput label="Investment Name" required {...invForm.getInputProps('name')} />
<TextInput label="Institution" placeholder="e.g. First National Bank" {...invForm.getInputProps('institution')} />
<Select
label="Investment Type"
required
data={[
{ value: 'cd', label: 'CD' },
{ value: 'money_market', label: 'Money Market' },
{ value: 'treasury', label: 'Treasury' },
{ value: 'savings', label: 'Savings' },
{ value: 'other', label: 'Brokerage / Other' },
]}
{...invForm.getInputProps('investmentType')}
/>
<Select
label="Fund Type"
required
data={[
{ value: 'operating', label: 'Operating' },
{ value: 'reserve', label: 'Reserve' },
]}
{...invForm.getInputProps('fundType')}
/>
<Divider label="Financial Details" labelPosition="center" />
<Group grow>
<NumberInput
label="Principal"
required
prefix="$"
decimalScale={2}
thousandSeparator=","
min={0}
{...invForm.getInputProps('principal')}
/>
<NumberInput
label="Current Value"
prefix="$"
decimalScale={2}
thousandSeparator=","
min={0}
{...invForm.getInputProps('currentValue')}
/>
</Group>
<NumberInput
label="Interest Rate (%)"
decimalScale={4}
suffix="%"
min={0}
max={100}
{...invForm.getInputProps('interestRate')}
/>
<Group grow>
<DateInput label="Purchase Date" clearable {...invForm.getInputProps('purchaseDate')} />
<DateInput label="Maturity Date" clearable {...invForm.getInputProps('maturityDate')} />
</Group>
<TextInput
label="Account # (last 4)"
placeholder="1234"
maxLength={4}
{...invForm.getInputProps('accountNumberLast4')}
/>
<Textarea label="Notes" autosize minRows={2} maxRows={4} {...invForm.getInputProps('notes')} />
<Button type="submit" loading={updateInvestmentMutation.isPending}>Update Investment</Button>
</Stack>
</form>
)}
</Modal>
</Stack>
);
}
// ── Account Table Component ──
function AccountTable({
accounts,
onEdit,
onArchive,
onSetPrimary,
onAdjustBalance,
isReadOnly = false,
isArchivedView = false,
}: {
accounts: Account[];
onEdit: (a: Account) => void;
onArchive: (a: Account) => void;
onSetPrimary: (id: string) => void;
onAdjustBalance: (a: Account) => void;
isReadOnly?: boolean;
isArchivedView?: boolean;
}) {
const hasRates = accounts.some((a) => a.interest_rate && parseFloat(a.interest_rate) > 0);
return (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th w={40}></Table.Th>
<Table.Th>Acct #</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Balance</Table.Th>
{hasRates && <Table.Th ta="right">Rate</Table.Th>}
{hasRates && <Table.Th ta="right">Est. Monthly</Table.Th>}
{hasRates && <Table.Th ta="right">Est. Annual</Table.Th>}
<Table.Th>1099</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{accounts.length === 0 && (
<Table.Tr>
<Table.Td colSpan={hasRates ? 11 : 8}>
<Text ta="center" c="dimmed" py="lg">
{isArchivedView ? 'No archived accounts' : 'No accounts found'}
</Text>
</Table.Td>
</Table.Tr>
)}
{accounts.map((a) => {
const rate = parseFloat(a.interest_rate || '0');
const balance = parseFloat(a.balance || '0');
const estAnnual = rate > 0 ? balance * (rate / 100) : 0;
const estMonthly = estAnnual / 12;
return (
<Table.Tr key={a.id} style={{ opacity: a.is_active ? 1 : 0.6 }}>
<Table.Td>
{a.is_primary && (
<Tooltip label="Primary account">
<IconStarFilled size={16} style={{ color: 'var(--mantine-color-yellow-5)' }} />
</Tooltip>
)}
</Table.Td>
<Table.Td fw={500}>{a.account_number}</Table.Td>
<Table.Td>
<div>
<Text size="sm">{a.name}</Text>
{a.description && (
<Text size="xs" c="dimmed">
{a.description}
</Text>
)}
</div>
</Table.Td>
<Table.Td>
<Badge color={accountTypeColors[a.account_type]} variant="light" size="sm">
{a.account_type}
</Badge>
</Table.Td>
<Table.Td>
<Badge color={a.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
{a.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">
{fmt(a.balance)}
</Table.Td>
{hasRates && (
<Table.Td ta="right">
{rate > 0 ? `${rate.toFixed(2)}%` : '-'}
</Table.Td>
)}
{hasRates && (
<Table.Td ta="right" ff="monospace">
{rate > 0 ? fmt(estMonthly) : '-'}
</Table.Td>
)}
{hasRates && (
<Table.Td ta="right" ff="monospace">
{rate > 0 ? fmt(estAnnual) : '-'}
</Table.Td>
)}
<Table.Td>
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
</Table.Td>
<Table.Td>
{!isReadOnly && (
<Group gap={4}>
{!a.is_system && (
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
<ActionIcon
variant="subtle"
color="yellow"
onClick={() => onSetPrimary(a.id)}
>
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar size={16} />}
</ActionIcon>
</Tooltip>
)}
{!a.is_system && (
<Tooltip label="Adjust Balance">
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
<IconAdjustments size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Edit account">
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
{!a.is_system && (
<Tooltip label={a.is_active ? 'Archive account' : 'Restore account'}>
<ActionIcon
variant="subtle"
color={a.is_active ? 'gray' : 'green'}
onClick={() => onArchive(a)}
>
{a.is_active ? <IconArchive size={16} /> : <IconArchiveOff size={16} />}
</ActionIcon>
</Tooltip>
)}
</Group>
)}
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
);
}
// ── Investment Table for Operating/Reserve tabs ──
function InvestmentMiniTable({
investments,
onEdit,
}: {
investments: Investment[];
onEdit: (inv: Investment) => void;
}) {
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
const totalValue = investments.reduce(
(s, i) => s + parseFloat(i.current_value || i.principal || '0'),
0,
);
const avgRate =
investments.length > 0
? investments.reduce((s, i) => s + parseFloat(i.interest_rate || '0'), 0) / investments.length
: 0;
return (
<Stack gap="sm">
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Card withBorder p="xs">
<Text size="xs" c="dimmed">Total Principal</Text>
<Text fw={700} size="sm">{fmt(totalPrincipal)}</Text>
</Card>
<Card withBorder p="xs">
<Text size="xs" c="dimmed">Total Current Value</Text>
<Text fw={700} size="sm" c="teal">{fmt(totalValue)}</Text>
</Card>
<Card withBorder p="xs">
<Text size="xs" c="dimmed">Avg Interest Rate</Text>
<Text fw={700} size="sm">{avgRate.toFixed(2)}%</Text>
</Card>
</SimpleGrid>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Institution</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Principal</Table.Th>
<Table.Th ta="right">Current Value</Table.Th>
<Table.Th ta="right">Rate</Table.Th>
<Table.Th ta="right">Interest Earned</Table.Th>
<Table.Th ta="right">Maturity Value</Table.Th>
<Table.Th>Maturity Date</Table.Th>
<Table.Th ta="right">Days Remaining</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{investments.length === 0 && (
<Table.Tr>
<Table.Td colSpan={12}>
<Text ta="center" c="dimmed" py="lg">No investment accounts</Text>
</Table.Td>
</Table.Tr>
)}
{investments.map((inv) => (
<Table.Tr key={inv.id}>
<Table.Td fw={500}>{inv.name}</Table.Td>
<Table.Td>{inv.institution || '-'}</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="teal">
{inv.investment_type}
</Badge>
</Table.Td>
<Table.Td>
<Badge color={inv.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
{inv.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(inv.principal)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(inv.current_value || inv.principal)}</Table.Td>
<Table.Td ta="right">{parseFloat(inv.interest_rate || '0').toFixed(2)}%</Table.Td>
<Table.Td ta="right" ff="monospace">
{inv.interest_earned !== null ? fmt(inv.interest_earned) : '-'}
</Table.Td>
<Table.Td ta="right" ff="monospace">
{inv.maturity_value !== null ? fmt(inv.maturity_value) : '-'}
</Table.Td>
<Table.Td>
{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}
</Table.Td>
<Table.Td ta="right">
{inv.days_remaining !== null ? (
<Badge
size="sm"
color={inv.days_remaining <= 30 ? 'red' : inv.days_remaining <= 90 ? 'yellow' : 'gray'}
variant="light"
>
{inv.days_remaining} days
</Badge>
) : (
'-'
)}
</Table.Td>
<Table.Td>
<Tooltip label="Edit investment">
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
);
}