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

@@ -23,6 +23,8 @@ import { CashFlowPage } from './pages/reports/CashFlowPage';
import { AgingReportPage } from './pages/reports/AgingReportPage';
import { YearEndPage } from './pages/reports/YearEndPage';
import { SettingsPage } from './pages/settings/SettingsPage';
import { AdminPage } from './pages/admin/AdminPage';
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
@@ -38,6 +40,14 @@ function OrgRequiredRoute({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
function SuperAdminRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
const user = useAuthStore((s) => s.user);
if (!token) return <Navigate to="/login" replace />;
if (!user?.isSuperadmin) return <Navigate to="/dashboard" replace />;
return <>{children}</>;
}
function AuthRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
const currentOrg = useAuthStore((s) => s.currentOrg);
@@ -73,6 +83,16 @@ export function App() {
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<SuperAdminRoute>
<AppLayout />
</SuperAdminRoute>
}
>
<Route index element={<AdminPage />} />
</Route>
<Route
path="/*"
element={
@@ -93,6 +113,7 @@ export function App() {
<Route path="reserves" element={<ReservesPage />} />
<Route path="investments" element={<InvestmentsPage />} />
<Route path="capital-projects" element={<CapitalProjectsPage />} />
<Route path="assessment-groups" element={<AssessmentGroupsPage />} />
<Route path="reports/balance-sheet" element={<BalanceSheetPage />} />
<Route path="reports/income-statement" element={<IncomeStatementPage />} />
<Route path="reports/budget-vs-actual" element={<BudgetVsActualPage />} />

View File

@@ -1,4 +1,4 @@
import { NavLink, ScrollArea } from '@mantine/core';
import { NavLink, ScrollArea, Divider, Text } from '@mantine/core';
import { useNavigate, useLocation } from 'react-router-dom';
import {
IconDashboard,
@@ -16,13 +16,17 @@ import {
IconUsers,
IconFileText,
IconSettings,
IconCrown,
IconCategory,
} from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore';
const navItems = [
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard' },
{ label: 'Chart of Accounts', icon: IconListDetails, path: '/accounts' },
{ label: 'Transactions', icon: IconReceipt, path: '/transactions' },
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' },
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups' },
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
{ label: 'Payments', icon: IconCash, path: '/payments' },
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' },
@@ -49,6 +53,7 @@ const navItems = [
export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const user = useAuthStore((s) => s.user);
return (
<ScrollArea p="sm">
@@ -81,6 +86,22 @@ export function Sidebar() {
/>
),
)}
{user?.isSuperadmin && (
<>
<Divider my="sm" />
<Text size="xs" c="dimmed" fw={700} tt="uppercase" px="sm" pb={4}>
Platform Admin
</Text>
<NavLink
label="Admin Panel"
leftSection={<IconCrown size={18} />}
active={location.pathname === '/admin'}
onClick={() => navigate('/admin')}
color="red"
/>
</>
)}
</ScrollArea>
);
}

View File

@@ -17,11 +17,13 @@ import {
Tabs,
Loader,
Center,
Tooltip,
SimpleGrid,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit, IconSearch } from '@tabler/icons-react';
import { IconPlus, IconEdit, IconSearch, IconArchive, IconArchiveOff } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
@@ -52,36 +54,41 @@ export function AccountsPage() {
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState<string | null>(null);
const [filterFund, setFilterFund] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const queryClient = useQueryClient();
const { data: accounts = [], isLoading } = useQuery<Account[]>({
queryKey: ['accounts'],
queryKey: ['accounts', showArchived],
queryFn: async () => {
const { data } = await api.get('/accounts');
const params = showArchived ? '?includeArchived=true' : '';
const { data } = await api.get(`/accounts${params}`);
return data;
},
});
const form = useForm({
initialValues: {
account_number: 0,
accountNumber: 0,
name: '',
description: '',
account_type: 'expense',
fund_type: 'operating',
is_1099_reportable: false,
accountType: 'expense',
fundType: 'operating',
is1099Reportable: false,
initialBalance: 0,
},
validate: {
account_number: (v) => (v > 0 ? null : 'Required'),
accountNumber: (v) => (v > 0 ? null : 'Required'),
name: (v) => (v.length > 0 ? null : 'Required'),
},
});
const createMutation = useMutation({
mutationFn: (values: any) =>
editing
? api.put(`/accounts/${editing.id}`, values)
: api.post('/accounts', values),
mutationFn: (values: any) => {
if (editing) {
return api.put(`/accounts/${editing.id}`, values);
}
return api.post('/accounts', values);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
notifications.show({ message: editing ? 'Account updated' : 'Account created', color: 'green' });
@@ -94,15 +101,28 @@ export function AccountsPage() {
},
});
const archiveMutation = useMutation({
mutationFn: (account: Account) =>
api.put(`/accounts/${account.id}`, { isActive: !account.is_active }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
notifications.show({ message: 'Account status updated', color: 'green' });
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' });
},
});
const handleEdit = (account: Account) => {
setEditing(account);
form.setValues({
account_number: account.account_number,
accountNumber: account.account_number,
name: account.name,
description: account.description || '',
account_type: account.account_type,
fund_type: account.fund_type,
is_1099_reportable: account.is_1099_reportable,
accountType: account.account_type,
fundType: account.fund_type,
is1099Reportable: account.is_1099_reportable,
initialBalance: 0,
});
open();
};
@@ -120,11 +140,18 @@ export function AccountsPage() {
return true;
});
const activeAccounts = filtered.filter(a => a.is_active);
const archivedAccounts = filtered.filter(a => !a.is_active);
const totalsByType = accounts.reduce((acc, a) => {
acc[a.account_type] = (acc[a.account_type] || 0) + parseFloat(a.balance || '0');
if (a.is_active) {
acc[a.account_type] = (acc[a.account_type] || 0) + parseFloat(a.balance || '0');
}
return acc;
}, {} as Record<string, number>);
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
if (isLoading) {
return <Center h={300}><Loader /></Center>;
}
@@ -133,11 +160,28 @@ export function AccountsPage() {
<Stack>
<Group justify="space-between">
<Title order={2}>Chart of Accounts</Title>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Account
</Button>
<Group>
<Switch
label="Show Archived"
checked={showArchived}
onChange={(e) => setShowArchived(e.currentTarget.checked)}
size="sm"
/>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Account
</Button>
</Group>
</Group>
<SimpleGrid cols={{ base: 2, sm: 5 }}>
{Object.entries(totalsByType).map(([type, total]) => (
<Card withBorder p="xs" key={type}>
<Text size="xs" c="dimmed" tt="capitalize">{type}</Text>
<Text fw={700} size="sm" c={accountTypeColors[type]}>{fmt(total)}</Text>
</Card>
))}
</SimpleGrid>
<Group>
<TextInput
placeholder="Search accounts..."
@@ -166,50 +210,69 @@ export function AccountsPage() {
<Tabs defaultValue="all">
<Tabs.List>
<Tabs.Tab value="all">All ({accounts.length})</Tabs.Tab>
<Tabs.Tab value="all">All ({activeAccounts.length})</Tabs.Tab>
<Tabs.Tab value="operating">Operating</Tabs.Tab>
<Tabs.Tab value="reserve">Reserve</Tabs.Tab>
{showArchived && archivedAccounts.length > 0 && (
<Tabs.Tab value="archived" color="gray">Archived ({archivedAccounts.length})</Tabs.Tab>
)}
</Tabs.List>
<Tabs.Panel value="all" pt="sm">
<AccountTable accounts={filtered} onEdit={handleEdit} />
<AccountTable accounts={activeAccounts} onEdit={handleEdit} onArchive={archiveMutation.mutate} />
</Tabs.Panel>
<Tabs.Panel value="operating" pt="sm">
<AccountTable accounts={filtered.filter(a => a.fund_type === 'operating')} onEdit={handleEdit} />
<AccountTable accounts={activeAccounts.filter(a => a.fund_type === 'operating')} onEdit={handleEdit} onArchive={archiveMutation.mutate} />
</Tabs.Panel>
<Tabs.Panel value="reserve" pt="sm">
<AccountTable accounts={filtered.filter(a => a.fund_type === 'reserve')} onEdit={handleEdit} />
<AccountTable accounts={activeAccounts.filter(a => a.fund_type === 'reserve')} onEdit={handleEdit} onArchive={archiveMutation.mutate} />
</Tabs.Panel>
{showArchived && (
<Tabs.Panel value="archived" pt="sm">
<AccountTable accounts={archivedAccounts} onEdit={handleEdit} onArchive={archiveMutation.mutate} isArchivedView />
</Tabs.Panel>
)}
</Tabs>
<Modal opened={opened} onClose={close} title={editing ? 'Edit Account' : 'New Account'} size="md">
<form onSubmit={form.onSubmit((values) => createMutation.mutate(values))}>
<Stack>
<NumberInput label="Account Number" required {...form.getInputProps('account_number')} />
<NumberInput label="Account Number" required {...form.getInputProps('accountNumber')} />
<TextInput label="Account Name" required {...form.getInputProps('name')} />
<TextInput label="Description" {...form.getInputProps('description')} />
<Select
label="Account Type"
required
data={[
{ value: 'asset', label: 'Asset' },
{ value: 'liability', label: 'Liability' },
{ value: 'equity', label: 'Equity' },
{ value: 'income', label: 'Income' },
{ value: 'expense', label: 'Expense' },
]}
{...form.getInputProps('account_type')}
/>
<Select
label="Fund Type"
required
data={[
{ value: 'operating', label: 'Operating' },
{ value: 'reserve', label: 'Reserve' },
]}
{...form.getInputProps('fund_type')}
/>
<Switch label="1099 Reportable" {...form.getInputProps('is_1099_reportable', { type: 'checkbox' })} />
<Group grow>
<Select
label="Account Type"
required
data={[
{ value: 'asset', label: 'Asset' },
{ value: 'liability', label: 'Liability' },
{ value: 'equity', label: 'Equity' },
{ value: 'income', label: 'Income' },
{ value: 'expense', label: 'Expense' },
]}
{...form.getInputProps('accountType')}
/>
<Select
label="Fund Type"
required
data={[
{ value: 'operating', label: 'Operating' },
{ value: 'reserve', label: 'Reserve' },
]}
{...form.getInputProps('fundType')}
/>
</Group>
<Switch label="1099 Reportable" {...form.getInputProps('is1099Reportable', { type: 'checkbox' })} />
{!editing && (
<NumberInput
label="Initial Balance"
description="Opening balance (creates a journal entry)"
prefix="$"
decimalScale={2}
{...form.getInputProps('initialBalance')}
/>
)}
<Button type="submit" loading={createMutation.isPending}>
{editing ? 'Update' : 'Create'}
</Button>
@@ -220,7 +283,17 @@ export function AccountsPage() {
);
}
function AccountTable({ accounts, onEdit }: { accounts: Account[]; onEdit: (a: Account) => void }) {
function AccountTable({
accounts,
onEdit,
onArchive,
isArchivedView = false,
}: {
accounts: Account[];
onEdit: (a: Account) => void;
onArchive: (a: Account) => void;
isArchivedView?: boolean;
}) {
const fmt = (v: string) => {
const n = parseFloat(v || '0');
return n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
@@ -240,10 +313,24 @@ function AccountTable({ accounts, onEdit }: { accounts: Account[]; onEdit: (a: A
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{accounts.length === 0 && (
<Table.Tr>
<Table.Td colSpan={7}>
<Text ta="center" c="dimmed" py="lg">
{isArchivedView ? 'No archived accounts' : 'No accounts found'}
</Text>
</Table.Td>
</Table.Tr>
)}
{accounts.map((a) => (
<Table.Tr key={a.id}>
<Table.Tr key={a.id} style={{ opacity: a.is_active ? 1 : 0.6 }}>
<Table.Td fw={500}>{a.account_number}</Table.Td>
<Table.Td>{a.name}</Table.Td>
<Table.Td>
<div>
<Text size="sm">{a.name}</Text>
{a.description && <Text size="xs" c="dimmed">{a.description}</Text>}
</div>
</Table.Td>
<Table.Td>
<Badge color={accountTypeColors[a.account_type]} variant="light" size="sm">
{a.account_type}
@@ -255,13 +342,26 @@ function AccountTable({ accounts, onEdit }: { accounts: Account[]; onEdit: (a: A
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(a.balance)}</Table.Td>
<Table.Td>{a.is_1099_reportable ? '1099' : ''}</Table.Td>
<Table.Td>{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}</Table.Td>
<Table.Td>
{!a.is_system && (
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
<IconEdit size={16} />
</ActionIcon>
)}
<Group gap={4}>
<Tooltip label="Edit account">
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
{!a.is_system && (
<Tooltip label={a.is_active ? 'Archive account' : 'Restore account'}>
<ActionIcon
variant="subtle"
color={a.is_active ? 'gray' : 'green'}
onClick={() => onArchive(a)}
>
{a.is_active ? <IconArchive size={16} /> : <IconArchiveOff size={16} />}
</ActionIcon>
</Tooltip>
)}
</Group>
</Table.Td>
</Table.Tr>
))}

View File

@@ -0,0 +1,238 @@
import { useState } from 'react';
import {
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
ThemeIcon, Tabs, ActionIcon, Switch, TextInput, Avatar,
} from '@mantine/core';
import {
IconUsers, IconBuilding, IconShieldLock, IconSearch,
IconCrown, IconUser,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
interface AdminUser {
id: string; email: string; firstName: string; lastName: string;
isSuperadmin: boolean; lastLoginAt: string; createdAt: string;
organizations: { id: string; name: string; role: string }[];
}
interface AdminOrg {
id: string; name: string; schema_name: string; status: string;
email: string; phone: string; member_count: string; created_at: string;
}
export function AdminPage() {
const [search, setSearch] = useState('');
const queryClient = useQueryClient();
const { data: users, isLoading: usersLoading } = useQuery<AdminUser[]>({
queryKey: ['admin-users'],
queryFn: async () => { const { data } = await api.get('/admin/users'); return data; },
});
const { data: orgs, isLoading: orgsLoading } = useQuery<AdminOrg[]>({
queryKey: ['admin-orgs'],
queryFn: async () => { const { data } = await api.get('/admin/organizations'); return data; },
});
const toggleSuperadmin = useMutation({
mutationFn: async ({ userId, isSuperadmin }: { userId: string; isSuperadmin: boolean }) => {
await api.post(`/admin/users/${userId}/superadmin`, { isSuperadmin });
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-users'] }),
});
const filteredUsers = (users || []).filter(u =>
!search || u.email.toLowerCase().includes(search.toLowerCase()) ||
`${u.firstName} ${u.lastName}`.toLowerCase().includes(search.toLowerCase())
);
const filteredOrgs = (orgs || []).filter(o =>
!search || o.name.toLowerCase().includes(search.toLowerCase()) ||
o.schema_name.toLowerCase().includes(search.toLowerCase())
);
return (
<Stack>
<Group justify="space-between">
<div>
<Title order={2}>Platform Administration</Title>
<Text c="dimmed" size="sm">SuperUser Admin Panel Manage tenants and users</Text>
</div>
<Badge color="red" variant="filled" size="lg" leftSection={<IconCrown size={14} />}>
SuperAdmin
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Card withBorder padding="lg">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Users</Text>
<Text fw={700} size="xl">{users?.length || 0}</Text>
</div>
<ThemeIcon color="blue" variant="light" size={48} radius="md">
<IconUsers size={28} />
</ThemeIcon>
</Group>
</Card>
<Card withBorder padding="lg">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Organizations</Text>
<Text fw={700} size="xl">{orgs?.length || 0}</Text>
</div>
<ThemeIcon color="green" variant="light" size={48} radius="md">
<IconBuilding size={28} />
</ThemeIcon>
</Group>
</Card>
<Card withBorder padding="lg">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>SuperAdmins</Text>
<Text fw={700} size="xl">{(users || []).filter(u => u.isSuperadmin).length}</Text>
</div>
<ThemeIcon color="red" variant="light" size={48} radius="md">
<IconShieldLock size={28} />
</ThemeIcon>
</Group>
</Card>
</SimpleGrid>
<TextInput
placeholder="Search users or organizations..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<Tabs defaultValue="users">
<Tabs.List>
<Tabs.Tab value="users" leftSection={<IconUsers size={16} />}>
Users ({filteredUsers.length})
</Tabs.Tab>
<Tabs.Tab value="orgs" leftSection={<IconBuilding size={16} />}>
Organizations ({filteredOrgs.length})
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="users" pt="md">
{usersLoading ? (
<Center h={200}><Loader /></Center>
) : (
<Card withBorder>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Email</Table.Th>
<Table.Th>Organizations</Table.Th>
<Table.Th>Last Login</Table.Th>
<Table.Th ta="center">SuperAdmin</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredUsers.map((u) => (
<Table.Tr key={u.id}>
<Table.Td>
<Group gap="xs">
<Avatar size="sm" radius="xl" color={u.isSuperadmin ? 'red' : 'blue'}>
{u.firstName?.[0]}{u.lastName?.[0]}
</Avatar>
<Text size="sm" fw={500}>{u.firstName} {u.lastName}</Text>
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" ff="monospace">{u.email}</Text>
</Table.Td>
<Table.Td>
<Group gap={4}>
{u.organizations.map((o) => (
<Badge key={o.id} size="xs" variant="light">
{o.name} ({o.role})
</Badge>
))}
{u.organizations.length === 0 && (
<Text size="xs" c="dimmed">No organizations</Text>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleDateString() : 'Never'}
</Text>
</Table.Td>
<Table.Td ta="center">
<Switch
checked={u.isSuperadmin}
onChange={() => toggleSuperadmin.mutate({
userId: u.id,
isSuperadmin: !u.isSuperadmin,
})}
size="sm"
color="red"
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
</Tabs.Panel>
<Tabs.Panel value="orgs" pt="md">
{orgsLoading ? (
<Center h={200}><Loader /></Center>
) : (
<Card withBorder>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Organization</Table.Th>
<Table.Th>Schema</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="center">Members</Table.Th>
<Table.Th>Contact</Table.Th>
<Table.Th>Created</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredOrgs.map((o) => (
<Table.Tr key={o.id}>
<Table.Td fw={500}>{o.name}</Table.Td>
<Table.Td>
<Text size="xs" ff="monospace" c="dimmed">{o.schema_name}</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={o.status === 'active' ? 'green' : o.status === 'trial' ? 'yellow' : 'red'}
>
{o.status}
</Badge>
</Table.Td>
<Table.Td ta="center">
<Badge variant="light" size="sm">{o.member_count}</Badge>
</Table.Td>
<Table.Td>
<Text size="xs">{o.email || 'N/A'}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{new Date(o.created_at).toLocaleDateString()}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
</Tabs.Panel>
</Tabs>
</Stack>
);
}

View File

@@ -0,0 +1,274 @@
import { useState } from 'react';
import {
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Switch, ActionIcon,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconPlus, IconEdit, IconCategory, IconCash, IconHome, IconArchive,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
interface AssessmentGroup {
id: string;
name: string;
description: string;
regular_assessment: string;
special_assessment: string;
unit_count: number;
actual_unit_count: string;
monthly_operating_income: string;
monthly_reserve_income: string;
total_monthly_income: string;
is_active: boolean;
}
interface Summary {
group_count: string;
total_monthly_operating: string;
total_monthly_reserve: string;
total_monthly_income: string;
total_units: string;
}
export function AssessmentGroupsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
const queryClient = useQueryClient();
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
queryKey: ['assessment-groups'],
queryFn: async () => { const { data } = await api.get('/assessment-groups'); return data; },
});
const { data: summary } = useQuery<Summary>({
queryKey: ['assessment-groups-summary'],
queryFn: async () => { const { data } = await api.get('/assessment-groups/summary'); return data; },
});
const form = useForm({
initialValues: {
name: '',
description: '',
regularAssessment: 0,
specialAssessment: 0,
unitCount: 0,
},
validate: {
name: (v) => (v.length > 0 ? null : 'Required'),
regularAssessment: (v) => (v >= 0 ? null : 'Must be >= 0'),
},
});
const saveMutation = useMutation({
mutationFn: (values: any) =>
editing
? api.put(`/assessment-groups/${editing.id}`, values)
: api.post('/assessment-groups', values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['assessment-groups'] });
queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] });
notifications.show({ message: editing ? 'Group updated' : 'Group created', color: 'green' });
close();
setEditing(null);
form.reset();
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' });
},
});
const archiveMutation = useMutation({
mutationFn: (group: AssessmentGroup) =>
api.put(`/assessment-groups/${group.id}`, { isActive: !group.is_active }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['assessment-groups'] });
queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] });
notifications.show({ message: 'Group status updated', color: 'green' });
},
});
const handleEdit = (group: AssessmentGroup) => {
setEditing(group);
form.setValues({
name: group.name,
description: group.description || '',
regularAssessment: parseFloat(group.regular_assessment || '0'),
specialAssessment: parseFloat(group.special_assessment || '0'),
unitCount: group.unit_count || 0,
});
open();
};
const handleNew = () => {
setEditing(null);
form.reset();
open();
};
const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
if (isLoading) return <Center h={300}><Loader /></Center>;
return (
<Stack>
<Group justify="space-between">
<div>
<Title order={2}>Assessment Groups</Title>
<Text c="dimmed" size="sm">Manage property types with different assessment rates</Text>
</div>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Group
</Button>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
<Card withBorder padding="lg">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Groups</Text>
<Text fw={700} size="xl">{summary?.group_count || 0}</Text>
</div>
<ThemeIcon color="blue" variant="light" size={48} radius="md">
<IconCategory size={28} />
</ThemeIcon>
</Group>
</Card>
<Card withBorder padding="lg">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Units</Text>
<Text fw={700} size="xl">{summary?.total_units || 0}</Text>
</div>
<ThemeIcon color="green" variant="light" size={48} radius="md">
<IconHome size={28} />
</ThemeIcon>
</Group>
</Card>
<Card withBorder padding="lg">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Monthly Operating</Text>
<Text fw={700} size="xl">{fmt(summary?.total_monthly_operating || '0')}</Text>
</div>
<ThemeIcon color="teal" variant="light" size={48} radius="md">
<IconCash size={28} />
</ThemeIcon>
</Group>
</Card>
<Card withBorder padding="lg">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Monthly Reserve</Text>
<Text fw={700} size="xl">{fmt(summary?.total_monthly_reserve || '0')}</Text>
</div>
<ThemeIcon color="violet" variant="light" size={48} radius="md">
<IconCash size={28} />
</ThemeIcon>
</Group>
</Card>
</SimpleGrid>
<Card withBorder>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Group Name</Table.Th>
<Table.Th ta="center">Units</Table.Th>
<Table.Th ta="right">Regular Assessment</Table.Th>
<Table.Th ta="right">Special Assessment</Table.Th>
<Table.Th ta="right">Monthly Operating</Table.Th>
<Table.Th ta="right">Monthly Reserve</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{groups.length === 0 && (
<Table.Tr>
<Table.Td colSpan={8}>
<Text ta="center" c="dimmed" py="lg">
No assessment groups yet. Create groups like "Single Family Homes", "Condos", etc.
</Text>
</Table.Td>
</Table.Tr>
)}
{groups.map((g) => (
<Table.Tr key={g.id} style={{ opacity: g.is_active ? 1 : 0.5 }}>
<Table.Td>
<div>
<Text fw={500}>{g.name}</Text>
{g.description && <Text size="xs" c="dimmed">{g.description}</Text>}
</div>
</Table.Td>
<Table.Td ta="center">
<Badge variant="light">{g.actual_unit_count || g.unit_count}</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(g.regular_assessment)}</Table.Td>
<Table.Td ta="right" ff="monospace">
{parseFloat(g.special_assessment || '0') > 0 ? (
<Badge color="orange" variant="light">{fmt(g.special_assessment)}</Badge>
) : '-'}
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(g.monthly_operating_income)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(g.monthly_reserve_income)}</Table.Td>
<Table.Td>
<Badge color={g.is_active ? 'green' : 'gray'} variant="light" size="sm">
{g.is_active ? 'Active' : 'Archived'}
</Badge>
</Table.Td>
<Table.Td>
<Group gap={4}>
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color={g.is_active ? 'gray' : 'green'}
onClick={() => archiveMutation.mutate(g)}
>
<IconArchive size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
<Modal opened={opened} onClose={close} title={editing ? 'Edit Assessment Group' : 'New Assessment Group'} size="md">
<form onSubmit={form.onSubmit((values) => saveMutation.mutate(values))}>
<Stack>
<TextInput label="Group Name" placeholder="e.g. Single Family Homes" required {...form.getInputProps('name')} />
<Textarea label="Description" placeholder="Optional description" {...form.getInputProps('description')} />
<Group grow>
<NumberInput
label="Regular Assessment (per unit)"
prefix="$"
decimalScale={2}
min={0}
{...form.getInputProps('regularAssessment')}
/>
<NumberInput
label="Special Assessment (per unit)"
prefix="$"
decimalScale={2}
min={0}
{...form.getInputProps('specialAssessment')}
/>
</Group>
<NumberInput label="Expected Unit Count" min={0} {...form.getInputProps('unitCount')} />
<Button type="submit" loading={saveMutation.isPending}>
{editing ? 'Update' : 'Create'}
</Button>
</Stack>
</form>
</Modal>
</Stack>
);
}

View File

@@ -1,10 +1,10 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import {
Title, Table, Group, Button, Stack, Text, NumberInput,
Select, Loader, Center, Badge, Card,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { IconDeviceFloppy, IconUpload, IconDownload } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
@@ -23,10 +23,49 @@ interface BudgetLine {
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function parseCSV(text: string): Record<string, string>[] {
const lines = text.trim().split('\n');
if (lines.length < 2) return [];
const headers = lines[0].split(',').map((h) => h.trim().toLowerCase());
const rows: Record<string, string>[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Handle quoted fields containing commas
const values: string[] = [];
let current = '';
let inQuotes = false;
for (let j = 0; j < line.length; j++) {
const ch = line[j];
if (ch === '"') {
inQuotes = !inQuotes;
} else if (ch === ',' && !inQuotes) {
values.push(current.trim());
current = '';
} else {
current += ch;
}
}
values.push(current.trim());
const row: Record<string, string> = {};
headers.forEach((h, idx) => {
row[h] = values[idx] || '';
});
rows.push(row);
}
return rows;
}
export function BudgetsPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const { isLoading } = useQuery<BudgetLine[]>({
queryKey: ['budgets', year],
@@ -59,6 +98,88 @@ export function BudgetsPage() {
},
});
const importMutation = useMutation({
mutationFn: async (lines: Record<string, string>[]) => {
const parsed = lines.map((row) => ({
account_number: row.account_number || row.accountnumber || '',
jan: parseFloat(row.jan) || 0,
feb: parseFloat(row.feb) || 0,
mar: parseFloat(row.mar) || 0,
apr: parseFloat(row.apr) || 0,
may: parseFloat(row.may) || 0,
jun: parseFloat(row.jun) || 0,
jul: parseFloat(row.jul) || 0,
aug: parseFloat(row.aug) || 0,
sep: parseFloat(row.sep) || 0,
oct: parseFloat(row.oct) || 0,
nov: parseFloat(row.nov) || 0,
dec_amt: parseFloat(row.dec_amt || row.dec) || 0,
}));
const { data } = await api.post(`/budgets/${year}/import`, parsed);
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
const msg = `Imported ${data.imported} budget line(s)` +
(data.errors?.length ? `. ${data.errors.length} error(s): ${data.errors.join('; ')}` : '');
notifications.show({
message: msg,
color: data.errors?.length ? 'yellow' : 'green',
autoClose: 8000,
});
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' });
},
});
const handleDownloadTemplate = async () => {
try {
const response = await api.get(`/budgets/${year}/template`, {
responseType: 'blob',
});
const blob = new Blob([response.data], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `budget_template_${year}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (err: any) {
notifications.show({ message: 'Failed to download template', color: 'red' });
}
};
const handleImportCSV = () => {
fileInputRef.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
if (!text) {
notifications.show({ message: 'Could not read file', color: 'red' });
return;
}
const rows = parseCSV(text);
if (rows.length === 0) {
notifications.show({ message: 'No data rows found in CSV', color: 'red' });
return;
}
importMutation.mutate(rows);
};
reader.readAsText(file);
// Reset input so the same file can be re-selected
event.target.value = '';
};
const updateCell = (idx: number, month: string, value: number) => {
const updated = [...budgetData];
(updated[idx] as any)[month] = value || 0;
@@ -86,6 +207,28 @@ export function BudgetsPage() {
<Title order={2}>Budget Manager</Title>
<Group>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
<Button
variant="outline"
leftSection={<IconDownload size={16} />}
onClick={handleDownloadTemplate}
>
Download Template
</Button>
<Button
variant="outline"
leftSection={<IconUpload size={16} />}
onClick={handleImportCSV}
loading={importMutation.isPending}
>
Import CSV
</Button>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".csv,.txt"
onChange={handleFileChange}
/>
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
Save Budget
</Button>

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>

View File

@@ -76,7 +76,7 @@ function InvoiceRows({ invoices }: { invoices: Invoice[] }) {
return (
<Table.Tr>
<Table.Td colSpan={9} p={0} style={{ background: 'var(--mantine-color-gray-0)' }}>
<Table fontSize="xs" horizontalSpacing="sm" verticalSpacing={4}>
<Table horizontalSpacing="sm" fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th w={40} />

View File

@@ -13,6 +13,7 @@ interface User {
email: string;
firstName: string;
lastName: string;
isSuperadmin?: boolean;
}
interface AuthState {