Projects with target years beyond the 5-year planning window now appear in the Future column of the Kanban board (previously they were invisible). Cards for these projects show their specific target year as a badge. The Future column uses a 2-column grid layout when it has more than 3 projects to maximize screen utilization. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
693 lines
24 KiB
TypeScript
693 lines
24 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;
|
|
target_month: number;
|
|
status: string;
|
|
priority: number;
|
|
notes: string;
|
|
}
|
|
|
|
const FUTURE_YEAR = 9999;
|
|
|
|
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' : 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 > 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 useWideLayout = isFuture && 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)' : 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}>
|
|
<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; target_month: number }) => {
|
|
const payload: Record<string, unknown> = { 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<HTMLDivElement>, 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<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,
|
|
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 <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 in the capital plan
|
|
</Title>
|
|
<Text c="dimmed" ta="center" size="sm">
|
|
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.
|
|
</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 = 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 === 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) => {
|
|
// 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 (
|
|
<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>
|
|
);
|
|
}
|