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:
2026-02-19 14:32:35 -05:00
parent 17fdacc0f2
commit 301f8a7bde
20 changed files with 1760 additions and 145 deletions

View File

@@ -8,9 +8,10 @@ import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconPlus, IconEdit, IconTable, IconLayoutKanban, IconFileTypePdf,
IconGripVertical,
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';
@@ -18,10 +19,21 @@ import api from '../../services/api';
// Types & constants
// ---------------------------------------------------------------------------
interface CapitalProject {
id: string; name: string; description: string; estimated_cost: string;
actual_cost: string; target_year: number; target_month: number;
status: string; fund_source: string; priority: number;
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;
@@ -38,17 +50,30 @@ const fmt = (v: string | number) =>
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: CapitalProject;
onEdit: (p: CapitalProject) => void;
onDragStart: (e: DragEvent<HTMLDivElement>, project: CapitalProject) => void;
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);
return (
<Card
shadow="sm"
@@ -85,9 +110,16 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
{fmt(project.estimated_cost)}
</Text>
<Badge size="xs" variant="light" color="violet">
{project.fund_source?.replace('_', ' ') || 'reserve'}
</Badge>
<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>
);
}
@@ -98,9 +130,9 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
interface KanbanColumnProps {
year: number;
projects: CapitalProject[];
onEdit: (p: CapitalProject) => void;
onDragStart: (e: DragEvent<HTMLDivElement>, project: CapitalProject) => void;
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;
@@ -177,7 +209,7 @@ const printStyles = `
export function CapitalProjectsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<CapitalProject | null>(null);
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);
@@ -186,12 +218,12 @@ export function CapitalProjectsPage() {
// ---- Data fetching ----
const { data: projects = [], isLoading } = useQuery<CapitalProject[]>({
queryKey: ['capital-projects'],
queryFn: async () => { const { data } = await api.get('/capital-projects'); return data; },
const { data: projects = [], isLoading } = useQuery<Project[]>({
queryKey: ['projects-planning'],
queryFn: async () => { const { data } = await api.get('/projects/planning'); return data; },
});
// ---- Form ----
// ---- Form (simplified edit modal) ----
const currentYear = new Date().getFullYear();
@@ -205,26 +237,48 @@ export function CapitalProjectsPage() {
const form = useForm({
initialValues: {
name: '', description: '', estimated_cost: 0, actual_cost: 0,
target_year: new Date().getFullYear(), target_month: 6,
status: 'planned', fund_source: 'reserve', priority: 3,
},
validate: {
name: (v) => (v.length > 0 ? null : 'Required'),
estimated_cost: (v) => (v > 0 ? null : 'Required'),
status: 'planned',
priority: 3,
target_year: currentYear,
target_month: 6,
planned_date: '',
notes: '',
},
});
// ---- Mutations ----
const saveMutation = useMutation({
mutationFn: (values: any) =>
editing
? api.put(`/capital-projects/${editing.id}`, values)
: api.post('/capital-projects', values),
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: ['capital-projects'] });
notifications.show({ message: editing ? 'Project updated' : 'Project created', color: 'green' });
queryClient.invalidateQueries({ queryKey: ['projects-planning'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
notifications.show({ message: 'Project updated', color: 'green' });
close(); setEditing(null); form.reset();
},
onError: (err: any) => {
@@ -233,10 +287,19 @@ export function CapitalProjectsPage() {
});
const moveProjectMutation = useMutation({
mutationFn: ({ id, target_year }: { id: string; target_year: number }) =>
api.put(`/capital-projects/${id}`, { target_year }),
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: ['capital-projects'] });
queryClient.invalidateQueries({ queryKey: ['projects-planning'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
notifications.show({ message: 'Project moved successfully', color: 'green' });
},
onError: (err: any) => {
@@ -261,25 +324,19 @@ export function CapitalProjectsPage() {
// ---- Handlers ----
const handleEdit = (p: CapitalProject) => {
const handleEdit = (p: Project) => {
setEditing(p);
form.setValues({
name: p.name, description: p.description || '',
estimated_cost: parseFloat(p.estimated_cost || '0'),
actual_cost: parseFloat(p.actual_cost || '0'),
target_year: p.target_year, target_month: p.target_month || 6,
status: p.status, fund_source: p.fund_source || 'reserve',
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 handleNewProject = () => {
setEditing(null);
form.reset();
open();
};
const handlePdfExport = () => {
// If already in table view, just print directly
if (viewMode === 'table') {
@@ -292,8 +349,12 @@ export function CapitalProjectsPage() {
// ---- Drag & Drop ----
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: CapitalProject) => {
e.dataTransfer.setData('application/json', JSON.stringify({ id: project.id, source_year: project.target_year }));
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';
}, []);
@@ -313,7 +374,11 @@ export function CapitalProjectsPage() {
try {
const payload = JSON.parse(e.dataTransfer.getData('application/json'));
if (payload.source_year !== targetYear) {
moveProjectMutation.mutate({ id: payload.id, target_year: targetYear });
moveProjectMutation.mutate({
id: payload.id,
target_year: targetYear,
target_month: payload.target_month || 6,
});
}
} catch {
// ignore malformed drag data
@@ -336,15 +401,50 @@ export function CapitalProjectsPage() {
// ---- 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 capital projects planned yet. Add your first project.
No projects in the capital plan. Assign a target year to projects in the Projects page.
</Text>
) : (
years.map((year) => {
@@ -361,12 +461,15 @@ export function CapitalProjectsPage() {
<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>
@@ -374,6 +477,7 @@ export function CapitalProjectsPage() {
{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'
@@ -394,10 +498,18 @@ export function CapitalProjectsPage() {
<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}</Badge></Table.Td>
<Table.Td>
<Badge size="sm" color={statusColors[p.status] || 'gray'}>{p.status}</Badge>
<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>
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
<IconEdit size={16} />
@@ -447,7 +559,7 @@ export function CapitalProjectsPage() {
<style>{printStyles}</style>
<Group justify="space-between">
<Title order={2}>Capital Projects</Title>
<Title order={2}>Capital Planning</Title>
<Group gap="sm">
<SegmentedControl
value={viewMode}
@@ -478,9 +590,6 @@ export function CapitalProjectsPage() {
PDF
</Button>
</Tooltip>
<Button leftSection={<IconPlus size={16} />} onClick={handleNewProject}>
Add Project
</Button>
</Group>
</Group>
@@ -503,14 +612,22 @@ export function CapitalProjectsPage() {
</>
)}
<Modal opened={opened} onClose={close} title={editing ? 'Edit Project' : 'New Capital Project'} size="lg">
{/* 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>
<TextInput label="Project Name" required {...form.getInputProps('name')} />
<Textarea label="Description" {...form.getInputProps('description')} />
{editing && (
<Text size="sm" fw={600} c="dimmed">
{editing.name}
</Text>
)}
<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')} />
<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
@@ -530,24 +647,14 @@ export function CapitalProjectsPage() {
onChange={(v) => form.setFieldValue('target_month', Number(v))}
/>
</Group>
<Group grow>
<Select
label="Status"
data={Object.keys(statusColors).map((s) => ({ value: s, label: s.replace('_', ' ') }))}
{...form.getInputProps('status')}
/>
<Select
label="Fund Source"
data={[
{ value: 'reserve', label: 'Reserve' },
{ value: 'operating', label: 'Operating' },
{ value: 'special_assessment', label: 'Special Assessment' },
]}
{...form.getInputProps('fund_source')}
/>
<NumberInput label="Priority (1=High, 5=Low)" min={1} max={5} {...form.getInputProps('priority')} />
</Group>
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
<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>