Files
HOA_Financial_Platform/frontend/src/pages/org-members/OrgMembersPage.tsx
olsch01 66e2f87a96 feat: UX enhancements, member limits, forecast fix, and menu cleanup (v2026.3.19)
- Onboarding wizard: add Reserve Account step between Operating and Assessments,
  redirect to Budget Planning on completion
- Dashboard: health score pending state shows clickable links to set up missing items
- Projects/Vendors: rich empty-state wizard screens with real-world examples and CTAs
- Investment Planning: auto-refresh AI recommendations when empty or stale (>30 days)
- Hide Invoices and Payments menus (see PARKING-LOT.md for re-enablement)
- Send welcome email via Resend when new members are added to a tenant
- Enforce 5-member limit for Starter/Standard/Professional plans (Enterprise unlimited)
- Cash flow forecast: only mark months as "Actual" when journal entries exist,
  fixing the issue where months without data showed as actuals
- Bump version to 2026.3.19

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 14:47:04 -04:00

389 lines
13 KiB
TypeScript

import { useState } from 'react';
import {
Title, Text, Card, Stack, Group, Table, Badge, Button, Modal,
TextInput, Select, ActionIcon, Tooltip, Alert, SimpleGrid,
ThemeIcon, Loader, Center,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconPlus, IconEdit, IconTrash, IconUserPlus, IconUsers,
IconShieldCheck, IconInfoCircle,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
interface OrgMember {
id: string;
userId: string;
email: string;
firstName: string;
lastName: string;
role: string;
isActive: boolean;
joinedAt: string;
lastLoginAt: string | null;
}
const ROLE_OPTIONS = [
{ value: 'president', label: 'President' },
{ value: 'treasurer', label: 'Treasurer' },
{ value: 'secretary', label: 'Secretary' },
{ value: 'board_member', label: 'Board Member' },
{ value: 'property_manager', label: 'Property Manager' },
{ value: 'viewer', label: 'Viewer (Read-Only)' },
];
const roleColors: Record<string, string> = {
president: 'red',
treasurer: 'blue',
secretary: 'green',
board_member: 'violet',
property_manager: 'orange',
viewer: 'gray',
admin: 'red',
};
export function OrgMembersPage() {
const [addOpened, { open: openAdd, close: closeAdd }] = useDisclosure(false);
const [editOpened, { open: openEdit, close: closeEdit }] = useDisclosure(false);
const [editingMember, setEditingMember] = useState<OrgMember | null>(null);
const queryClient = useQueryClient();
const { user, currentOrg } = useAuthStore();
const isReadOnly = useIsReadOnly();
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
queryKey: ['org-members'],
queryFn: async () => {
const { data } = await api.get('/organizations/members');
return data;
},
});
const addForm = useForm({
initialValues: {
email: '',
firstName: '',
lastName: '',
password: '',
role: 'board_member',
},
validate: {
email: (v) => (/^\S+@\S+\.\S+$/.test(v) ? null : 'Valid email required'),
firstName: (v) => (v.length > 0 ? null : 'Required'),
lastName: (v) => (v.length > 0 ? null : 'Required'),
password: (v) => (v.length >= 6 ? null : 'Min 6 characters'),
},
});
const editForm = useForm({
initialValues: {
role: 'board_member',
},
});
const addMemberMutation = useMutation({
mutationFn: (values: typeof addForm.values) =>
api.post('/organizations/members', values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['org-members'] });
notifications.show({ message: 'Member added successfully', color: 'green' });
closeAdd();
addForm.reset();
},
onError: (err: any) => {
notifications.show({
message: err.response?.data?.message || 'Failed to add member',
color: 'red',
});
},
});
const updateRoleMutation = useMutation({
mutationFn: ({ membershipId, role }: { membershipId: string; role: string }) =>
api.put(`/organizations/members/${membershipId}/role`, { role }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['org-members'] });
notifications.show({ message: 'Role updated', color: 'green' });
closeEdit();
setEditingMember(null);
},
onError: (err: any) => {
notifications.show({
message: err.response?.data?.message || 'Failed to update role',
color: 'red',
});
},
});
const removeMemberMutation = useMutation({
mutationFn: (membershipId: string) =>
api.delete(`/organizations/members/${membershipId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['org-members'] });
notifications.show({ message: 'Member removed', color: 'green' });
},
onError: (err: any) => {
notifications.show({
message: err.response?.data?.message || 'Failed to remove member',
color: 'red',
});
},
});
const handleEditRole = (member: OrgMember) => {
setEditingMember(member);
editForm.setValues({ role: member.role });
openEdit();
};
const handleRemove = (member: OrgMember) => {
if (member.userId === user?.id) {
notifications.show({ message: 'You cannot remove yourself', color: 'red' });
return;
}
if (window.confirm(`Remove ${member.firstName} ${member.lastName} (${member.email}) from this organization?`)) {
removeMemberMutation.mutate(member.id);
}
};
const activeMembers = members.filter((m) => m.isActive);
const inactiveMembers = members.filter((m) => !m.isActive);
if (isLoading) {
return <Center h={300}><Loader /></Center>;
}
return (
<Stack>
<Group justify="space-between">
<div>
<Title order={2}>Organization Members</Title>
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
</div>
{!isReadOnly && (
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
Add Member
</Button>
)}
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Card withBorder p="xs">
<Group>
<ThemeIcon color="blue" variant="light" size={36} radius="md">
<IconUsers size={20} />
</ThemeIcon>
<div>
<Text size="xs" c="dimmed">Total Members</Text>
<Text fw={700}>{activeMembers.length}</Text>
</div>
</Group>
</Card>
<Card withBorder p="xs">
<Group>
<ThemeIcon color="green" variant="light" size={36} radius="md">
<IconShieldCheck size={20} />
</ThemeIcon>
<div>
<Text size="xs" c="dimmed">Board Members</Text>
<Text fw={700}>
{activeMembers.filter((m) =>
['president', 'treasurer', 'secretary', 'board_member'].includes(m.role),
).length}
</Text>
</div>
</Group>
</Card>
<Card withBorder p="xs">
<Group>
<ThemeIcon color="violet" variant="light" size={36} radius="md">
<IconInfoCircle size={20} />
</ThemeIcon>
<div>
<Text size="xs" c="dimmed">Your Role</Text>
<Text fw={700} tt="capitalize">{currentOrg?.role || 'N/A'}</Text>
</div>
</Group>
</Card>
</SimpleGrid>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
As an organization administrator, you can add board members, property managers, and
viewers to give them access to this tenant. Each member can log in with their own
credentials and see the same financial data.
{currentOrg?.planLevel && !['enterprise'].includes(currentOrg.planLevel) && (
<Text size="sm" mt={6} fw={500}>
Your {currentOrg.planLevel === 'professional' ? 'Professional' : 'Starter'} plan
supports up to 5 user accounts ({activeMembers.length}/5 used).
{activeMembers.length >= 5 && ' Upgrade to Enterprise for unlimited members.'}
</Text>
)}
</Alert>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Email</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Joined</Table.Th>
<Table.Th>Last Login</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{activeMembers.length === 0 && (
<Table.Tr>
<Table.Td colSpan={7}>
<Text ta="center" c="dimmed" py="lg">
No members yet. Add your first board member above.
</Text>
</Table.Td>
</Table.Tr>
)}
{activeMembers.map((member) => (
<Table.Tr key={member.id}>
<Table.Td fw={500}>
{member.firstName} {member.lastName}
{member.userId === user?.id && (
<Badge size="xs" variant="light" ml={6}>You</Badge>
)}
</Table.Td>
<Table.Td>{member.email}</Table.Td>
<Table.Td>
<Badge color={roleColors[member.role] || 'gray'} variant="light" size="sm" tt="capitalize">
{member.role.replace(/_/g, ' ')}
</Badge>
</Table.Td>
<Table.Td>
<Badge color="green" variant="light" size="sm">Active</Badge>
</Table.Td>
<Table.Td>
{member.joinedAt ? new Date(member.joinedAt).toLocaleDateString() : '-'}
</Table.Td>
<Table.Td>
{member.lastLoginAt ? new Date(member.lastLoginAt).toLocaleDateString() : 'Never'}
</Table.Td>
<Table.Td>
{!isReadOnly && (
<Group gap={4}>
<Tooltip label="Change role">
<ActionIcon variant="subtle" onClick={() => handleEditRole(member)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
{member.userId !== user?.id && (
<Tooltip label="Remove member">
<ActionIcon variant="subtle" color="red" onClick={() => handleRemove(member)}>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
)}
</Table.Td>
</Table.Tr>
))}
{inactiveMembers.map((member) => (
<Table.Tr key={member.id} style={{ opacity: 0.5 }}>
<Table.Td fw={500}>{member.firstName} {member.lastName}</Table.Td>
<Table.Td>{member.email}</Table.Td>
<Table.Td>
<Badge color={roleColors[member.role] || 'gray'} variant="light" size="sm" tt="capitalize">
{member.role.replace(/_/g, ' ')}
</Badge>
</Table.Td>
<Table.Td>
<Badge color="gray" variant="light" size="sm">Inactive</Badge>
</Table.Td>
<Table.Td>
{member.joinedAt ? new Date(member.joinedAt).toLocaleDateString() : '-'}
</Table.Td>
<Table.Td>-</Table.Td>
<Table.Td></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{/* Add Member Modal */}
<Modal opened={addOpened} onClose={closeAdd} title="Add Organization Member" size="md" closeOnClickOutside={false}>
<form onSubmit={addForm.onSubmit((values) => addMemberMutation.mutate(values))}>
<Stack>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light" mb="xs">
This will create a new user account (or link an existing one) and add them to
your organization. They will be able to log in immediately.
</Alert>
<TextInput
label="Email Address"
placeholder="boardmember@example.com"
required
{...addForm.getInputProps('email')}
/>
<Group grow>
<TextInput
label="First Name"
placeholder="John"
required
{...addForm.getInputProps('firstName')}
/>
<TextInput
label="Last Name"
placeholder="Smith"
required
{...addForm.getInputProps('lastName')}
/>
</Group>
<TextInput
label="Temporary Password"
placeholder="Min 6 characters"
description="The user can change this after first login"
required
{...addForm.getInputProps('password')}
/>
<Select
label="Role"
required
data={ROLE_OPTIONS}
{...addForm.getInputProps('role')}
/>
<Button type="submit" leftSection={<IconPlus size={16} />} loading={addMemberMutation.isPending}>
Add Member
</Button>
</Stack>
</form>
</Modal>
{/* Edit Role Modal */}
<Modal opened={editOpened} onClose={closeEdit} title="Change Member Role" size="sm" closeOnClickOutside={false}>
{editingMember && (
<form
onSubmit={editForm.onSubmit((values) =>
updateRoleMutation.mutate({ membershipId: editingMember.id, role: values.role }),
)}
>
<Stack>
<Text size="sm" c="dimmed">
Changing role for <strong>{editingMember.firstName} {editingMember.lastName}</strong>
</Text>
<Select
label="New Role"
required
data={ROLE_OPTIONS}
{...editForm.getInputProps('role')}
/>
<Button type="submit" loading={updateRoleMutation.isPending}>
Update Role
</Button>
</Stack>
</form>
)}
</Modal>
</Stack>
);
}