fix: budget save error and add read-only view mode (v2026.03.10)
Fix budget save 500 error caused by three data mismatches between
frontend and backend: wrapped payload ({lines:[...]}) vs expected
raw array, snake_case vs camelCase field names (account_id vs
accountId), and dec_amt vs dec for December values.
Add read-only budget view as default for existing budgets with an
"Edit Budget" button to enter edit mode, and Cancel to discard
changes - reducing accidental edits.
Bump version to 2026.03.10 across all packages and settings.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.7-beta",
|
"version": "2026.03.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.7-beta",
|
"version": "2026.03.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.4.15",
|
"@nestjs/common": "^10.4.15",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^3.3.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.7-beta",
|
"version": "2026.03.10",
|
||||||
"description": "HOA LedgerIQ - Backend API",
|
"description": "HOA LedgerIQ - Backend API",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ async function bootstrap() {
|
|||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('HOA LedgerIQ API')
|
.setTitle('HOA LedgerIQ API')
|
||||||
.setDescription('API for the HOA LedgerIQ')
|
.setDescription('API for the HOA LedgerIQ')
|
||||||
.setVersion('2026.3.7')
|
.setVersion('2026.03.10')
|
||||||
.addBearerAuth()
|
.addBearerAuth()
|
||||||
.build();
|
.build();
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.7-beta",
|
"version": "2026.03.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.7-beta",
|
"version": "2026.03.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/core": "^7.15.3",
|
"@mantine/core": "^7.15.3",
|
||||||
"@mantine/dates": "^7.15.3",
|
"@mantine/dates": "^7.15.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.7-beta",
|
"version": "2026.03.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Select, Loader, Center, Badge, Card, Alert,
|
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, IconInfoCircle } from '@tabler/icons-react';
|
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle, IconPencil, IconX } 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';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
@@ -96,6 +96,7 @@ function parseCSV(text: string): Record<string, string>[] {
|
|||||||
export function BudgetsPage() {
|
export function BudgetsPage() {
|
||||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||||
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = useIsReadOnly();
|
||||||
@@ -105,6 +106,11 @@ export function BudgetsPage() {
|
|||||||
const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
|
||||||
const expenseSectionBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
|
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 { isLoading } = useQuery<BudgetLine[]>({
|
const { isLoading } = useQuery<BudgetLine[]>({
|
||||||
queryKey: ['budgets', year],
|
queryKey: ['budgets', year],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -112,25 +118,27 @@ export function BudgetsPage() {
|
|||||||
// Hydrate each line: ensure numbers and compute annual_total
|
// Hydrate each line: ensure numbers and compute annual_total
|
||||||
const hydrated = (data as any[]).map(hydrateBudgetLine);
|
const hydrated = (data as any[]).map(hydrateBudgetLine);
|
||||||
setBudgetData(hydrated);
|
setBudgetData(hydrated);
|
||||||
|
setIsEditing(false); // Reset to view mode when year changes or data reloads
|
||||||
return hydrated;
|
return hydrated;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const lines = budgetData
|
const payload = budgetData
|
||||||
.filter((b) => months.some((m) => (b as any)[m] > 0))
|
.filter((b) => months.some((m) => (b as any)[m] > 0))
|
||||||
.map((b) => ({
|
.map((b) => ({
|
||||||
account_id: b.account_id,
|
accountId: b.account_id,
|
||||||
fund_type: b.fund_type,
|
fundType: b.fund_type,
|
||||||
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
|
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
|
||||||
may: b.may, jun: b.jun, jul: b.jul, aug: b.aug,
|
may: b.may, jun: b.jun, jul: b.jul, aug: b.aug,
|
||||||
sep: b.sep, oct: b.oct, nov: b.nov, dec_amt: b.dec_amt,
|
sep: b.sep, oct: b.oct, nov: b.nov, dec: b.dec_amt,
|
||||||
}));
|
}));
|
||||||
return api.put(`/budgets/${year}`, { lines });
|
return api.put(`/budgets/${year}`, payload);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
||||||
|
setIsEditing(false);
|
||||||
notifications.show({ message: 'Budget saved', color: 'green' });
|
notifications.show({ message: 'Budget saved', color: 'green' });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
@@ -227,6 +235,12 @@ export function BudgetsPage() {
|
|||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
// Re-fetch to discard unsaved changes
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
||||||
|
};
|
||||||
|
|
||||||
const updateCell = (idx: number, month: string, value: number) => {
|
const updateCell = (idx: number, month: string, value: number) => {
|
||||||
const updated = [...budgetData];
|
const updated = [...budgetData];
|
||||||
(updated[idx] as any)[month] = value || 0;
|
(updated[idx] as any)[month] = value || 0;
|
||||||
@@ -281,9 +295,35 @@ export function BudgetsPage() {
|
|||||||
accept=".csv,.txt"
|
accept=".csv,.txt"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
|
{hasBudget && !isEditing ? (
|
||||||
Save Budget
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
|
leftSection={<IconPencil size={16} />}
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
>
|
||||||
|
Edit Budget
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isEditing && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<IconX size={16} />}
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
leftSection={<IconDeviceFloppy size={16} />}
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
loading={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
Save Budget
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>)}
|
</>)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -397,16 +437,21 @@ export function BudgetsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
{months.map((m) => (
|
{months.map((m) => (
|
||||||
<Table.Td key={m} p={2}>
|
<Table.Td key={m} p={2}>
|
||||||
<NumberInput
|
{cellsEditable ? (
|
||||||
value={(line as any)[m] || 0}
|
<NumberInput
|
||||||
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
|
value={(line as any)[m] || 0}
|
||||||
size="xs"
|
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
|
||||||
hideControls
|
size="xs"
|
||||||
decimalScale={2}
|
hideControls
|
||||||
min={0}
|
decimalScale={2}
|
||||||
disabled={isReadOnly}
|
min={0}
|
||||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" ta="right" ff="monospace">
|
||||||
|
{fmt((line as any)[m] || 0)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
))}
|
))}
|
||||||
<Table.Td ta="right" fw={500} ff="monospace">
|
<Table.Td ta="right" fw={500} ff="monospace">
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function SettingsPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Version</Text>
|
<Text size="sm" c="dimmed">Version</Text>
|
||||||
<Badge variant="light">2026.3.7 (Beta)</Badge>
|
<Badge variant="light">2026.03.10</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">API</Text>
|
<Text size="sm" c="dimmed">API</Text>
|
||||||
|
|||||||
Reference in New Issue
Block a user