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:
2026-02-19 14:32:35 -05:00
parent 17fdacc0f2
commit 301f8a7bde
20 changed files with 1760 additions and 145 deletions

View File

@@ -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>
);
}