Phase 2 tweaks: admin tenant creation, unit delete, frequency, UI overhaul

- Admin panel: create tenants with org + first user, manage org status
  (active/suspended/archived), contract number and plan level fields
- Units: delete with invoice check, assessment group dropdown binding
- Assessment groups: frequency field (monthly/quarterly/annual) with
  income calculations normalized to monthly equivalents
- Sidebar: grouped nav sections (Financials, Assessments, Transactions,
  Planning, Reports, Admin), renamed Chart of Accounts to Accounts
- Header: replaced text with SVG logo
- Capital projects: Kanban as default view, table-only PDF export,
  Future category (beyond 5-year plan)
- Auth: block login for suspended/archived organizations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 20:00:16 -05:00
parent 01502e07bc
commit 17fdacc0f2
20 changed files with 992 additions and 148 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, DragEvent } from 'react';
import { useState, useCallback, useEffect, useRef, DragEvent } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
@@ -24,6 +24,8 @@ interface CapitalProject {
status: string; fund_source: string; priority: number;
}
const FUTURE_YEAR = 9999;
const statusColors: Record<string, string> = {
planned: 'blue', approved: 'green', in_progress: 'yellow',
completed: 'teal', deferred: 'gray', cancelled: 'red',
@@ -34,6 +36,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));
// ---------------------------------------------------------------------------
// Kanban card
// ---------------------------------------------------------------------------
@@ -129,7 +133,7 @@ function KanbanColumn({
onDrop={(e) => onDrop(e, year)}
>
<Group justify="space-between" mb="sm">
<Title order={5}>{year}</Title>
<Title order={5}>{yearLabel(year)}</Title>
<Badge size="sm" variant="light">{fmt(totalEst)}</Badge>
</Group>
@@ -152,6 +156,21 @@ function KanbanColumn({
);
}
// ---------------------------------------------------------------------------
// Print styles - hides kanban and shows the table view when printing
// ---------------------------------------------------------------------------
const printStyles = `
@media print {
.capital-projects-kanban-view {
display: none !important;
}
.capital-projects-table-view {
display: block !important;
}
}
`;
// ---------------------------------------------------------------------------
// Main page component
// ---------------------------------------------------------------------------
@@ -159,8 +178,10 @@ function KanbanColumn({
export function CapitalProjectsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<CapitalProject | null>(null);
const [viewMode, setViewMode] = useState<string>('table');
const [viewMode, setViewMode] = useState<string>('kanban');
const [printMode, setPrintMode] = useState(false);
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
const printModeRef = useRef(false);
const queryClient = useQueryClient();
// ---- Data fetching ----
@@ -172,6 +193,16 @@ export function CapitalProjectsPage() {
// ---- Form ----
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 form = useForm({
initialValues: {
name: '', description: '', estimated_cost: 0, actual_cost: 0,
@@ -213,6 +244,21 @@ export function CapitalProjectsPage() {
},
});
// ---- Print mode effect ----
useEffect(() => {
if (printMode) {
printModeRef.current = true;
// Wait for the table to render before printing
const timer = setTimeout(() => {
window.print();
setPrintMode(false);
printModeRef.current = false;
}, 300);
return () => clearTimeout(timer);
}
}, [printMode]);
// ---- Handlers ----
const handleEdit = (p: CapitalProject) => {
@@ -235,7 +281,13 @@ export function CapitalProjectsPage() {
};
const handlePdfExport = () => {
window.print();
// If already in table view, just print directly
if (viewMode === 'table') {
window.print();
return;
}
// Otherwise, trigger printMode which renders the table for printing
setPrintMode(true);
};
// ---- Drag & Drop ----
@@ -270,7 +322,17 @@ export function CapitalProjectsPage() {
// ---- Derived data ----
const years = [...new Set(projects.map((p) => p.target_year))].sort();
// 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 hasFutureProjects = projectYears.includes(FUTURE_YEAR);
// 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;
// Kanban columns: always current..current+4 plus Future
const kanbanYears = [...baseYears, FUTURE_YEAR];
// ---- Loading state ----
@@ -287,11 +349,12 @@ export function CapitalProjectsPage() {
) : (
years.map((year) => {
const yearProjects = 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 (
<Stack key={year} gap="xs">
<Group>
<Title order={4}>{year}</Title>
<Title order={4}>{yearLabel(year)}</Title>
<Badge size="lg" variant="light">{fmt(totalEst)} estimated</Badge>
</Group>
<Table striped highlightOnHover>
@@ -312,10 +375,17 @@ export function CapitalProjectsPage() {
<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}
{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>
<Badge size="sm" color={priorityColor(p.priority)}>P{p.priority}</Badge>
@@ -348,29 +418,23 @@ export function CapitalProjectsPage() {
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 align="flex-start" wrap="nowrap" gap="md" py="sm" style={{ minWidth: kanbanYears.length * 300 }}>
{kanbanYears.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>
);
@@ -379,8 +443,11 @@ export function CapitalProjectsPage() {
return (
<Stack>
{/* Print-specific styles */}
<style>{printStyles}</style>
<Group justify="space-between">
<Title order={2}>Capital Projects (5-Year Plan)</Title>
<Title order={2}>Capital Projects</Title>
<Group gap="sm">
<SegmentedControl
value={viewMode}
@@ -417,7 +484,24 @@ export function CapitalProjectsPage() {
</Group>
</Group>
{viewMode === 'table' ? renderTableView() : renderKanbanView()}
{/* Main visible view */}
{viewMode === 'table' ? (
<div className="capital-projects-table-view">
{renderTableView()}
</div>
) : (
<>
<div className="capital-projects-kanban-view">
{renderKanbanView()}
</div>
{/* Hidden table view for print mode - rendered when printMode is true */}
{printMode && (
<div className="capital-projects-table-view" style={{ display: 'none' }}>
{renderTableView()}
</div>
)}
</>
)}
<Modal opened={opened} onClose={close} title={editing ? 'Edit Project' : 'New Capital Project'} size="lg">
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
@@ -429,7 +513,13 @@ export function CapitalProjectsPage() {
<NumberInput label="Actual Cost" prefix="$" decimalScale={2} min={0} {...form.getInputProps('actual_cost')} />
</Group>
<Group grow>
<NumberInput label="Target Year" required min={2024} max={2040} {...form.getInputProps('target_year')} />
<Select
label="Target Year"
required
data={targetYearOptions}
value={String(form.values.target_year)}
onChange={(v) => form.setFieldValue('target_year', Number(v))}
/>
<Select
label="Target Month"
data={Array.from({ length: 12 }, (_, i) => ({