- 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>
1199 lines
44 KiB
TypeScript
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>
|
|
);
|
|
}
|