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:
Alireza Hassani
2026-08-10 21:31:28 +03:30
co-authored by Cursor
parent 404c008ced
commit f622e6d605
28 changed files with 1280 additions and 241 deletions
+117 -7
View File
@@ -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')}