Files
HOA_Financial_Platform/frontend/src/pages/projects/ProjectsPage.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

662 lines
21 KiB
TypeScript

import { useState, useRef } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
Card, SimpleGrid, Progress, Switch, Tooltip,
} 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, IconUpload, IconDownload, IconLock, IconLockOpen } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { parseCSV, downloadBlob } from '../../utils/csv';
import { useIsReadOnly } from '../../stores/authStore';
// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------
interface Project {
id: string;
name: string;
description: string;
category: string;
estimated_cost: string;
actual_cost: string;
current_fund_balance: string;
annual_contribution: string;
fund_source: string;
funded_percentage: string;
useful_life_years: number;
remaining_life_years: number;
condition_rating: number;
last_replacement_date: string;
next_replacement_date: string;
planned_date: string;
target_year: number;
target_month: number;
status: string;
priority: number;
account_id: string;
notes: string;
is_active: boolean;
is_funding_locked: boolean;
}
const FUTURE_YEAR = 9999;
const categories = [
'roof', 'pool', 'hvac', 'paving', 'painting',
'fencing', 'elevator', 'irrigation', 'clubhouse', 'other',
];
const statusColors: Record<string, string> = {
planned: 'blue',
approved: 'green',
in_progress: 'yellow',
completed: 'teal',
deferred: 'gray',
cancelled: 'red',
};
const fundSourceColors: Record<string, string> = {
operating: 'gray',
reserve: 'violet',
special_assessment: 'orange',
};
const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
// ---------------------------------------------------------------------------
// Main page component
// ---------------------------------------------------------------------------
export function ProjectsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Project | null>(null);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = useIsReadOnly();
// ---- Data fetching ----
const { data: projects = [], isLoading } = useQuery<Project[]>({
queryKey: ['projects'],
queryFn: async () => {
const { data } = await api.get('/projects');
return data;
},
});
// ---- Derived summary values ----
const totalEstimatedCost = projects.reduce(
(sum, p) => sum + parseFloat(p.estimated_cost || '0'),
0,
);
const reserveProjects = projects.filter((p) => p.fund_source === 'reserve');
const totalFundedReserve = reserveProjects.reduce(
(sum, p) => sum + parseFloat(p.current_fund_balance || '0'),
0,
);
const totalReserveReplacementCost = reserveProjects.reduce(
(sum, p) => sum + parseFloat(p.estimated_cost || '0'),
0,
);
const pctFundedReserve =
totalReserveReplacementCost > 0
? (totalFundedReserve / totalReserveReplacementCost) * 100
: 0;
// ---- Form setup ----
const currentYear = new Date().getFullYear();
const targetYearOptions = [
...Array.from({ length: 6 }, (_, i) => ({
value: String(currentYear + i),
label: String(currentYear + i),
})),
{ value: String(FUTURE_YEAR), label: 'Future (Beyond 5-Year)' },
];
const monthOptions = Array.from({ length: 12 }, (_, i) => ({
value: String(i + 1),
label: new Date(2000, i).toLocaleString('default', { month: 'long' }),
}));
const form = useForm({
initialValues: {
name: '',
category: 'other',
description: '',
fund_source: 'reserve',
status: 'planned',
priority: 3,
estimated_cost: 0,
actual_cost: 0,
current_fund_balance: 0,
annual_contribution: 0,
funded_percentage: 0,
useful_life_years: 20,
remaining_life_years: 10,
condition_rating: 5,
last_replacement_date: null as Date | null,
next_replacement_date: null as Date | null,
planned_date: null as Date | null,
target_year: currentYear,
target_month: 6,
notes: '',
is_funding_locked: false,
},
validate: {
name: (v) => (v.length > 0 ? null : 'Required'),
estimated_cost: (v) => (v > 0 ? null : 'Required'),
},
});
// ---- Mutations ----
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,
planned_date:
values.planned_date?.toISOString?.()?.split('T')[0] || null,
};
return editing
? api.put(`/projects/${editing.id}`, payload)
: api.post('/projects', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
notifications.show({
message: editing ? 'Project updated' : 'Project created',
color: 'green',
});
close();
setEditing(null);
form.reset();
},
onError: (err: any) => {
notifications.show({
message: err.response?.data?.message || 'Error',
color: 'red',
});
},
});
const importMutation = useMutation({
mutationFn: async (rows: Record<string, string>[]) => {
const { data } = await api.post('/projects/import', rows);
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
let msg = `Imported: ${data.created} created, ${data.updated} updated`;
if (data.errors?.length) msg += `. ${data.errors.length} error(s): ${data.errors.slice(0, 3).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 handleExport = async () => {
try {
const response = await api.get('/projects/export', { responseType: 'blob' });
downloadBlob(response.data, 'projects.csv');
} catch { notifications.show({ message: 'Export failed', color: 'red' }); }
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
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) { notifications.show({ message: 'No data rows found', color: 'red' }); return; }
importMutation.mutate(rows);
};
reader.readAsText(file);
event.target.value = '';
};
// ---- Handlers ----
const handleEdit = (p: Project) => {
setEditing(p);
form.setValues({
name: p.name,
category: p.category || 'other',
description: p.description || '',
fund_source: p.fund_source || 'reserve',
status: p.status || 'planned',
priority: p.priority || 3,
estimated_cost: parseFloat(p.estimated_cost || '0'),
actual_cost: parseFloat(p.actual_cost || '0'),
current_fund_balance: parseFloat(p.current_fund_balance || '0'),
annual_contribution: parseFloat(p.annual_contribution || '0'),
funded_percentage: parseFloat(p.funded_percentage || '0'),
useful_life_years: p.useful_life_years || 0,
remaining_life_years: p.remaining_life_years || 0,
condition_rating: p.condition_rating || 5,
last_replacement_date: p.last_replacement_date
? new Date(p.last_replacement_date)
: null,
next_replacement_date: p.next_replacement_date
? new Date(p.next_replacement_date)
: null,
planned_date: p.planned_date ? new Date(p.planned_date) : null,
target_year: p.target_year || currentYear,
target_month: p.target_month || 6,
notes: p.notes || '',
is_funding_locked: p.is_funding_locked || false,
});
open();
};
const handleNew = () => {
setEditing(null);
form.reset();
open();
};
// ---- Helpers for table rendering ----
const formatDate = (dateStr: string | null | undefined) => {
if (!dateStr) return '-';
const d = new Date(dateStr);
if (isNaN(d.getTime())) return '-';
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const conditionBadge = (rating: number | null | undefined) => {
if (rating == null) return <Text c="dimmed">-</Text>;
const color = rating >= 7 ? 'green' : rating >= 4 ? 'yellow' : 'red';
return (
<Badge size="sm" color={color}>
{rating}/10
</Badge>
);
};
const fundedPercentageCell = (project: Project) => {
if (project.fund_source !== 'reserve') {
return <Text c="dimmed">-</Text>;
}
const cost = parseFloat(project.estimated_cost || '0');
const funded = parseFloat(project.current_fund_balance || '0');
const pct = cost > 0 ? (funded / cost) * 100 : 0;
const color = pct >= 70 ? 'green' : pct >= 40 ? 'yellow' : 'red';
return (
<Group gap={4} justify="flex-end">
{project.is_funding_locked && (
<Tooltip label="Funding manually locked">
<IconLock size={14} style={{ color: 'var(--mantine-color-blue-5)' }} />
</Tooltip>
)}
<Text span c={color} ff="monospace">
{pct.toFixed(0)}%
</Text>
</Group>
);
};
// ---- Loading state ----
if (isLoading) return <Center h={300}><Loader /></Center>;
// ---- Render ----
return (
<Stack>
{/* Header */}
<Group justify="space-between">
<Title order={2}>Projects</Title>
<Group>
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={projects.length === 0}>
Export CSV
</Button>
{!isReadOnly && (<>
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
loading={importMutation.isPending}>
Import CSV
</Button>
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
+ Add Project
</Button>
</>)}
</Group>
</Group>
{/* Summary Cards */}
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Total Estimated Cost
</Text>
<Text fw={700} size="xl">
{fmt(totalEstimatedCost)}
</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Total Funded - Reserve Only
</Text>
<Text fw={700} size="xl" c="green">
{fmt(totalFundedReserve)}
</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Percent Funded - Reserve Only
</Text>
<Group>
<Text
fw={700}
size="xl"
c={
pctFundedReserve >= 70
? 'green'
: pctFundedReserve >= 40
? 'yellow'
: 'red'
}
>
{pctFundedReserve.toFixed(1)}%
</Text>
<Progress
value={pctFundedReserve}
size="lg"
style={{ flex: 1 }}
color={
pctFundedReserve >= 70
? 'green'
: pctFundedReserve >= 40
? 'yellow'
: 'red'
}
/>
</Group>
</Card>
</SimpleGrid>
{/* Projects Table */}
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Project Name</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Fund Source</Table.Th>
<Table.Th ta="right">Estimated Cost</Table.Th>
<Table.Th ta="right">Funded %</Table.Th>
<Table.Th>Condition</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Planned Date</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{projects.map((p) => (
<Table.Tr key={p.id}>
<Table.Td>
<Text fw={500}>{p.name}</Text>
{p.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{p.description}
</Text>
)}
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light">
{p.category
? p.category.charAt(0).toUpperCase() + p.category.slice(1)
: '-'}
</Badge>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={fundSourceColors[p.fund_source] || 'gray'}
>
{p.fund_source?.replace('_', ' ') || '-'}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">
{fmt(p.estimated_cost)}
</Table.Td>
<Table.Td ta="right">{fundedPercentageCell(p)}</Table.Td>
<Table.Td>{conditionBadge(p.condition_rating)}</Table.Td>
<Table.Td>
<Badge
size="sm"
color={statusColors[p.status] || 'gray'}
>
{p.status?.replace('_', ' ') || '-'}
</Badge>
</Table.Td>
<Table.Td>{formatDate(p.planned_date)}</Table.Td>
<Table.Td>
{!isReadOnly && (
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
<IconEdit size={16} />
</ActionIcon>
)}
</Table.Td>
</Table.Tr>
))}
{projects.length === 0 && (
<Table.Tr>
<Table.Td colSpan={9}>
<Text ta="center" c="dimmed" py="lg">
No projects yet
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
{/* Create / Edit Modal */}
<Modal
opened={opened}
onClose={close}
title={editing ? 'Edit Project' : 'New Project'}
size="lg"
>
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
<Stack>
{/* Row 1: Name + Category */}
<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>
{/* Row 2: Description */}
<Textarea
label="Description"
{...form.getInputProps('description')}
/>
{/* Row 3: Fund Source, Status, Priority */}
<Group grow>
<Select
label="Fund Source"
data={[
{ value: 'operating', label: 'Operating' },
{ value: 'reserve', label: 'Reserve' },
{ value: 'special_assessment', label: 'Special Assessment' },
]}
{...form.getInputProps('fund_source')}
/>
<Select
label="Status"
data={Object.keys(statusColors).map((s) => ({
value: s,
label: s.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()),
}))}
{...form.getInputProps('status')}
/>
<NumberInput
label="Priority (1-5)"
min={1}
max={5}
{...form.getInputProps('priority')}
/>
</Group>
{/* Row 4: Estimated Cost, Actual Cost */}
<Group grow>
<NumberInput
label="Estimated Cost"
required
prefix="$"
decimalScale={2}
min={0}
{...form.getInputProps('estimated_cost')}
/>
<NumberInput
label="Actual Cost"
prefix="$"
decimalScale={2}
min={0}
{...form.getInputProps('actual_cost')}
/>
</Group>
{/* Row 5: Conditional reserve fields */}
{form.values.fund_source === 'reserve' && (
<>
<Switch
label="Lock Funding"
description="When locked, the fund balance and percentage you set here will be used as-is instead of being auto-calculated from the reserve balance"
{...form.getInputProps('is_funding_locked', { type: 'checkbox' })}
/>
<Group grow>
<NumberInput
label="Current Fund Balance"
prefix="$"
decimalScale={2}
min={0}
disabled={!form.values.is_funding_locked}
{...form.getInputProps('current_fund_balance')}
/>
<NumberInput
label="Annual Contribution"
prefix="$"
decimalScale={2}
min={0}
{...form.getInputProps('annual_contribution')}
/>
<NumberInput
label="Funded Percentage"
suffix="%"
decimalScale={1}
min={0}
max={100}
disabled={!form.values.is_funding_locked}
{...form.getInputProps('funded_percentage')}
/>
</Group>
<Group grow>
<NumberInput
label="Useful Life (years)"
min={0}
{...form.getInputProps('useful_life_years')}
/>
<NumberInput
label="Remaining Life (years)"
min={0}
decimalScale={1}
{...form.getInputProps('remaining_life_years')}
/>
<NumberInput
label="Condition Rating (1-10)"
min={1}
max={10}
{...form.getInputProps('condition_rating')}
/>
</Group>
</>
)}
{/* Row 6: Last / Next Replacement Date */}
<Group grow>
<DateInput
label="Last Replacement Date"
clearable
{...form.getInputProps('last_replacement_date')}
/>
<DateInput
label="Next Replacement Date"
clearable
{...form.getInputProps('next_replacement_date')}
/>
</Group>
{/* Row 7: Planned Date */}
<DateInput
label="Planned Date"
description="Defaults to Next Replacement Date if not set"
clearable
{...form.getInputProps('planned_date')}
/>
{/* Row 8: Target Year + Target Month */}
<Group grow>
<Select
label="Target Year"
data={targetYearOptions}
value={String(form.values.target_year)}
onChange={(v) => form.setFieldValue('target_year', Number(v))}
/>
<Select
label="Target Month"
data={monthOptions}
value={String(form.values.target_month)}
onChange={(v) => form.setFieldValue('target_month', Number(v))}
/>
</Group>
{/* Row 9: Notes */}
<Textarea label="Notes" {...form.getInputProps('notes')} />
<Button type="submit" loading={saveMutation.isPending}>
{editing ? 'Update' : 'Create'}
</Button>
</Stack>
</form>
</Modal>
</Stack>
);
}