Add customer/manager access UI and tighten customers filters.
Business and super-admin can set Customer vs Manager roles (Admin only for super-admin), and the customers page drops clutter while filtering by all/customers/managers. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
404c008ced
commit
f622e6d605
@@ -1,28 +1,33 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { MessageSquare, Pencil, Plus, RotateCcw, Search, Ticket, Trash2 } from 'lucide-react'
|
||||
import { MessageSquare, Pencil, Plus, RotateCcw, Search, Shield, Ticket, Trash2 } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { AddCustomerModal } from '../components/AddCustomerModal'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ChangeUserAccessModal } from '../components/ChangeUserAccessModal'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { EditCustomerModal } from '../components/EditCustomerModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { ToggleSwitch } from '../components/ToggleSwitch'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import { getActiveBusinessId } from '../lib/businessContext'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
import {
|
||||
listCustomers,
|
||||
removeCustomer,
|
||||
updateCustomerEnabled,
|
||||
type BusinessCustomerListItem,
|
||||
type CustomerAccessFilter,
|
||||
type CustomersListResponse,
|
||||
} from '../services/customerService'
|
||||
import { formatIrtPrice } from '../utils/irtPrice'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import filterStyles from '../components/ListFiltersPanel.module.css'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import accessStyles from '../components/ChangeUserAccessModal.module.css'
|
||||
import styles from './CustomersPage.module.css'
|
||||
|
||||
const PAGE_SIZE = 24
|
||||
@@ -47,26 +52,66 @@ function formatTransactionTotal(total: number | null | undefined) {
|
||||
return formatIrtPrice(total)
|
||||
}
|
||||
|
||||
function accessBadgeKey(user: BusinessCustomerListItem): string | null {
|
||||
if (user.isBusinessOwner) return 'customers.access.badge.owner'
|
||||
if (user.teamRole === 'admin') return 'customers.access.badge.admin'
|
||||
if (user.teamRole === 'editor') return 'customers.access.badge.editor'
|
||||
if (user.teamRole === 'viewer') return 'customers.access.badge.viewer'
|
||||
if (user.businessMemberId) return 'customers.access.badge.manager'
|
||||
return null
|
||||
}
|
||||
|
||||
export function CustomersPage() {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const { user: authUser } = useAuth()
|
||||
const { showToast } = useToast()
|
||||
const [data, setData] = useState<CustomersListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [appliedFilters, setAppliedFilters] = useState<{ name?: string; cellNumber?: string }>({})
|
||||
const [appliedFilters, setAppliedFilters] = useState<{
|
||||
name?: string
|
||||
cellNumber?: string
|
||||
access: CustomerAccessFilter
|
||||
}>({ access: 'all' })
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [draftName, setDraftName] = useState('')
|
||||
const [draftCell, setDraftCell] = useState('')
|
||||
const [draftAccess, setDraftAccess] = useState<CustomerAccessFilter>('all')
|
||||
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null)
|
||||
const [editTarget, setEditTarget] = useState<BusinessCustomerListItem | null>(null)
|
||||
const [accessTarget, setAccessTarget] = useState<BusinessCustomerListItem | null>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [removeTarget, setRemoveTarget] = useState<BusinessCustomerListItem | null>(null)
|
||||
const [removing, setRemoving] = useState(false)
|
||||
|
||||
const membership = useMemo(() => {
|
||||
const activeId = getActiveBusinessId()
|
||||
if (!authUser?.businesses.length) return undefined
|
||||
return (
|
||||
authUser.businesses.find((b) => String(b.id) === String(activeId)) ??
|
||||
authUser.businesses[0]
|
||||
)
|
||||
}, [authUser])
|
||||
const isSuperAdmin = Boolean(
|
||||
authUser?.isSuperAdmin || authUser?.roles.includes('super_admin'),
|
||||
)
|
||||
const canManageAccess =
|
||||
isSuperAdmin ||
|
||||
Boolean(membership?.isOwner) ||
|
||||
Boolean(membership?.permissions.includes('business.team.update'))
|
||||
|
||||
function accessChangeDisabledReason(customer: BusinessCustomerListItem): string | null {
|
||||
if (customer.isBusinessOwner) return t('customers.access.ownerLocked')
|
||||
if (customer.teamRole === 'admin' && !isSuperAdmin) {
|
||||
return t('customers.access.adminLocked')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -97,7 +142,7 @@ export function CustomersPage() {
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
}, [page, appliedFilters.name, appliedFilters.cellNumber, t])
|
||||
}, [page, appliedFilters.name, appliedFilters.cellNumber, appliedFilters.access, t])
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
const total = data?.total ?? 0
|
||||
@@ -117,6 +162,7 @@ export function CustomersPage() {
|
||||
function applyFilters() {
|
||||
setPage(1)
|
||||
setAppliedFilters({
|
||||
access: draftAccess,
|
||||
...(draftName.trim() ? { name: draftName.trim() } : {}),
|
||||
...(draftCell.trim() ? { cellNumber: draftCell.trim() } : {}),
|
||||
})
|
||||
@@ -125,8 +171,19 @@ export function CustomersPage() {
|
||||
function clearFilters() {
|
||||
setDraftName('')
|
||||
setDraftCell('')
|
||||
setDraftAccess('all')
|
||||
setPage(1)
|
||||
setAppliedFilters({})
|
||||
setAppliedFilters({ access: 'all' })
|
||||
}
|
||||
|
||||
function handleAccessFilterChange(value: CustomerAccessFilter) {
|
||||
setDraftAccess(value)
|
||||
setPage(1)
|
||||
setAppliedFilters({
|
||||
access: value,
|
||||
...(draftName.trim() ? { name: draftName.trim() } : {}),
|
||||
...(draftCell.trim() ? { cellNumber: draftCell.trim() } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
async function handleToggleEnabled(customer: BusinessCustomerListItem, isEnabled: boolean) {
|
||||
@@ -201,6 +258,22 @@ export function CustomersPage() {
|
||||
showToast(t('customers.toast.updated', { name: displayName(updated) }), 'success')
|
||||
}
|
||||
|
||||
function handleAccessSaved(updated: BusinessCustomerListItem) {
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) => (item.id === updated.id ? { ...item, ...updated } : item)),
|
||||
}
|
||||
})
|
||||
const roleLabel = updated.isBusinessOwner
|
||||
? t('customers.access.badge.owner')
|
||||
: updated.teamRole
|
||||
? t(`customers.access.role.${updated.teamRole}`)
|
||||
: t('customers.access.customer')
|
||||
showToast(t('customers.toast.accessUpdated', { role: roleLabel }), 'success')
|
||||
}
|
||||
|
||||
function handleCustomerCreated(customer: BusinessCustomerListItem) {
|
||||
setPage(1)
|
||||
setData((prev) => {
|
||||
@@ -234,17 +307,28 @@ export function CustomersPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div className={`${pageStyles.pageHeader} ${styles.pageHeaderCompact}`}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>{t('customers.title')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('customers.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={filterStyles.filtersPanel}>
|
||||
<div className={filterStyles.filtersTitle}>{t('customers.filters')}</div>
|
||||
<form className={filterStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
||||
<div className={filterStyles.filtersInputs}>
|
||||
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
|
||||
<select
|
||||
id="filter-customer-access"
|
||||
value={draftAccess}
|
||||
onChange={(e) => handleAccessFilterChange(e.target.value as CustomerAccessFilter)}
|
||||
aria-label={t('customers.filter.access')}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="all">{t('customers.filter.access.all')}</option>
|
||||
<option value="customers">{t('customers.filter.access.customers')}</option>
|
||||
<option value="managers">{t('customers.filter.access.managers')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
|
||||
<input
|
||||
id="filter-customer-name"
|
||||
@@ -355,6 +439,8 @@ export function CustomersPage() {
|
||||
data?.items?.map((customer) => {
|
||||
const name = displayName(customer)
|
||||
const nameLocale = textLocaleAttrs(name)
|
||||
const badgeKey = accessBadgeKey(customer)
|
||||
const accessLocked = accessChangeDisabledReason(customer)
|
||||
return (
|
||||
<tr
|
||||
key={customer.id}
|
||||
@@ -374,6 +460,9 @@ export function CustomersPage() {
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
{badgeKey ? (
|
||||
<span className={accessStyles.roleBadge}>{t(badgeKey)}</span>
|
||||
) : null}
|
||||
{!customer.isEnabled && (
|
||||
<div className={styles.statusDisabled}>{t('customers.disabled')}</div>
|
||||
)}
|
||||
@@ -417,6 +506,19 @@ export function CustomersPage() {
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{canManageAccess ? (
|
||||
<Tooltip label={accessLocked ?? t('customers.access.change')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionBtn}
|
||||
onClick={() => setAccessTarget(customer)}
|
||||
aria-label={accessLocked ?? t('customers.access.change')}
|
||||
disabled={Boolean(accessLocked) || removing}
|
||||
>
|
||||
<Shield size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip label={t('customers.edit')}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -499,6 +601,14 @@ export function CustomersPage() {
|
||||
onSaved={handleCustomerSaved}
|
||||
/>
|
||||
|
||||
<ChangeUserAccessModal
|
||||
open={accessTarget !== null}
|
||||
user={accessTarget}
|
||||
canAssignAdmin={isSuperAdmin}
|
||||
onClose={() => setAccessTarget(null)}
|
||||
onSaved={handleAccessSaved}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={removeTarget !== null}
|
||||
title={t('customers.deleteTitle')}
|
||||
|
||||
Reference in New Issue
Block a user