Flexible budget import with auto-account creation and text-based account numbers
Change account_number from INTEGER to VARCHAR(50) to support segmented codes like 30-3001-0000 used by real HOA accounting systems. Budget CSV import now: - Auto-creates income/expense accounts from CSV when they don't exist - Infers account_type and fund_type from account number prefix conventions - Parses currency-formatted values ($48,065.21, $(13,000.00), $-, etc.) - Reports created accounts back to the user Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,16 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import {
|
||||
Title, Table, Group, Button, Stack, Text, NumberInput,
|
||||
Select, Loader, Center, Badge, Card,
|
||||
Select, Loader, Center, Badge, Card, Alert,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconDeviceFloppy, IconUpload, IconDownload } from '@tabler/icons-react';
|
||||
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface BudgetLine {
|
||||
account_id: string;
|
||||
account_number: number;
|
||||
account_number: string;
|
||||
account_name: string;
|
||||
account_type: string;
|
||||
fund_type: string;
|
||||
@@ -23,6 +23,23 @@ interface BudgetLine {
|
||||
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
|
||||
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
/**
|
||||
* Parse a currency-formatted value: "$48,065.21", "$(13,000.00)", " $- "
|
||||
*/
|
||||
function parseCurrencyValue(val: string): number {
|
||||
if (!val) return 0;
|
||||
let s = val.trim();
|
||||
if (!s || s === '-' || s === '$-' || s === '$ -') return 0;
|
||||
|
||||
const isNegative = s.includes('(') && s.includes(')');
|
||||
s = s.replace(/[$,\s()]/g, '');
|
||||
if (!s || s === '-') return 0;
|
||||
|
||||
const num = parseFloat(s);
|
||||
if (isNaN(num)) return 0;
|
||||
return isNegative ? -num : num;
|
||||
}
|
||||
|
||||
function parseCSV(text: string): Record<string, string>[] {
|
||||
const lines = text.trim().split('\n');
|
||||
if (lines.length < 2) return [];
|
||||
@@ -102,30 +119,37 @@ export function BudgetsPage() {
|
||||
mutationFn: async (lines: Record<string, string>[]) => {
|
||||
const parsed = lines.map((row) => ({
|
||||
account_number: row.account_number || row.accountnumber || '',
|
||||
jan: parseFloat(row.jan) || 0,
|
||||
feb: parseFloat(row.feb) || 0,
|
||||
mar: parseFloat(row.mar) || 0,
|
||||
apr: parseFloat(row.apr) || 0,
|
||||
may: parseFloat(row.may) || 0,
|
||||
jun: parseFloat(row.jun) || 0,
|
||||
jul: parseFloat(row.jul) || 0,
|
||||
aug: parseFloat(row.aug) || 0,
|
||||
sep: parseFloat(row.sep) || 0,
|
||||
oct: parseFloat(row.oct) || 0,
|
||||
nov: parseFloat(row.nov) || 0,
|
||||
dec_amt: parseFloat(row.dec_amt || row.dec) || 0,
|
||||
account_name: row.account_name || row.accountname || '',
|
||||
jan: parseCurrencyValue(row.jan),
|
||||
feb: parseCurrencyValue(row.feb),
|
||||
mar: parseCurrencyValue(row.mar),
|
||||
apr: parseCurrencyValue(row.apr),
|
||||
may: parseCurrencyValue(row.may),
|
||||
jun: parseCurrencyValue(row.jun),
|
||||
jul: parseCurrencyValue(row.jul),
|
||||
aug: parseCurrencyValue(row.aug),
|
||||
sep: parseCurrencyValue(row.sep),
|
||||
oct: parseCurrencyValue(row.oct),
|
||||
nov: parseCurrencyValue(row.nov),
|
||||
dec_amt: parseCurrencyValue(row.dec_amt || row.dec || ''),
|
||||
}));
|
||||
const { data } = await api.post(`/budgets/${year}/import`, parsed);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
||||
const msg = `Imported ${data.imported} budget line(s)` +
|
||||
(data.errors?.length ? `. ${data.errors.length} error(s): ${data.errors.join('; ')}` : '');
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
let msg = `Imported ${data.imported} budget line(s)`;
|
||||
if (data.created?.length) {
|
||||
msg += `. Created ${data.created.length} new account(s)`;
|
||||
}
|
||||
if (data.errors?.length) {
|
||||
msg += `. ${data.errors.length} error(s): ${data.errors.join('; ')}`;
|
||||
}
|
||||
notifications.show({
|
||||
message: msg,
|
||||
color: data.errors?.length ? 'yellow' : 'green',
|
||||
autoClose: 8000,
|
||||
autoClose: 10000,
|
||||
});
|
||||
},
|
||||
onError: (err: any) => {
|
||||
@@ -235,6 +259,14 @@ export function BudgetsPage() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{budgetData.length === 0 && !isLoading && (
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
No budget data for {year}. Import a CSV to get started. Your CSV should have columns:{' '}
|
||||
<Text span ff="monospace" size="xs">account_number, account_name, jan, feb, ..., dec</Text>.
|
||||
Accounts will be auto-created if they don't exist yet.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group>
|
||||
<Card withBorder p="sm">
|
||||
<Text size="xs" c="dimmed">Total Income</Text>
|
||||
@@ -256,7 +288,7 @@ export function BudgetsPage() {
|
||||
<Table striped highlightOnHover style={{ minWidth: 1400 }}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 1, minWidth: 250 }}>Account</Table.Th>
|
||||
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 1, minWidth: 280 }}>Account</Table.Th>
|
||||
{monthLabels.map((m) => (
|
||||
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
|
||||
))}
|
||||
@@ -267,7 +299,7 @@ export function BudgetsPage() {
|
||||
{budgetData.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={14}>
|
||||
<Text ta="center" c="dimmed" py="lg">No budget data. Income and expense accounts will appear here.</Text>
|
||||
<Text ta="center" c="dimmed" py="lg">No budget data. Import a CSV or add income/expense accounts to get started.</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user