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:
@@ -35,7 +35,7 @@ export class TenantSchemaService {
|
|||||||
// Accounts (Chart of Accounts)
|
// Accounts (Chart of Accounts)
|
||||||
`CREATE TABLE "${s}".accounts (
|
`CREATE TABLE "${s}".accounts (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
account_number INTEGER NOT NULL,
|
account_number VARCHAR(50) NOT NULL,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
account_type VARCHAR(50) NOT NULL CHECK (account_type IN ('asset', 'liability', 'equity', 'income', 'expense')),
|
account_type VARCHAR(50) NOT NULL CHECK (account_type IN ('asset', 'liability', 'equity', 'income', 'expense')),
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export class AccountsService {
|
|||||||
const acctCredit = isDebitNormal ? 0 : absAmount;
|
const acctCredit = isDebitNormal ? 0 : absAmount;
|
||||||
|
|
||||||
// Determine equity offset account based on fund type (auto-create if missing)
|
// Determine equity offset account based on fund type (auto-create if missing)
|
||||||
const equityAccountNumber = dto.fundType === 'reserve' ? 3100 : 3000;
|
const equityAccountNumber = dto.fundType === 'reserve' ? '3100' : '3000';
|
||||||
const equityName = dto.fundType === 'reserve' ? 'Reserve Fund Balance' : 'Operating Fund Balance';
|
const equityName = dto.fundType === 'reserve' ? 'Reserve Fund Balance' : 'Operating Fund Balance';
|
||||||
let equityRows = await this.tenant.query(
|
let equityRows = await this.tenant.query(
|
||||||
'SELECT id FROM accounts WHERE account_number = $1',
|
'SELECT id FROM accounts WHERE account_number = $1',
|
||||||
@@ -247,7 +247,7 @@ export class AccountsService {
|
|||||||
const fiscalPeriodId = periods[0].id;
|
const fiscalPeriodId = periods[0].id;
|
||||||
|
|
||||||
// Determine the equity offset account based on fund_type
|
// Determine the equity offset account based on fund_type
|
||||||
const equityAccountNumber = account.fund_type === 'reserve' ? 3100 : 3000;
|
const equityAccountNumber = account.fund_type === 'reserve' ? '3100' : '3000';
|
||||||
const equityRows = await this.tenant.query(
|
const equityRows = await this.tenant.query(
|
||||||
'SELECT id, account_type FROM accounts WHERE account_number = $1',
|
'SELECT id, account_type FROM accounts WHERE account_number = $1',
|
||||||
[equityAccountNumber],
|
[equityAccountNumber],
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { IsString, IsInt, IsOptional, IsBoolean, IsIn, IsUUID } from 'class-validator';
|
import { IsString, IsOptional, IsBoolean, IsIn, IsUUID } from 'class-validator';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class CreateAccountDto {
|
export class CreateAccountDto {
|
||||||
@ApiProperty({ example: 6600 })
|
@ApiProperty({ example: '6600' })
|
||||||
@IsInt()
|
@IsString()
|
||||||
accountNumber: number;
|
accountNumber: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Equipment Repairs' })
|
@ApiProperty({ example: 'Equipment Repairs' })
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsString, IsOptional, IsBoolean, IsIn, IsInt } from 'class-validator';
|
import { IsString, IsOptional, IsBoolean, IsIn } from 'class-validator';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class UpdateAccountDto {
|
export class UpdateAccountDto {
|
||||||
@@ -28,9 +28,9 @@ export class UpdateAccountDto {
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsInt()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
accountNumber?: number;
|
accountNumber?: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsIn(['operating', 'reserve'])
|
@IsIn(['operating', 'reserve'])
|
||||||
|
|||||||
@@ -2,6 +2,82 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { TenantService } from '../../database/tenant.service';
|
import { TenantService } from '../../database/tenant.service';
|
||||||
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a currency-formatted string into a number.
|
||||||
|
* Handles: "$48,065.21", " $- ", "$(13,000.00)", "$500.00 ", etc.
|
||||||
|
*/
|
||||||
|
function parseCurrency(val: string | number | undefined | null): number {
|
||||||
|
if (val === undefined || val === null) return 0;
|
||||||
|
if (typeof val === 'number') return val;
|
||||||
|
|
||||||
|
let s = String(val).trim();
|
||||||
|
if (!s || s === '-' || s === '$-' || s === '$ -' || s === '$- ') return 0;
|
||||||
|
|
||||||
|
// Detect negative: $(1,000.00) format
|
||||||
|
const isNegative = s.includes('(') && s.includes(')');
|
||||||
|
|
||||||
|
// Remove $, commas, spaces, parentheses
|
||||||
|
s = s.replace(/[$,\s()]/g, '');
|
||||||
|
if (!s || s === '-') return 0;
|
||||||
|
|
||||||
|
const num = parseFloat(s);
|
||||||
|
if (isNaN(num)) return 0;
|
||||||
|
return isNegative ? -num : num;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer account_type and fund_type from an account number prefix.
|
||||||
|
* Follows common HOA chart-of-accounts conventions:
|
||||||
|
* 10-19xx or 1xxx = asset
|
||||||
|
* 20-29xx or 2xxx = liability
|
||||||
|
* 30-39xx or 3xxx = income (operating) -- common HOA: 30 prefix = revenue
|
||||||
|
* 40-49xx or 4-5xxx = expense (operating)
|
||||||
|
* 60-69xx or 6xxx = expense (operating) -- utilities
|
||||||
|
* 70-79xx or 7xxx = expense (reserve)
|
||||||
|
* 80-89xx or 8xxx = expense (operating) -- admin
|
||||||
|
* 90-99xx or 9xxx = income/expense (reserve) depending on name
|
||||||
|
*/
|
||||||
|
function inferAccountType(accountNumber: string, accountName: string): { accountType: string; fundType: string } {
|
||||||
|
const prefix = accountNumber.split('-')[0].trim();
|
||||||
|
const prefixNum = parseInt(prefix, 10);
|
||||||
|
|
||||||
|
if (isNaN(prefixNum)) {
|
||||||
|
// Can't infer, default to expense/operating
|
||||||
|
return { accountType: 'expense', fundType: 'operating' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for reserve keywords in name
|
||||||
|
const nameUpper = (accountName || '').toUpperCase();
|
||||||
|
const isReserve = nameUpper.includes('RESERVE') ||
|
||||||
|
nameUpper.includes('CAPITAL') ||
|
||||||
|
prefixNum >= 90;
|
||||||
|
|
||||||
|
if (prefixNum >= 10 && prefixNum <= 19) return { accountType: 'asset', fundType: 'operating' };
|
||||||
|
if (prefixNum >= 20 && prefixNum <= 29) return { accountType: 'liability', fundType: 'operating' };
|
||||||
|
if (prefixNum >= 30 && prefixNum <= 39) return { accountType: 'income', fundType: 'operating' };
|
||||||
|
if (prefixNum >= 40 && prefixNum <= 69) return { accountType: 'expense', fundType: 'operating' };
|
||||||
|
if (prefixNum >= 70 && prefixNum <= 79) return { accountType: 'expense', fundType: 'reserve' };
|
||||||
|
if (prefixNum >= 80 && prefixNum <= 89) return { accountType: 'expense', fundType: 'operating' };
|
||||||
|
if (prefixNum >= 90 && prefixNum <= 99) {
|
||||||
|
// 90-series: could be reserve income or reserve expense
|
||||||
|
// Look for disbursement/expense keywords
|
||||||
|
if (nameUpper.includes('DISBURSEMENT') || nameUpper.includes('EXPENSE') || nameUpper.includes('CAPITAL EXPENSE')) {
|
||||||
|
return { accountType: 'expense', fundType: 'reserve' };
|
||||||
|
}
|
||||||
|
return { accountType: 'income', fundType: 'reserve' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// For simple numeric codes (like existing 4000, 5000 series)
|
||||||
|
if (prefixNum >= 1000 && prefixNum < 2000) return { accountType: 'asset', fundType: 'operating' };
|
||||||
|
if (prefixNum >= 2000 && prefixNum < 3000) return { accountType: 'liability', fundType: 'operating' };
|
||||||
|
if (prefixNum >= 3000 && prefixNum < 4000) return { accountType: 'equity', fundType: prefixNum >= 3100 ? 'reserve' : 'operating' };
|
||||||
|
if (prefixNum >= 4000 && prefixNum < 5000) return { accountType: 'income', fundType: 'operating' };
|
||||||
|
if (prefixNum >= 5000 && prefixNum < 7000) return { accountType: 'expense', fundType: 'operating' };
|
||||||
|
if (prefixNum >= 7000 && prefixNum < 8000) return { accountType: 'expense', fundType: 'reserve' };
|
||||||
|
|
||||||
|
return { accountType: 'expense', fundType: 'operating' };
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BudgetsService {
|
export class BudgetsService {
|
||||||
constructor(private tenant: TenantService) {}
|
constructor(private tenant: TenantService) {}
|
||||||
@@ -9,23 +85,40 @@ export class BudgetsService {
|
|||||||
async importBudget(year: number, lines: any[]) {
|
async importBudget(year: number, lines: any[]) {
|
||||||
const results = [];
|
const results = [];
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
const created: string[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
const accountNumber = String(line.accountNumber || line.account_number || '').trim();
|
const accountNumber = String(line.accountNumber || line.account_number || '').trim();
|
||||||
|
const accountName = String(line.accountName || line.account_name || '').trim();
|
||||||
if (!accountNumber) {
|
if (!accountNumber) {
|
||||||
errors.push(`Row ${i + 1}: missing account_number`);
|
errors.push(`Row ${i + 1}: missing account_number`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up account by account_number
|
// Look up account by account_number (text match)
|
||||||
const accounts = await this.tenant.query(
|
let accounts = await this.tenant.query(
|
||||||
`SELECT id, fund_type FROM accounts WHERE account_number = $1 AND is_active = true`,
|
`SELECT id, fund_type, account_type FROM accounts WHERE account_number = $1 AND is_active = true`,
|
||||||
[parseInt(accountNumber, 10)],
|
[accountNumber],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Auto-create account if not found and we have a name
|
||||||
|
if ((!accounts || accounts.length === 0) && accountName) {
|
||||||
|
const { accountType, fundType } = inferAccountType(accountNumber, accountName);
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO accounts (account_number, name, account_type, fund_type, is_system)
|
||||||
|
VALUES ($1, $2, $3, $4, false)`,
|
||||||
|
[accountNumber, accountName, accountType, fundType],
|
||||||
|
);
|
||||||
|
accounts = await this.tenant.query(
|
||||||
|
`SELECT id, fund_type, account_type FROM accounts WHERE account_number = $1 AND is_active = true`,
|
||||||
|
[accountNumber],
|
||||||
|
);
|
||||||
|
created.push(`${accountNumber} - ${accountName} (${accountType}/${fundType})`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!accounts || accounts.length === 0) {
|
if (!accounts || accounts.length === 0) {
|
||||||
errors.push(`Row ${i + 1}: account_number ${accountNumber} not found`);
|
errors.push(`Row ${i + 1}: account_number "${accountNumber}" not found and no account_name provided to auto-create`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,25 +135,25 @@ export class BudgetsService {
|
|||||||
year,
|
year,
|
||||||
account.id,
|
account.id,
|
||||||
fundType,
|
fundType,
|
||||||
parseFloat(line.jan) || 0,
|
parseCurrency(line.jan),
|
||||||
parseFloat(line.feb) || 0,
|
parseCurrency(line.feb),
|
||||||
parseFloat(line.mar) || 0,
|
parseCurrency(line.mar),
|
||||||
parseFloat(line.apr) || 0,
|
parseCurrency(line.apr),
|
||||||
parseFloat(line.may) || 0,
|
parseCurrency(line.may),
|
||||||
parseFloat(line.jun) || 0,
|
parseCurrency(line.jun),
|
||||||
parseFloat(line.jul) || 0,
|
parseCurrency(line.jul),
|
||||||
parseFloat(line.aug) || 0,
|
parseCurrency(line.aug),
|
||||||
parseFloat(line.sep) || 0,
|
parseCurrency(line.sep),
|
||||||
parseFloat(line.oct) || 0,
|
parseCurrency(line.oct),
|
||||||
parseFloat(line.nov) || 0,
|
parseCurrency(line.nov),
|
||||||
parseFloat(line.dec_amt || line.dec) || 0,
|
parseCurrency(line.dec_amt || line.dec),
|
||||||
line.notes || null,
|
line.notes || null,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
results.push(rows[0]);
|
results.push(rows[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { imported: results.length, errors };
|
return { imported: results.length, errors, created };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTemplate(year: number): Promise<string> {
|
async getTemplate(year: number): Promise<string> {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export class InvestmentsService {
|
|||||||
);
|
);
|
||||||
if (primaryRows.length) {
|
if (primaryRows.length) {
|
||||||
const primaryAccount = primaryRows[0];
|
const primaryAccount = primaryRows[0];
|
||||||
const equityAccountNumber = fundType === 'reserve' ? 3100 : 3000;
|
const equityAccountNumber = fundType === 'reserve' ? '3100' : '3000';
|
||||||
const equityRows = await this.tenant.query(
|
const equityRows = await this.tenant.query(
|
||||||
'SELECT id FROM accounts WHERE account_number = $1',
|
'SELECT id FROM accounts WHERE account_number = $1',
|
||||||
[equityAccountNumber],
|
[equityAccountNumber],
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ export class InvoicesService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Create journal entry: DR Accounts Receivable, CR Assessment Income
|
// Create journal entry: DR Accounts Receivable, CR Assessment Income
|
||||||
const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = 1200`);
|
const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '1200'`);
|
||||||
const incomeAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = 4000`);
|
const incomeAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '4000'`);
|
||||||
|
|
||||||
if (arAccount.length && incomeAccount.length) {
|
if (arAccount.length && incomeAccount.length) {
|
||||||
const je = await this.tenant.query(
|
const je = await this.tenant.query(
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ export class PaymentsService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Create journal entry: DR Cash, CR Accounts Receivable
|
// Create journal entry: DR Cash, CR Accounts Receivable
|
||||||
const cashAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = 1000`);
|
const cashAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '1000'`);
|
||||||
const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = 1200`);
|
const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '1200'`);
|
||||||
|
|
||||||
if (cashAccount.length && arAccount.length) {
|
if (cashAccount.length && arAccount.length) {
|
||||||
const je = await this.tenant.query(
|
const je = await this.tenant.query(
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const ACCOUNT_TYPE_OPTIONS = [
|
|||||||
|
|
||||||
interface Account {
|
interface Account {
|
||||||
id: string;
|
id: string;
|
||||||
account_number: number;
|
account_number: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
account_type: string;
|
account_type: string;
|
||||||
@@ -93,7 +93,7 @@ interface Investment {
|
|||||||
|
|
||||||
interface TrialBalanceEntry {
|
interface TrialBalanceEntry {
|
||||||
id: string;
|
id: string;
|
||||||
account_number: number;
|
account_number: string;
|
||||||
name: string;
|
name: string;
|
||||||
account_type: string;
|
account_type: string;
|
||||||
fund_type: string;
|
fund_type: string;
|
||||||
@@ -158,7 +158,7 @@ export function AccountsPage() {
|
|||||||
// ── Create / Edit form ──
|
// ── Create / Edit form ──
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
accountNumber: 0,
|
accountNumber: '',
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
accountType: 'expense',
|
accountType: 'expense',
|
||||||
@@ -176,7 +176,7 @@ export function AccountsPage() {
|
|||||||
withdrawFromPrimary: true,
|
withdrawFromPrimary: true,
|
||||||
},
|
},
|
||||||
validate: {
|
validate: {
|
||||||
accountNumber: (v, values) => isInvestmentType(values.accountType) ? null : (v > 0 ? null : 'Required'),
|
accountNumber: (v, values) => isInvestmentType(values.accountType) ? null : (v.trim().length > 0 ? null : 'Required'),
|
||||||
name: (v) => (v.length > 0 ? null : 'Required'),
|
name: (v) => (v.length > 0 ? null : 'Required'),
|
||||||
principal: (v, values) => isInvestmentType(values.accountType) ? (v > 0 ? null : 'Required') : null,
|
principal: (v, values) => isInvestmentType(values.accountType) ? (v > 0 ? null : 'Required') : null,
|
||||||
},
|
},
|
||||||
@@ -609,7 +609,7 @@ export function AccountsPage() {
|
|||||||
|
|
||||||
{!isInvestmentType(form.values.accountType) && (
|
{!isInvestmentType(form.values.accountType) && (
|
||||||
<>
|
<>
|
||||||
<NumberInput label="Account Number" required {...form.getInputProps('accountNumber')} />
|
<TextInput label="Account Number" placeholder="e.g. 4000 or 40-4007-0000" required {...form.getInputProps('accountNumber')} />
|
||||||
<TextInput label="Description" {...form.getInputProps('description')} />
|
<TextInput label="Description" {...form.getInputProps('description')} />
|
||||||
{editing && (
|
{editing && (
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, Text, NumberInput,
|
Title, Table, Group, Button, Stack, Text, NumberInput,
|
||||||
Select, Loader, Center, Badge, Card,
|
Select, Loader, Center, Badge, Card, Alert,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { notifications } from '@mantine/notifications';
|
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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
interface BudgetLine {
|
interface BudgetLine {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
account_number: number;
|
account_number: string;
|
||||||
account_name: string;
|
account_name: string;
|
||||||
account_type: string;
|
account_type: string;
|
||||||
fund_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 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'];
|
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>[] {
|
function parseCSV(text: string): Record<string, string>[] {
|
||||||
const lines = text.trim().split('\n');
|
const lines = text.trim().split('\n');
|
||||||
if (lines.length < 2) return [];
|
if (lines.length < 2) return [];
|
||||||
@@ -102,30 +119,37 @@ export function BudgetsPage() {
|
|||||||
mutationFn: async (lines: Record<string, string>[]) => {
|
mutationFn: async (lines: Record<string, string>[]) => {
|
||||||
const parsed = lines.map((row) => ({
|
const parsed = lines.map((row) => ({
|
||||||
account_number: row.account_number || row.accountnumber || '',
|
account_number: row.account_number || row.accountnumber || '',
|
||||||
jan: parseFloat(row.jan) || 0,
|
account_name: row.account_name || row.accountname || '',
|
||||||
feb: parseFloat(row.feb) || 0,
|
jan: parseCurrencyValue(row.jan),
|
||||||
mar: parseFloat(row.mar) || 0,
|
feb: parseCurrencyValue(row.feb),
|
||||||
apr: parseFloat(row.apr) || 0,
|
mar: parseCurrencyValue(row.mar),
|
||||||
may: parseFloat(row.may) || 0,
|
apr: parseCurrencyValue(row.apr),
|
||||||
jun: parseFloat(row.jun) || 0,
|
may: parseCurrencyValue(row.may),
|
||||||
jul: parseFloat(row.jul) || 0,
|
jun: parseCurrencyValue(row.jun),
|
||||||
aug: parseFloat(row.aug) || 0,
|
jul: parseCurrencyValue(row.jul),
|
||||||
sep: parseFloat(row.sep) || 0,
|
aug: parseCurrencyValue(row.aug),
|
||||||
oct: parseFloat(row.oct) || 0,
|
sep: parseCurrencyValue(row.sep),
|
||||||
nov: parseFloat(row.nov) || 0,
|
oct: parseCurrencyValue(row.oct),
|
||||||
dec_amt: parseFloat(row.dec_amt || row.dec) || 0,
|
nov: parseCurrencyValue(row.nov),
|
||||||
|
dec_amt: parseCurrencyValue(row.dec_amt || row.dec || ''),
|
||||||
}));
|
}));
|
||||||
const { data } = await api.post(`/budgets/${year}/import`, parsed);
|
const { data } = await api.post(`/budgets/${year}/import`, parsed);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
||||||
const msg = `Imported ${data.imported} budget line(s)` +
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
(data.errors?.length ? `. ${data.errors.length} error(s): ${data.errors.join('; ')}` : '');
|
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({
|
notifications.show({
|
||||||
message: msg,
|
message: msg,
|
||||||
color: data.errors?.length ? 'yellow' : 'green',
|
color: data.errors?.length ? 'yellow' : 'green',
|
||||||
autoClose: 8000,
|
autoClose: 10000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
@@ -235,6 +259,14 @@ export function BudgetsPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</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>
|
<Group>
|
||||||
<Card withBorder p="sm">
|
<Card withBorder p="sm">
|
||||||
<Text size="xs" c="dimmed">Total Income</Text>
|
<Text size="xs" c="dimmed">Total Income</Text>
|
||||||
@@ -256,7 +288,7 @@ export function BudgetsPage() {
|
|||||||
<Table striped highlightOnHover style={{ minWidth: 1400 }}>
|
<Table striped highlightOnHover style={{ minWidth: 1400 }}>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<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) => (
|
{monthLabels.map((m) => (
|
||||||
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
|
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
|
||||||
))}
|
))}
|
||||||
@@ -267,7 +299,7 @@ export function BudgetsPage() {
|
|||||||
{budgetData.length === 0 && (
|
{budgetData.length === 0 && (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={14}>
|
<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.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { DateInput } from '@mantine/dates';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
interface AccountLine { account_number: number; name: string; balance: string; fund_type: string; }
|
interface AccountLine { account_number: string; name: string; balance: string; fund_type: string; }
|
||||||
interface BalanceSheetData {
|
interface BalanceSheetData {
|
||||||
as_of: string;
|
as_of: string;
|
||||||
assets: AccountLine[]; liabilities: AccountLine[]; equity: AccountLine[];
|
assets: AccountLine[]; liabilities: AccountLine[]; equity: AccountLine[];
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import api from '../../services/api';
|
|||||||
|
|
||||||
interface BudgetVsActualLine {
|
interface BudgetVsActualLine {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
account_number: number;
|
account_number: string;
|
||||||
account_name: string;
|
account_name: string;
|
||||||
account_type: string;
|
account_type: string;
|
||||||
fund_type: string;
|
fund_type: string;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { DateInput } from '@mantine/dates';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
interface AccountLine { account_number: number; name: string; amount: string; fund_type: string; }
|
interface AccountLine { account_number: string; name: string; amount: string; fund_type: string; }
|
||||||
interface IncomeStatementData {
|
interface IncomeStatementData {
|
||||||
from: string; to: string;
|
from: string; to: string;
|
||||||
income: AccountLine[]; expenses: AccountLine[];
|
income: AccountLine[]; expenses: AccountLine[];
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface JournalEntryLine {
|
|||||||
id?: string;
|
id?: string;
|
||||||
account_id: string;
|
account_id: string;
|
||||||
account_name?: string;
|
account_name?: string;
|
||||||
account_number?: number;
|
account_number?: string;
|
||||||
debit: number;
|
debit: number;
|
||||||
credit: number;
|
credit: number;
|
||||||
memo: string;
|
memo: string;
|
||||||
@@ -38,7 +38,7 @@ interface JournalEntry {
|
|||||||
|
|
||||||
interface Account {
|
interface Account {
|
||||||
id: string;
|
id: string;
|
||||||
account_number: number;
|
account_number: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user