Phase 3: Optimize & clean up — unified projects, account enhancements, new tenant fix
- Unify reserve_components + capital_projects into single projects model with full CRUD backend and new Projects page frontend - Rewrite Capital Planning to read from unified projects/planning endpoint; add empty state directing users to Projects page when no planning items exist - Add default designation to assessment groups with auto-set on first creation; units now require an assessment group (pre-populated with default) - Add primary account designation (one per fund type) and balance adjustment via journal entries against equity offset accounts (3000/3100) - Add computed investment fields (interest earned, maturity value, days remaining) with PostgreSQL date arithmetic fix for DATE - DATE integer result - Restructure sidebar: investments in Accounts tab, Year-End under Reports, Planning section with Projects and Capital Planning - Fix new tenant creation seeding unwanted default chart of accounts — new tenants now start with a blank slate Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,11 +19,23 @@ import {
|
||||
Center,
|
||||
Tooltip,
|
||||
SimpleGrid,
|
||||
Alert,
|
||||
} 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 } from '@tabler/icons-react';
|
||||
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';
|
||||
|
||||
@@ -37,6 +49,36 @@ interface Account {
|
||||
is_1099_reportable: boolean;
|
||||
is_active: boolean;
|
||||
is_system: boolean;
|
||||
is_primary: boolean;
|
||||
balance: string;
|
||||
}
|
||||
|
||||
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: number;
|
||||
name: string;
|
||||
account_type: string;
|
||||
fund_type: string;
|
||||
total_debits: string;
|
||||
total_credits: string;
|
||||
balance: string;
|
||||
}
|
||||
|
||||
@@ -48,15 +90,21 @@ const accountTypeColors: Record<string, string> = {
|
||||
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 [editing, setEditing] = useState<Account | null>(null);
|
||||
const [adjustingAccount, setAdjustingAccount] = useState<Account | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterType, setFilterType] = useState<string | null>(null);
|
||||
const [filterFund, setFilterFund] = useState<string | null>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// ── Accounts query ──
|
||||
const { data: accounts = [], isLoading } = useQuery<Account[]>({
|
||||
queryKey: ['accounts', showArchived],
|
||||
queryFn: async () => {
|
||||
@@ -66,6 +114,25 @@ export function AccountsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// ── 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: 0,
|
||||
@@ -82,6 +149,20 @@ export function AccountsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// ── 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) {
|
||||
@@ -113,6 +194,38 @@ export function AccountsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
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' });
|
||||
},
|
||||
});
|
||||
|
||||
// ── Handlers ──
|
||||
const handleEdit = (account: Account) => {
|
||||
setEditing(account);
|
||||
form.setValues({
|
||||
@@ -133,6 +246,28 @@ export function AccountsPage() {
|
||||
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 ──
|
||||
const filtered = accounts.filter((a) => {
|
||||
if (search && !a.name.toLowerCase().includes(search.toLowerCase()) && !String(a.account_number).includes(search)) return false;
|
||||
if (filterType && a.account_type !== filterType) return false;
|
||||
@@ -140,26 +275,42 @@ export function AccountsPage() {
|
||||
return true;
|
||||
});
|
||||
|
||||
const activeAccounts = filtered.filter(a => a.is_active);
|
||||
const archivedAccounts = filtered.filter(a => !a.is_active);
|
||||
const activeAccounts = filtered.filter((a) => a.is_active);
|
||||
const archivedAccounts = filtered.filter((a) => !a.is_active);
|
||||
|
||||
const totalsByType = accounts.reduce((acc, a) => {
|
||||
if (a.is_active) {
|
||||
acc[a.account_type] = (acc[a.account_type] || 0) + parseFloat(a.balance || '0');
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
// ── Summary cards ──
|
||||
const totalsByType = accounts.reduce(
|
||||
(acc, a) => {
|
||||
if (a.is_active) {
|
||||
acc[a.account_type] = (acc[a.account_type] || 0) + parseFloat(a.balance || '0');
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
// ── 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 (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Chart of Accounts</Title>
|
||||
<Title order={2}>Accounts</Title>
|
||||
<Group>
|
||||
<Switch
|
||||
label="Show Archived"
|
||||
@@ -176,8 +327,12 @@ export function AccountsPage() {
|
||||
<SimpleGrid cols={{ base: 2, sm: 5 }}>
|
||||
{Object.entries(totalsByType).map(([type, total]) => (
|
||||
<Card withBorder p="xs" key={type}>
|
||||
<Text size="xs" c="dimmed" tt="capitalize">{type}</Text>
|
||||
<Text fw={700} size="sm" c={accountTypeColors[type]}>{fmt(total)}</Text>
|
||||
<Text size="xs" c="dimmed" tt="capitalize">
|
||||
{type}
|
||||
</Text>
|
||||
<Text fw={700} size="sm" c={accountTypeColors[type]}>
|
||||
{fmt(total)}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
@@ -213,27 +368,59 @@ export function AccountsPage() {
|
||||
<Tabs.Tab value="all">All ({activeAccounts.length})</Tabs.Tab>
|
||||
<Tabs.Tab value="operating">Operating</Tabs.Tab>
|
||||
<Tabs.Tab value="reserve">Reserve</Tabs.Tab>
|
||||
<Tabs.Tab value="investments">Investments</Tabs.Tab>
|
||||
{showArchived && archivedAccounts.length > 0 && (
|
||||
<Tabs.Tab value="archived" color="gray">Archived ({archivedAccounts.length})</Tabs.Tab>
|
||||
<Tabs.Tab value="archived" color="gray">
|
||||
Archived ({archivedAccounts.length})
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="all" pt="sm">
|
||||
<AccountTable accounts={activeAccounts} onEdit={handleEdit} onArchive={archiveMutation.mutate} />
|
||||
<AccountTable
|
||||
accounts={activeAccounts}
|
||||
onEdit={handleEdit}
|
||||
onArchive={archiveMutation.mutate}
|
||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||
onAdjustBalance={handleAdjustBalance}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="operating" pt="sm">
|
||||
<AccountTable accounts={activeAccounts.filter(a => a.fund_type === 'operating')} onEdit={handleEdit} onArchive={archiveMutation.mutate} />
|
||||
<AccountTable
|
||||
accounts={activeAccounts.filter((a) => a.fund_type === 'operating')}
|
||||
onEdit={handleEdit}
|
||||
onArchive={archiveMutation.mutate}
|
||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||
onAdjustBalance={handleAdjustBalance}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="reserve" pt="sm">
|
||||
<AccountTable accounts={activeAccounts.filter(a => a.fund_type === 'reserve')} onEdit={handleEdit} onArchive={archiveMutation.mutate} />
|
||||
<AccountTable
|
||||
accounts={activeAccounts.filter((a) => a.fund_type === 'reserve')}
|
||||
onEdit={handleEdit}
|
||||
onArchive={archiveMutation.mutate}
|
||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||
onAdjustBalance={handleAdjustBalance}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="investments" pt="sm">
|
||||
<InvestmentsTab investments={investments} isLoading={investmentsLoading} />
|
||||
</Tabs.Panel>
|
||||
{showArchived && (
|
||||
<Tabs.Panel value="archived" pt="sm">
|
||||
<AccountTable accounts={archivedAccounts} onEdit={handleEdit} onArchive={archiveMutation.mutate} isArchivedView />
|
||||
<AccountTable
|
||||
accounts={archivedAccounts}
|
||||
onEdit={handleEdit}
|
||||
onArchive={archiveMutation.mutate}
|
||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||
onAdjustBalance={handleAdjustBalance}
|
||||
isArchivedView
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{/* Create / Edit Account Modal */}
|
||||
<Modal opened={opened} onClose={close} title={editing ? 'Edit Account' : 'New Account'} size="md">
|
||||
<form onSubmit={form.onSubmit((values) => createMutation.mutate(values))}>
|
||||
<Stack>
|
||||
@@ -279,30 +466,87 @@ export function AccountsPage() {
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Balance Adjustment Modal */}
|
||||
<Modal opened={adjustOpened} onClose={closeAdjust} title="Adjust Balance" size="md">
|
||||
{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>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Account Table Component ──
|
||||
|
||||
function AccountTable({
|
||||
accounts,
|
||||
onEdit,
|
||||
onArchive,
|
||||
onSetPrimary,
|
||||
onAdjustBalance,
|
||||
isArchivedView = false,
|
||||
}: {
|
||||
accounts: Account[];
|
||||
onEdit: (a: Account) => void;
|
||||
onArchive: (a: Account) => void;
|
||||
onSetPrimary: (id: string) => void;
|
||||
onAdjustBalance: (a: Account) => void;
|
||||
isArchivedView?: boolean;
|
||||
}) {
|
||||
const fmt = (v: string) => {
|
||||
const n = parseFloat(v || '0');
|
||||
return n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
};
|
||||
|
||||
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>
|
||||
@@ -315,7 +559,7 @@ function AccountTable({
|
||||
<Table.Tbody>
|
||||
{accounts.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Table.Td colSpan={8}>
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
{isArchivedView ? 'No archived accounts' : 'No accounts found'}
|
||||
</Text>
|
||||
@@ -324,11 +568,22 @@ function AccountTable({
|
||||
)}
|
||||
{accounts.map((a) => (
|
||||
<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>}
|
||||
{a.description && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{a.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -341,10 +596,32 @@ function AccountTable({
|
||||
{a.fund_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(a.balance)}</Table.Td>
|
||||
<Table.Td>{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">
|
||||
{fmt(a.balance)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
{a.account_type === 'asset' && (
|
||||
<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.account_type === 'asset' && (
|
||||
<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} />
|
||||
@@ -369,3 +646,134 @@ function AccountTable({
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Investments Tab Component ──
|
||||
|
||||
function InvestmentsTab({
|
||||
investments,
|
||||
isLoading,
|
||||
}: {
|
||||
investments: Investment[];
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
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;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
Total Principal
|
||||
</Text>
|
||||
<Text fw={700} size="xl">
|
||||
{fmt(totalPrincipal)}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
Total Current Value
|
||||
</Text>
|
||||
<Text fw={700} size="xl" c="green">
|
||||
{fmt(totalValue)}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
Avg Interest Rate
|
||||
</Text>
|
||||
<Text fw={700} size="xl">
|
||||
{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">Rate</Table.Th>
|
||||
<Table.Th ta="right">Interest Earned</Table.Th>
|
||||
<Table.Th ta="right">Maturity Value</Table.Th>
|
||||
<Table.Th ta="right">Days Remaining</Table.Th>
|
||||
<Table.Th>Maturity Date</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{investments.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={10}>
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
No investments yet
|
||||
</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">
|
||||
{inv.investment_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={inv.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light">
|
||||
{inv.fund_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">
|
||||
{fmt(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 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>
|
||||
{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user