Remove Investments tab, enhance fund tabs with full investment details and edit
- Removed standalone Investments tab — investment accounts now show only under their respective Operating/Reserve tabs with full detail columns - Investment sub-tables now include: Principal, Current Value, Rate, Interest Earned, Maturity Value, Maturity Date, Days Remaining, plus summary cards (Total Principal, Total Current Value, Avg Rate) - Added investment edit capability via modal (name, institution, type, fund, principal, current value, rate, purchase/maturity dates, notes) - Fixed primary account star icon — now shows for all non-system accounts (was previously restricted to asset type only), allowing users to set default Operating and Reserve accounts regardless of account type - Fixed Adjust Balance icon to also show for all non-system accounts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -117,7 +117,9 @@ const fmt = (v: string | number) =>
|
||||
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);
|
||||
@@ -277,7 +279,73 @@ export function AccountsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// ── 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({
|
||||
@@ -436,7 +504,6 @@ export function AccountsPage() {
|
||||
<Tabs.Tab value="reserve">
|
||||
Reserve ({activeAccounts.filter(a => a.fund_type === 'reserve').length + reserveInvestments.length})
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="investments">Investments ({investments.filter(i => i.is_active).length})</Tabs.Tab>
|
||||
{showArchived && archivedAccounts.length > 0 && (
|
||||
<Tabs.Tab value="archived" color="gray">
|
||||
Archived ({archivedAccounts.length})
|
||||
@@ -465,7 +532,7 @@ export function AccountsPage() {
|
||||
{operatingInvestments.length > 0 && (
|
||||
<>
|
||||
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
|
||||
<InvestmentMiniTable investments={operatingInvestments} />
|
||||
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -482,14 +549,11 @@ export function AccountsPage() {
|
||||
{reserveInvestments.length > 0 && (
|
||||
<>
|
||||
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
|
||||
<InvestmentMiniTable investments={reserveInvestments} />
|
||||
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="investments" pt="sm">
|
||||
<InvestmentsTab investments={investments} isLoading={investmentsLoading} />
|
||||
</Tabs.Panel>
|
||||
{showArchived && (
|
||||
<Tabs.Panel value="archived" pt="sm">
|
||||
<AccountTable
|
||||
@@ -693,6 +757,79 @@ export function AccountsPage() {
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Investment Edit Modal */}
|
||||
<Modal opened={invEditOpened} onClose={closeInvEdit} title="Edit Investment Account" size="md">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -776,7 +913,7 @@ function AccountTable({
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
{a.account_type === 'asset' && (
|
||||
{!a.is_system && (
|
||||
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
@@ -787,7 +924,7 @@ function AccountTable({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{a.account_type === 'asset' && (
|
||||
{!a.is_system && (
|
||||
<Tooltip label="Adjust Balance">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
|
||||
<IconAdjustments size={16} />
|
||||
@@ -819,14 +956,14 @@ function AccountTable({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Investments Tab Component ──
|
||||
// ── Investment Table for Operating/Reserve tabs ──
|
||||
|
||||
function InvestmentsTab({
|
||||
function InvestmentMiniTable({
|
||||
investments,
|
||||
isLoading,
|
||||
onEdit,
|
||||
}: {
|
||||
investments: Investment[];
|
||||
isLoading: boolean;
|
||||
onEdit: (inv: Investment) => void;
|
||||
}) {
|
||||
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
|
||||
const totalValue = investments.reduce(
|
||||
@@ -838,40 +975,20 @@ function InvestmentsTab({
|
||||
? investments.reduce((s, i) => s + parseFloat(i.interest_rate || '0'), 0) / investments.length
|
||||
: 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack gap="sm">
|
||||
<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 withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">Total Principal</Text>
|
||||
<Text fw={700} size="sm">{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 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="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
Avg Interest Rate
|
||||
</Text>
|
||||
<Text fw={700} size="xl">
|
||||
{avgRate.toFixed(2)}%
|
||||
</Text>
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">Avg Interest Rate</Text>
|
||||
<Text fw={700} size="sm">{avgRate.toFixed(2)}%</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -881,51 +998,45 @@ function InvestmentsTab({
|
||||
<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 ta="right">Days Remaining</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={10}>
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
No investments yet
|
||||
</Text>
|
||||
<Table.Td colSpan={11}>
|
||||
<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>{inv.institution || '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light">
|
||||
<Badge size="sm" variant="light" color="teal">
|
||||
{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">{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
|
||||
@@ -940,79 +1051,11 @@ function InvestmentsTab({
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Compact Investment Table for Operating/Reserve tabs ──
|
||||
|
||||
function InvestmentMiniTable({ investments }: { investments: Investment[] }) {
|
||||
const totalValue = investments.reduce(
|
||||
(s, i) => s + parseFloat(i.current_value || i.principal || '0'),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{investments.length} investment{investments.length !== 1 ? 's' : ''} — Total: {fmt(totalValue)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Institution</Table.Th>
|
||||
<Table.Th ta="right">Principal / Value</Table.Th>
|
||||
<Table.Th ta="right">Rate</Table.Th>
|
||||
<Table.Th ta="right">Interest Earned</Table.Th>
|
||||
<Table.Th>Maturity</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{investments.map((inv) => (
|
||||
<Table.Tr key={inv.id}>
|
||||
<Table.Td fw={500}>{inv.name}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color="teal">
|
||||
{inv.investment_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{inv.institution || '-'}</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>
|
||||
{inv.maturity_date ? (
|
||||
<Group gap={4}>
|
||||
<Text size="sm">{new Date(inv.maturity_date).toLocaleDateString()}</Text>
|
||||
{inv.days_remaining !== null && (
|
||||
<Badge
|
||||
size="xs"
|
||||
color={inv.days_remaining <= 30 ? 'red' : inv.days_remaining <= 90 ? 'yellow' : 'gray'}
|
||||
variant="light"
|
||||
>
|
||||
{inv.days_remaining}d
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
<Tooltip label="Edit investment">
|
||||
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user