Quality-of-life enhancements: CSV import/export, opening balances, interest rates, mobile UX

- CSV import/export for Units, Projects, and Vendors with match-on-name/number upsert
- Cash Flow report toggle for Cash Only vs Cash + Investments
- Per-account and bulk opening balance setting with as-of date
- Interest rate field on normal accounts with estimated monthly/annual interest display
- Mobile sidebar auto-close on navigation
- Shared CSV parsing/export utility extracted to frontend/src/utils/csv.ts

DB migration needed for existing tenants:
  ALTER TABLE accounts ADD COLUMN IF NOT EXISTS interest_rate DECIMAL(6,4);

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 09:13:51 -05:00
parent 32af961173
commit 45a267d787
21 changed files with 1015 additions and 128 deletions

View File

@@ -37,6 +37,7 @@ import {
IconStarFilled,
IconAdjustments,
IconInfoCircle,
IconCurrencyDollar,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
@@ -71,6 +72,7 @@ interface Account {
is_system: boolean;
is_primary: boolean;
balance: string;
interest_rate: string | null;
}
interface Investment {
@@ -281,6 +283,63 @@ export function AccountsPage() {
},
});
// ── Opening balance state + mutations ──
const [obOpened, { open: openOB, close: closeOB }] = useDisclosure(false);
const [bulkOBOpened, { open: openBulkOB, close: closeBulkOB }] = useDisclosure(false);
const [obAccount, setOBAccount] = useState<Account | null>(null);
const [bulkOBDate, setBulkOBDate] = useState<Date | null>(new Date());
const [bulkOBEntries, setBulkOBEntries] = useState<Record<string, number>>({});
const obForm = 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'),
},
});
const openingBalanceMutation = useMutation({
mutationFn: (values: { accountId: string; targetBalance: number; asOfDate: string; memo: string }) =>
api.post(`/accounts/${values.accountId}/opening-balance`, {
targetBalance: values.targetBalance,
asOfDate: values.asOfDate,
memo: values.memo,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
queryClient.invalidateQueries({ queryKey: ['trial-balance'] });
notifications.show({ message: 'Opening balance set successfully', color: 'green' });
closeOB();
setOBAccount(null);
obForm.reset();
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Error setting opening balance', color: 'red' });
},
});
const bulkOBMutation = useMutation({
mutationFn: (dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] }) =>
api.post('/accounts/bulk-opening-balances', dto),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
queryClient.invalidateQueries({ queryKey: ['trial-balance'] });
const d = res.data;
let msg = `Opening balances: ${d.processed} updated, ${d.skipped} unchanged`;
if (d.errors?.length) msg += `. ${d.errors.length} error(s)`;
notifications.show({ message: msg, color: d.errors?.length ? 'yellow' : 'green', autoClose: 10000 });
closeBulkOB();
setBulkOBEntries({});
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Error setting opening balances', color: 'red' });
},
});
// ── Investment edit form ──
const invForm = useForm({
initialValues: {
@@ -358,6 +417,7 @@ export function AccountsPage() {
fundType: account.fund_type,
is1099Reportable: account.is_1099_reportable,
initialBalance: 0,
interestRate: parseFloat(account.interest_rate || '0'),
});
open();
};
@@ -389,6 +449,51 @@ export function AccountsPage() {
});
};
// ── Opening balance handlers ──
const handleSetOpeningBalance = (account: Account) => {
setOBAccount(account);
const tbEntry = trialBalance.find((tb) => tb.id === account.id);
obForm.setValues({
targetBalance: parseFloat(tbEntry?.balance || account.balance || '0'),
asOfDate: new Date(),
memo: '',
});
openOB();
};
const handleOBSubmit = (values: { targetBalance: number; asOfDate: Date | null; memo: string }) => {
if (!obAccount || !values.asOfDate) return;
openingBalanceMutation.mutate({
accountId: obAccount.id,
targetBalance: values.targetBalance,
asOfDate: values.asOfDate.toISOString().split('T')[0],
memo: values.memo,
});
};
const handleOpenBulkOB = () => {
const entries: Record<string, number> = {};
for (const a of accounts.filter((acc) => ['asset', 'liability'].includes(acc.account_type) && acc.is_active && !acc.is_system)) {
const tb = trialBalance.find((t) => t.id === a.id);
entries[a.id] = parseFloat(tb?.balance || a.balance || '0');
}
setBulkOBEntries(entries);
setBulkOBDate(new Date());
openBulkOB();
};
const handleBulkOBSubmit = () => {
if (!bulkOBDate) return;
const entries = Object.entries(bulkOBEntries).map(([accountId, targetBalance]) => ({
accountId,
targetBalance,
}));
bulkOBMutation.mutate({
asOfDate: bulkOBDate.toISOString().split('T')[0],
entries,
});
};
// ── Filtering ──
// Only show asset and liability accounts — these represent real cash positions.
// Income, expense, and equity accounts are internal bookkeeping managed via
@@ -434,6 +539,21 @@ export function AccountsPage() {
// Net position = assets + investments - liabilities
const netPosition = (totalsByType['asset'] || 0) + investmentTotal - (totalsByType['liability'] || 0);
// ── Estimated monthly interest across all accounts with rates ──
const estMonthlyInterest = 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);
// ── Opening balance modal: current balance ──
const obCurrentBalance = obAccount
? parseFloat(trialBalance.find((tb) => tb.id === obAccount.id)?.balance || obAccount.balance || '0')
: 0;
const obAdjustmentAmount = (obForm.values.targetBalance || 0) - obCurrentBalance;
// ── Adjust modal: current balance from trial balance ──
const adjustCurrentBalance = adjustingAccount
? parseFloat(
@@ -463,6 +583,9 @@ export function AccountsPage() {
onChange={(e) => setShowArchived(e.currentTarget.checked)}
size="sm"
/>
<Button variant="light" leftSection={<IconCurrencyDollar size={16} />} onClick={handleOpenBulkOB}>
Set Opening Balances
</Button>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Account
</Button>
@@ -490,6 +613,12 @@ export function AccountsPage() {
<Text size="xs" c="dimmed">Net Position</Text>
<Text fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</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>
</Card>
)}
</SimpleGrid>
<Group>
@@ -545,6 +674,7 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance}
onSetOpeningBalance={handleSetOpeningBalance}
/>
{investments.filter(i => i.is_active).length > 0 && (
<>
@@ -562,6 +692,7 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance}
onSetOpeningBalance={handleSetOpeningBalance}
/>
{operatingInvestments.length > 0 && (
<>
@@ -579,6 +710,7 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance}
onSetOpeningBalance={handleSetOpeningBalance}
/>
{reserveInvestments.length > 0 && (
<>
@@ -596,6 +728,7 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance}
onSetOpeningBalance={handleSetOpeningBalance}
isArchivedView
/>
</Tabs.Panel>
@@ -729,6 +862,15 @@ export function AccountsPage() {
{/* 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
@@ -804,6 +946,126 @@ export function AccountsPage() {
)}
</Modal>
{/* Opening Balance Modal */}
<Modal opened={obOpened} onClose={closeOB} title="Set Opening Balance" size="md" closeOnClickOutside={false}>
{obAccount && (
<form onSubmit={obForm.onSubmit(handleOBSubmit)}>
<Stack>
<Text size="sm" c="dimmed">
Account: <strong>{obAccount.account_number} - {obAccount.name}</strong>
</Text>
<TextInput
label="Current Balance"
value={fmt(obCurrentBalance)}
readOnly
variant="filled"
/>
<NumberInput
label="Target Opening Balance"
description="The balance this account should have as of the selected date"
required
prefix="$"
decimalScale={2}
thousandSeparator=","
allowNegative
{...obForm.getInputProps('targetBalance')}
/>
<DateInput
label="As-of Date"
description="The date the balance should be effective"
required
clearable
{...obForm.getInputProps('asOfDate')}
/>
<TextInput
label="Memo"
placeholder="Optional memo"
{...obForm.getInputProps('memo')}
/>
<Alert icon={<IconInfoCircle size={16} />} color={obAdjustmentAmount >= 0 ? 'blue' : 'orange'} variant="light">
<Text size="sm">
Adjustment: <strong>{fmt(obAdjustmentAmount)}</strong>
{obAdjustmentAmount > 0 && ' (increase)'}
{obAdjustmentAmount < 0 && ' (decrease)'}
{obAdjustmentAmount === 0 && ' (no change)'}
</Text>
</Alert>
<Button type="submit" loading={openingBalanceMutation.isPending}>
Set Opening Balance
</Button>
</Stack>
</form>
)}
</Modal>
{/* Bulk Opening Balance Modal */}
<Modal opened={bulkOBOpened} onClose={closeBulkOB} title="Set Opening Balances" size="xl" closeOnClickOutside={false}>
<Stack>
<DateInput
label="As-of Date"
description="All opening balances will be effective as of this date"
required
value={bulkOBDate}
onChange={setBulkOBDate}
/>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Acct #</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Current Balance</Table.Th>
<Table.Th ta="right">Target Balance</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{accounts
.filter((a) => ['asset', 'liability'].includes(a.account_type) && a.is_active && !a.is_system)
.map((a) => {
const currentBal = parseFloat(trialBalance.find((t) => t.id === a.id)?.balance || a.balance || '0');
return (
<Table.Tr key={a.id}>
<Table.Td>{a.account_number}</Table.Td>
<Table.Td>{a.name}</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(currentBal)}</Table.Td>
<Table.Td>
<NumberInput
size="xs"
prefix="$"
decimalScale={2}
thousandSeparator=","
allowNegative
value={bulkOBEntries[a.id] ?? currentBal}
onChange={(v) => setBulkOBEntries((prev) => ({ ...prev, [a.id]: Number(v) || 0 }))}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
<Button onClick={handleBulkOBSubmit} loading={bulkOBMutation.isPending}>
Apply Opening Balances
</Button>
</Stack>
</Modal>
{/* Investment Edit Modal */}
<Modal opened={invEditOpened} onClose={closeInvEdit} title="Edit Investment Account" size="md" closeOnClickOutside={false}>
{editingInvestment && (
@@ -888,6 +1150,7 @@ function AccountTable({
onArchive,
onSetPrimary,
onAdjustBalance,
onSetOpeningBalance,
isArchivedView = false,
}: {
accounts: Account[];
@@ -895,8 +1158,11 @@ function AccountTable({
onArchive: (a: Account) => void;
onSetPrimary: (id: string) => void;
onAdjustBalance: (a: Account) => void;
onSetOpeningBalance: (a: Account) => void;
isArchivedView?: boolean;
}) {
const hasRates = accounts.some((a) => a.interest_rate && parseFloat(a.interest_rate) > 0);
return (
<Table striped highlightOnHover>
<Table.Thead>
@@ -907,6 +1173,9 @@ function AccountTable({
<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>
@@ -914,89 +1183,117 @@ function AccountTable({
<Table.Tbody>
{accounts.length === 0 && (
<Table.Tr>
<Table.Td colSpan={8}>
<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) => (
<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>
{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>
)}
</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>
<Table.Td>
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
</Table.Td>
<Table.Td>
<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} />}
{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>
<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="Set Opening Balance">
<ActionIcon variant="subtle" color="teal" onClick={() => onSetOpeningBalance(a)}>
<IconCurrencyDollar 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="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>
))}
{!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>
);