Capital Planning: add Unscheduled bucket for imported projects without target_year

Projects imported via CSV that lack a target_year were invisible in Capital
Planning because findForPlanning() filtered on target_year IS NOT NULL. This
removes that filter and adds an "Unscheduled" Kanban column (orange background,
2-col layout) so users can drag unscheduled projects into year buckets.
Also bumps app version to 2026.3.2 (beta).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 14:03:39 -05:00
parent ad2f16d93b
commit 063741adc7
5 changed files with 57 additions and 35 deletions

View File

@@ -30,7 +30,7 @@ interface Project {
fund_source: string;
funded_percentage: string;
planned_date: string;
target_year: number;
target_year: number | null;
target_month: number;
status: string;
priority: number;
@@ -38,6 +38,7 @@ interface Project {
}
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',
@@ -49,7 +50,8 @@ 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 yearLabel = (year: number) =>
year === FUTURE_YEAR ? 'Future' : year === UNSCHEDULED ? 'Unscheduled' : String(year);
const formatPlannedDate = (d: string | null | undefined) => {
if (!d) return null;
@@ -154,7 +156,8 @@ function KanbanColumn({
}: 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;
const isUnscheduled = year === UNSCHEDULED;
const useWideLayout = (isFuture || isUnscheduled) && projects.length > 3;
return (
<Paper
@@ -167,7 +170,11 @@ function KanbanColumn({
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
backgroundColor: isDragOver ? 'var(--mantine-color-blue-0)' : undefined,
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',
}}
@@ -178,6 +185,9 @@ function KanbanColumn({
<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>
@@ -311,10 +321,10 @@ export function CapitalProjectsPage() {
});
const moveProjectMutation = useMutation({
mutationFn: ({ id, target_year, target_month }: { id: string; target_year: number; target_month: number }) => {
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 === FUTURE_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`;
@@ -353,7 +363,7 @@ export function CapitalProjectsPage() {
form.setValues({
status: p.status || 'planned',
priority: p.priority || 3,
target_year: p.target_year,
target_year: p.target_year ?? currentYear,
target_month: p.target_month || 6,
planned_date: p.planned_date || '',
notes: p.notes || '',
@@ -376,7 +386,7 @@ export function CapitalProjectsPage() {
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: Project) => {
e.dataTransfer.setData('application/json', JSON.stringify({
id: project.id,
source_year: project.target_year,
source_year: project.target_year ?? UNSCHEDULED,
target_month: project.target_month,
}));
e.dataTransfer.effectAllowed = 'move';
@@ -400,7 +410,7 @@ export function CapitalProjectsPage() {
if (payload.source_year !== targetYear) {
moveProjectMutation.mutate({
id: payload.id,
target_year: targetYear,
target_year: targetYear === UNSCHEDULED ? null : targetYear,
target_month: payload.target_month || 6,
});
}
@@ -413,15 +423,20 @@ export function CapitalProjectsPage() {
// 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 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 = hasFutureProjects ? [...regularYears, FUTURE_YEAR] : regularYears;
const years = [
...(hasUnscheduledProjects ? [UNSCHEDULED] : []),
...regularYears,
...(hasFutureProjects ? [FUTURE_YEAR] : []),
];
// Kanban columns: always current..current+4 plus Future
const kanbanYears = [...baseYears, FUTURE_YEAR];
// Kanban columns: Unscheduled + current..current+4 + Future
const kanbanYears = [UNSCHEDULED, ...baseYears, FUTURE_YEAR];
// ---- Loading state ----
@@ -441,12 +456,11 @@ export function CapitalProjectsPage() {
<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
No projects yet
</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.
projects. They'll appear here for capital planning and scheduling.
</Text>
<Button
variant="light"
@@ -472,7 +486,9 @@ export function CapitalProjectsPage() {
</Text>
) : (
years.map((year) => {
const yearProjects = projects.filter((p) => p.target_year === 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 (
@@ -503,16 +519,18 @@ export function CapitalProjectsPage() {
<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}
</>
)
{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>
@@ -558,10 +576,14 @@ export function CapitalProjectsPage() {
<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);
// 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}

View File

@@ -117,7 +117,7 @@ export function SettingsPage() {
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">0.2.0 MVP_P2</Badge>
<Badge variant="light">2026.3.2 (beta)</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">API</Text>