Files
HOA_Financial_Platform/frontend/src/pages/capital-projects/CapitalProjectsPage.tsx
olsch01 337b6061b2 feat: reliability enhancements for AI services and capital planning
1. Health Scores — separate operating/reserve refresh
   - Added POST /health-scores/calculate/operating and /calculate/reserve
   - Each health card now has its own Refresh button
   - On failure, shows cached (last good) data with "last analysis failed"
     watermark instead of blank "Error calculating score"
   - Backend getLatestScores returns latest complete score + failure flag

2. Investment Planning — increased AI timeout to 5 minutes
   - Backend callAI timeout: 180s → 300s
   - Frontend axios timeout: set explicitly to 300s (was browser default)
   - Host nginx proxy_read_timeout: 180s → 300s
   - Loading message updated to reflect longer wait times

3. Capital Planning — Unscheduled column moved to rightmost position
   - Kanban column order: current year → future → unscheduled (was leftmost)
   - Puts immediate/near-term projects front and center

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 12:02:30 -05:00

715 lines
25 KiB
TypeScript

import { useState, useCallback, useEffect, useRef, DragEvent } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
SegmentedControl, Card, Paper, ScrollArea, Box, Tooltip,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconEdit, IconTable, IconLayoutKanban, IconFileTypePdf,
IconGripVertical, IconCalendar, IconClipboardList,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------
interface Project {
id: string;
name: string;
description: string;
category: string;
estimated_cost: string;
actual_cost: string;
fund_source: string;
funded_percentage: string;
planned_date: string;
target_year: number | null;
target_month: number;
status: string;
priority: number;
notes: string;
}
const FUTURE_YEAR = 9999;
const UNSCHEDULED = -1; // sentinel for projects with no target_year
const statusColors: Record<string, string> = {
planned: 'blue', approved: 'green', in_progress: 'yellow',
completed: 'teal', deferred: 'gray', cancelled: 'red',
};
const priorityColor = (p: number) => (p <= 2 ? 'red' : p <= 3 ? 'yellow' : 'gray');
const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const yearLabel = (year: number) =>
year === FUTURE_YEAR ? 'Future' : year === UNSCHEDULED ? 'Unscheduled' : String(year);
const formatPlannedDate = (d: string | null | undefined) => {
if (!d) return null;
try {
const date = new Date(d);
if (isNaN(date.getTime())) return null;
return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
} catch {
return null;
}
};
// ---------------------------------------------------------------------------
// Kanban card
// ---------------------------------------------------------------------------
interface KanbanCardProps {
project: Project;
onEdit: (p: Project) => void;
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
}
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
const plannedLabel = formatPlannedDate(project.planned_date);
// For projects in the Future bucket with a specific year, show the year
const currentYear = new Date().getFullYear();
const isBeyondWindow = project.target_year !== null && project.target_year > currentYear + 4 && project.target_year !== FUTURE_YEAR;
return (
<Card
shadow="sm"
padding="sm"
radius="md"
withBorder
draggable
onDragStart={(e) => onDragStart(e, project)}
style={{ cursor: 'grab', userSelect: 'none' }}
mb="xs"
>
<Group justify="space-between" wrap="nowrap" mb={4}>
<Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}>
<IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />
<Text fw={600} size="sm" truncate>
{project.name}
</Text>
</Group>
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
<IconEdit size={14} />
</ActionIcon>
</Group>
<Group gap={6} mb={6}>
<Badge size="xs" color={statusColors[project.status] || 'gray'}>
{project.status.replace('_', ' ')}
</Badge>
<Badge size="xs" color={priorityColor(project.priority)} variant="outline">
P{project.priority}
</Badge>
{isBeyondWindow && (
<Badge size="xs" variant="light" color="gray">
{project.target_year}
</Badge>
)}
</Group>
<Text size="xs" ff="monospace" fw={500} mb={4}>
{fmt(project.estimated_cost)}
</Text>
<Group gap={6} wrap="wrap">
<Badge size="xs" variant="light" color="violet">
{project.fund_source?.replace('_', ' ') || 'reserve'}
</Badge>
{plannedLabel && (
<Badge size="xs" variant="light" color="cyan" leftSection={<IconCalendar size={10} />}>
{plannedLabel}
</Badge>
)}
</Group>
</Card>
);
}
// ---------------------------------------------------------------------------
// Kanban column (year)
// ---------------------------------------------------------------------------
interface KanbanColumnProps {
year: number;
projects: Project[];
onEdit: (p: Project) => void;
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
onDrop: (e: DragEvent<HTMLDivElement>, targetYear: number) => void;
isDragOver: boolean;
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
onDragLeave: () => void;
}
function KanbanColumn({
year, projects, onEdit, onDragStart, onDrop,
isDragOver, onDragOverHandler, onDragLeave,
}: KanbanColumnProps) {
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
const isFuture = year === FUTURE_YEAR;
const isUnscheduled = year === UNSCHEDULED;
const useWideLayout = (isFuture || isUnscheduled) && projects.length > 3;
return (
<Paper
withBorder
radius="md"
p="sm"
miw={useWideLayout ? 580 : 280}
maw={useWideLayout ? 640 : 320}
style={{
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
backgroundColor: isDragOver
? 'var(--mantine-color-blue-0)'
: isUnscheduled
? 'var(--mantine-color-orange-0)'
: undefined,
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
transition: 'background-color 150ms ease, border 150ms ease',
}}
onDragOver={(e) => onDragOverHandler(e, year)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, year)}
>
<Group justify="space-between" mb="sm">
<Title order={5}>{yearLabel(year)}</Title>
<Group gap={6}>
{isUnscheduled && projects.length > 0 && (
<Badge size="xs" variant="light" color="orange">needs scheduling</Badge>
)}
<Badge size="sm" variant="light">{fmt(totalEst)}</Badge>
</Group>
</Group>
<Text size="xs" c="dimmed" mb="xs">
{projects.length} project{projects.length !== 1 ? 's' : ''}
</Text>
<Box style={{ flex: 1, minHeight: 60 }}>
{projects.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="lg">
Drop projects here
</Text>
) : useWideLayout ? (
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 'var(--mantine-spacing-xs)',
}}>
{projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
))}
</div>
) : (
projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
))
)}
</Box>
</Paper>
);
}
// ---------------------------------------------------------------------------
// Print styles - hides kanban and shows the table view when printing
// ---------------------------------------------------------------------------
const printStyles = `
@media print {
.capital-projects-kanban-view {
display: none !important;
}
.capital-projects-table-view {
display: block !important;
}
}
`;
// ---------------------------------------------------------------------------
// Main page component
// ---------------------------------------------------------------------------
export function CapitalProjectsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Project | null>(null);
const [viewMode, setViewMode] = useState<string>('kanban');
const [printMode, setPrintMode] = useState(false);
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
const printModeRef = useRef(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
// ---- Data fetching ----
const { data: projects = [], isLoading } = useQuery<Project[]>({
queryKey: ['projects-planning'],
queryFn: async () => { const { data } = await api.get('/projects/planning'); return data; },
});
// ---- Form (simplified edit modal) ----
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 form = useForm({
initialValues: {
status: 'planned',
priority: 3,
target_year: currentYear,
target_month: 6,
planned_date: '',
notes: '',
},
});
// ---- Mutations ----
const saveMutation = useMutation({
mutationFn: (values: {
status: string;
priority: number;
target_year: number;
target_month: number;
planned_date: string;
notes: string;
}) => {
if (!editing) return Promise.reject(new Error('No project selected'));
const payload: Record<string, unknown> = {
status: values.status,
priority: values.priority,
target_year: values.target_year,
target_month: values.target_month,
notes: values.notes,
};
// Derive planned_date from target_year/target_month if not explicitly set
if (values.planned_date) {
payload.planned_date = values.planned_date;
} else if (values.target_year !== FUTURE_YEAR) {
payload.planned_date = `${values.target_year}-${String(values.target_month || 6).padStart(2, '0')}-01`;
} else {
payload.planned_date = null;
}
return api.put(`/projects/${editing.id}`, payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects-planning'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
notifications.show({ message: 'Project updated', color: 'green' });
close(); setEditing(null); form.reset();
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' });
},
});
const moveProjectMutation = useMutation({
mutationFn: ({ id, target_year, target_month }: { id: string; target_year: number | null; target_month: number }) => {
const payload: Record<string, unknown> = { target_year };
// Derive planned_date based on the new year
if (target_year === null || target_year === FUTURE_YEAR) {
payload.planned_date = null;
} else {
payload.planned_date = `${target_year}-${String(target_month || 6).padStart(2, '0')}-01`;
}
return api.put(`/projects/${id}`, payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects-planning'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
notifications.show({ message: 'Project moved successfully', color: 'green' });
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Failed to move project', color: 'red' });
},
});
// ---- Print mode effect ----
useEffect(() => {
if (printMode) {
printModeRef.current = true;
// Wait for the table to render before printing
const timer = setTimeout(() => {
window.print();
setPrintMode(false);
printModeRef.current = false;
}, 300);
return () => clearTimeout(timer);
}
}, [printMode]);
// ---- Handlers ----
const handleEdit = (p: Project) => {
setEditing(p);
form.setValues({
status: p.status || 'planned',
priority: p.priority || 3,
target_year: p.target_year ?? currentYear,
target_month: p.target_month || 6,
planned_date: p.planned_date || '',
notes: p.notes || '',
});
open();
};
const handlePdfExport = () => {
// If already in table view, just print directly
if (viewMode === 'table') {
window.print();
return;
}
// Otherwise, trigger printMode which renders the table for printing
setPrintMode(true);
};
// ---- Drag & Drop ----
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: Project) => {
e.dataTransfer.setData('application/json', JSON.stringify({
id: project.id,
source_year: project.target_year ?? UNSCHEDULED,
target_month: project.target_month,
}));
e.dataTransfer.effectAllowed = 'move';
}, []);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>, year: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverYear(year);
}, []);
const handleDragLeave = useCallback(() => {
setDragOverYear(null);
}, []);
const handleDrop = useCallback((e: DragEvent<HTMLDivElement>, targetYear: number) => {
e.preventDefault();
setDragOverYear(null);
try {
const payload = JSON.parse(e.dataTransfer.getData('application/json'));
if (payload.source_year !== targetYear) {
moveProjectMutation.mutate({
id: payload.id,
target_year: targetYear === UNSCHEDULED ? null : targetYear,
target_month: payload.target_month || 6,
});
}
} catch {
// ignore malformed drag data
}
}, [moveProjectMutation]);
// ---- Derived data ----
// Always show current year through current+4, plus FUTURE_YEAR if any projects have it
const baseYears = Array.from({ length: 5 }, (_, i) => currentYear + i);
const projectYears = [...new Set(projects.map((p) => p.target_year).filter((y): y is number => y !== null))];
const hasFutureProjects = projectYears.includes(FUTURE_YEAR);
const hasUnscheduledProjects = projects.some((p) => p.target_year === null);
// Merge base years with any extra years from projects (excluding FUTURE_YEAR for now)
const regularYears = [...new Set([...baseYears, ...projectYears.filter((y) => y !== FUTURE_YEAR)])].sort();
const years = [
...regularYears,
...(hasFutureProjects ? [FUTURE_YEAR] : []),
...(hasUnscheduledProjects ? [UNSCHEDULED] : []),
];
// Kanban columns: current..current+4 + Future + Unscheduled (rightmost)
const kanbanYears = [...baseYears, FUTURE_YEAR, UNSCHEDULED];
// ---- Loading state ----
const navigate = useNavigate();
if (isLoading) return <Center h={300}><Loader /></Center>;
// ---- Empty state when no planning projects exist ----
if (projects.length === 0) {
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Capital Planning</Title>
</Group>
<Center py={80}>
<Stack align="center" gap="md" maw={420}>
<IconClipboardList size={64} color="var(--mantine-color-dimmed)" stroke={1.2} />
<Title order={3} c="dimmed" ta="center">
No projects yet
</Title>
<Text c="dimmed" ta="center" size="sm">
Head over to the Projects page to define your reserve and operating
projects. They'll appear here for capital planning and scheduling.
</Text>
<Button
variant="light"
size="md"
leftSection={<IconClipboardList size={18} />}
onClick={() => navigate('/projects')}
>
Go to Projects
</Button>
</Stack>
</Center>
</Stack>
);
}
// ---- Render: Table view ----
const renderTableView = () => (
<>
{years.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No projects in the capital plan. Assign a target year to projects in the Projects page.
</Text>
) : (
years.map((year) => {
const yearProjects = year === UNSCHEDULED
? projects.filter((p) => p.target_year === null)
: projects.filter((p) => p.target_year === year);
if (yearProjects.length === 0) return null;
const totalEst = yearProjects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
return (
<Stack key={year} gap="xs">
<Group>
<Title order={4}>{yearLabel(year)}</Title>
<Badge size="lg" variant="light">{fmt(totalEst)} estimated</Badge>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Project</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Target</Table.Th>
<Table.Th>Priority</Table.Th>
<Table.Th ta="right">Estimated</Table.Th>
<Table.Th ta="right">Actual</Table.Th>
<Table.Th>Source</Table.Th>
<Table.Th>Funded</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Planned Date</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{yearProjects.map((p) => (
<Table.Tr key={p.id}>
<Table.Td fw={500}>{p.name}</Table.Td>
<Table.Td>{p.category || '-'}</Table.Td>
<Table.Td>
{p.target_year === null
? <Text size="sm" c="dimmed" fs="italic">Unscheduled</Text>
: p.target_year === FUTURE_YEAR
? 'Future'
: (
<>
{p.target_month
? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' })
: ''}{' '}
{p.target_year}
</>
)
}
</Table.Td>
<Table.Td>
<Badge size="sm" color={priorityColor(p.priority)}>P{p.priority}</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(p.estimated_cost)}</Table.Td>
<Table.Td ta="right" ff="monospace">
{parseFloat(p.actual_cost || '0') > 0 ? fmt(p.actual_cost) : '-'}
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light">{p.fund_source?.replace('_', ' ') || 'reserve'}</Badge>
</Table.Td>
<Table.Td>
{parseFloat(p.funded_percentage || '0') > 0
? `${parseFloat(p.funded_percentage).toFixed(0)}%`
: '-'}
</Table.Td>
<Table.Td>
<Badge size="sm" color={statusColors[p.status] || 'gray'}>{p.status?.replace('_', ' ')}</Badge>
</Table.Td>
<Table.Td>{formatPlannedDate(p.planned_date) || '-'}</Table.Td>
<Table.Td>
{!isReadOnly && <ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
<IconEdit size={16} />
</ActionIcon>}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
);
})
)}
</>
);
// ---- Render: Kanban view ----
const maxPlannedYear = currentYear + 4; // last year in the 5-year window
const renderKanbanView = () => (
<ScrollArea type="auto" offsetScrollbars>
<Group align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: kanbanYears.length * 300 }}>
{kanbanYears.map((year) => {
// Unscheduled: projects with no target_year
// Future: projects with target_year === 9999 OR beyond the 5-year window
// Otherwise: exact year match
const yearProjects = year === UNSCHEDULED
? projects.filter((p) => p.target_year === null)
: year === FUTURE_YEAR
? projects.filter((p) => p.target_year === FUTURE_YEAR || (p.target_year !== null && p.target_year > maxPlannedYear))
: projects.filter((p) => p.target_year === year);
return (
<KanbanColumn
key={year}
year={year}
projects={yearProjects}
onEdit={handleEdit}
onDragStart={handleDragStart}
onDrop={handleDrop}
isDragOver={dragOverYear === year}
onDragOverHandler={handleDragOver}
onDragLeave={handleDragLeave}
/>
);
})}
</Group>
</ScrollArea>
);
// ---- Render ----
return (
<Stack>
{/* Print-specific styles */}
<style>{printStyles}</style>
<Group justify="space-between">
<Title order={2}>Capital Planning</Title>
<Group gap="sm">
<SegmentedControl
value={viewMode}
onChange={setViewMode}
data={[
{
value: 'table',
label: (
<Group gap={6} wrap="nowrap">
<IconTable size={16} />
<Text size="sm">Table</Text>
</Group>
),
},
{
value: 'kanban',
label: (
<Group gap={6} wrap="nowrap">
<IconLayoutKanban size={16} />
<Text size="sm">Kanban</Text>
</Group>
),
},
]}
/>
<Tooltip label="Export as PDF (browser print)">
<Button variant="light" leftSection={<IconFileTypePdf size={16} />} onClick={handlePdfExport}>
PDF
</Button>
</Tooltip>
</Group>
</Group>
{/* Main visible view */}
{viewMode === 'table' ? (
<div className="capital-projects-table-view">
{renderTableView()}
</div>
) : (
<>
<div className="capital-projects-kanban-view">
{renderKanbanView()}
</div>
{/* Hidden table view for print mode - rendered when printMode is true */}
{printMode && (
<div className="capital-projects-table-view" style={{ display: 'none' }}>
{renderTableView()}
</div>
)}
</>
)}
{/* Simplified edit modal - full project editing is done in ProjectsPage */}
<Modal opened={opened} onClose={close} title="Edit Capital Plan Details" size="md">
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
<Stack>
{editing && (
<Text size="sm" fw={600} c="dimmed">
{editing.name}
</Text>
)}
<Group grow>
<Select
label="Status"
data={Object.keys(statusColors).map((s) => ({ value: s, label: s.replace('_', ' ') }))}
{...form.getInputProps('status')}
/>
<NumberInput label="Priority (1=High, 5=Low)" min={1} max={5} {...form.getInputProps('priority')} />
</Group>
<Group grow>
<Select
label="Target Year"
required
data={targetYearOptions}
value={String(form.values.target_year)}
onChange={(v) => form.setFieldValue('target_year', Number(v))}
/>
<Select
label="Target Month"
data={Array.from({ length: 12 }, (_, i) => ({
value: String(i + 1),
label: new Date(2026, i).toLocaleString('default', { month: 'long' }),
}))}
value={String(form.values.target_month)}
onChange={(v) => form.setFieldValue('target_month', Number(v))}
/>
</Group>
<TextInput
label="Planned Date"
placeholder="YYYY-MM-DD"
description="Leave blank to auto-derive from target year/month"
{...form.getInputProps('planned_date')}
/>
<Textarea label="Notes" autosize minRows={2} maxRows={6} {...form.getInputProps('notes')} />
<Button type="submit" loading={saveMutation.isPending}>Update</Button>
</Stack>
</form>
</Modal>
</Stack>
);
}