- 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>
178 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
}
|