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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user