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; target_month: number; status: string; priority: number; notes: string; } const FUTURE_YEAR = 9999; const statusColors: Record = { 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' : 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, 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 > currentYear + 4 && project.target_year !== FUTURE_YEAR; return ( onDragStart(e, project)} style={{ cursor: 'grab', userSelect: 'none' }} mb="xs" > {project.name} onEdit(project)}> {project.status.replace('_', ' ')} P{project.priority} {isBeyondWindow && ( {project.target_year} )} {fmt(project.estimated_cost)} {project.fund_source?.replace('_', ' ') || 'reserve'} {plannedLabel && ( }> {plannedLabel} )} ); } // --------------------------------------------------------------------------- // Kanban column (year) // --------------------------------------------------------------------------- interface KanbanColumnProps { year: number; projects: Project[]; onEdit: (p: Project) => void; onDragStart: (e: DragEvent, project: Project) => void; onDrop: (e: DragEvent, targetYear: number) => void; isDragOver: boolean; onDragOverHandler: (e: DragEvent, 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 useWideLayout = isFuture && projects.length > 3; return ( onDragOverHandler(e, year)} onDragLeave={onDragLeave} onDrop={(e) => onDrop(e, year)} > {yearLabel(year)} {fmt(totalEst)} {projects.length} project{projects.length !== 1 ? 's' : ''} {projects.length === 0 ? ( Drop projects here ) : useWideLayout ? (
{projects.map((p) => ( ))}
) : ( projects.map((p) => ( )) )}
); } // --------------------------------------------------------------------------- // 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(null); const [viewMode, setViewMode] = useState('kanban'); const [printMode, setPrintMode] = useState(false); const [dragOverYear, setDragOverYear] = useState(null); const printModeRef = useRef(false); const queryClient = useQueryClient(); const isReadOnly = useIsReadOnly(); // ---- Data fetching ---- const { data: projects = [], isLoading } = useQuery({ 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 = { 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; target_month: number }) => { const payload: Record = { target_year }; // Derive planned_date based on the new year if (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, 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, project: Project) => { e.dataTransfer.setData('application/json', JSON.stringify({ id: project.id, source_year: project.target_year, target_month: project.target_month, })); e.dataTransfer.effectAllowed = 'move'; }, []); const handleDragOver = useCallback((e: DragEvent, year: number) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverYear(year); }, []); const handleDragLeave = useCallback(() => { setDragOverYear(null); }, []); const handleDrop = useCallback((e: DragEvent, 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, 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))]; const hasFutureProjects = projectYears.includes(FUTURE_YEAR); // 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 = hasFutureProjects ? [...regularYears, FUTURE_YEAR] : regularYears; // Kanban columns: always current..current+4 plus Future const kanbanYears = [...baseYears, FUTURE_YEAR]; // ---- Loading state ---- const navigate = useNavigate(); if (isLoading) return
; // ---- Empty state when no planning projects exist ---- if (projects.length === 0) { return ( Capital Planning
No projects in the capital plan Capital Planning displays projects that have a target year assigned. Head over to the Projects page to define your reserve and operating projects, then assign target years to see them here.
); } // ---- Render: Table view ---- const renderTableView = () => ( <> {years.length === 0 ? ( No projects in the capital plan. Assign a target year to projects in the Projects page. ) : ( years.map((year) => { const yearProjects = 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 ( {yearLabel(year)} {fmt(totalEst)} estimated Project Category Target Priority Estimated Actual Source Funded Status Planned Date {yearProjects.map((p) => ( {p.name} {p.category || '-'} {p.target_year === FUTURE_YEAR ? 'Future' : ( <> {p.target_month ? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' }) : ''}{' '} {p.target_year} ) } P{p.priority} {fmt(p.estimated_cost)} {parseFloat(p.actual_cost || '0') > 0 ? fmt(p.actual_cost) : '-'} {p.fund_source?.replace('_', ' ') || 'reserve'} {parseFloat(p.funded_percentage || '0') > 0 ? `${parseFloat(p.funded_percentage).toFixed(0)}%` : '-'} {p.status?.replace('_', ' ')} {formatPlannedDate(p.planned_date) || '-'} {!isReadOnly && handleEdit(p)}> } ))}
); }) )} ); // ---- Render: Kanban view ---- const maxPlannedYear = currentYear + 4; // last year in the 5-year window const renderKanbanView = () => ( {kanbanYears.map((year) => { // Future bucket: collect projects with target_year === 9999 OR beyond the 5-year window const yearProjects = year === FUTURE_YEAR ? projects.filter((p) => p.target_year === FUTURE_YEAR || p.target_year > maxPlannedYear) : projects.filter((p) => p.target_year === year); return ( ); })} ); // ---- Render ---- return ( {/* Print-specific styles */} Capital Planning Table ), }, { value: 'kanban', label: ( Kanban ), }, ]} /> {/* Main visible view */} {viewMode === 'table' ? (
{renderTableView()}
) : ( <>
{renderKanbanView()}
{/* Hidden table view for print mode - rendered when printMode is true */} {printMode && (
{renderTableView()}
)} )} {/* Simplified edit modal - full project editing is done in ProjectsPage */}
saveMutation.mutate(v))}> {editing && ( {editing.name} )} form.setFieldValue('target_year', Number(v))} />