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 = { planned: 'blue', approved: 'green', in_progress: 'yellow', completed: 'teal', deferred: 'gray', cancelled: 'red', }; const fundSourceColors: Record = { 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(null); const queryClient = useQueryClient(); const fileInputRef = useRef(null); const isReadOnly = useIsReadOnly(); // ---- Data fetching ---- const { data: projects = [], isLoading } = useQuery({ 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[]) => { 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) => { 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 -; const color = rating >= 7 ? 'green' : rating >= 4 ? 'yellow' : 'red'; return ( {rating}/10 ); }; const fundedPercentageCell = (project: Project) => { if (project.fund_source !== 'reserve') { return -; } 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 ( {project.is_funding_locked && ( )} {pct.toFixed(0)}% ); }; // ---- Loading state ---- if (isLoading) return
; // ---- Render ---- return ( {/* Header */} Projects {!isReadOnly && (<> )} {/* Summary Cards */} Total Estimated Cost {fmt(totalEstimatedCost)} Total Funded - Reserve Only {fmt(totalFundedReserve)} Percent Funded - Reserve Only = 70 ? 'green' : pctFundedReserve >= 40 ? 'yellow' : 'red' } > {pctFundedReserve.toFixed(1)}% = 70 ? 'green' : pctFundedReserve >= 40 ? 'yellow' : 'red' } /> {/* Projects Table */} Project Name Category Fund Source Estimated Cost Funded % Condition Status Planned Date {projects.map((p) => ( {p.name} {p.description && ( {p.description} )} {p.category ? p.category.charAt(0).toUpperCase() + p.category.slice(1) : '-'} {p.fund_source?.replace('_', ' ') || '-'} {fmt(p.estimated_cost)} {fundedPercentageCell(p)} {conditionBadge(p.condition_rating)} {p.status?.replace('_', ' ') || '-'} {formatDate(p.planned_date)} {!isReadOnly && ( handleEdit(p)}> )} ))} {projects.length === 0 && ( No projects yet )}
{/* Create / Edit Modal */}
saveMutation.mutate(v))}> {/* Row 1: Name + Category */}