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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-frontend",
|
||||
"version": "0.2.0",
|
||||
"version": "2026.3.2-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user