Sprint 5: User profile menu, preferences, org member management, v0.2.0

- Move Settings from sidebar Admin section to User Profile dropdown menu
- Add User Preferences page (placeholder for future: dark mode, timezone,
  notifications, feature visibility)
- Add Manage Members page for tenant admins to invite/manage board members:
  - List all org members with roles, status, join date, last login
  - Add new members (creates user account + org membership)
  - Change member roles (president, treasurer, secretary, board member,
    property manager, viewer)
  - Remove members (soft-deactivate)
  - Role-gated: only president, admin, treasurer can manage members
- Backend: new org member management endpoints on OrganizationsController
  - GET /organizations/members
  - POST /organizations/members
  - PUT /organizations/members/:id/role
  - DELETE /organizations/members/:id
- Bump version to 0.2.0 MVP_P2 (package.json + Settings page)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 16:39:17 -05:00
parent b5861de609
commit ea49b91bb3
10 changed files with 756 additions and 13 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoa-ledgeriq-backend", "name": "hoa-ledgeriq-backend",
"version": "0.1.0", "version": "0.2.0",
"description": "HOA LedgerIQ - Backend API", "description": "HOA LedgerIQ - Backend API",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -1,4 +1,4 @@
import { Controller, Post, Get, Body, UseGuards, Request } from '@nestjs/common'; import { Controller, Post, Get, Put, Delete, Body, Param, UseGuards, Request, ForbiddenException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { OrganizationsService } from './organizations.service'; import { OrganizationsService } from './organizations.service';
import { CreateOrganizationDto } from './dto/create-organization.dto'; import { CreateOrganizationDto } from './dto/create-organization.dto';
@@ -22,4 +22,48 @@ export class OrganizationsController {
async findMine(@Request() req: any) { async findMine(@Request() req: any) {
return this.orgService.findByUser(req.user.sub); return this.orgService.findByUser(req.user.sub);
} }
// ── Org Member Management ──
private requireTenantAdmin(req: any) {
const role = req.user.role;
if (!['president', 'admin', 'treasurer'].includes(role) && !req.user.isSuperadmin) {
throw new ForbiddenException('Only organization administrators can manage members');
}
}
@Get('members')
@ApiOperation({ summary: 'List members of current organization' })
async getMembers(@Request() req: any) {
this.requireTenantAdmin(req);
return this.orgService.getMembers(req.user.orgId);
}
@Post('members')
@ApiOperation({ summary: 'Add a member to the current organization' })
async addMember(
@Request() req: any,
@Body() body: { email: string; firstName: string; lastName: string; password: string; role: string },
) {
this.requireTenantAdmin(req);
return this.orgService.addMember(req.user.orgId, body);
}
@Put('members/:id/role')
@ApiOperation({ summary: 'Update a member role' })
async updateMemberRole(
@Request() req: any,
@Param('id') id: string,
@Body() body: { role: string },
) {
this.requireTenantAdmin(req);
return this.orgService.updateMemberRole(req.user.orgId, id, body.role);
}
@Delete('members/:id')
@ApiOperation({ summary: 'Remove a member from the organization' })
async removeMember(@Request() req: any, @Param('id') id: string) {
this.requireTenantAdmin(req);
return this.orgService.removeMember(req.user.orgId, id, req.user.sub);
}
} }

View File

