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