Initial BizOne portal setup
This commit is contained in:
770
frontend/src/components/users-management-board.tsx
Normal file
770
frontend/src/components/users-management-board.tsx
Normal file
@ -0,0 +1,770 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type UserRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
status: 'invited' | 'active' | 'inactive' | 'suspended';
|
||||
roleId: string | null;
|
||||
roleName: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastLoginAt: string | null;
|
||||
emailVerifiedAt: string | null;
|
||||
};
|
||||
|
||||
type RoleOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialUsers: UserRecord[];
|
||||
initialTotal: number;
|
||||
initialPage: number;
|
||||
initialPageSize: number;
|
||||
initialTotalPages: number;
|
||||
availableRoles: RoleOption[];
|
||||
};
|
||||
|
||||
type PanelMode = 'create' | 'edit' | null;
|
||||
|
||||
function formatLastSeen(value: string | null, status: UserRecord['status']) {
|
||||
if (status === 'invited') return 'Pending invite';
|
||||
if (!value) return 'Never';
|
||||
|
||||
const diffMs = Date.now() - new Date(value).getTime();
|
||||
const minutes = Math.max(1, Math.floor(diffMs / 60000));
|
||||
if (minutes < 60) return 'Just now';
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} day${days === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
function formatStatusLabel(status: UserRecord['status']) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'Active';
|
||||
case 'invited':
|
||||
return 'Invited';
|
||||
case 'inactive':
|
||||
return 'Inactive';
|
||||
case 'suspended':
|
||||
return 'Suspended';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusTone(status: UserRecord['status']) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'success';
|
||||
case 'invited':
|
||||
return 'warning';
|
||||
case 'suspended':
|
||||
return 'error';
|
||||
case 'inactive':
|
||||
return 'muted';
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleTone(roleName: string) {
|
||||
const normalized = roleName.toLowerCase();
|
||||
if (normalized.includes('admin')) return 'admin';
|
||||
if (normalized.includes('editor')) return 'editor';
|
||||
return 'agent';
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() || '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
const roleDescriptions: Record<string, string> = {
|
||||
Admin: 'Full system access, including billing, user management, and API settings.',
|
||||
Editor: 'Manage broadcasts and message templates, but cannot change system settings.',
|
||||
Agent: 'Limited to handling conversations, contacts, and operational response flow.',
|
||||
};
|
||||
|
||||
export function UsersManagementBoard({
|
||||
initialUsers,
|
||||
initialTotal,
|
||||
initialPage,
|
||||
initialPageSize,
|
||||
initialTotalPages,
|
||||
availableRoles,
|
||||
}: Props) {
|
||||
const [users, setUsers] = useState(initialUsers);
|
||||
const [total, setTotal] = useState(initialTotal);
|
||||
const [page, setPage] = useState(initialPage);
|
||||
const [pageSize] = useState(initialPageSize);
|
||||
const [totalPages, setTotalPages] = useState(initialTotalPages);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [roleFilter, setRoleFilter] = useState('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [panelMode, setPanelMode] = useState<PanelMode>(null);
|
||||
const [editingUserId, setEditingUserId] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [inviteName, setInviteName] = useState('');
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRoleId, setInviteRoleId] = useState(availableRoles[0]?.id || '');
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editEmail, setEditEmail] = useState('');
|
||||
const [editRoleId, setEditRoleId] = useState('');
|
||||
const [editStatus, setEditStatus] = useState<UserRecord['status']>('active');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const editingUser = useMemo(
|
||||
() => users.find((user) => user.id === editingUserId) ?? null,
|
||||
[editingUserId, users],
|
||||
);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const totalUsers = total;
|
||||
const pendingInvites = users.filter((user) => user.status === 'invited').length;
|
||||
const activeRecently = users.filter((user) => {
|
||||
if (!user.lastLoginAt) return false;
|
||||
return Date.now() - new Date(user.lastLoginAt).getTime() <= 24 * 60 * 60 * 1000;
|
||||
}).length;
|
||||
return { totalUsers, pendingInvites, activeRecently };
|
||||
}, [total, users]);
|
||||
|
||||
const visibleRoles = useMemo(() => {
|
||||
const base = availableRoles.map((role) => role.name);
|
||||
return base.slice(0, 3);
|
||||
}, [availableRoles]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
setDebouncedSearch(search.trim());
|
||||
setPage(1);
|
||||
}, 250);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('limit', String(pageSize));
|
||||
if (debouncedSearch) params.set('search', debouncedSearch);
|
||||
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||
if (roleFilter !== 'all') params.set('roleId', roleFilter);
|
||||
|
||||
const response = await fetch(`/api/users?${params.toString()}`, {
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setFeedback(payload.message || 'Failed to load users');
|
||||
return;
|
||||
}
|
||||
|
||||
setUsers(payload.items);
|
||||
setTotal(payload.total);
|
||||
setTotalPages(payload.totalPages);
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError') {
|
||||
setFeedback('Failed to load users');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadUsers();
|
||||
return () => controller.abort();
|
||||
}, [debouncedSearch, page, pageSize, roleFilter, statusFilter]);
|
||||
|
||||
function closePanel() {
|
||||
setPanelMode(null);
|
||||
setEditingUserId(null);
|
||||
}
|
||||
|
||||
function openCreatePanel() {
|
||||
setPanelMode('create');
|
||||
setEditingUserId(null);
|
||||
setFeedback('');
|
||||
}
|
||||
|
||||
function beginEdit(user: UserRecord) {
|
||||
setPanelMode('edit');
|
||||
setEditingUserId(user.id);
|
||||
setEditName(user.name);
|
||||
setEditEmail(user.email);
|
||||
setEditRoleId(user.roleId || '');
|
||||
setEditStatus(user.status);
|
||||
setFeedback('');
|
||||
}
|
||||
|
||||
async function submitInvite() {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: inviteName,
|
||||
email: inviteEmail,
|
||||
roleId: inviteRoleId || undefined,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setFeedback(payload.message || 'Failed to invite user');
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(
|
||||
payload.emailSent
|
||||
? `Invitation sent to ${payload.email}.`
|
||||
: `User invited, but email was not sent. Activation link: ${payload.invitationUrl}`,
|
||||
);
|
||||
setInviteName('');
|
||||
setInviteEmail('');
|
||||
setInviteRoleId(availableRoles[0]?.id || '');
|
||||
closePanel();
|
||||
setPage(1);
|
||||
setDebouncedSearch('');
|
||||
setSearch('');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editingUser) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch(`/api/users/${editingUser.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: editName,
|
||||
email: editEmail,
|
||||
roleId: editRoleId || undefined,
|
||||
status: editStatus,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setFeedback(payload.message || 'Failed to update user');
|
||||
return;
|
||||
}
|
||||
|
||||
setUsers((current) =>
|
||||
current.map((user) =>
|
||||
user.id === payload.id
|
||||
? {
|
||||
...user,
|
||||
name: payload.name,
|
||||
email: payload.email,
|
||||
status: payload.status,
|
||||
roleId: payload.roleId,
|
||||
roleName: payload.roleName,
|
||||
lastLoginAt: payload.lastLoginAt,
|
||||
emailVerifiedAt: payload.emailVerifiedAt,
|
||||
}
|
||||
: user,
|
||||
),
|
||||
);
|
||||
setFeedback(`User ${payload.email} updated.`);
|
||||
closePanel();
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resendInvite(user: UserRecord) {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
roleId: user.roleId || undefined,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setFeedback(payload.message || 'Failed to resend invite');
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(
|
||||
payload.emailSent
|
||||
? `Invitation resent to ${payload.email}.`
|
||||
: `Invite refreshed, but email was not sent. Activation link: ${payload.invitationUrl}`,
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setSearch('');
|
||||
setDebouncedSearch('');
|
||||
setStatusFilter('all');
|
||||
setRoleFilter('all');
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function exportUsers() {
|
||||
const params = new URLSearchParams();
|
||||
if (debouncedSearch) params.set('search', debouncedSearch);
|
||||
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||
if (roleFilter !== 'all') params.set('roleId', roleFilter);
|
||||
window.location.href = `/api/users/export${params.size ? `?${params.toString()}` : ''}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="users-board">
|
||||
<section className="page-header split users-page-header">
|
||||
<div>
|
||||
<p className="page-eyebrow">Users</p>
|
||||
<h1 className="page-heading">User Management</h1>
|
||||
<p className="page-copy">Manage team access levels, roles, and security permissions.</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button type="button" className="users-hero-button" onClick={openCreatePanel}>
|
||||
<span className="material-symbols-outlined">person_add</span>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="users-stats-grid">
|
||||
<article className="users-stat-card">
|
||||
<div>
|
||||
<p className="users-stat-label">Total Users</p>
|
||||
<h3>{stats.totalUsers}</h3>
|
||||
<p className="users-stat-copy">
|
||||
<span className="material-symbols-outlined">trending_up</span>
|
||||
Directory accounts
|
||||
</p>
|
||||
</div>
|
||||
<div className="users-stat-icon tone-primary">
|
||||
<span className="material-symbols-outlined">group</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="users-stat-card">
|
||||
<div>
|
||||
<p className="users-stat-label">Pending Invites</p>
|
||||
<h3>{stats.pendingInvites}</h3>
|
||||
<p className="users-stat-copy is-warning">
|
||||
<span className="material-symbols-outlined">schedule</span>
|
||||
Awaiting response
|
||||
</p>
|
||||
</div>
|
||||
<div className="users-stat-icon tone-secondary">
|
||||
<span className="material-symbols-outlined">mail</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="users-stat-card is-accent">
|
||||
<div className="users-stat-content">
|
||||
<p className="users-stat-label">Active Sessions</p>
|
||||
<h3>{stats.activeRecently}</h3>
|
||||
<p className="users-stat-copy">Current real-time activity</p>
|
||||
</div>
|
||||
<div className="users-stat-icon tone-contrast">
|
||||
<span className="material-symbols-outlined">bolt</span>
|
||||
</div>
|
||||
<div className="users-stat-glow" />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{panelMode === 'create' ? (
|
||||
<section className="users-panel-layout">
|
||||
<div className="users-create-main">
|
||||
<article className="users-form-card create">
|
||||
<div className="users-form-head create">
|
||||
<div>
|
||||
<p className="card-kicker">Settings / Team Management</p>
|
||||
<h2>Add New Team Member</h2>
|
||||
<p>Invite a new administrator or support agent to your business dashboard.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="users-create-section">
|
||||
<h3>
|
||||
<span className="material-symbols-outlined">person_add</span>
|
||||
Profile Information
|
||||
</h3>
|
||||
<div className="users-form-grid">
|
||||
<label className="users-form-field">
|
||||
<span>Full Name</span>
|
||||
<input value={inviteName} onChange={(event) => setInviteName(event.target.value)} placeholder="e.g. Sarah Jenkins" />
|
||||
</label>
|
||||
<label className="users-form-field">
|
||||
<span>Email Address</span>
|
||||
<input value={inviteEmail} onChange={(event) => setInviteEmail(event.target.value)} placeholder="sarah.j@enterprise.com" />
|
||||
</label>
|
||||
<label className="users-form-field">
|
||||
<span>Phone Number (Optional)</span>
|
||||
<input placeholder="+1 (555) 000-0000" disabled />
|
||||
</label>
|
||||
<label className="users-form-field">
|
||||
<span>Role Selection</span>
|
||||
<select value={inviteRoleId} onChange={(event) => setInviteRoleId(event.target.value)}>
|
||||
{availableRoles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="users-footer-actions">
|
||||
<button type="button" className="secondary-button" onClick={closePanel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="users-hero-button" onClick={submitInvite} disabled={isSaving}>
|
||||
<span className="material-symbols-outlined">send</span>
|
||||
{isSaving ? 'Creating...' : 'Create User'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<aside className="users-create-side">
|
||||
<article className="users-summary-card">
|
||||
<h4>Summary</h4>
|
||||
<div className="users-summary-list">
|
||||
<div>
|
||||
<span>Role</span>
|
||||
<strong>
|
||||
{availableRoles.find((role) => role.id === inviteRoleId)?.name || 'Support Agent'}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Status</span>
|
||||
<strong>Draft Invitation</strong>
|
||||
</div>
|
||||
<p>
|
||||
Inviting this user will send a secure email link so they can verify their address and
|
||||
set their own password.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="users-illustration-card">
|
||||
<div className="users-illustration-art" />
|
||||
<div className="users-illustration-copy">
|
||||
<h5>Secure Onboarding</h5>
|
||||
<p>Users receive a time-limited invitation link to create their own credentials.</p>
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{panelMode === 'edit' && editingUser ? (
|
||||
<section className="users-edit-layout">
|
||||
<aside className="users-edit-side">
|
||||
<article className="users-profile-card">
|
||||
<div className="users-profile-avatar">{initials(editingUser.name)}</div>
|
||||
<h3>{editingUser.name}</h3>
|
||||
<p>{editingUser.email}</p>
|
||||
<div className="users-profile-meta">
|
||||
<div>
|
||||
<div>
|
||||
<p>Status</p>
|
||||
<strong className={`tone-${getStatusTone(editingUser.status)}`}>
|
||||
{formatStatusLabel(editingUser.status)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>Last Active</p>
|
||||
<strong>{formatLastSeen(editingUser.lastLoginAt, editingUser.status)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="users-security-card">
|
||||
<h4>Security Actions</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="users-toolbar-button full"
|
||||
onClick={() => resendInvite(editingUser)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<span className="material-symbols-outlined">lock_reset</span>
|
||||
Send Password Reset Link
|
||||
</button>
|
||||
</article>
|
||||
</aside>
|
||||
|
||||
<div className="users-edit-main">
|
||||
<article className="users-form-card edit">
|
||||
<div className="users-form-head edit">
|
||||
<div>
|
||||
<p className="card-kicker">Edit User</p>
|
||||
<h2>Update user access and account status</h2>
|
||||
<p>Change display details, role assignment, and lifecycle state without leaving the directory.</p>
|
||||
</div>
|
||||
<span className={`users-panel-badge tone-${getStatusTone(editingUser.status)}`}>
|
||||
{formatStatusLabel(editingUser.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="users-form-grid">
|
||||
<label className="users-form-field">
|
||||
<span>Full Name</span>
|
||||
<input value={editName} onChange={(event) => setEditName(event.target.value)} />
|
||||
</label>
|
||||
<label className="users-form-field">
|
||||
<span>Email Address</span>
|
||||
<input value={editEmail} onChange={(event) => setEditEmail(event.target.value)} />
|
||||
</label>
|
||||
<label className="users-form-field">
|
||||
<span>Role</span>
|
||||
<select value={editRoleId} onChange={(event) => setEditRoleId(event.target.value)}>
|
||||
<option value="">Unassigned</option>
|
||||
{availableRoles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="users-form-field">
|
||||
<span>Status</span>
|
||||
<select value={editStatus} onChange={(event) => setEditStatus(event.target.value as UserRecord['status'])}>
|
||||
<option value="invited">Invited</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="users-edit-meta">
|
||||
<div>
|
||||
<strong>{editingUser.emailVerifiedAt ? 'Verified' : 'Pending Verification'}</strong>
|
||||
<span>Email verification</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{formatLastSeen(editingUser.lastLoginAt, editingUser.status)}</strong>
|
||||
<span>Last activity</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="users-footer-actions">
|
||||
<button type="button" className="secondary-button" onClick={closePanel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="users-hero-button" onClick={submitEdit} disabled={isSaving}>
|
||||
<span className="material-symbols-outlined">save</span>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="users-danger-card">
|
||||
<div className="users-danger-icon">
|
||||
<span className="material-symbols-outlined">warning</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Danger Zone</h4>
|
||||
<p>Deleting this user is not enabled yet. For now, use `Suspended` status to revoke access safely.</p>
|
||||
<button type="button" disabled>
|
||||
Delete User
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{feedback ? <p className="users-feedback">{feedback}</p> : null}
|
||||
|
||||
<section className="users-table-shell">
|
||||
<div className="users-table-toolbar">
|
||||
<h4>Team Members</h4>
|
||||
<div className="users-toolbar-actions">
|
||||
<button type="button" className="users-toolbar-button large" onClick={() => setShowFilters((current) => !current)}>
|
||||
<span className="material-symbols-outlined">filter_list</span>
|
||||
Filter
|
||||
</button>
|
||||
<button type="button" className="users-toolbar-button large" onClick={exportUsers}>
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilters ? (
|
||||
<div className="users-filters-panel">
|
||||
<label className="users-filter-field">
|
||||
<span>Search team members</span>
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search name or email..." />
|
||||
</label>
|
||||
<label className="users-filter-field">
|
||||
<span>Status</span>
|
||||
<select value={statusFilter} onChange={(event) => {
|
||||
setStatusFilter(event.target.value);
|
||||
setPage(1);
|
||||
}}>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="invited">Invited</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="users-filter-field">
|
||||
<span>Role</span>
|
||||
<select value={roleFilter} onChange={(event) => {
|
||||
setRoleFilter(event.target.value);
|
||||
setPage(1);
|
||||
}}>
|
||||
<option value="all">All Roles</option>
|
||||
{availableRoles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="users-filter-actions">
|
||||
<button type="button" className="secondary-button" onClick={resetFilters}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="users-table-wrap">
|
||||
<table className="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name / Email</th>
|
||||
<th>Role</th>
|
||||
<th>Last Active</th>
|
||||
<th>Status</th>
|
||||
<th className="align-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>
|
||||
<div className="users-identity">
|
||||
<div className={`users-avatar tone-${getRoleTone(user.roleName)}`}>{initials(user.name)}</div>
|
||||
<div>
|
||||
<p>{user.name}</p>
|
||||
<span>{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`users-role-pill tone-${getRoleTone(user.roleName)}`}>
|
||||
{user.roleName.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="users-muted">{formatLastSeen(user.lastLoginAt, user.status)}</td>
|
||||
<td>
|
||||
<div className="users-status">
|
||||
<span className={`users-status-dot tone-${getStatusTone(user.status)}`} />
|
||||
<span>{formatStatusLabel(user.status)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="users-actions">
|
||||
{user.status === 'invited' ? (
|
||||
<button type="button" onClick={() => resendInvite(user)} title="Resend Invite">
|
||||
<span className="material-symbols-outlined">mail</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={() => beginEdit(user)} title="Edit User">
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="users-empty-state">
|
||||
No users matched the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="users-pagination">
|
||||
<p>
|
||||
Showing {users.length === 0 ? 0 : (page - 1) * pageSize + 1} to {(page - 1) * pageSize + users.length} of {total} team members
|
||||
</p>
|
||||
<div className="users-pagination-buttons">
|
||||
<button type="button" onClick={() => setPage((current) => Math.max(1, current - 1))} disabled={page <= 1}>
|
||||
<span className="material-symbols-outlined">chevron_left</span>
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, index) => index + 1)
|
||||
.slice(Math.max(0, page - 2), Math.max(0, page - 2) + 3)
|
||||
.map((pageNumber) => (
|
||||
<button
|
||||
key={pageNumber}
|
||||
type="button"
|
||||
className={pageNumber === page ? 'is-active' : ''}
|
||||
onClick={() => setPage(pageNumber)}
|
||||
>
|
||||
{pageNumber}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={() => setPage((current) => Math.min(totalPages, current + 1))} disabled={page >= totalPages}>
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? <div className="users-loading-bar" /> : null}
|
||||
</section>
|
||||
|
||||
<section className="users-role-overview">
|
||||
<div>
|
||||
<h5>Role Permissions</h5>
|
||||
<p>Quick overview of what each team member can access across the WhatsApp Business platform.</p>
|
||||
</div>
|
||||
<div className="users-role-cards">
|
||||
{visibleRoles.map((roleName) => (
|
||||
<article key={roleName} className="users-role-card">
|
||||
<div className={`users-role-card-head tone-${getRoleTone(roleName)}`}>
|
||||
<span className="material-symbols-outlined">
|
||||
{getRoleTone(roleName) === 'admin'
|
||||
? 'verified_user'
|
||||
: getRoleTone(roleName) === 'editor'
|
||||
? 'edit_note'
|
||||
: 'support_agent'}
|
||||
</span>
|
||||
<span>{roleName.toUpperCase()}</span>
|
||||
</div>
|
||||
<p>{roleDescriptions[roleName] || 'Operational access based on the assigned role matrix.'}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user