Initial BizOne portal setup

This commit is contained in:
2026-05-11 11:36:33 +07:00
commit 57017dd397
249 changed files with 41305 additions and 0 deletions

View 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>
);
}