Implement Phase 2 features: roles, assessment groups, budget import, Kanban

- Add hierarchical roles: SuperUser Admin (is_superadmin flag), Tenant Admin,
  Tenant User with separate /admin route and admin panel
- Add Assessment Groups module for property type-based assessment rates
  (SFHs, Condos, Estate Lots with different regular/special rates)
- Enhance Chart of Accounts: initial balance on create (with journal entry),
  archive/restore accounts, edit all fields including account number & fund type
- Add Budget CSV import with downloadable template and account mapping
- Add Capital Projects Kanban board with drag-and-drop between year columns,
  table/kanban view toggle, and PDF export via browser print
- Update seed data with assessment groups, second test user, superadmin flag
- Create repeatable reseed.sh script for clean database population
- Fix AgingReportPage Mantine v7 Table prop compatibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 14:28:46 -05:00
parent e0272f9d8a
commit 01502e07bc
29 changed files with 1792 additions and 142 deletions

View File

@@ -1,15 +1,23 @@
import { useState } from 'react';
import { useState, useCallback, DragEvent } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
SegmentedControl, Card, Paper, ScrollArea, Box, Tooltip,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit } from '@tabler/icons-react';
import {
IconPlus, IconEdit, IconTable, IconLayoutKanban, IconFileTypePdf,
IconGripVertical,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
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;
@@ -21,95 +29,395 @@ const statusColors: Record<string, string> = {
completed: 'teal', deferred: 'gray', cancelled: 'red',
};
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' });
// ---------------------------------------------------------------------------
// Kanban card
// ---------------------------------------------------------------------------
interface KanbanCardProps {
project: CapitalProject;
onEdit: (p: CapitalProject) => void;
onDragStart: (e: DragEvent<HTMLDivElement>, project: CapitalProject) => void;
}
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
return (
<Card
shadow="sm"
padding="sm"
radius="md"
withBorder
draggable
onDragStart={(e) => onDragStart(e, project)}
style={{ cursor: 'grab', userSelect: 'none' }}
mb="xs"
>
<Group justify="space-between" wrap="nowrap" mb={4}>
<Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}>
<IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />
<Text fw={600} size="sm" truncate>
{project.name}
</Text>
</Group>
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
<IconEdit size={14} />
</ActionIcon>
</Group>
<Group gap={6} mb={6}>
<Badge size="xs" color={statusColors[project.status] || 'gray'}>
{project.status.replace('_', ' ')}
</Badge>
<Badge size="xs" color={priorityColor(project.priority)} variant="outline">
P{project.priority}
</Badge>
</Group>
<Text size="xs" ff="monospace" fw={500} mb={4}>
{fmt(project.estimated_cost)}
</Text>
<Badge size="xs" variant="light" color="violet">
{project.fund_source?.replace('_', ' ') || 'reserve'}
</Badge>
</Card>
);
}
// ---------------------------------------------------------------------------
// Kanban column (year)
// ---------------------------------------------------------------------------
interface KanbanColumnProps {
year: number;
projects: CapitalProject[];
onEdit: (p: CapitalProject) => void;
onDragStart: (e: DragEvent<HTMLDivElement>, project: CapitalProject) => void;
onDrop: (e: DragEvent<HTMLDivElement>, targetYear: number) => void;
isDragOver: boolean;
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
onDragLeave: () => void;
}
function KanbanColumn({
year, projects, onEdit, onDragStart, onDrop,
isDragOver, onDragOverHandler, onDragLeave,
}: KanbanColumnProps) {
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
return (
<Paper
withBorder
radius="md"
p="sm"
miw={280}
maw={320}
style={{
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
backgroundColor: isDragOver ? 'var(--mantine-color-blue-0)' : undefined,
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
transition: 'background-color 150ms ease, border 150ms ease',
}}
onDragOver={(e) => onDragOverHandler(e, year)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, year)}
>
<Group justify="space-between" mb="sm">
<Title order={5}>{year}</Title>
<Badge size="sm" variant="light">{fmt(totalEst)}</Badge>
</Group>
<Text size="xs" c="dimmed" mb="xs">
{projects.length} project{projects.length !== 1 ? 's' : ''}
</Text>
<Box style={{ flex: 1, minHeight: 60 }}>
{projects.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="lg">
Drop projects here
</Text>
) : (
projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
))
)}
</Box>
</Paper>
);
}
// ---------------------------------------------------------------------------
// Main page component
// ---------------------------------------------------------------------------
export function CapitalProjectsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<CapitalProject | null>(null);
const [viewMode, setViewMode] = useState<string>('table');
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
const queryClient = useQueryClient();
// ---- Data fetching ----
const { data: projects = [], isLoading } = useQuery<CapitalProject[]>({
queryKey: ['capital-projects'],
queryFn: async () => { const { data } = await api.get('/capital-projects'); return data; },
});
// ---- Form ----
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') },
validate: {
name: (v) => (v.length > 0 ? null : 'Required'),
estimated_cost: (v) => (v > 0 ? null : 'Required'),
},
});
// ---- Mutations ----
const saveMutation = useMutation({
mutationFn: (values: any) => editing ? api.put(`/capital-projects/${editing.id}`, values) : api.post('/capital-projects', values),
mutationFn: (values: any) =>
editing
? api.put(`/capital-projects/${editing.id}`, values)
: api.post('/capital-projects', values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['capital-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' }); },
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' });
},
});
const moveProjectMutation = useMutation({
mutationFn: ({ id, target_year }: { id: string; target_year: number }) =>
api.put(`/capital-projects/${id}`, { target_year }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['capital-projects'] });
notifications.show({ message: 'Project moved successfully', color: 'green' });
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Failed to move project', color: 'red' });
},
});
// ---- Handlers ----
const handleEdit = (p: CapitalProject) => {
setEditing(p);
form.setValues({
name: p.name, description: p.description || '',
estimated_cost: parseFloat(p.estimated_cost || '0'), actual_cost: parseFloat(p.actual_cost || '0'),
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', priority: p.priority || 3,
status: p.status, fund_source: p.fund_source || 'reserve',
priority: p.priority || 3,
});
open();
};
const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const years = [...new Set(projects.map(p => p.target_year))].sort();
const handleNewProject = () => {
setEditing(null);
form.reset();
open();
};
const handlePdfExport = () => {
window.print();
};
// ---- 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 }));
e.dataTransfer.effectAllowed = 'move';
}, []);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>, year: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverYear(year);
}, []);
const handleDragLeave = useCallback(() => {
setDragOverYear(null);
}, []);
const handleDrop = useCallback((e: DragEvent<HTMLDivElement>, targetYear: number) => {
e.preventDefault();
setDragOverYear(null);
try {
const payload = JSON.parse(e.dataTransfer.getData('application/json'));
if (payload.source_year !== targetYear) {
moveProjectMutation.mutate({ id: payload.id, target_year: targetYear });
}
} catch {
// ignore malformed drag data
}
}, [moveProjectMutation]);
// ---- Derived data ----
const years = [...new Set(projects.map((p) => p.target_year))].sort();
// ---- Loading state ----
if (isLoading) return <Center h={300}><Loader /></Center>;
// ---- Render: Table view ----
const renderTableView = () => (
<>
{years.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No capital projects planned yet. Add your first project.
</Text>
) : (
years.map((year) => {
const yearProjects = projects.filter((p) => p.target_year === year);
const totalEst = yearProjects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
return (
<Stack key={year} gap="xs">
<Group>
<Title order={4}>{year}</Title>
<Badge size="lg" variant="light">{fmt(totalEst)} estimated</Badge>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Project</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>Status</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{yearProjects.map((p) => (
<Table.Tr key={p.id}>
<Table.Td fw={500}>{p.name}</Table.Td>
<Table.Td>
{p.target_month
? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' })
: ''}{' '}
{p.target_year}
</Table.Td>
<Table.Td>
<Badge size="sm" color={priorityColor(p.priority)}>P{p.priority}</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(p.estimated_cost)}</Table.Td>
<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>
</Table.Td>
<Table.Td>
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
<IconEdit size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
);
})
)}
</>
);
// ---- Render: Kanban view ----
const renderKanbanView = () => (
<ScrollArea type="auto" offsetScrollbars>
<Group align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: years.length * 300 }}>
{years.length === 0 ? (
<Text c="dimmed" ta="center" py="xl" w="100%">
No capital projects planned yet. Add your first project.
</Text>
) : (
years.map((year) => {
const yearProjects = projects.filter((p) => p.target_year === year);
return (
<KanbanColumn
key={year}
year={year}
projects={yearProjects}
onEdit={handleEdit}
onDragStart={handleDragStart}
onDrop={handleDrop}
isDragOver={dragOverYear === year}
onDragOverHandler={handleDragOver}
onDragLeave={handleDragLeave}
/>
);
})
)}
</Group>
</ScrollArea>
);
// ---- Render ----
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Capital Projects (5-Year Plan)</Title>
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Project</Button>
<Group gap="sm">
<SegmentedControl
value={viewMode}
onChange={setViewMode}
data={[
{
value: 'table',
label: (
<Group gap={6} wrap="nowrap">
<IconTable size={16} />
<Text size="sm">Table</Text>
</Group>
),
},
{
value: 'kanban',
label: (
<Group gap={6} wrap="nowrap">
<IconLayoutKanban size={16} />
<Text size="sm">Kanban</Text>
</Group>
),
},
]}
/>
<Tooltip label="Export as PDF (browser print)">
<Button variant="light" leftSection={<IconFileTypePdf size={16} />} onClick={handlePdfExport}>
PDF
</Button>
</Tooltip>
<Button leftSection={<IconPlus size={16} />} onClick={handleNewProject}>
Add Project
</Button>
</Group>
</Group>
{years.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">No capital projects planned yet. Add your first project.</Text>
) : years.map(year => {
const yearProjects = projects.filter(p => p.target_year === year);
const totalEst = yearProjects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
return (
<Stack key={year} gap="xs">
<Group>
<Title order={4}>{year}</Title>
<Badge size="lg" variant="light">{fmt(totalEst)} estimated</Badge>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Project</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>Status</Table.Th><Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{yearProjects.map((p) => (
<Table.Tr key={p.id}>
<Table.Td fw={500}>{p.name}</Table.Td>
<Table.Td>{p.target_month ? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' }) : ''} {p.target_year}</Table.Td>
<Table.Td><Badge size="sm" color={p.priority <= 2 ? 'red' : p.priority <= 3 ? 'yellow' : 'gray'}>P{p.priority}</Badge></Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(p.estimated_cost)}</Table.Td>
<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></Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(p)}><IconEdit size={16} /></ActionIcon></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
);
})}
{viewMode === 'table' ? renderTableView() : renderKanbanView()}
<Modal opened={opened} onClose={close} title={editing ? 'Edit Project' : 'New Capital Project'} size="lg">
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
@@ -122,12 +430,31 @@ export function CapitalProjectsPage() {
</Group>
<Group grow>
<NumberInput label="Target Year" required min={2024} max={2040} {...form.getInputProps('target_year')} />
<Select label="Target Month" data={Array.from({length:12},(_,i)=>({value:String(i+1),label:new Date(2026,i).toLocaleString('default',{month:'long'})}))}
value={String(form.values.target_month)} onChange={(v) => form.setFieldValue('target_month', Number(v))} />
<Select
label="Target Month"
data={Array.from({ length: 12 }, (_, i) => ({
value: String(i + 1),
label: new Date(2026, i).toLocaleString('default', { month: 'long' }),
}))}
value={String(form.values.target_month)}
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')} />
<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>