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",
|
||||
"version": "2026.3.7-beta",
|
||||
"version": "2026.03.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hoa-ledgeriq-backend",
|
||||
"version": "2026.3.7-beta",
|
||||
"version": "2026.03.10",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-backend",
|
||||
"version": "2026.3.7-beta",
|
||||
"version": "2026.03.10",
|
||||
"description": "HOA LedgerIQ - Backend API",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -67,7 +67,7 @@ async function bootstrap() {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('HOA LedgerIQ API')
|
||||
.setDescription('API for the HOA LedgerIQ')
|
||||
.setVersion('2026.3.7')
|
||||
.setVersion('2026.03.10')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
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",
|
||||
"version": "2026.3.7-beta",
|
||||
"version": "2026.03.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hoa-ledgeriq-frontend",
|
||||
"version": "2026.3.7-beta",
|
||||
"version": "2026.03.10",
|
||||
"dependencies": {
|
||||
"@mantine/core": "^7.15.3",
|
||||
"@mantine/dates": "^7.15.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-frontend",
|
||||
"version": "2026.3.7-beta",
|
||||
"version": "2026.03.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Select, Loader, Center, Badge, Card, Alert,
|
||||
} from '@mantine/core';
|
||||
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 api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
@@ -96,6 +96,7 @@ function parseCSV(text: string): Record<string, string>[] {
|
||||
export function BudgetsPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const isReadOnly = useIsReadOnly();
|
||||
@@ -105,6 +106,11 @@ 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 { isLoading } = useQuery<BudgetLine[]>({
|
||||
queryKey: ['budgets', year],
|
||||
queryFn: async () => {
|
||||
@@ -112,25 +118,27 @@ export function BudgetsPage() {
|
||||
// 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
|
||||
return hydrated;
|
||||
},
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const lines = budgetData
|
||||
const payload = budgetData
|
||||
.filter((b) => months.some((m) => (b as any)[m] > 0))
|
||||
.map((b) => ({
|
||||
account_id: b.account_id,
|
||||
fund_type: b.fund_type,
|
||||
accountId: b.account_id,
|
||||
fundType: b.fund_type,
|
||||
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
|
||||
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: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
||||
setIsEditing(false);
|
||||
notifications.show({ message: 'Budget saved', color: 'green' });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
@@ -227,6 +235,12 @@ export function BudgetsPage() {
|
||||
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 updated = [...budgetData];
|
||||
(updated[idx] as any)[month] = value || 0;
|
||||
@@ -281,9 +295,35 @@ export function BudgetsPage() {
|
||||
accept=".csv,.txt"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
|
||||
{hasBudget && !isEditing ? (
|
||||
<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>
|
||||
@@ -397,6 +437,7 @@ export function BudgetsPage() {
|
||||
</Table.Td>
|
||||
{months.map((m) => (
|
||||
<Table.Td key={m} p={2}>
|
||||
{cellsEditable ? (
|
||||
<NumberInput
|
||||
value={(line as any)[m] || 0}
|
||||
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
|
||||
@@ -404,9 +445,13 @@ export function BudgetsPage() {
|
||||
hideControls
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
disabled={isReadOnly}
|
||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm" ta="right" ff="monospace">
|
||||
{fmt((line as any)[m] || 0)}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
))}
|
||||
<Table.Td ta="right" fw={500} ff="monospace">
|
||||
|
||||
@@ -117,7 +117,7 @@ export function SettingsPage() {
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Version</Text>
|
||||
<Badge variant="light">2026.3.7 (Beta)</Badge>
|
||||
<Badge variant="light">2026.03.10</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">API</Text>
|
||||
|
||||
Reference in New Issue
Block a user