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

@@ -1,6 +1,6 @@
{ {
"name": "hoa-ledgeriq-backend", "name": "hoa-ledgeriq-backend",
"version": "0.2.0", "version": "2026.3.2-beta",
"description": "HOA LedgerIQ - Backend API", "description": "HOA LedgerIQ - Backend API",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -20,7 +20,7 @@ export class ProjectsService {
async findForPlanning() { async findForPlanning() {
const projects = await this.tenant.query( const projects = await this.tenant.query(
'SELECT * FROM projects WHERE is_active = true AND target_year IS NOT NULL ORDER BY target_year, target_month NULLS LAST, priority', 'SELECT * FROM projects WHERE is_active = true ORDER BY target_year NULLS LAST, target_month NULLS LAST, priority',
); );
return this.computeFunding(projects); return this.computeFunding(projects);
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoa-ledgeriq-frontend", "name": "hoa-ledgeriq-frontend",
"version": "0.2.0", "version": "2026.3.2-beta",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -30,7 +30,7 @@ interface Project {
fund_source: string; fund_source: string;
funded_percentage: string; funded_percentage: string;
planned_date: string; planned_date: string;
target_year: number; target_year: number | null;
target_month: number; target_month: number;
status: string; status: string;
priority: number; priority: number;
@@ -38,6 +38,7 @@ interface Project {
} }
const FUTURE_YEAR = 9999; const FUTURE_YEAR = 9999;
const UNSCHEDULED = -1; // sentinel for projects with no target_year
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
planned: 'blue', approved: 'green', in_progress: 'yellow', 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) => const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); 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) => { const formatPlannedDate = (d: string | null | undefined) => {
if (!d) return null; if (!d) return null;
@@ -154,7 +156,8 @@ function KanbanColumn({
}: KanbanColumnProps) { }: KanbanColumnProps) {
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0); const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
const isFuture = year === FUTURE_YEAR; const isFuture = year === FUTURE_YEAR;
const useWideLayout = isFuture && projects.length > 3; const isUnscheduled = year === UNSCHEDULED;
const useWideLayout = (isFuture || isUnscheduled) && projects.length > 3;
return ( return (
<Paper <Paper
@@ -167,7 +170,11 @@ function KanbanColumn({
flexShrink: 0, flexShrink: 0,
display: 'flex', display: 'flex',
flexDirection: 'column', 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, border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
transition: 'background-color 150ms ease, border 150ms ease', transition: 'background-color 150ms ease, border 150ms ease',
}} }}
@@ -178,6 +185,9 @@ function KanbanColumn({
<Group justify="space-between" mb="sm"> <Group justify="space-between" mb="sm">
<Title order={5}>{yearLabel(year)}</Title> <Title order={5}>{yearLabel(year)}</Title>
<Group gap={6}> <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> <Badge size="sm" variant="light">{fmt(totalEst)}</Badge>
</Group> </Group>
</Group> </Group>
@@ -311,10 +321,10 @@ export function CapitalProjectsPage() {
}); });
const moveProjectMutation = useMutation({ 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 }; const payload: Record<string, unknown> = { target_year };
// Derive planned_date based on the new 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; payload.planned_date = null;
} else { } else {
payload.planned_date = `${target_year}-${String(target_month || 6).padStart(2, '0')}-01`; payload.planned_date = `${target_year}-${String(target_month || 6).padStart(2, '0')}-01`;
@@ -353,7 +363,7 @@ export function CapitalProjectsPage() {
form.setValues({ form.setValues({
status: p.status || 'planned', status: p.status || 'planned',
priority: p.priority || 3, priority: p.priority || 3,
target_year: p.target_year, target_year: p.target_year ?? currentYear,
target_month: p.target_month || 6, target_month: p.target_month || 6,
planned_date: p.planned_date || '', planned_date: p.planned_date || '',
notes: p.notes || '', notes: p.notes || '',
@@ -376,7 +386,7 @@ export function CapitalProjectsPage() {
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: Project) => { const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: Project) => {
e.dataTransfer.setData('application/json', JSON.stringify({ e.dataTransfer.setData('application/json', JSON.stringify({
id: project.id, id: project.id,
source_year: project.target_year, source_year: project.target_year ?? UNSCHEDULED,
target_month: project.target_month, target_month: project.target_month,
})); }));
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
@@ -400,7 +410,7 @@ export function CapitalProjectsPage() {
if (payload.source_year !== targetYear) { if (payload.source_year !== targetYear) {
moveProjectMutation.mutate({ moveProjectMutation.mutate({
id: payload.id, id: payload.id,
target_year: targetYear, target_year: targetYear === UNSCHEDULED ? null : targetYear,
target_month: payload.target_month || 6, 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 // 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 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 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) // 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 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 // Kanban columns: Unscheduled + current..current+4 + Future
const kanbanYears = [...baseYears, FUTURE_YEAR]; const kanbanYears = [UNSCHEDULED, ...baseYears, FUTURE_YEAR];
// ---- Loading state ---- // ---- Loading state ----
@@ -441,12 +456,11 @@ export function CapitalProjectsPage() {
<Stack align="center" gap="md" maw={420}> <Stack align="center" gap="md" maw={420}>
<IconClipboardList size={64} color="var(--mantine-color-dimmed)" stroke={1.2} /> <IconClipboardList size={64} color="var(--mantine-color-dimmed)" stroke={1.2} />
<Title order={3} c="dimmed" ta="center"> <Title order={3} c="dimmed" ta="center">
No projects in the capital plan No projects yet
</Title> </Title>
<Text c="dimmed" ta="center" size="sm"> <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 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> </Text>
<Button <Button
variant="light" variant="light"
@@ -472,7 +486,9 @@ export function CapitalProjectsPage() {
</Text> </Text>
) : ( ) : (
years.map((year) => { 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; if (yearProjects.length === 0) return null;
const totalEst = yearProjects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0); const totalEst = yearProjects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
return ( return (
@@ -503,7 +519,9 @@ export function CapitalProjectsPage() {
<Table.Td fw={500}>{p.name}</Table.Td> <Table.Td fw={500}>{p.name}</Table.Td>
<Table.Td>{p.category || '-'}</Table.Td> <Table.Td>{p.category || '-'}</Table.Td>
<Table.Td> <Table.Td>
{p.target_year === FUTURE_YEAR {p.target_year === null
? <Text size="sm" c="dimmed" fs="italic">Unscheduled</Text>
: p.target_year === FUTURE_YEAR
? 'Future' ? 'Future'
: ( : (
<> <>
@@ -558,9 +576,13 @@ export function CapitalProjectsPage() {
<ScrollArea type="auto" offsetScrollbars> <ScrollArea type="auto" offsetScrollbars>
<Group align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: kanbanYears.length * 300 }}> <Group align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: kanbanYears.length * 300 }}>
{kanbanYears.map((year) => { {kanbanYears.map((year) => {
// Future bucket: collect projects with target_year === 9999 OR beyond the 5-year window // Unscheduled: projects with no target_year
const yearProjects = year === FUTURE_YEAR // Future: projects with target_year === 9999 OR beyond the 5-year window
? projects.filter((p) => p.target_year === FUTURE_YEAR || p.target_year > maxPlannedYear) // 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); : projects.filter((p) => p.target_year === year);
return ( return (
<KanbanColumn <KanbanColumn

View File

@@ -117,7 +117,7 @@ export function SettingsPage() {
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text> <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>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed">API</Text> <Text size="sm" c="dimmed">API</Text>