This commit is contained in:
2025-09-01 13:10:35 -04:00
parent d4f4747fde
commit aa524d8ac7
30 changed files with 731 additions and 1691 deletions

View File

@ -1,29 +1,14 @@
import React, { useState, useEffect } from 'react';
import {
Button,
Group,
Paper,
Text,
Table,
import {
DataTable,
TableColumn,
Badge,
ActionIcon,
TextInput,
Select,
Pagination,
Stack,
LoadingOverlay,
Avatar,
} from '@mantine/core';
import {
IconPlus,
IconEdit,
IconTrash,
IconSearch,
IconUser,
IconMail,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { modals } from '@mantine/modals';
Group,
Text,
Stack
} from '@skybridge/web-components';
import { Avatar } from '@mantine/core';
import { IconUser, IconMail } from '@tabler/icons-react';
import UserSidebar from './UserSidebar';
import { userService } from '../services/userService';
import { User, UserStatus, UserRole, ListUsersRequest } from '../types/user';
@ -31,9 +16,7 @@ import { User, UserStatus, UserRole, ListUsersRequest } from '../types/user';
const UserManagement: React.FC = () => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [roleFilter, setRoleFilter] = useState<string | null>(null);
const [filters, setFilters] = useState({});
const [currentPage, setCurrentPage] = useState(1);
const [totalUsers, setTotalUsers] = useState(0);
const [userSidebarOpened, setUserSidebarOpened] = useState(false);
@ -41,13 +24,13 @@ const UserManagement: React.FC = () => {
const pageSize = 10;
const loadUsers = async (page: number = currentPage) => {
const loadUsers = async (page: number = currentPage, newFilters = filters) => {
setLoading(true);
try {
const request: ListUsersRequest = {
search: searchTerm || undefined,
status: statusFilter as UserStatus || undefined,
role: roleFilter as UserRole || undefined,
search: newFilters.search || undefined,
status: newFilters.status as UserStatus || undefined,
role: newFilters.role as UserRole || undefined,
limit: pageSize,
offset: (page - 1) * pageSize,
order_by: 'created_at',
@ -60,95 +43,108 @@ const UserManagement: React.FC = () => {
setCurrentPage(page);
} catch (error) {
console.error('Failed to load users:', error);
notifications.show({
title: 'Error',
message: 'Failed to load users',
color: 'red',
});
} finally {
setLoading(false);
}
};
useEffect(() => {
loadUsers(1);
}, [searchTerm, statusFilter, roleFilter]);
loadUsers(1, filters);
}, [filters]);
const handleCreateUser = () => {
const handleAdd = () => {
setEditingUser(null);
setUserSidebarOpened(true);
};
const handleEditUser = (user: User) => {
const handleEdit = (user: User) => {
setEditingUser(user);
setUserSidebarOpened(true);
};
const handleDeleteUser = (user: User) => {
modals.openConfirmModal({
title: 'Delete User',
children: (
<Text size="sm">
Are you sure you want to delete {user.first_name} {user.last_name}? This action cannot be undone.
</Text>
),
labels: { confirm: 'Delete', cancel: 'Cancel' },
confirmProps: { color: 'red' },
onConfirm: async () => {
try {
await userService.deleteUser(user.id);
notifications.show({
title: 'Success',
message: 'User deleted successfully',
color: 'green',
});
loadUsers();
} catch (error) {
console.error('Failed to delete user:', error);
notifications.show({
title: 'Error',
message: 'Failed to delete user',
color: 'red',
});
}
},
});
};
const handleUserSidebarSuccess = () => {
const handleDelete = async (user: User) => {
await userService.deleteUser(user.id);
loadUsers();
};
const handleUserSidebarClose = () => {
const handleSuccess = () => {
setUserSidebarOpened(false);
setEditingUser(null);
loadUsers();
};
const getStatusColor = (status: UserStatus): string => {
switch (status) {
case 'active': return 'green';
case 'inactive': return 'gray';
case 'suspended': return 'red';
case 'pending': return 'yellow';
default: return 'gray';
}
const handleFiltersChange = (newFilters) => {
setFilters(newFilters);
setCurrentPage(1);
};
const getRoleColor = (role: UserRole): string => {
switch (role) {
case 'admin': return 'red';
case 'moderator': return 'orange';
case 'user': return 'blue';
case 'viewer': return 'gray';
default: return 'gray';
}
};
const totalPages = Math.ceil(totalUsers / pageSize);
const columns: TableColumn[] = [
{
key: 'user',
label: 'User',
render: (_, user: User) => (
<Group gap="sm">
<Avatar
src={user.avatar || null}
radius="sm"
size={32}
>
<IconUser size={16} />
</Avatar>
<div>
<Text size="sm" fw={500}>
{user.display_name || `${user.first_name} ${user.last_name}`}
</Text>
<Text size="xs" c="dimmed">
{user.first_name} {user.last_name}
</Text>
</div>
</Group>
)
},
{
key: 'email',
label: 'Email',
render: (value) => (
<Group gap="xs">
<IconMail size={14} />
<Text size="sm">{value}</Text>
</Group>
)
},
{
key: 'role',
label: 'Role',
render: (value) => {
const roleColors = {
admin: 'red',
moderator: 'orange',
user: 'blue',
viewer: 'gray'
};
return (
<Badge color={roleColors[value] || 'blue'} size="sm">
{value}
</Badge>
);
}
},
{
key: 'status',
label: 'Status'
},
{
key: 'created_at',
label: 'Created',
render: (value) => (
<Text size="sm">
{new Date(value).toLocaleDateString()}
</Text>
)
},
];
return (
<>
{/* Main user management interface */}
<Stack
gap="md"
style={{
@ -156,160 +152,32 @@ const UserManagement: React.FC = () => {
marginRight: userSidebarOpened ? '400px' : '0',
}}
>
<Group justify="space-between">
<Button leftSection={<IconPlus size={16} />} onClick={handleCreateUser}>
Add User
</Button>
</Group>
<Paper p="md" withBorder>
<Group justify="space-between" mb="md">
<Group gap="sm">
<TextInput
placeholder="Search users..."
leftSection={<IconSearch size={16} />}
value={searchTerm}
onChange={(e) => setSearchTerm(e.currentTarget.value)}
style={{ minWidth: 250 }}
/>
<Select
placeholder="Filter by status"
data={[
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'pending', label: 'Pending' },
]}
value={statusFilter}
onChange={setStatusFilter}
clearable
/>
<Select
placeholder="Filter by role"
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'moderator', label: 'Moderator' },
{ value: 'user', label: 'User' },
{ value: 'viewer', label: 'Viewer' },
]}
value={roleFilter}
onChange={setRoleFilter}
clearable
/>
</Group>
<Text size="sm" c="dimmed">
{totalUsers} users found
</Text>
</Group>
<div style={{ position: 'relative' }}>
<LoadingOverlay visible={loading} />
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Email</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{users.map((user) => (
<Table.Tr key={user.id}>
<Table.Td>
<Group gap="sm">
<Avatar
src={user.avatar || null}
radius="sm"
size={32}
>
<IconUser size={16} />
</Avatar>
<div>
<Text size="sm" fw={500}>
{user.display_name || `${user.first_name} ${user.last_name}`}
</Text>
<Text size="xs" c="dimmed">
{user.first_name} {user.last_name}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Group gap="xs">
<IconMail size={14} />
<Text size="sm">{user.email}</Text>
</Group>
</Table.Td>
<Table.Td>
<Badge color={getRoleColor(user.role)} variant="light">
{user.role}
</Badge>
</Table.Td>
<Table.Td>
<Badge color={getStatusColor(user.status)} variant="light">
{user.status}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">
{new Date(user.created_at).toLocaleDateString()}
</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon
variant="light"
size="sm"
onClick={() => handleEditUser(user)}
>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
size="sm"
onClick={() => handleDeleteUser(user)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{users.length === 0 && !loading && (
<Text ta="center" py="xl" c="dimmed">
No users found
</Text>
)}
</div>
{totalPages > 1 && (
<Group justify="center" mt="md">
<Pagination
value={currentPage}
onChange={loadUsers}
total={totalPages}
size="sm"
/>
</Group>
)}
</Paper>
</Stack>
{/* User sidebar */}
<DataTable
data={users}
columns={columns}
loading={loading}
title="User Management"
total={totalUsers}
page={currentPage}
pageSize={pageSize}
onPageChange={loadUsers}
searchable
filters={filters}
onFiltersChange={handleFiltersChange}
onAdd={handleAdd}
onEdit={handleEdit}
onDelete={handleDelete}
onRefresh={() => loadUsers()}
emptyMessage="No users found"
/>
<UserSidebar
opened={userSidebarOpened}
onClose={handleUserSidebarClose}
onSuccess={handleUserSidebarSuccess}
onClose={() => setUserSidebarOpened(false)}
onSuccess={handleSuccess}
editUser={editingUser}
/>
</>
</Stack>
);
};

View File

@ -1,21 +1,10 @@
import React, { useEffect } from 'react';
import {
Paper,
TextInput,
Select,
Button,
Group,
Stack,
Title,
ActionIcon,
ScrollArea,
Box,
} from '@mantine/core';
import { IconX } from '@tabler/icons-react';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import React from 'react';
import {
FormSidebar,
FormField
} from '@skybridge/web-components';
import { userService } from '../services/userService';
import { User, CreateUserRequest, UpdateUserRequest, UserRole, UserStatus } from '../types/user';
import { User } from '../types/user';
interface UserSidebarProps {
opened: boolean;
@ -30,225 +19,91 @@ const UserSidebar: React.FC<UserSidebarProps> = ({
onSuccess,
editUser,
}) => {
const isEditing = !!editUser;
const form = useForm({
initialValues: {
email: '',
first_name: '',
last_name: '',
display_name: '',
avatar: '',
role: 'user' as UserRole,
status: 'pending' as UserStatus,
const fields: FormField[] = [
{
name: 'first_name',
label: 'First Name',
type: 'text',
required: true,
placeholder: 'Enter first name',
},
validate: {
email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
first_name: (value) => (value.trim().length < 1 ? 'First name is required' : null),
last_name: (value) => (value.trim().length < 1 ? 'Last name is required' : null),
{
name: 'last_name',
label: 'Last Name',
type: 'text',
required: true,
placeholder: 'Enter last name',
},
});
{
name: 'display_name',
label: 'Display Name',
type: 'text',
required: false,
placeholder: 'Enter display name (optional)',
},
{
name: 'email',
label: 'Email',
type: 'email',
required: true,
placeholder: 'Enter email address',
validation: { email: true },
},
{
name: 'avatar',
label: 'Avatar URL',
type: 'text',
required: false,
placeholder: 'Enter avatar URL (optional)',
},
{
name: 'role',
label: 'Role',
type: 'select',
required: true,
options: [
{ value: 'admin', label: 'Admin' },
{ value: 'moderator', label: 'Moderator' },
{ value: 'user', label: 'User' },
{ value: 'viewer', label: 'Viewer' },
],
defaultValue: 'user',
},
{
name: 'status',
label: 'Status',
type: 'select',
required: true,
options: [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'pending', label: 'Pending' },
],
defaultValue: 'pending',
},
];
// Update form values when editUser changes
useEffect(() => {
const handleSubmit = async (values: any) => {
if (editUser) {
form.setValues({
email: editUser.email || '',
first_name: editUser.first_name || '',
last_name: editUser.last_name || '',
display_name: editUser.display_name || '',
avatar: editUser.avatar || '',
role: editUser.role || 'user' as UserRole,
status: editUser.status || 'pending' as UserStatus,
});
await userService.updateUser(editUser.id, values);
} else {
// Reset to default values when not editing
form.setValues({
email: '',
first_name: '',
last_name: '',
display_name: '',
avatar: '',
role: 'user' as UserRole,
status: 'pending' as UserStatus,
});
}
}, [editUser, opened]);
const handleSubmit = async (values: typeof form.values) => {
try {
if (isEditing && editUser) {
const updateRequest: UpdateUserRequest = {
email: values.email !== editUser.email ? values.email : undefined,
first_name: values.first_name !== editUser.first_name ? values.first_name : undefined,
last_name: values.last_name !== editUser.last_name ? values.last_name : undefined,
display_name: values.display_name !== editUser.display_name ? values.display_name : undefined,
avatar: values.avatar !== editUser.avatar ? values.avatar : undefined,
role: values.role !== editUser.role ? values.role : undefined,
status: values.status !== editUser.status ? values.status : undefined,
};
// Only send fields that have changed
const hasChanges = Object.values(updateRequest).some(value => value !== undefined);
if (!hasChanges) {
notifications.show({
title: 'No Changes',
message: 'No changes detected',
color: 'blue',
});
onClose();
return;
}
await userService.updateUser(editUser.id, updateRequest);
notifications.show({
title: 'Success',
message: 'User updated successfully',
color: 'green',
});
} else {
const createRequest: CreateUserRequest = {
email: values.email,
first_name: values.first_name,
last_name: values.last_name,
display_name: values.display_name || undefined,
avatar: values.avatar || undefined,
role: values.role,
status: values.status,
};
await userService.createUser(createRequest);
notifications.show({
title: 'Success',
message: 'User created successfully',
color: 'green',
});
}
onSuccess();
onClose();
form.reset();
} catch (error: any) {
console.error('Failed to save user:', error);
notifications.show({
title: 'Error',
message: error.message || 'Failed to save user',
color: 'red',
});
await userService.createUser(values);
}
};
return (
<Paper
style={{
position: 'fixed',
top: 60, // Below header
right: opened ? 0 : '-400px',
bottom: 0,
width: '400px',
zIndex: 1000,
borderRadius: 0,
display: 'flex',
flexDirection: 'column',
borderLeft: '1px solid var(--mantine-color-gray-3)',
backgroundColor: 'var(--mantine-color-body)',
transition: 'right 0.3s ease',
}}
>
{/* Header */}
<Group justify="space-between" p="md" style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}>
<Title order={4}>
{isEditing ? 'Edit User' : 'Create User'}
</Title>
<ActionIcon
variant="subtle"
color="gray"
onClick={onClose}
>
<IconX size={18} />
</ActionIcon>
</Group>
{/* Content */}
<ScrollArea style={{ flex: 1 }}>
<Box p="md">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Group grow>
<TextInput
label="First Name"
placeholder="Enter first name"
required
{...form.getInputProps('first_name')}
/>
<TextInput
label="Last Name"
placeholder="Enter last name"
required
{...form.getInputProps('last_name')}
/>
</Group>
<TextInput
label="Display Name"
placeholder="Enter display name (optional)"
{...form.getInputProps('display_name')}
/>
<TextInput
label="Email"
placeholder="Enter email address"
required
type="email"
{...form.getInputProps('email')}
/>
<TextInput
label="Avatar URL"
placeholder="Enter avatar URL (optional)"
{...form.getInputProps('avatar')}
/>
<Group grow>
<Select
label="Role"
placeholder="Select role"
required
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'moderator', label: 'Moderator' },
{ value: 'user', label: 'User' },
{ value: 'viewer', label: 'Viewer' },
]}
{...form.getInputProps('role')}
/>
<Select
label="Status"
placeholder="Select status"
required
data={[
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'pending', label: 'Pending' },
]}
{...form.getInputProps('status')}
/>
</Group>
<Group justify="flex-end" mt="xl">
<Button variant="light" onClick={onClose}>
Cancel
</Button>
<Button type="submit">
{isEditing ? 'Update' : 'Create'} User
</Button>
</Group>
</Stack>
</form>
</Box>
</ScrollArea>
</Paper>
<FormSidebar
opened={opened}
onClose={onClose}
onSuccess={onSuccess}
title="User"
editMode={!!editUser}
editItem={editUser}
fields={fields}
onSubmit={handleSubmit}
width={400}
/>
);
};