Phase 3: Optimize & clean up — unified projects, account enhancements, new tenant fix
- Unify reserve_components + capital_projects into single projects model with full CRUD backend and new Projects page frontend - Rewrite Capital Planning to read from unified projects/planning endpoint; add empty state directing users to Projects page when no planning items exist - Add default designation to assessment groups with auto-set on first creation; units now require an assessment group (pre-populated with default) - Add primary account designation (one per fund type) and balance adjustment via journal entries against equity offset accounts (3000/3100) - Add computed investment fields (interest earned, maturity value, days remaining) with PostgreSQL date arithmetic fix for DATE - DATE integer result - Restructure sidebar: investments in Accounts tab, Year-End under Reports, Planning section with Projects and Capital Planning - Fix new tenant creation seeding unwanted default chart of accounts — new tenants now start with a blank slate Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
590
frontend/src/pages/projects/ProjectsPage.tsx
Normal file
590
frontend/src/pages/projects/ProjectsPage.tsx
Normal file
@@ -0,0 +1,590 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
|
||||
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
|
||||
Card, SimpleGrid, Progress,
|
||||
} from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
estimated_cost: string;
|
||||
actual_cost: string;
|
||||
current_fund_balance: string;
|
||||
annual_contribution: string;
|
||||
fund_source: string;
|
||||
funded_percentage: string;
|
||||
useful_life_years: number;
|
||||
remaining_life_years: number;
|
||||
condition_rating: number;
|
||||
last_replacement_date: string;
|
||||
next_replacement_date: string;
|
||||
planned_date: string;
|
||||
target_year: number;
|
||||
target_month: number;
|
||||
status: string;
|
||||
priority: number;
|
||||
account_id: string;
|
||||
notes: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
const FUTURE_YEAR = 9999;
|
||||
|
||||
const categories = [
|
||||
'roof', 'pool', 'hvac', 'paving', 'painting',
|
||||
'fencing', 'elevator', 'irrigation', 'clubhouse', 'other',
|
||||
];
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
planned: 'blue',
|
||||
approved: 'green',
|
||||
in_progress: 'yellow',
|
||||
completed: 'teal',
|
||||
deferred: 'gray',
|
||||
cancelled: 'red',
|
||||
};
|
||||
|
||||
const fundSourceColors: Record<string, string> = {
|
||||
operating: 'gray',
|
||||
reserve: 'violet',
|
||||
special_assessment: 'orange',
|
||||
};
|
||||
|
||||
const fmt = (v: string | number) =>
|
||||
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ProjectsPage() {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [editing, setEditing] = useState<Project | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// ---- Data fetching ----
|
||||
|
||||
const { data: projects = [], isLoading } = useQuery<Project[]>({
|
||||
queryKey: ['projects'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/projects');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// ---- Derived summary values ----
|
||||
|
||||
const totalEstimatedCost = projects.reduce(
|
||||
(sum, p) => sum + parseFloat(p.estimated_cost || '0'),
|
||||
0,
|
||||
);
|
||||
|
||||
const reserveProjects = projects.filter((p) => p.fund_source === 'reserve');
|
||||
|
||||
const totalFundedReserve = reserveProjects.reduce(
|
||||
(sum, p) => sum + parseFloat(p.current_fund_balance || '0'),
|
||||
0,
|
||||
);
|
||||
|
||||
const totalReserveReplacementCost = reserveProjects.reduce(
|
||||
(sum, p) => sum + parseFloat(p.estimated_cost || '0'),
|
||||
0,
|
||||
);
|
||||
|
||||
const pctFundedReserve =
|
||||
totalReserveReplacementCost > 0
|
||||
? (totalFundedReserve / totalReserveReplacementCost) * 100
|
||||
: 0;
|
||||
|
||||
// ---- Form setup ----
|
||||
|
||||
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 monthOptions = Array.from({ length: 12 }, (_, i) => ({
|
||||
value: String(i + 1),
|
||||
label: new Date(2000, i).toLocaleString('default', { month: 'long' }),
|
||||
}));
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: '',
|
||||
category: 'other',
|
||||
description: '',
|
||||
fund_source: 'reserve',
|
||||
status: 'planned',
|
||||
priority: 3,
|
||||
estimated_cost: 0,
|
||||
actual_cost: 0,
|
||||
current_fund_balance: 0,
|
||||
annual_contribution: 0,
|
||||
funded_percentage: 0,
|
||||
useful_life_years: 20,
|
||||
remaining_life_years: 10,
|
||||
condition_rating: 5,
|
||||
last_replacement_date: null as Date | null,
|
||||
next_replacement_date: null as Date | null,
|
||||
planned_date: null as Date | null,
|
||||
target_year: currentYear,
|
||||
target_month: 6,
|
||||
notes: '',
|
||||
},
|
||||
validate: {
|
||||
name: (v) => (v.length > 0 ? null : 'Required'),
|
||||
estimated_cost: (v) => (v > 0 ? null : 'Required'),
|
||||
},
|
||||
});
|
||||
|
||||
// ---- Mutations ----
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (values: any) => {
|
||||
const payload = {
|
||||
...values,
|
||||
last_replacement_date:
|
||||
values.last_replacement_date?.toISOString?.()?.split('T')[0] || null,
|
||||
next_replacement_date:
|
||||
values.next_replacement_date?.toISOString?.()?.split('T')[0] || null,
|
||||
planned_date:
|
||||
values.planned_date?.toISOString?.()?.split('T')[0] || null,
|
||||
};
|
||||
return editing
|
||||
? api.put(`/projects/${editing.id}`, payload)
|
||||
: api.post('/projects', payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
notifications.show({
|
||||
message: editing ? 'Project updated' : 'Project created',
|
||||
color: 'green',
|
||||
});
|
||||
close();
|
||||
setEditing(null);
|
||||
form.reset();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
notifications.show({
|
||||
message: err.response?.data?.message || 'Error',
|
||||
color: 'red',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ---- Handlers ----
|
||||
|
||||
const handleEdit = (p: Project) => {
|
||||
setEditing(p);
|
||||
form.setValues({
|
||||
name: p.name,
|
||||
category: p.category || 'other',
|
||||
description: p.description || '',
|
||||
fund_source: p.fund_source || 'reserve',
|
||||
status: p.status || 'planned',
|
||||
priority: p.priority || 3,
|
||||
estimated_cost: parseFloat(p.estimated_cost || '0'),
|
||||
actual_cost: parseFloat(p.actual_cost || '0'),
|
||||
current_fund_balance: parseFloat(p.current_fund_balance || '0'),
|
||||
annual_contribution: parseFloat(p.annual_contribution || '0'),
|
||||
funded_percentage: parseFloat(p.funded_percentage || '0'),
|
||||
useful_life_years: p.useful_life_years || 0,
|
||||
remaining_life_years: p.remaining_life_years || 0,
|
||||
condition_rating: p.condition_rating || 5,
|
||||
last_replacement_date: p.last_replacement_date
|
||||
? new Date(p.last_replacement_date)
|
||||
: null,
|
||||
next_replacement_date: p.next_replacement_date
|
||||
? new Date(p.next_replacement_date)
|
||||
: null,
|
||||
planned_date: p.planned_date ? new Date(p.planned_date) : null,
|
||||
target_year: p.target_year || currentYear,
|
||||
target_month: p.target_month || 6,
|
||||
notes: p.notes || '',
|
||||
});
|
||||
open();
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
setEditing(null);
|
||||
form.reset();
|
||||
open();
|
||||
};
|
||||
|
||||
// ---- Helpers for table rendering ----
|
||||
|
||||
const formatDate = (dateStr: string | null | undefined) => {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return '-';
|
||||
return d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const conditionBadge = (rating: number | null | undefined) => {
|
||||
if (rating == null) return <Text c="dimmed">-</Text>;
|
||||
const color = rating >= 7 ? 'green' : rating >= 4 ? 'yellow' : 'red';
|
||||
return (
|
||||
<Badge size="sm" color={color}>
|
||||
{rating}/10
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const fundedPercentageCell = (project: Project) => {
|
||||
if (project.fund_source !== 'reserve') {
|
||||
return <Text c="dimmed">-</Text>;
|
||||
}
|
||||
const cost = parseFloat(project.estimated_cost || '0');
|
||||
const funded = parseFloat(project.current_fund_balance || '0');
|
||||
const pct = cost > 0 ? (funded / cost) * 100 : 0;
|
||||
const color = pct >= 70 ? 'green' : pct >= 40 ? 'yellow' : 'red';
|
||||
return (
|
||||
<Text span c={color} ff="monospace">
|
||||
{pct.toFixed(0)}%
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- Loading state ----
|
||||
|
||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||
|
||||
// ---- Render ----
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{/* Header */}
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Projects</Title>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||
+ Add Project
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
Total Estimated Cost
|
||||
</Text>
|
||||
<Text fw={700} size="xl">
|
||||
{fmt(totalEstimatedCost)}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
Total Funded - Reserve Only
|
||||
</Text>
|
||||
<Text fw={700} size="xl" c="green">
|
||||
{fmt(totalFundedReserve)}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
Percent Funded - Reserve Only
|
||||
</Text>
|
||||
<Group>
|
||||
<Text
|
||||
fw={700}
|
||||
size="xl"
|
||||
c={
|
||||
pctFundedReserve >= 70
|
||||
? 'green'
|
||||
: pctFundedReserve >= 40
|
||||
? 'yellow'
|
||||
: 'red'
|
||||
}
|
||||
>
|
||||
{pctFundedReserve.toFixed(1)}%
|
||||
</Text>
|
||||
<Progress
|
||||
value={pctFundedReserve}
|
||||
size="lg"
|
||||
style={{ flex: 1 }}
|
||||
color={
|
||||
pctFundedReserve >= 70
|
||||
? 'green'
|
||||
: pctFundedReserve >= 40
|
||||
? 'yellow'
|
||||
: 'red'
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Projects Table */}
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Project Name</Table.Th>
|
||||
<Table.Th>Category</Table.Th>
|
||||
<Table.Th>Fund Source</Table.Th>
|
||||
<Table.Th ta="right">Estimated Cost</Table.Th>
|
||||
<Table.Th ta="right">Funded %</Table.Th>
|
||||
<Table.Th>Condition</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Planned Date</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{projects.map((p) => (
|
||||
<Table.Tr key={p.id}>
|
||||
<Table.Td>
|
||||
<Text fw={500}>{p.name}</Text>
|
||||
{p.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{p.description}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light">
|
||||
{p.category
|
||||
? p.category.charAt(0).toUpperCase() + p.category.slice(1)
|
||||
: '-'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={fundSourceColors[p.fund_source] || 'gray'}
|
||||
>
|
||||
{p.fund_source?.replace('_', ' ') || '-'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">
|
||||
{fmt(p.estimated_cost)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">{fundedPercentageCell(p)}</Table.Td>
|
||||
<Table.Td>{conditionBadge(p.condition_rating)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={statusColors[p.status] || 'gray'}
|
||||
>
|
||||
{p.status?.replace('_', ' ') || '-'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{formatDate(p.planned_date)}</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{projects.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={9}>
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
No projects yet
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{/* Create / Edit Modal */}
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={editing ? 'Edit Project' : 'New Project'}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
|
||||
<Stack>
|
||||
{/* Row 1: Name + Category */}
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Name"
|
||||
required
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<Select
|
||||
label="Category"
|
||||
data={categories.map((c) => ({
|
||||
value: c,
|
||||
label: c.charAt(0).toUpperCase() + c.slice(1),
|
||||
}))}
|
||||
{...form.getInputProps('category')}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Row 2: Description */}
|
||||
<Textarea
|
||||
label="Description"
|
||||
{...form.getInputProps('description')}
|
||||
/>
|
||||
|
||||
{/* Row 3: Fund Source, Status, Priority */}
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Fund Source"
|
||||
data={[
|
||||
{ value: 'operating', label: 'Operating' },
|
||||
{ value: 'reserve', label: 'Reserve' },
|
||||
{ value: 'special_assessment', label: 'Special Assessment' },
|
||||
]}
|
||||
{...form.getInputProps('fund_source')}
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
data={Object.keys(statusColors).map((s) => ({
|
||||
value: s,
|
||||
label: s.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()),
|
||||
}))}
|
||||
{...form.getInputProps('status')}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Priority (1-5)"
|
||||
min={1}
|
||||
max={5}
|
||||
{...form.getInputProps('priority')}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Row 4: Estimated Cost, Actual Cost */}
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Estimated Cost"
|
||||
required
|
||||
prefix="$"
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
{...form.getInputProps('estimated_cost')}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Actual Cost"
|
||||
prefix="$"
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
{...form.getInputProps('actual_cost')}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Row 5: Conditional reserve fields */}
|
||||
{form.values.fund_source === 'reserve' && (
|
||||
<>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Current Fund Balance"
|
||||
prefix="$"
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
{...form.getInputProps('current_fund_balance')}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Annual Contribution"
|
||||
prefix="$"
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
{...form.getInputProps('annual_contribution')}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Funded Percentage"
|
||||
suffix="%"
|
||||
decimalScale={1}
|
||||
min={0}
|
||||
max={100}
|
||||
{...form.getInputProps('funded_percentage')}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Useful Life (years)"
|
||||
min={0}
|
||||
{...form.getInputProps('useful_life_years')}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Remaining Life (years)"
|
||||
min={0}
|
||||
decimalScale={1}
|
||||
{...form.getInputProps('remaining_life_years')}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Condition Rating (1-10)"
|
||||
min={1}
|
||||
max={10}
|
||||
{...form.getInputProps('condition_rating')}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Row 6: Last / Next Replacement Date */}
|
||||
<Group grow>
|
||||
<DateInput
|
||||
label="Last Replacement Date"
|
||||
clearable
|
||||
{...form.getInputProps('last_replacement_date')}
|
||||
/>
|
||||
<DateInput
|
||||
label="Next Replacement Date"
|
||||
clearable
|
||||
{...form.getInputProps('next_replacement_date')}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Row 7: Planned Date */}
|
||||
<DateInput
|
||||
label="Planned Date"
|
||||
description="Defaults to Next Replacement Date if not set"
|
||||
clearable
|
||||
{...form.getInputProps('planned_date')}
|
||||
/>
|
||||
|
||||
{/* Row 8: Target Year + Target Month */}
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Target Year"
|
||||
data={targetYearOptions}
|
||||
value={String(form.values.target_year)}
|
||||
onChange={(v) => form.setFieldValue('target_year', Number(v))}
|
||||
/>
|
||||
<Select
|
||||
label="Target Month"
|
||||
data={monthOptions}
|
||||
value={String(form.values.target_month)}
|
||||
onChange={(v) => form.setFieldValue('target_month', Number(v))}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Row 9: Notes */}
|
||||
<Textarea label="Notes" {...form.getInputProps('notes')} />
|
||||
|
||||
<Button type="submit" loading={saveMutation.isPending}>
|
||||
{editing ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user