@@ -1,10 +1,11 @@
import { Injectable, ConflictException } from '@nestjs/common'; import { Injectable, ConflictException, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Organization } from './entities/organization.entity'; import { Organization } from './entities/organization.entity';
import { UserOrganization } from './entities/user-organization.entity'; import { UserOrganization } from './entities/user-organization.entity';
import { TenantSchemaService } from '../../database/tenant-schema.service'; import { TenantSchemaService } from '../../database/tenant-schema.service';
import { CreateOrganizationDto } from './dto/create-organization.dto'; import { CreateOrganizationDto } from './dto/create-organization.dto';
import * as bcrypt from 'bcrypt';
@Injectable() @Injectable()
export class OrganizationsService { export class OrganizationsService {
@@ -76,6 +77,105 @@ export class OrganizationsService {
return this.orgRepository.findOne({ where: { id } }); return this.orgRepository.findOne({ where: { id } });
} }
// ── Org Member Management ──
async getMembers(orgId: string) {
const dataSource = this.orgRepository.manager.connection;
const rows = await dataSource.query(
`SELECT
uo.id,
uo.user_id as "userId",
u.email,
u.first_name as "firstName",
u.last_name as "lastName",
uo.role,
uo.is_active as "isActive",
uo.joined_at as "joinedAt",
u.last_login_at as "lastLoginAt"
FROM shared.user_organizations uo
JOIN shared.users u ON u.id = uo.user_id
WHERE uo.organization_id = $1
ORDER BY uo.joined_at ASC`,
[orgId],
);
return rows;
}
async addMember(
orgId: string,
data: { email: string; firstName: string; lastName: string; password: string; role: string },
) {
const dataSource = this.orgRepository.manager.connection;
// Check if user already exists
let userRows = await dataSource.query(
`SELECT id FROM shared.users WHERE email = $1`,
[data.email.toLowerCase()],
);
let userId: string;
if (userRows.length > 0) {
userId = userRows[0].id;
// Check if already a member of this org
const existing = await this.userOrgRepository.findOne({
where: { userId, organizationId: orgId },
});
if (existing) {
if (existing.isActive) {
throw new ConflictException('User is already a member of this organization');
}
// Re-activate an existing inactive membership
existing.isActive = true;
existing.role = data.role;
return this.userOrgRepository.save(existing);
}
} else {
// Create new user
const passwordHash = await bcrypt.hash(data.password, 12);
const result = await dataSource.query(
`INSERT INTO shared.users (email, password_hash, first_name, last_name)
VALUES ($1, $2, $3, $4)
RETURNING id`,
[data.email.toLowerCase(), passwordHash, data.firstName, data.lastName],
);
userId = result[0].id;
}
// Create membership
const membership = this.userOrgRepository.create({
userId,
organizationId: orgId,
role: data.role,
});
return this.userOrgRepository.save(membership);
}
async updateMemberRole(orgId: string, membershipId: string, role: string) {
const membership = await this.userOrgRepository.findOne({
where: { id: membershipId, organizationId: orgId },
});
if (!membership) {
throw new NotFoundException('Membership not found');
}
membership.role = role;
return this.userOrgRepository.save(membership);
}
async removeMember(orgId: string, membershipId: string, requestingUserId: string) {
const membership = await this.userOrgRepository.findOne({
where: { id: membershipId, organizationId: orgId },
});
if (!membership) {
throw new NotFoundException('Membership not found');
}
if (membership.userId === requestingUserId) {
throw new BadRequestException('You cannot remove yourself from the organization');
}
membership.isActive = false;
return this.userOrgRepository.save(membership);
}
private generateSchemaName(name: string): string { private generateSchemaName(name: string): string {
const clean = name const clean = name
.toLowerCase() .toLowerCase()

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoa-ledgeriq-frontend", "name": "hoa-ledgeriq-frontend",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -23,6 +23,8 @@ import { CashFlowPage } from './pages/reports/CashFlowPage';
import { AgingReportPage } from './pages/reports/AgingReportPage'; import { AgingReportPage } from './pages/reports/AgingReportPage';
import { YearEndPage } from './pages/reports/YearEndPage'; import { YearEndPage } from './pages/reports/YearEndPage';
import { SettingsPage } from './pages/settings/SettingsPage'; import { SettingsPage } from './pages/settings/SettingsPage';
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
import { AdminPage } from './pages/admin/AdminPage'; import { AdminPage } from './pages/admin/AdminPage';
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage'; import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage'; import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
@@ -124,6 +126,8 @@ export function App() {
<Route path="reports/sankey" element={<SankeyPage />} /> <Route path="reports/sankey" element={<SankeyPage />} />
<Route path="reports/year-end" element={<YearEndPage />} /> <Route path="reports/year-end" element={<YearEndPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="preferences" element={<UserPreferencesPage />} />
<Route path="org-members" element={<OrgMembersPage />} />
</Route> </Route>
</Routes> </Routes>
); );

View File

@@ -4,6 +4,9 @@ import {
IconLogout, IconLogout,
IconSwitchHorizontal, IconSwitchHorizontal,
IconChevronDown, IconChevronDown,
IconSettings,
IconUserCog,
IconUsersGroup,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { Outlet, useNavigate } from 'react-router-dom'; import { Outlet, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
@@ -20,6 +23,9 @@ export function AppLayout() {
navigate('/login'); navigate('/login');
}; };
// Tenant admins (president role) can manage org members
const isTenantAdmin = currentOrg?.role === 'president' || currentOrg?.role === 'admin';
return ( return (
<AppShell <AppShell
header={{ height: 60 }} header={{ height: 60 }}
@@ -36,7 +42,7 @@ export function AppLayout() {
{currentOrg && ( {currentOrg && (
<Text size="sm" c="dimmed">{currentOrg.name}</Text> <Text size="sm" c="dimmed">{currentOrg.name}</Text>
)} )}
<Menu shadow="md" width={200}> <Menu shadow="md" width={220}>
<Menu.Target> <Menu.Target>
<UnstyledButton> <UnstyledButton>
<Group gap="xs"> <Group gap="xs">
@@ -49,6 +55,28 @@ export function AppLayout() {
</UnstyledButton> </UnstyledButton>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Label>Account</Menu.Label>
<Menu.Item
leftSection={<IconUserCog size={14} />}
onClick={() => navigate('/preferences')}
>
User Preferences
</Menu.Item>
<Menu.Item
leftSection={<IconSettings size={14} />}
onClick={() => navigate('/settings')}
>
Settings
</Menu.Item>
{isTenantAdmin && (
<Menu.Item
leftSection={<IconUsersGroup size={14} />}
onClick={() => navigate('/org-members')}
>
Manage Members
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item <Menu.Item
leftSection={<IconSwitchHorizontal size={14} />} leftSection={<IconSwitchHorizontal size={14} />}
onClick={() => navigate('/select-org')} onClick={() => navigate('/select-org')}

View File

@@ -12,7 +12,6 @@ import {
IconShieldCheck, IconShieldCheck,
IconBuildingBank, IconBuildingBank,
IconUsers, IconUsers,
IconSettings,
IconCrown, IconCrown,
IconCategory, IconCategory,
IconChartAreaLine, IconChartAreaLine,
@@ -74,12 +73,6 @@ const navSections = [
}, },
], ],
}, },
{
label: 'Admin',
items: [
{ label: 'Settings', icon: IconSettings, path: '/settings' },
],
},
]; ];
export function Sidebar() { export function Sidebar() {

View File

@@ -0,0 +1,376 @@
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 } 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 { 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>
<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.
</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>
<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>
);
}

View File

@@ -0,0 +1,198 @@
import {
Title, Text, Card, Stack, Group, SimpleGrid, ThemeIcon, Switch,
Select, Badge, Divider,
} from '@mantine/core';
import {
IconUser, IconPalette, IconClock, IconBell, IconEye,
} from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore';
export function UserPreferencesPage() {
const { user, currentOrg } = useAuthStore();
return (
<Stack>
<div>
<Title order={2}>User Preferences</Title>
<Text c="dimmed" size="sm">Customize your experience</Text>
</div>
<SimpleGrid cols={{ base: 1, md: 2 }}>
{/* Profile */}
<Card withBorder padding="lg">
<Group mb="md">
<ThemeIcon color="blue" variant="light" size={40} radius="md">
<IconUser size={24} />
</ThemeIcon>
<div>
<Text fw={600} size="lg">Profile</Text>
<Text c="dimmed" size="sm">Your account information</Text>
</div>
</Group>
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">Name</Text>
<Text size="sm" fw={500}>{user?.firstName} {user?.lastName}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Email</Text>
<Text size="sm" fw={500}>{user?.email}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Organization</Text>
<Text size="sm" fw={500}>{currentOrg?.name || 'N/A'}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Role</Text>
<Badge variant="light" tt="capitalize">{currentOrg?.role || 'N/A'}</Badge>
</Group>
</Stack>
</Card>
{/* Display Preferences */}
<Card withBorder padding="lg">
<Group mb="md">
<ThemeIcon color="violet" variant="light" size={40} radius="md">
<IconPalette size={24} />
</ThemeIcon>
<div>
<Text fw={600} size="lg">Display</Text>
<Text c="dimmed" size="sm">Appearance and layout</Text>
</div>
</Group>
<Stack gap="md">
<Group justify="space-between">
<div>
<Text size="sm">Dark Mode</Text>
<Text size="xs" c="dimmed">Switch to dark color theme</Text>
</div>
<Switch disabled />
</Group>
<Group justify="space-between">
<div>
<Text size="sm">Compact View</Text>
<Text size="xs" c="dimmed">Reduce spacing in tables and lists</Text>
</div>
<Switch disabled />
</Group>
<Divider />
<Text size="xs" c="dimmed" ta="center">Display preferences coming in a future release</Text>
</Stack>
</Card>
{/* Regional */}
<Card withBorder padding="lg">
<Group mb="md">
<ThemeIcon color="green" variant="light" size={40} radius="md">
<IconClock size={24} />
</ThemeIcon>
<div>
<Text fw={600} size="lg">Regional</Text>
<Text c="dimmed" size="sm">Locale and time settings</Text>
</div>
</Group>
<Stack gap="md">
<Select
label="Time Zone"
placeholder="Auto-detect"
data={[
{ value: 'auto', label: `Auto (${Intl.DateTimeFormat().resolvedOptions().timeZone})` },
{ value: 'America/New_York', label: 'Eastern Time (ET)' },
{ value: 'America/Chicago', label: 'Central Time (CT)' },
{ value: 'America/Denver', label: 'Mountain Time (MT)' },
{ value: 'America/Los_Angeles', label: 'Pacific Time (PT)' },
{ value: 'America/Anchorage', label: 'Alaska Time (AKT)' },
{ value: 'Pacific/Honolulu', label: 'Hawaii Time (HT)' },
]}
defaultValue="auto"
disabled
/>
<Select
label="Date Format"
data={[
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' },
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' },
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' },
]}
defaultValue="MM/DD/YYYY"
disabled
/>
<Divider />
<Text size="xs" c="dimmed" ta="center">Regional preferences coming in a future release</Text>
</Stack>
</Card>
{/* Notifications */}
<Card withBorder padding="lg">
<Group mb="md">
<ThemeIcon color="orange" variant="light" size={40} radius="md">
<IconBell size={24} />
</ThemeIcon>
<div>
<Text fw={600} size="lg">Notifications</Text>
<Text c="dimmed" size="sm">Email and in-app alerts</Text>
</div>
</Group>
<Stack gap="md">
<Group justify="space-between">
<div>
<Text size="sm">Email Notifications</Text>
<Text size="xs" c="dimmed">Receive alerts via email</Text>
</div>
<Switch disabled />
</Group>
<Group justify="space-between">
<div>
<Text size="sm">Payment Alerts</Text>
<Text size="xs" c="dimmed">Notify when payments are received</Text>
</div>
<Switch disabled />
</Group>
<Group justify="space-between">
<div>
<Text size="sm">Budget Alerts</Text>
<Text size="xs" c="dimmed">Warn when budget thresholds exceeded</Text>
</div>
<Switch disabled />
</Group>
<Divider />
<Text size="xs" c="dimmed" ta="center">Notification preferences coming in a future release</Text>
</Stack>
</Card>
{/* Feature Visibility */}
<Card withBorder padding="lg">
<Group mb="md">
<ThemeIcon color="cyan" variant="light" size={40} radius="md">
<IconEye size={24} />
</ThemeIcon>
<div>
<Text fw={600} size="lg">Feature Visibility</Text>
<Text c="dimmed" size="sm">Show or hide sidebar sections</Text>
</div>
</Group>
<Stack gap="md">
<Group justify="space-between">
<Text size="sm">Assessments</Text>
<Switch disabled defaultChecked />
</Group>
<Group justify="space-between">
<Text size="sm">Planning</Text>
<Switch disabled defaultChecked />
</Group>
<Group justify="space-between">
<Text size="sm">Invoices & Payments</Text>
<Switch disabled defaultChecked />
</Group>
<Group justify="space-between">
<Text size="sm">Capital Projects</Text>
<Switch disabled defaultChecked />
</Group>
<Divider />
<Text size="xs" c="dimmed" ta="center">Feature visibility preferences coming in a future release</Text>
</Stack>
</Card>
</SimpleGrid>
</Stack>
);
}

View File

@@ -117,7 +117,7 @@ export function SettingsPage() {
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text> <Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">0.1.0 MVP</Badge> <Badge variant="light">0.2.0 MVP_P2</Badge>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed">API</Text> <Text size="sm" c="dimmed">API</Text>