mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Rename Customers to Users with FA/EN titles, compact headers and filter bars across apps, and refine manager role copy plus a customer-header link to the business dashboard. Co-authored-by: Cursor <cursoragent@cursor.com>
1202 lines
40 KiB
TypeScript
1202 lines
40 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
import { KeyRound, MessageSquare, Pencil, Plus, RotateCcw, Search, Shield, Trash2, X } from 'lucide-react'
|
|
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
|
import { Modal } from '../components/Modal'
|
|
import { formatCellForDisplay, toE164CellNumber } from '../lib/cellNumber'
|
|
import type { RoleOption, UserListItem, UsersListResponse } from '../types/user'
|
|
import type { ListUsersParams } from '../services/userService'
|
|
import { ApiError, isAbortError } from '../lib/api'
|
|
import {
|
|
assignBusinessAccess,
|
|
listTeamRoles,
|
|
type BusinessAccess,
|
|
} from '../services/teamService'
|
|
import {
|
|
adminResetUserPassword,
|
|
createUser,
|
|
listUserRoles,
|
|
listUsers,
|
|
removeUser,
|
|
sendUserMessage,
|
|
updateUser,
|
|
updateUserRole,
|
|
} from '../services/userService'
|
|
import { useToast } from '../context/ToastContext'
|
|
import { Pagination } from '@meshkee/dashboard-ui'
|
|
import pageStyles from '../components/PageContent.module.css'
|
|
import tableStyles from './BusinessesPage.module.css'
|
|
import styles from './UsersPage.module.css'
|
|
|
|
const PAGE_SIZE = 24
|
|
|
|
function formatDate(value: string) {
|
|
const d = new Date(value)
|
|
if (Number.isNaN(d.getTime())) return value
|
|
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })
|
|
}
|
|
|
|
function displayName(user: UserListItem) {
|
|
const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
|
|
return name || '—'
|
|
}
|
|
|
|
const TEAM_ROLE_ORDER = ['admin', 'editor', 'viewer'] as const
|
|
|
|
const TEAM_ROLE_LABELS: Record<string, string> = {
|
|
admin: 'Admin',
|
|
editor: 'Editor',
|
|
viewer: 'Observer',
|
|
}
|
|
|
|
function teamRoleLabel(slug: string | null | undefined, teamRoles: RoleOption[]) {
|
|
if (!slug) return null
|
|
return (
|
|
TEAM_ROLE_LABELS[slug] ??
|
|
teamRoles.find((role) => role.slug === slug)?.name ??
|
|
slug
|
|
)
|
|
}
|
|
|
|
function formatGlobalRoleLabel(user: UserListItem, teamRoles: RoleOption[]) {
|
|
if (user.isBusinessOwner) return 'Business Owner'
|
|
if (user.teamRole) {
|
|
const teamName = teamRoleLabel(user.teamRole, teamRoles)
|
|
return teamName ? `Manager · ${teamName}` : 'Manager'
|
|
}
|
|
if (user.roleSlug === 'business_staff' && user.teamRole) {
|
|
const teamName = teamRoleLabel(user.teamRole, teamRoles)
|
|
return teamName ? `Manager · ${teamName}` : 'Manager'
|
|
}
|
|
return user.roles
|
|
}
|
|
|
|
function formatBusinessUserRoleLabel(user: UserListItem, teamRoles: RoleOption[]) {
|
|
if (user.isBusinessOwner) return 'Business Owner'
|
|
if (user.businessMemberId && user.teamRole) {
|
|
return teamRoleLabel(user.teamRole, teamRoles) ?? 'Manager'
|
|
}
|
|
if (user.businessMemberId) return 'Manager'
|
|
return 'Customer'
|
|
}
|
|
|
|
export function UsersPage() {
|
|
const { showToast } = useToast()
|
|
const navigate = useNavigate()
|
|
const [searchParams] = useSearchParams()
|
|
const [data, setData] = useState<UsersListResponse | null>(null)
|
|
const [roles, setRoles] = useState<RoleOption[]>([])
|
|
const [teamRoles, setTeamRoles] = useState<RoleOption[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
const [appliedFilters, setAppliedFilters] = useState<Omit<ListUsersParams, 'page' | 'pageSize'>>({})
|
|
const [page, setPage] = useState(1)
|
|
|
|
const [draftName, setDraftName] = useState('')
|
|
const [draftCell, setDraftCell] = useState('')
|
|
const [draftRole, setDraftRole] = useState('')
|
|
|
|
const [editOpen, setEditOpen] = useState(false)
|
|
const [editUser, setEditUser] = useState<UserListItem | null>(null)
|
|
const [editFirstName, setEditFirstName] = useState('')
|
|
const [editLastName, setEditLastName] = useState('')
|
|
const [editEmail, setEditEmail] = useState('')
|
|
const [editCell, setEditCell] = useState('')
|
|
const [editSubmitting, setEditSubmitting] = useState(false)
|
|
const [editError, setEditError] = useState('')
|
|
|
|
const [resetOpen, setResetOpen] = useState(false)
|
|
const [resetUser, setResetUser] = useState<UserListItem | null>(null)
|
|
const [resetPassword, setResetPassword] = useState('')
|
|
const [resetConfirm, setResetConfirm] = useState('')
|
|
const [resetSubmitting, setResetSubmitting] = useState(false)
|
|
const [resetError, setResetError] = useState('')
|
|
|
|
const [messageOpen, setMessageOpen] = useState(false)
|
|
const [messageUser, setMessageUser] = useState<UserListItem | null>(null)
|
|
const [messageText, setMessageText] = useState('')
|
|
const [messageSubmitting, setMessageSubmitting] = useState(false)
|
|
const [messageError, setMessageError] = useState('')
|
|
|
|
const [removeTarget, setRemoveTarget] = useState<UserListItem | null>(null)
|
|
|
|
const [roleOpen, setRoleOpen] = useState(false)
|
|
const [roleUser, setRoleUser] = useState<UserListItem | null>(null)
|
|
const [selectedRoleSlug, setSelectedRoleSlug] = useState('')
|
|
const [businessAccess, setBusinessAccess] = useState<BusinessAccess>('customer')
|
|
const [selectedTeamRoleSlug, setSelectedTeamRoleSlug] = useState('admin')
|
|
const [roleSubmitting, setRoleSubmitting] = useState(false)
|
|
const [roleError, setRoleError] = useState('')
|
|
|
|
const [createOpen, setCreateOpen] = useState(false)
|
|
const [createFirstName, setCreateFirstName] = useState('')
|
|
const [createLastName, setCreateLastName] = useState('')
|
|
const [createCell, setCreateCell] = useState('')
|
|
const [createPassword, setCreatePassword] = useState('')
|
|
const [createEmail, setCreateEmail] = useState('')
|
|
const [createSubmitting, setCreateSubmitting] = useState(false)
|
|
const [createError, setCreateError] = useState('')
|
|
const [listVersion, setListVersion] = useState(0)
|
|
|
|
const businessFilter = useMemo(() => {
|
|
const businessIdRaw = searchParams.get('businessId')
|
|
const membershipRaw = searchParams.get('membership')
|
|
if (!businessIdRaw || (membershipRaw !== 'staff' && membershipRaw !== 'all')) {
|
|
return null
|
|
}
|
|
const businessId = Number(businessIdRaw)
|
|
if (!Number.isFinite(businessId)) return null
|
|
return {
|
|
businessId,
|
|
membership: membershipRaw as 'staff' | 'all',
|
|
businessName: searchParams.get('businessName') ?? 'Business',
|
|
}
|
|
}, [searchParams])
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController()
|
|
|
|
async function load() {
|
|
setLoading(true)
|
|
setError('')
|
|
try {
|
|
const [result, roleResult, teamRoleResult] = await Promise.all([
|
|
listUsers(
|
|
{
|
|
page,
|
|
pageSize: PAGE_SIZE,
|
|
...appliedFilters,
|
|
...(businessFilter
|
|
? { businessId: businessFilter.businessId, membership: businessFilter.membership }
|
|
: {}),
|
|
},
|
|
controller.signal,
|
|
),
|
|
listUserRoles(controller.signal),
|
|
listTeamRoles(controller.signal),
|
|
])
|
|
if (controller.signal.aborted) return
|
|
setData(result)
|
|
setRoles(roleResult.items)
|
|
setTeamRoles(teamRoleResult.items)
|
|
} catch (err) {
|
|
if (isAbortError(err) || controller.signal.aborted) return
|
|
setError(err instanceof ApiError ? err.message : 'Unable to load users.')
|
|
} finally {
|
|
if (!controller.signal.aborted) setLoading(false)
|
|
}
|
|
}
|
|
|
|
void load()
|
|
|
|
return () => {
|
|
controller.abort()
|
|
}
|
|
}, [
|
|
page,
|
|
appliedFilters.name,
|
|
appliedFilters.cellNumber,
|
|
appliedFilters.role,
|
|
businessFilter?.businessId,
|
|
businessFilter?.membership,
|
|
listVersion,
|
|
])
|
|
|
|
useEffect(() => {
|
|
setPage(1)
|
|
}, [businessFilter?.businessId, businessFilter?.membership])
|
|
|
|
const totalPages = useMemo(() => {
|
|
const total = data?.total ?? 0
|
|
return Math.max(1, Math.ceil(total / PAGE_SIZE))
|
|
}, [data?.total])
|
|
|
|
const showingFrom = useMemo(() => {
|
|
if (!data) return 0
|
|
return (page - 1) * PAGE_SIZE + 1
|
|
}, [data, page])
|
|
|
|
const showingTo = useMemo(() => {
|
|
if (!data) return 0
|
|
return Math.min(data.total, page * PAGE_SIZE)
|
|
}, [data, page])
|
|
|
|
function applyFilters() {
|
|
setPage(1)
|
|
setAppliedFilters({
|
|
...(draftName.trim() ? { name: draftName.trim() } : {}),
|
|
...(draftCell.trim() ? { cellNumber: draftCell.trim() } : {}),
|
|
...(draftRole ? { role: draftRole } : {}),
|
|
})
|
|
}
|
|
|
|
function clearFilters() {
|
|
setDraftName('')
|
|
setDraftCell('')
|
|
setDraftRole('')
|
|
setPage(1)
|
|
setAppliedFilters({})
|
|
}
|
|
|
|
function clearBusinessFilter() {
|
|
setPage(1)
|
|
navigate('/users')
|
|
}
|
|
|
|
function resetCreateForm() {
|
|
setCreateFirstName('')
|
|
setCreateLastName('')
|
|
setCreateCell('')
|
|
setCreatePassword('')
|
|
setCreateEmail('')
|
|
setCreateError('')
|
|
}
|
|
|
|
function openCreate() {
|
|
resetCreateForm()
|
|
setCreateOpen(true)
|
|
}
|
|
|
|
async function submitCreate() {
|
|
if (!businessFilter || businessFilter.membership !== 'all') return
|
|
const cellNumber = toE164CellNumber(createCell.trim())
|
|
if (!cellNumber) return
|
|
|
|
setCreateSubmitting(true)
|
|
setCreateError('')
|
|
try {
|
|
await createUser({
|
|
businessId: businessFilter.businessId,
|
|
cellNumber,
|
|
firstName: createFirstName.trim(),
|
|
lastName: createLastName.trim(),
|
|
...(createPassword.trim() ? { password: createPassword.trim() } : {}),
|
|
...(createEmail.trim() ? { email: createEmail.trim() } : {}),
|
|
})
|
|
showToast('User added to business.', 'success')
|
|
setCreateOpen(false)
|
|
resetCreateForm()
|
|
setPage(1)
|
|
setListVersion((v) => v + 1)
|
|
} catch (err) {
|
|
setCreateError(err instanceof ApiError ? err.message : 'Unable to add user.')
|
|
} finally {
|
|
setCreateSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const canCreateUser =
|
|
createFirstName.trim().length >= 2 &&
|
|
createLastName.trim().length >= 2 &&
|
|
toE164CellNumber(createCell.trim()).length > 0
|
|
|
|
const orderedTeamRoles = useMemo(() => {
|
|
const bySlug = new Map(teamRoles.map((role) => [role.slug, role]))
|
|
const ordered = TEAM_ROLE_ORDER.map((slug) => bySlug.get(slug)).filter(
|
|
(role): role is RoleOption => Boolean(role),
|
|
)
|
|
const extras = teamRoles.filter(
|
|
(role) => !TEAM_ROLE_ORDER.includes(role.slug as (typeof TEAM_ROLE_ORDER)[number]),
|
|
)
|
|
return [...ordered, ...extras]
|
|
}, [teamRoles])
|
|
|
|
function openRoleChange(user: UserListItem) {
|
|
if (businessFilter && user.isBusinessOwner) return
|
|
|
|
setRoleUser(user)
|
|
setSelectedRoleSlug(user.roleSlug ?? '')
|
|
if (businessFilter) {
|
|
const isStaff = Boolean(user.businessMemberId) && !user.isBusinessOwner
|
|
setBusinessAccess(isStaff ? 'staff' : 'customer')
|
|
setSelectedTeamRoleSlug(
|
|
(isStaff && user.teamRole) || orderedTeamRoles[0]?.slug || 'admin',
|
|
)
|
|
} else {
|
|
setBusinessAccess('customer')
|
|
setSelectedTeamRoleSlug(orderedTeamRoles[0]?.slug || 'admin')
|
|
}
|
|
setRoleError('')
|
|
setRoleOpen(true)
|
|
}
|
|
|
|
const canSaveRole = businessFilter
|
|
? !roleUser?.isBusinessOwner &&
|
|
(businessAccess === 'customer' || Boolean(selectedTeamRoleSlug))
|
|
: Boolean(selectedRoleSlug)
|
|
|
|
async function submitRoleChange() {
|
|
if (!roleUser || !canSaveRole) return
|
|
setRoleSubmitting(true)
|
|
setRoleError('')
|
|
try {
|
|
if (businessFilter) {
|
|
const result = await assignBusinessAccess(businessFilter.businessId, {
|
|
userId: roleUser.id,
|
|
access: businessAccess,
|
|
roleSlug: businessAccess === 'staff' ? selectedTeamRoleSlug : undefined,
|
|
})
|
|
|
|
const accessLabel =
|
|
businessAccess === 'staff'
|
|
? teamRoleLabel(selectedTeamRoleSlug, teamRoles) ?? 'Manager'
|
|
: 'Customer'
|
|
|
|
showToast(`Access updated to ${accessLabel}.`, 'success')
|
|
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === roleUser.id
|
|
? {
|
|
...item,
|
|
roleSlug: businessAccess === 'staff' ? 'business_staff' : 'customer',
|
|
roles: accessLabel,
|
|
teamRole: businessAccess === 'staff' ? selectedTeamRoleSlug : null,
|
|
businessMemberId:
|
|
businessAccess === 'staff'
|
|
? result.member?.id != null
|
|
? Number(result.member.id)
|
|
: item.businessMemberId
|
|
: null,
|
|
isBusinessOwner: false,
|
|
}
|
|
: item,
|
|
),
|
|
}
|
|
})
|
|
} else {
|
|
await updateUserRole(roleUser.id, selectedRoleSlug)
|
|
|
|
const roleName =
|
|
roles.find((role) => role.slug === selectedRoleSlug)?.name ?? selectedRoleSlug
|
|
|
|
showToast(`Role updated to ${roleName}.`, 'success')
|
|
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === roleUser.id
|
|
? {
|
|
...item,
|
|
roleSlug: selectedRoleSlug,
|
|
roles: roleName,
|
|
}
|
|
: item,
|
|
),
|
|
}
|
|
})
|
|
}
|
|
|
|
setRoleOpen(false)
|
|
setRoleUser(null)
|
|
setSelectedTeamRoleSlug('admin')
|
|
} catch (err) {
|
|
setRoleError(err instanceof ApiError ? err.message : 'Unable to update role.')
|
|
} finally {
|
|
setRoleSubmitting(false)
|
|
}
|
|
}
|
|
|
|
function openEdit(user: UserListItem) {
|
|
setEditUser(user)
|
|
setEditFirstName(user.firstName ?? '')
|
|
setEditLastName(user.lastName ?? '')
|
|
setEditEmail('')
|
|
setEditCell(user.cellNumber)
|
|
setEditError('')
|
|
setEditOpen(true)
|
|
}
|
|
|
|
async function submitEdit() {
|
|
if (!editUser) return
|
|
setEditSubmitting(true)
|
|
setEditError('')
|
|
try {
|
|
await updateUser(editUser.id, {
|
|
firstName: editFirstName.trim(),
|
|
lastName: editLastName.trim(),
|
|
cellNumber: editCell.trim(),
|
|
...(editEmail.trim() ? { email: editEmail.trim() } : {}),
|
|
})
|
|
setEditOpen(false)
|
|
setEditUser(null)
|
|
showToast(`"${displayName(editUser)}" has been updated.`, 'success')
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === editUser.id
|
|
? {
|
|
...item,
|
|
firstName: editFirstName.trim(),
|
|
lastName: editLastName.trim(),
|
|
cellNumber: editCell.trim(),
|
|
}
|
|
: item,
|
|
),
|
|
}
|
|
})
|
|
} catch (err) {
|
|
setEditError(err instanceof ApiError ? err.message : 'Unable to update user.')
|
|
} finally {
|
|
setEditSubmitting(false)
|
|
}
|
|
}
|
|
|
|
function openResetPassword(user: UserListItem) {
|
|
setResetUser(user)
|
|
setResetPassword('')
|
|
setResetConfirm('')
|
|
setResetError('')
|
|
setResetOpen(true)
|
|
}
|
|
|
|
async function submitResetPassword() {
|
|
if (!resetUser) return
|
|
if (resetPassword !== resetConfirm) {
|
|
setResetError('Passwords do not match.')
|
|
return
|
|
}
|
|
setResetSubmitting(true)
|
|
setResetError('')
|
|
try {
|
|
await adminResetUserPassword(resetUser.id, resetPassword)
|
|
setResetOpen(false)
|
|
setResetUser(null)
|
|
showToast(`Password reset for "${displayName(resetUser)}".`, 'success')
|
|
} catch (err) {
|
|
setResetError(err instanceof ApiError ? err.message : 'Unable to reset password.')
|
|
} finally {
|
|
setResetSubmitting(false)
|
|
}
|
|
}
|
|
|
|
function openSendMessage(user: UserListItem) {
|
|
setMessageUser(user)
|
|
setMessageText('')
|
|
setMessageError('')
|
|
setMessageOpen(true)
|
|
}
|
|
|
|
async function submitSendMessage() {
|
|
if (!messageUser) return
|
|
setMessageSubmitting(true)
|
|
setMessageError('')
|
|
try {
|
|
const result = await sendUserMessage(messageUser.id, messageText.trim())
|
|
setMessageOpen(false)
|
|
setMessageUser(null)
|
|
showToast(result.message, result.enabled ? 'success' : 'info')
|
|
} catch (err) {
|
|
setMessageError(err instanceof ApiError ? err.message : 'Unable to send message.')
|
|
} finally {
|
|
setMessageSubmitting(false)
|
|
}
|
|
}
|
|
|
|
async function confirmRemove() {
|
|
if (!removeTarget) return
|
|
setError('')
|
|
try {
|
|
await removeUser(removeTarget.id)
|
|
setRemoveTarget(null)
|
|
showToast(`"${displayName(removeTarget)}" has been removed.`, 'success')
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === removeTarget.id ? { ...item, isActive: false } : item,
|
|
),
|
|
}
|
|
})
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : 'Unable to remove user.')
|
|
}
|
|
}
|
|
|
|
const canResetPassword =
|
|
resetPassword.length >= 8 && resetConfirm.length >= 8 && resetPassword === resetConfirm
|
|
|
|
return (
|
|
<main className={pageStyles.content}>
|
|
<div className={pageStyles.pageHeader}>
|
|
<div>
|
|
<h2 className={pageStyles.pageTitle}>Users</h2>
|
|
<p className={pageStyles.pageSubtitle}>
|
|
View and manage all platform users and their access levels.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{businessFilter && (
|
|
<div className={styles.businessBanner}>
|
|
<span>
|
|
Showing {businessFilter.membership === 'staff' ? 'staff' : 'all users'} for{' '}
|
|
<strong>{businessFilter.businessName}</strong>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className={styles.businessBannerClear}
|
|
onClick={clearBusinessFilter}
|
|
aria-label="Clear business filter"
|
|
title="Clear business filter"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className={tableStyles.filtersPanel}>
|
|
<div className={tableStyles.filtersTitle}>Filters</div>
|
|
<form className={tableStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
|
<div className={tableStyles.filtersInputs}>
|
|
<div className={`${tableStyles.field} ${tableStyles.fieldCol3}`}>
|
|
<label htmlFor="filter-user-name">Name</label>
|
|
<input
|
|
id="filter-user-name"
|
|
value={draftName}
|
|
onChange={(e) => setDraftName(e.target.value)}
|
|
placeholder="Search by name"
|
|
/>
|
|
</div>
|
|
<div className={`${tableStyles.field} ${tableStyles.fieldCol3}`}>
|
|
<label htmlFor="filter-user-cell">Cell number</label>
|
|
<input
|
|
id="filter-user-cell"
|
|
value={draftCell}
|
|
onChange={(e) => setDraftCell(e.target.value)}
|
|
placeholder="0912..."
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
<div className={`${tableStyles.field} ${tableStyles.fieldCol3}`}>
|
|
<label htmlFor="filter-user-role">Role</label>
|
|
<select
|
|
id="filter-user-role"
|
|
value={draftRole}
|
|
onChange={(e) => setDraftRole(e.target.value)}
|
|
>
|
|
<option value="">All roles</option>
|
|
{roles.map((role) => (
|
|
<option key={role.slug} value={role.slug}>
|
|
{role.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className={tableStyles.filterActions}>
|
|
<button
|
|
type="submit"
|
|
className={`${tableStyles.iconActionBtn} ${tableStyles.iconActionBtnPrimary}`}
|
|
disabled={loading}
|
|
aria-label="Search"
|
|
title="Search"
|
|
>
|
|
<Search size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.iconActionBtn} ${tableStyles.iconActionBtnGhost}`}
|
|
onClick={clearFilters}
|
|
disabled={loading}
|
|
aria-label="Clear filters"
|
|
title="Clear filters"
|
|
>
|
|
<RotateCcw size={18} />
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div className={tableStyles.tablePanel}>
|
|
<div className={tableStyles.tableWrap}>
|
|
<div className={tableStyles.tableHeader}>
|
|
<div className={tableStyles.tableHeaderTitle}>User list</div>
|
|
<div className={tableStyles.meta}>
|
|
{data ? (
|
|
<>
|
|
Showing {showingFrom} - {showingTo} of {data.total}
|
|
</>
|
|
) : (
|
|
' '
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div style={{ padding: 16 }}>
|
|
<div className={tableStyles.meta} style={{ color: '#b91c1c' }}>
|
|
{error}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table className={tableStyles.table}>
|
|
<thead>
|
|
<tr>
|
|
<th className={tableStyles.th}>Name</th>
|
|
<th className={tableStyles.th}>Cell number</th>
|
|
<th className={tableStyles.th}>Role</th>
|
|
<th className={tableStyles.th}>Business</th>
|
|
<th className={tableStyles.th}>Date joined</th>
|
|
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading && (
|
|
<tr>
|
|
<td className={tableStyles.td} colSpan={6}>
|
|
Loading...
|
|
</td>
|
|
</tr>
|
|
)}
|
|
|
|
{!loading && data?.items?.length === 0 && (
|
|
<tr>
|
|
<td className={tableStyles.td} colSpan={6}>
|
|
No results found.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
|
|
{!loading &&
|
|
data?.items?.map((user) => (
|
|
<tr key={user.id} className={!user.isActive ? styles.inactiveRow : undefined}>
|
|
<td className={tableStyles.td}>
|
|
<div>{displayName(user)}</div>
|
|
{!user.isActive && <div className={styles.statusInactive}>Inactive</div>}
|
|
</td>
|
|
<td className={tableStyles.td}>{formatCellForDisplay(user.cellNumber)}</td>
|
|
<td className={tableStyles.td}>
|
|
{(() => {
|
|
const label = businessFilter
|
|
? formatBusinessUserRoleLabel(user, teamRoles)
|
|
: formatGlobalRoleLabel(user, teamRoles)
|
|
return label ? (
|
|
<span className={styles.roleBadge}>{label}</span>
|
|
) : (
|
|
<span className={tableStyles.subText}>—</span>
|
|
)
|
|
})()}
|
|
</td>
|
|
<td className={tableStyles.td}>
|
|
{user.businesses ?? <span className={tableStyles.subText}>—</span>}
|
|
</td>
|
|
<td className={tableStyles.td}>{formatDate(user.createdAt)}</td>
|
|
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
|
|
<div className={tableStyles.rowActions}>
|
|
<button
|
|
type="button"
|
|
className={tableStyles.controlBtn}
|
|
onClick={() => openRoleChange(user)}
|
|
title={
|
|
businessFilter && user.isBusinessOwner
|
|
? 'Business owner cannot be changed'
|
|
: 'Change role'
|
|
}
|
|
aria-label={
|
|
businessFilter && user.isBusinessOwner
|
|
? 'Business owner cannot be changed'
|
|
: 'Change role'
|
|
}
|
|
disabled={!user.isActive || Boolean(businessFilter && user.isBusinessOwner)}
|
|
>
|
|
<Shield size={16} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={tableStyles.controlBtn}
|
|
onClick={() => openResetPassword(user)}
|
|
title="Reset password"
|
|
aria-label="Reset password"
|
|
disabled={!user.isActive}
|
|
>
|
|
<KeyRound size={16} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={tableStyles.controlBtn}
|
|
onClick={() => openEdit(user)}
|
|
title="Edit"
|
|
aria-label="Edit"
|
|
disabled={!user.isActive}
|
|
>
|
|
<Pencil size={16} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={tableStyles.controlBtn}
|
|
onClick={() => openSendMessage(user)}
|
|
title="Send message"
|
|
aria-label="Send message"
|
|
disabled={!user.isActive}
|
|
>
|
|
<MessageSquare size={16} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.controlBtn} ${tableStyles.controlBtnDanger}`}
|
|
onClick={() => setRemoveTarget(user)}
|
|
title="Remove"
|
|
aria-label="Remove"
|
|
disabled={!user.isActive}
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className={tableStyles.pagination}>
|
|
<div>
|
|
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
|
|
</div>
|
|
<Pagination
|
|
currentPage={page}
|
|
totalPages={totalPages}
|
|
onPageChange={setPage}
|
|
disabled={loading}
|
|
variant="inline"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Modal
|
|
open={editOpen}
|
|
title="Edit user"
|
|
onClose={() => {
|
|
setEditOpen(false)
|
|
setEditUser(null)
|
|
setEditError('')
|
|
}}
|
|
>
|
|
{editError ? (
|
|
<p className={tableStyles.alertError} role="alert">
|
|
{editError}
|
|
</p>
|
|
) : null}
|
|
<div className={tableStyles.formGrid}>
|
|
<div className={tableStyles.field}>
|
|
<label htmlFor="edit-first-name">First name</label>
|
|
<input
|
|
id="edit-first-name"
|
|
value={editFirstName}
|
|
onChange={(e) => setEditFirstName(e.target.value)}
|
|
disabled={editSubmitting}
|
|
/>
|
|
</div>
|
|
<div className={tableStyles.field}>
|
|
<label htmlFor="edit-last-name">Last name</label>
|
|
<input
|
|
id="edit-last-name"
|
|
value={editLastName}
|
|
onChange={(e) => setEditLastName(e.target.value)}
|
|
disabled={editSubmitting}
|
|
/>
|
|
</div>
|
|
<div className={`${tableStyles.field} ${tableStyles.formFull}`}>
|
|
<label htmlFor="edit-cell">Cell number</label>
|
|
<input
|
|
id="edit-cell"
|
|
value={editCell}
|
|
onChange={(e) => setEditCell(e.target.value)}
|
|
autoComplete="off"
|
|
disabled={editSubmitting}
|
|
/>
|
|
</div>
|
|
<div className={`${tableStyles.field} ${tableStyles.formFull}`}>
|
|
<label htmlFor="edit-email">Email (optional)</label>
|
|
<input
|
|
id="edit-email"
|
|
type="email"
|
|
value={editEmail}
|
|
onChange={(e) => setEditEmail(e.target.value)}
|
|
disabled={editSubmitting}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div style={{ height: 12 }} />
|
|
<div className={tableStyles.actionsRow}>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
|
onClick={() => setEditOpen(false)}
|
|
disabled={editSubmitting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
|
onClick={() => void submitEdit()}
|
|
disabled={
|
|
editSubmitting ||
|
|
!editFirstName.trim() ||
|
|
!editLastName.trim() ||
|
|
!editCell.trim()
|
|
}
|
|
>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={resetOpen}
|
|
title="Reset password"
|
|
onClose={() => {
|
|
setResetOpen(false)
|
|
setResetUser(null)
|
|
setResetError('')
|
|
}}
|
|
>
|
|
{resetError ? (
|
|
<p className={tableStyles.alertError} role="alert">
|
|
{resetError}
|
|
</p>
|
|
) : null}
|
|
<p className={tableStyles.meta} style={{ marginBottom: 12 }}>
|
|
Set a new password for {resetUser ? displayName(resetUser) : 'user'}.
|
|
</p>
|
|
<div className={tableStyles.field}>
|
|
<label htmlFor="reset-password">New password</label>
|
|
<input
|
|
id="reset-password"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={resetPassword}
|
|
onChange={(e) => setResetPassword(e.target.value)}
|
|
disabled={resetSubmitting}
|
|
/>
|
|
</div>
|
|
<div style={{ height: 10 }} />
|
|
<div className={tableStyles.field}>
|
|
<label htmlFor="reset-confirm">Confirm password</label>
|
|
<input
|
|
id="reset-confirm"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={resetConfirm}
|
|
onChange={(e) => setResetConfirm(e.target.value)}
|
|
disabled={resetSubmitting}
|
|
/>
|
|
</div>
|
|
<div style={{ height: 12 }} />
|
|
<div className={tableStyles.actionsRow}>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
|
onClick={() => setResetOpen(false)}
|
|
disabled={resetSubmitting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
|
onClick={() => void submitResetPassword()}
|
|
disabled={resetSubmitting || !canResetPassword}
|
|
>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={messageOpen}
|
|
title="Send SMS message"
|
|
onClose={() => {
|
|
setMessageOpen(false)
|
|
setMessageUser(null)
|
|
setMessageError('')
|
|
}}
|
|
>
|
|
{messageError ? (
|
|
<p className={tableStyles.alertError} role="alert">
|
|
{messageError}
|
|
</p>
|
|
) : null}
|
|
<p className={tableStyles.meta} style={{ marginBottom: 12 }}>
|
|
Send an SMS to {messageUser ? formatCellForDisplay(messageUser.cellNumber) : 'user'}.
|
|
</p>
|
|
<div className={tableStyles.field}>
|
|
<label htmlFor="sms-message">Message</label>
|
|
<textarea
|
|
id="sms-message"
|
|
value={messageText}
|
|
onChange={(e) => setMessageText(e.target.value)}
|
|
disabled={messageSubmitting}
|
|
rows={4}
|
|
/>
|
|
</div>
|
|
<div style={{ height: 12 }} />
|
|
<div className={tableStyles.actionsRow}>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
|
onClick={() => setMessageOpen(false)}
|
|
disabled={messageSubmitting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
|
onClick={() => void submitSendMessage()}
|
|
disabled={messageSubmitting || !messageText.trim()}
|
|
>
|
|
Send
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={roleOpen}
|
|
title="Change role"
|
|
onClose={() => {
|
|
setRoleOpen(false)
|
|
setRoleUser(null)
|
|
setSelectedTeamRoleSlug('admin')
|
|
setRoleError('')
|
|
}}
|
|
>
|
|
{roleError ? (
|
|
<p className={tableStyles.alertError} role="alert">
|
|
{roleError}
|
|
</p>
|
|
) : null}
|
|
<p className={tableStyles.meta} style={{ marginBottom: 12 }}>
|
|
{businessFilter
|
|
? `Choose customer or manager access for ${roleUser ? displayName(roleUser) : 'user'} on ${businessFilter.businessName}.`
|
|
: `Select a role for ${roleUser ? displayName(roleUser) : 'user'}.`}
|
|
</p>
|
|
|
|
{businessFilter ? (
|
|
<>
|
|
<div className={styles.roleList}>
|
|
<label className={styles.roleOption}>
|
|
<input
|
|
type="radio"
|
|
name="business-access"
|
|
value="customer"
|
|
checked={businessAccess === 'customer'}
|
|
onChange={() => setBusinessAccess('customer')}
|
|
disabled={roleSubmitting}
|
|
/>
|
|
<span className={styles.roleOptionLabel}>Customer</span>
|
|
</label>
|
|
<label className={styles.roleOption}>
|
|
<input
|
|
type="radio"
|
|
name="business-access"
|
|
value="staff"
|
|
checked={businessAccess === 'staff'}
|
|
onChange={() => {
|
|
setBusinessAccess('staff')
|
|
if (!selectedTeamRoleSlug) {
|
|
setSelectedTeamRoleSlug(orderedTeamRoles[0]?.slug || 'admin')
|
|
}
|
|
}}
|
|
disabled={roleSubmitting}
|
|
/>
|
|
<span className={styles.roleOptionLabel}>Manager</span>
|
|
</label>
|
|
</div>
|
|
|
|
{businessAccess === 'staff' ? (
|
|
<>
|
|
<p className={styles.roleHint}>
|
|
Only a super admin can assign Admin. Admins can assign Editor or Observer.
|
|
</p>
|
|
<div className={`${styles.roleList} ${styles.roleListNested}`}>
|
|
{orderedTeamRoles.map((role) => (
|
|
<label key={role.slug} className={styles.roleOption}>
|
|
<input
|
|
type="radio"
|
|
name="team-role"
|
|
value={role.slug}
|
|
checked={selectedTeamRoleSlug === role.slug}
|
|
onChange={() => setSelectedTeamRoleSlug(role.slug)}
|
|
disabled={roleSubmitting}
|
|
/>
|
|
<span className={styles.roleOptionLabel}>
|
|
{teamRoleLabel(role.slug, teamRoles) ?? role.name}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<div className={styles.roleList}>
|
|
{roles.map((role) => (
|
|
<label key={role.slug} className={styles.roleOption}>
|
|
<input
|
|
type="radio"
|
|
name="user-role"
|
|
value={role.slug}
|
|
checked={selectedRoleSlug === role.slug}
|
|
onChange={() => setSelectedRoleSlug(role.slug)}
|
|
disabled={roleSubmitting}
|
|
/>
|
|
<span className={styles.roleOptionLabel}>{role.name}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ height: 12 }} />
|
|
<div className={tableStyles.actionsRow}>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
|
onClick={() => setRoleOpen(false)}
|
|
disabled={roleSubmitting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
|
onClick={() => void submitRoleChange()}
|
|
disabled={roleSubmitting || !canSaveRole}
|
|
>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
|
|
<ConfirmDeleteModal
|
|
open={removeTarget !== null}
|
|
title="Remove user?"
|
|
message={
|
|
removeTarget
|
|
? `Are you sure you want to remove "${displayName(removeTarget)}"? They will no longer be able to sign in.`
|
|
: ''
|
|
}
|
|
onCancel={() => setRemoveTarget(null)}
|
|
onConfirm={() => void confirmRemove()}
|
|
/>
|
|
|
|
{businessFilter?.membership === 'all' && (
|
|
<button
|
|
type="button"
|
|
className={tableStyles.fab}
|
|
onClick={openCreate}
|
|
aria-label="Add user to business"
|
|
title="Add user to business"
|
|
>
|
|
<Plus size={24} />
|
|
</button>
|
|
)}
|
|
|
|
<Modal
|
|
open={createOpen}
|
|
title={`Add user to ${businessFilter?.businessName ?? 'business'}`}
|
|
onClose={() => {
|
|
setCreateOpen(false)
|
|
resetCreateForm()
|
|
}}
|
|
>
|
|
{createError ? (
|
|
<p className={tableStyles.alertError} role="alert">
|
|
{createError}
|
|
</p>
|
|
) : null}
|
|
<p className={tableStyles.meta} style={{ marginBottom: 12 }}>
|
|
Creates a new account or links an existing user as a customer of this business.
|
|
Password is required only for new accounts.
|
|
</p>
|
|
<div className={tableStyles.formGrid}>
|
|
<div className={tableStyles.field}>
|
|
<label htmlFor="create-user-first-name">First name</label>
|
|
<input
|
|
id="create-user-first-name"
|
|
value={createFirstName}
|
|
onChange={(e) => setCreateFirstName(e.target.value)}
|
|
disabled={createSubmitting}
|
|
/>
|
|
</div>
|
|
<div className={tableStyles.field}>
|
|
<label htmlFor="create-user-last-name">Last name</label>
|
|
<input
|
|
id="create-user-last-name"
|
|
value={createLastName}
|
|
onChange={(e) => setCreateLastName(e.target.value)}
|
|
disabled={createSubmitting}
|
|
/>
|
|
</div>
|
|
<div className={`${tableStyles.field} ${tableStyles.formFull}`}>
|
|
<label htmlFor="create-user-cell">Cell number</label>
|
|
<input
|
|
id="create-user-cell"
|
|
value={createCell}
|
|
onChange={(e) => setCreateCell(e.target.value)}
|
|
placeholder="0912..."
|
|
autoComplete="off"
|
|
disabled={createSubmitting}
|
|
/>
|
|
</div>
|
|
<div className={`${tableStyles.field} ${tableStyles.formFull}`}>
|
|
<label htmlFor="create-user-password">Password (new users)</label>
|
|
<input
|
|
id="create-user-password"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={createPassword}
|
|
onChange={(e) => setCreatePassword(e.target.value)}
|
|
placeholder="Min. 8 characters"
|
|
disabled={createSubmitting}
|
|
/>
|
|
</div>
|
|
<div className={`${tableStyles.field} ${tableStyles.formFull}`}>
|
|
<label htmlFor="create-user-email">Email (optional)</label>
|
|
<input
|
|
id="create-user-email"
|
|
type="email"
|
|
value={createEmail}
|
|
onChange={(e) => setCreateEmail(e.target.value)}
|
|
disabled={createSubmitting}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div style={{ height: 12 }} />
|
|
<div className={tableStyles.actionsRow}>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
|
onClick={() => setCreateOpen(false)}
|
|
disabled={createSubmitting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
|
onClick={() => void submitCreate()}
|
|
disabled={createSubmitting || !canCreateUser}
|
|
>
|
|
Add user
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
</main>
|
|
)
|
|
}
|