diff --git a/backend/src/modules/board-planning/board-planning.controller.ts b/backend/src/modules/board-planning/board-planning.controller.ts index 76b128d..91fdca4 100644 --- a/backend/src/modules/board-planning/board-planning.controller.ts +++ b/backend/src/modules/board-planning/board-planning.controller.ts @@ -1,4 +1,5 @@ -import { Controller, Get, Post, Put, Delete, Body, Param, Query, Req, UseGuards } from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Body, Param, Query, Req, Res, UseGuards } from '@nestjs/common'; +import { Response } from 'express'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { AllowViewer } from '../../common/decorators/allow-viewer.decorator'; @@ -170,6 +171,28 @@ export class BoardPlanningController { return this.budgetPlanning.advanceStatus(parseInt(year, 10), dto.status, req.user.sub); } + @Post('budget-plans/:year/import') + importBudgetPlanLines( + @Param('year') year: string, + @Body() lines: any[], + @Req() req: any, + ) { + return this.budgetPlanning.importLines(parseInt(year, 10), lines, req.user.sub); + } + + @Get('budget-plans/:year/template') + async getBudgetPlanTemplate( + @Param('year') year: string, + @Res() res: Response, + ) { + const csv = await this.budgetPlanning.getTemplate(parseInt(year, 10)); + res.set({ + 'Content-Type': 'text/csv', + 'Content-Disposition': `attachment; filename="budget_template_${year}.csv"`, + }); + res.send(csv); + } + @Delete('budget-plans/:year') deleteBudgetPlan(@Param('year') year: string) { return this.budgetPlanning.deletePlan(parseInt(year, 10)); diff --git a/backend/src/modules/board-planning/budget-planning.service.ts b/backend/src/modules/board-planning/budget-planning.service.ts index 6d5e117..78a67a2 100644 --- a/backend/src/modules/board-planning/budget-planning.service.ts +++ b/backend/src/modules/board-planning/budget-planning.service.ts @@ -40,7 +40,9 @@ export class BudgetPlanningService { const result = await this.tenant.query( 'SELECT MAX(fiscal_year) as max_year FROM budgets', ); - const latestBudgetYear = result[0]?.max_year || new Date().getFullYear(); + const rawMaxYear = result[0]?.max_year; + const latestBudgetYear = rawMaxYear || null; // null means no budgets exist at all + const baseYear = rawMaxYear || new Date().getFullYear(); // Also find years that already have plans const existingPlans = await this.tenant.query( @@ -51,10 +53,11 @@ export class BudgetPlanningService { status: p.status, })); - // Return next 5 years after latest budget, marking which have plans + // Return next 5 years (or current year + 4 if no budgets exist) const years = []; - for (let i = 1; i <= 5; i++) { - const yr = latestBudgetYear + i; + const startOffset = rawMaxYear ? 1 : 0; // include current year if no budgets exist + for (let i = startOffset; i <= startOffset + 4; i++) { + const yr = baseYear + i; const existing = planYears.find((p: any) => p.year === yr); years.push({ year: yr, @@ -83,12 +86,12 @@ export class BudgetPlanningService { const plan = rows[0]; // Generate inflated lines from base year - await this.generateLines(plan.id, baseYear, inflationRate); + await this.generateLines(plan.id, baseYear, inflationRate, fiscalYear); return this.getPlan(fiscalYear); } - async generateLines(planId: string, baseYear: number, inflationRate: number) { + async generateLines(planId: string, baseYear: number, inflationRate: number, fiscalYear: number) { // Delete existing non-manually-adjusted lines (or all if fresh) await this.tenant.query( 'DELETE FROM budget_plan_lines WHERE budget_plan_id = $1 AND is_manually_adjusted = false', @@ -115,7 +118,9 @@ export class BudgetPlanningService { if (!baseLines.length) return; - const multiplier = 1 + inflationRate / 100; + // Compound inflation: (1 + rate/100)^yearsGap + const yearsGap = Math.max(1, fiscalYear - baseYear); + const multiplier = Math.pow(1 + inflationRate / 100, yearsGap); // Get existing manually-adjusted lines to avoid duplicates const manualLines = await this.tenant.query( @@ -185,7 +190,7 @@ export class BudgetPlanningService { ); // Re-generate only non-manually-adjusted lines - await this.generateLines(plan.id, plan.base_year, inflationRate); + await this.generateLines(plan.id, plan.base_year, inflationRate, fiscalYear); return this.getPlan(fiscalYear); } @@ -253,6 +258,139 @@ export class BudgetPlanningService { ); } + async importLines(fiscalYear: number, lines: any[], userId: string) { + // Ensure plan exists (create if needed) + let plans = await this.tenant.query( + 'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear], + ); + if (!plans.length) { + await this.tenant.query( + `INSERT INTO budget_plans (fiscal_year, base_year, inflation_rate, created_by) + VALUES ($1, $1, 0, $2) RETURNING *`, + [fiscalYear, userId], + ); + plans = await this.tenant.query( + 'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear], + ); + } + const plan = plans[0]; + const errors: string[] = []; + const created: string[] = []; + let imported = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const accountNumber = String(line.accountNumber || line.account_number || '').trim(); + const accountName = String(line.accountName || line.account_name || '').trim(); + if (!accountNumber) { + errors.push(`Row ${i + 1}: missing account_number`); + continue; + } + + let accounts = await this.tenant.query( + `SELECT id, fund_type, account_type FROM accounts WHERE account_number = $1 AND is_active = true`, + [accountNumber], + ); + + // Auto-create account if not found + if ((!accounts || accounts.length === 0) && accountName) { + const accountType = this.inferAccountType(accountNumber, accountName); + const fundType = this.inferFundType(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) { + errors.push(`Row ${i + 1}: account "${accountNumber}" not found`); + continue; + } + + const account = accounts[0]; + const fundType = line.fund_type || account.fund_type || 'operating'; + const monthValues = monthCols.map((m) => { + const key = m === 'dec_amt' ? 'dec' : m; + return this.parseCurrency(line[key] ?? line[m] ?? 0); + }); + + await this.tenant.query( + `INSERT INTO budget_plan_lines (budget_plan_id, account_id, fund_type, + jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, is_manually_adjusted) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, true) + ON CONFLICT (budget_plan_id, account_id, fund_type) + DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9, + jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15, + is_manually_adjusted=true`, + [plan.id, account.id, fundType, ...monthValues], + ); + imported++; + } + + return { imported, errors, created, plan: await this.getPlan(fiscalYear) }; + } + + async getTemplate(fiscalYear: number): Promise { + const rows = await this.tenant.query( + `SELECT a.account_number, a.name as account_name, + COALESCE(b.jan, 0) as jan, COALESCE(b.feb, 0) as feb, + COALESCE(b.mar, 0) as mar, COALESCE(b.apr, 0) as apr, + COALESCE(b.may, 0) as may, COALESCE(b.jun, 0) as jun, + COALESCE(b.jul, 0) as jul, COALESCE(b.aug, 0) as aug, + COALESCE(b.sep, 0) as sep, COALESCE(b.oct, 0) as oct, + COALESCE(b.nov, 0) as nov, COALESCE(b.dec_amt, 0) as dec + FROM accounts a + LEFT JOIN budgets b ON b.account_id = a.id AND b.fiscal_year = $1 + WHERE a.is_active = true + AND a.account_type IN ('income', 'expense') + ORDER BY a.account_number`, + [fiscalYear], + ); + + const header = 'account_number,account_name,jan,feb,mar,apr,may,jun,jul,aug,sep,oct,nov,dec'; + const csvLines = rows.map((r: any) => { + const name = String(r.account_name).includes(',') ? `"${r.account_name}"` : r.account_name; + return [r.account_number, name, r.jan, r.feb, r.mar, r.apr, r.may, r.jun, r.jul, r.aug, r.sep, r.oct, r.nov, r.dec].join(','); + }); + return [header, ...csvLines].join('\n'); + } + + private 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 === '$ -') 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; + } + + private inferAccountType(accountNumber: string, accountName: string): string { + const prefix = parseInt(accountNumber.split('-')[0].trim(), 10); + if (isNaN(prefix)) return 'expense'; + const nameUpper = (accountName || '').toUpperCase(); + if (prefix >= 3000 && prefix < 4000) return 'income'; + if (nameUpper.includes('INCOME') || nameUpper.includes('REVENUE') || nameUpper.includes('ASSESSMENT')) return 'income'; + return 'expense'; + } + + private inferFundType(accountNumber: string, accountName: string): string { + const prefix = parseInt(accountNumber.split('-')[0].trim(), 10); + const nameUpper = (accountName || '').toUpperCase(); + if (nameUpper.includes('RESERVE')) return 'reserve'; + if (prefix >= 7000 && prefix < 8000) return 'reserve'; + return 'operating'; + } + async deletePlan(fiscalYear: number) { const plans = await this.tenant.query( 'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear], diff --git a/frontend/src/pages/board-planning/BudgetPlanningPage.tsx b/frontend/src/pages/board-planning/BudgetPlanningPage.tsx index 2721924..d51cd00 100644 --- a/frontend/src/pages/board-planning/BudgetPlanningPage.tsx +++ b/frontend/src/pages/board-planning/BudgetPlanningPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Title, Table, Group, Button, Stack, Text, NumberInput, Select, Loader, Center, Badge, Card, Alert, Modal, @@ -7,6 +7,7 @@ import { notifications } from '@mantine/notifications'; import { IconDeviceFloppy, IconInfoCircle, IconPencil, IconX, IconCheck, IconArrowBack, IconTrash, IconRefresh, + IconUpload, IconDownload, } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; @@ -41,6 +42,43 @@ function hydrateLine(row: any): PlanLine { return line as PlanLine; } +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[] { + const lines = text.trim().split('\n'); + if (lines.length < 2) return []; + const headers = lines[0].split(',').map((h) => h.trim().toLowerCase()); + const rows: Record[] = []; + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + const values: string[] = []; + let current = ''; + let inQuotes = false; + for (let j = 0; j < line.length; j++) { + const ch = line[j]; + if (ch === '"') { inQuotes = !inQuotes; } + else if (ch === ',' && !inQuotes) { values.push(current.trim()); current = ''; } + else { current += ch; } + } + values.push(current.trim()); + const row: Record = {}; + headers.forEach((h, idx) => { row[h] = values[idx] || ''; }); + rows.push(row); + } + return rows; +} + const statusColors: Record = { planning: 'blue', approved: 'yellow', @@ -61,6 +99,7 @@ export function BudgetPlanningPage() { const [isEditing, setIsEditing] = useState(false); const [inflationInput, setInflationInput] = useState(2.5); const [confirmModal, setConfirmModal] = useState<{ action: string; title: string; message: string } | null>(null); + const fileInputRef = useRef(null); // Available years const { data: availableYears } = useQuery({ @@ -99,11 +138,19 @@ export function BudgetPlanningPage() { } }, [plan]); + const hasBaseBudget = !!availableYears?.latestBudgetYear; + const yearOptions = (availableYears?.years || []).map((y: any) => ({ value: String(y.year), label: `${y.year}${y.hasPlan ? ` (${y.status})` : ''}`, })); + // If no base budget at all, also offer the current year as an option + const currentYear = new Date().getFullYear(); + const allYearOptions = !hasBaseBudget && !yearOptions.find((y: any) => y.value === String(currentYear)) + ? [{ value: String(currentYear), label: String(currentYear) }, ...yearOptions] + : yearOptions; + // Mutations const createMutation = useMutation({ mutationFn: async () => { @@ -150,6 +197,45 @@ export function BudgetPlanningPage() { }, }); + const importMutation = useMutation({ + mutationFn: async (lines: Record[]) => { + const parsed = lines.map((row) => ({ + account_number: row.account_number || row.accountnumber || '', + 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(`/board-planning/budget-plans/${selectedYear}/import`, parsed); + return data; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] }); + queryClient.invalidateQueries({ queryKey: ['budget-plan-available-years'] }); + 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: 10000, + }); + }, + onError: (err: any) => { + notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' }); + }, + }); + const inflationMutation = useMutation({ mutationFn: () => api.put(`/board-planning/budget-plans/${selectedYear}/inflation`, { inflationRate: inflationInput, @@ -197,6 +283,45 @@ export function BudgetPlanningPage() { queryClient.invalidateQueries({ queryKey: ['budget-plan', selectedYear] }); }; + const handleDownloadTemplate = async () => { + try { + const yr = selectedYear || currentYear; + const response = await api.get(`/board-planning/budget-plans/${yr}/template`, { + responseType: 'blob', + }); + const blob = new Blob([response.data], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `budget_template_${yr}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + } catch (err: any) { + notifications.show({ message: 'Failed to download template', color: 'red' }); + } + }; + + const handleImportCSV = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result as string; + if (!text) { notifications.show({ message: 'Could not read file', color: 'red' }); return; } + const rows = parseCSV(text); + if (rows.length === 0) { notifications.show({ message: 'No data rows found in CSV', color: 'red' }); return; } + importMutation.mutate(rows); + }; + reader.readAsText(file); + event.target.value = ''; + }; + const hasPlan = !!plan?.id; const status = plan?.status || 'planning'; const cellsEditable = !isReadOnly && isEditing && status !== 'ratified'; @@ -223,22 +348,72 @@ export function BudgetPlanningPage() { + {isLoading &&
} - {/* Empty state - no plan for selected year */} - {!isLoading && !hasPlan && selectedYear && ( + {/* Empty state - no base budget exists at all */} + {!isLoading && !hasPlan && selectedYear && !hasBaseBudget && ( + } color="orange" variant="light"> + + No budget data found in the system + + To get started with budget planning, you need to load an initial budget. + You can either create a new budget from scratch or import an existing budget from a CSV file. + + + Use Download Template above to get a CSV with your chart of accounts pre-populated, + fill in the monthly amounts, then import it below. + + + + + + + + )} + + {/* Empty state - base budget exists but no plan for this year */} + {!isLoading && !hasPlan && selectedYear && hasBaseBudget && ( } color="blue" variant="light"> - No budget plan exists for {selectedYear}. Create one based on the current active budget with an inflation adjustment. + No budget plan exists for {selectedYear}. Create one based on the {availableYears?.latestBudgetYear} budget with an inflation adjustment, or import a CSV directly. Create Budget Plan for {selectedYear} + or + - Base year: {availableYears?.latestBudgetYear || 'N/A'}. Each monthly amount will be inflated by the specified percentage. + Base year: {availableYears?.latestBudgetYear}. Each monthly amount will be compounded annually by the specified inflation rate. @@ -292,7 +477,7 @@ export function BudgetPlanningPage() { setConfirmModal({ action: 'inflation', title: 'Apply Inflation Rate', - message: `This will recalculate all non-manually-adjusted lines using ${inflationInput}% inflation from the base year (${plan.base_year}). Manually adjusted lines will be preserved.`, + message: `This will recalculate all non-manually-adjusted lines using ${inflationInput}% inflation compounded annually from the base year (${plan.base_year}). Manually adjusted lines will be preserved.`, }); }} disabled={status === 'ratified' || isReadOnly} @@ -305,6 +490,19 @@ export function BudgetPlanningPage() { {!isReadOnly && ( <> + {/* Import CSV into existing plan */} + {status !== 'ratified' && ( + + )} + {/* Status actions */} {status === 'planning' && ( <> diff --git a/frontend/src/pages/budgets/BudgetsPage.tsx b/frontend/src/pages/budgets/BudgetsPage.tsx index 0cbf8ed..6cb3b28 100644 --- a/frontend/src/pages/budgets/BudgetsPage.tsx +++ b/frontend/src/pages/budgets/BudgetsPage.tsx @@ -1,11 +1,12 @@ -import { useState, useRef } from 'react'; +import { useState } from 'react'; import { Title, Table, Group, Button, Stack, Text, NumberInput, Select, Loader, Center, Badge, Card, Alert, } from '@mantine/core'; import { notifications } from '@mantine/notifications'; -import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle, IconPencil, IconX } from '@tabler/icons-react'; +import { IconDeviceFloppy, IconInfoCircle, IconPencil, IconX, IconArrowRight } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; import api from '../../services/api'; import { useIsReadOnly } from '../../stores/authStore'; import { usePreferencesStore } from '../../stores/preferencesStore'; @@ -25,23 +26,6 @@ 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; -} - /** * Ensure all monthly values are numbers (PostgreSQL can return strings for NUMERIC columns) * and compute annual_total as the sum of all monthly values. @@ -55,50 +39,12 @@ function hydrateBudgetLine(row: any): BudgetLine { return line as BudgetLine; } -function parseCSV(text: string): Record[] { - const lines = text.trim().split('\n'); - if (lines.length < 2) return []; - - const headers = lines[0].split(',').map((h) => h.trim().toLowerCase()); - const rows: Record[] = []; - - for (let i = 1; i < lines.length; i++) { - const line = lines[i].trim(); - if (!line) continue; - - // Handle quoted fields containing commas - const values: string[] = []; - let current = ''; - let inQuotes = false; - for (let j = 0; j < line.length; j++) { - const ch = line[j]; - if (ch === '"') { - inQuotes = !inQuotes; - } else if (ch === ',' && !inQuotes) { - values.push(current.trim()); - current = ''; - } else { - current += ch; - } - } - values.push(current.trim()); - - const row: Record = {}; - headers.forEach((h, idx) => { - row[h] = values[idx] || ''; - }); - rows.push(row); - } - - return rows; -} - export function BudgetsPage() { const [year, setYear] = useState(new Date().getFullYear().toString()); const [budgetData, setBudgetData] = useState([]); const [isEditing, setIsEditing] = useState(false); const queryClient = useQueryClient(); - const fileInputRef = useRef(null); + const navigate = useNavigate(); const isReadOnly = useIsReadOnly(); const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark'; const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white'; @@ -106,19 +52,16 @@ export function BudgetsPage() { const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6'; const expenseSectionBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8'; - // Budget exists when there is data loaded for the selected year const hasBudget = budgetData.length > 0; - // Cells are editable only when editing an existing budget or creating a new one (no data yet) - const cellsEditable = !isReadOnly && (isEditing || !hasBudget); + const cellsEditable = !isReadOnly && isEditing; const { isLoading } = useQuery({ queryKey: ['budgets', year], queryFn: async () => { const { data } = await api.get(`/budgets/${year}`); - // Hydrate each line: ensure numbers and compute annual_total const hydrated = (data as any[]).map(hydrateBudgetLine); setBudgetData(hydrated); - setIsEditing(false); // Reset to view mode when year changes or data reloads + setIsEditing(false); return hydrated; }, }); @@ -146,98 +89,8 @@ export function BudgetsPage() { }, }); - const importMutation = useMutation({ - mutationFn: async (lines: Record[]) => { - const parsed = lines.map((row) => ({ - account_number: row.account_number || row.accountnumber || '', - 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] }); - 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: 10000, - }); - }, - onError: (err: any) => { - notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' }); - }, - }); - - const handleDownloadTemplate = async () => { - try { - const response = await api.get(`/budgets/${year}/template`, { - responseType: 'blob', - }); - const blob = new Blob([response.data], { type: 'text/csv' }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `budget_template_${year}.csv`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - } catch (err: any) { - notifications.show({ message: 'Failed to download template', color: 'red' }); - } - }; - - const handleImportCSV = () => { - fileInputRef.current?.click(); - }; - - const handleFileChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - - const reader = new FileReader(); - reader.onload = (e) => { - const text = e.target?.result as string; - if (!text) { - notifications.show({ message: 'Could not read file', color: 'red' }); - return; - } - const rows = parseCSV(text); - if (rows.length === 0) { - notifications.show({ message: 'No data rows found in CSV', color: 'red' }); - return; - } - importMutation.mutate(rows); - }; - reader.readAsText(file); - - // Reset input so the same file can be re-selected - event.target.value = ''; - }; - const handleCancelEdit = () => { setIsEditing(false); - // Re-fetch to discard unsaved changes queryClient.invalidateQueries({ queryKey: ['budgets', year] }); }; @@ -263,7 +116,6 @@ export function BudgetsPage() { const expenseLines = budgetData.filter((b) => b.account_type === 'expense'); const totalOperatingIncome = operatingIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0); const totalReserveIncome = reserveIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0); - const totalIncome = totalOperatingIncome + totalReserveIncome; const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0); return ( @@ -272,40 +124,18 @@ export function BudgetsPage() { Budget Manager - {hasBudget && !isEditing ? ( - - ) : ( - <> - {isEditing && ( + {!isReadOnly && hasBudget && ( + <> + {!isEditing ? ( + + ) : ( + <> - )} - - - )} - )} + + + )} + + )} - {budgetData.length === 0 && !isLoading && ( + {!hasBudget && !isLoading && ( } color="blue" variant="light"> - No budget data for {year}. Import a CSV to get started. Your CSV should have columns:{' '} - account_number, account_name, jan, feb, ..., dec. - Accounts will be auto-created if they don't exist yet. + + No budget data for {year}. + + To create or import a budget, use the Budget Planner to build, + review, and ratify a budget for this year. Once ratified, it will appear here. + + + )} @@ -375,7 +217,7 @@ export function BudgetsPage() { {budgetData.length === 0 && ( - No budget data. Import a CSV or add income/expense accounts to get started. + No budget data for this year. )}