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 = { 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(null); const [editingInvestment, setEditingInvestment] = useState(null); const [adjustingAccount, setAdjustingAccount] = useState(null); const [search, setSearch] = useState(''); const [filterType, setFilterType] = useState(null); const [showArchived, setShowArchived] = useState(false); const queryClient = useQueryClient(); const isReadOnly = useIsReadOnly(); // ── Accounts query ── const { data: accounts = [], isLoading } = useQuery({ 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({ queryKey: ['investments'], queryFn: async () => { const { data } = await api.get('/investment-accounts'); return data; }, }); // ── Trial balance query (for balance adjustment) ── const { data: trialBalance = [] } = useQuery({ 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 = { 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, ); // 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 (
); } return ( Accounts setShowArchived(e.currentTarget.checked)} size="sm" /> {!isReadOnly && ( )} Operating Fund {fmt(operatingCash)} {opInvTotal > 0 && Investments: {fmt(opInvTotal)}} Reserve Fund {fmt(reserveCash)} {resInvTotal > 0 && Investments: {fmt(resInvTotal)}} Total All Funds = 0 ? 'green' : 'red'}>{fmt(netPosition)} Op: {fmt(operatingCash + opInvTotal)} | Res: {fmt(reserveCash + resInvTotal)} {estMonthlyInterest > 0 && ( Est. Monthly Interest {fmt(estMonthlyInterest)} Op: {fmt(opMonthlyInterest)} | Res: {fmt(resMonthlyInterest)} )} } value={search} onChange={(e) => setSearch(e.currentTarget.value)} style={{ flex: 1 }} /> )} {!isInvestmentType(form.values.accountType) && ( <> {editing && ( {/* Investment-specific fields */} {!editing && isInvestmentType(form.values.accountType) && ( <>