Files
HOA_Financial_Platform/frontend/src/pages/reserves/ReservesPage.tsx
olsch01 c92eb1b57b RBAC: Enforce read-only viewer role across backend and frontend
- Add global WriteAccessGuard that blocks POST/PUT/PATCH/DELETE for viewer role
- Add @AllowViewer() decorator for endpoints viewers need (switch-org, intro-seen, AI recommendations)
- Add useIsReadOnly hook to auth store for frontend role checks
- Hide write UI (add/edit/delete/import buttons, inline editors) in all 13 data pages for viewers
- Disable inline NumberInputs on Budgets and Monthly Actuals pages for viewers
- Skip onboarding wizard for viewer role users

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:18:32 -05:00

178 lines
9.1 KiB
TypeScript

import { useState } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
Card, SimpleGrid, Progress, RingProgress,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface ReserveComponent {
id: string; name: string; category: string; description: string;
useful_life_years: number; remaining_life_years: number;
replacement_cost: string; current_fund_balance: string;
annual_contribution: string; last_replacement_date: string;
next_replacement_date: string; condition_rating: number;
}
const categories = ['roof', 'pool', 'hvac', 'paving', 'painting', 'fencing', 'elevator', 'irrigation', 'clubhouse', 'other'];
export function ReservesPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<ReserveComponent | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
queryKey: ['reserve-components'],
queryFn: async () => { const { data } = await api.get('/reserve-components'); return data; },
});
const form = useForm({
initialValues: {
name: '', category: 'other', description: '', useful_life_years: 20,
remaining_life_years: 10, replacement_cost: 0, current_fund_balance: 0,
annual_contribution: 0, condition_rating: 5,
last_replacement_date: null as Date | null, next_replacement_date: null as Date | null,
},
validate: {
name: (v) => (v.length > 0 ? null : 'Required'),
useful_life_years: (v) => (v > 0 ? null : 'Required'),
replacement_cost: (v) => (v > 0 ? null : 'Required'),
},
});
const saveMutation = useMutation({
mutationFn: (values: any) => {
const payload = {
...values,
last_replacement_date: values.last_replacement_date?.toISOString?.()?.split('T')[0] || null,
next_replacement_date: values.next_replacement_date?.toISOString?.()?.split('T')[0] || null,
};
return editing ? api.put(`/reserve-components/${editing.id}`, payload) : api.post('/reserve-components', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reserve-components'] });
notifications.show({ message: editing ? 'Component updated' : 'Component created', color: 'green' });
close(); setEditing(null); form.reset();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const handleEdit = (c: ReserveComponent) => {
setEditing(c);
form.setValues({
name: c.name, category: c.category || 'other', description: c.description || '',
useful_life_years: c.useful_life_years, remaining_life_years: c.remaining_life_years || 0,
replacement_cost: parseFloat(c.replacement_cost || '0'),
current_fund_balance: parseFloat(c.current_fund_balance || '0'),
annual_contribution: parseFloat(c.annual_contribution || '0'),
condition_rating: c.condition_rating || 5,
last_replacement_date: c.last_replacement_date ? new Date(c.last_replacement_date) : null,
next_replacement_date: c.next_replacement_date ? new Date(c.next_replacement_date) : null,
});
open();
};
const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const totalCost = components.reduce((s, c) => s + parseFloat(c.replacement_cost || '0'), 0);
const totalFunded = components.reduce((s, c) => s + parseFloat(c.current_fund_balance || '0'), 0);
const pctFunded = totalCost > 0 ? (totalFunded / totalCost) * 100 : 0;
if (isLoading) return <Center h={300}><Loader /></Center>;
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Reserve Components</Title>
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Component</Button>}
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Replacement Cost</Text>
<Text fw={700} size="xl">{fmt(totalCost)}</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Funded</Text>
<Text fw={700} size="xl" c="green">{fmt(totalFunded)}</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Percent Funded</Text>
<Group>
<Text fw={700} size="xl" c={pctFunded >= 70 ? 'green' : pctFunded >= 40 ? 'yellow' : 'red'}>
{pctFunded.toFixed(1)}%
</Text>
<Progress value={pctFunded} size="lg" style={{ flex: 1 }} color={pctFunded >= 70 ? 'green' : pctFunded >= 40 ? 'yellow' : 'red'} />
</Group>
</Card>
</SimpleGrid>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Component</Table.Th><Table.Th>Category</Table.Th>
<Table.Th>Useful Life</Table.Th><Table.Th>Remaining</Table.Th>
<Table.Th ta="right">Replacement Cost</Table.Th><Table.Th ta="right">Funded</Table.Th>
<Table.Th>Condition</Table.Th><Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{components.map((c) => {
const funded = parseFloat(c.current_fund_balance || '0');
const cost = parseFloat(c.replacement_cost || '0');
const pct = cost > 0 ? (funded / cost) * 100 : 0;
return (
<Table.Tr key={c.id}>
<Table.Td fw={500}>{c.name}</Table.Td>
<Table.Td><Badge size="sm" variant="light">{c.category}</Badge></Table.Td>
<Table.Td>{c.useful_life_years} yrs</Table.Td>
<Table.Td>{c.remaining_life_years} yrs</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(c.replacement_cost)}</Table.Td>
<Table.Td ta="right" ff="monospace">
<Text span c={pct >= 70 ? 'green' : pct >= 40 ? 'yellow' : 'red'}>{fmt(c.current_fund_balance)} ({pct.toFixed(0)}%)</Text>
</Table.Td>
<Table.Td>
<Badge color={c.condition_rating >= 7 ? 'green' : c.condition_rating >= 4 ? 'yellow' : 'red'} size="sm">
{c.condition_rating}/10
</Badge>
</Table.Td>
<Table.Td>{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(c)}><IconEdit size={16} /></ActionIcon>}</Table.Td>
</Table.Tr>
);
})}
{components.length === 0 && <Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No reserve components yet</Text></Table.Td></Table.Tr>}
</Table.Tbody>
</Table>
<Modal opened={opened} onClose={close} title={editing ? 'Edit Component' : 'New Reserve Component'} size="lg">
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
<Stack>
<Group grow><TextInput label="Name" required {...form.getInputProps('name')} />
<Select label="Category" data={categories.map(c => ({ value: c, label: c.charAt(0).toUpperCase() + c.slice(1) }))} {...form.getInputProps('category')} /></Group>
<Textarea label="Description" {...form.getInputProps('description')} />
<Group grow>
<NumberInput label="Useful Life (years)" required min={1} {...form.getInputProps('useful_life_years')} />
<NumberInput label="Remaining Life (years)" min={0} decimalScale={1} {...form.getInputProps('remaining_life_years')} />
<NumberInput label="Condition (1-10)" min={1} max={10} {...form.getInputProps('condition_rating')} />
</Group>
<Group grow>
<NumberInput label="Replacement Cost" required prefix="$" decimalScale={2} min={0} {...form.getInputProps('replacement_cost')} />
<NumberInput label="Current Fund Balance" prefix="$" decimalScale={2} min={0} {...form.getInputProps('current_fund_balance')} />
<NumberInput label="Annual Contribution" prefix="$" decimalScale={2} min={0} {...form.getInputProps('annual_contribution')} />
</Group>
<Group grow>
<DateInput label="Last Replacement" clearable {...form.getInputProps('last_replacement_date')} />
<DateInput label="Next Replacement" clearable {...form.getInputProps('next_replacement_date')} />
</Group>
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
</Stack>
</form>
</Modal>
</Stack>
);
}