diff --git a/frontend/opsce/features/users/UsersPage.tsx b/frontend/opsce/features/users/UsersPage.tsx new file mode 100644 index 00000000..9f5a55df --- /dev/null +++ b/frontend/opsce/features/users/UsersPage.tsx @@ -0,0 +1,226 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { Search, Shield, UserCircle } from 'lucide-react'; +import { format } from 'date-fns'; +import { clsx } from 'clsx'; +import { useUsersList, useUpdateUserRole } from '@/lib/query/hooks/useUsers'; +import { useAuthStore } from '@/store/auth.store'; +import { AppUser, UserRole } from '@/lib/api/users'; + +const ROLES: UserRole[] = ['admin', 'manager', 'staff']; + +const roleConfig: Record = { + admin: { label: 'Admin', className: 'bg-purple-100 text-purple-700' }, + manager: { label: 'Manager', className: 'bg-blue-100 text-blue-700' }, + staff: { label: 'Staff', className: 'bg-gray-100 text-gray-600' }, +}; + +export default function UsersPage() { + const currentUser = useAuthStore((s) => s.user); + const [search, setSearch] = useState(''); + const [roleFilter, setRoleFilter] = useState(''); + + const { data: users = [], isLoading } = useUsersList(); + const updateRole = useUpdateUserRole(); + + // client-side filter (list is typically small) + const filtered = useMemo(() => { + const q = search.toLowerCase(); + return users.filter((u) => { + const matchSearch = + !q || + u.firstName.toLowerCase().includes(q) || + u.lastName.toLowerCase().includes(q) || + u.email.toLowerCase().includes(q); + const matchRole = !roleFilter || u.role === roleFilter; + return matchSearch && matchRole; + }); + }, [users, search, roleFilter]); + + const handleRoleChange = (user: AppUser, newRole: UserRole) => { + if (newRole === user.role) return; + updateRole.mutate({ id: user.id, role: newRole }); + }; + + return ( +
+ {/* Header */} +
+
+

Users

+

+ {users.length} member{users.length !== 1 ? 's' : ''} in your organisation +

+
+
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-900" + /> +
+ + +
+ + {/* Table */} +
+ + + + + + + + + + + {isLoading ? ( + + + + ) : filtered.length === 0 ? ( + + + + ) : ( + filtered.map((user) => { + const isCurrentUser = user.id === currentUser?.id; + const initials = `${user.firstName[0]}${user.lastName[0]}`.toUpperCase(); + + return ( + + {/* Avatar + name */} + + + {/* Email */} + + + {/* Role dropdown */} + + + {/* Joined */} + + + ); + }) + )} + +
UserEmailRoleJoined
Loading users...
+ +

+ {search || roleFilter ? 'No users match your filters.' : 'No users found.'} +

+
+
+
+ {initials} +
+
+

+ {user.firstName} {user.lastName} + {isCurrentUser && ( + (you) + )} +

+
+
+
{user.email} + handleRoleChange(user, role)} + /> + + {format(new Date(user.createdAt), 'MMM d, yyyy')} +
+ + {/* Role legend */} + {users.length > 0 && ( +
+

+ + Role permissions: +

+ {ROLES.map((r) => ( + + {roleConfig[r].label} + + ))} +
+ )} +
+
+ ); +} + +// ── Role Dropdown ──────────────────────────────────────────── + +function RoleDropdown({ + value, + disabled, + onChange, +}: { + value: UserRole; + disabled: boolean; + onChange: (role: UserRole) => void; +}) { + const config = roleConfig[value]; + + return ( +
+ + + ▾ + +
+ ); +}