Business admins can configure site-wide Schema.org and optional per-item SEO title/description; mass SMS continues on failures with an end report. Co-authored-by: Cursor <cursoragent@cursor.com>
967 lines
34 KiB
TypeScript
967 lines
34 KiB
TypeScript
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import {
|
|
Download,
|
|
Loader2,
|
|
MessageSquare,
|
|
Pencil,
|
|
Plus,
|
|
RotateCcw,
|
|
Search,
|
|
Trash2,
|
|
Upload,
|
|
Users,
|
|
} 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 { CustomerActionsOverflowMenu } from '../components/CustomerActionsOverflowMenu'
|
|
import { EditCustomerModal } from '../components/EditCustomerModal'
|
|
import { EmptyTableRow } from '../components/EmptyState'
|
|
import { PageTitle } from '../components/PageTitle'
|
|
import { Pagination } from '../components/Pagination'
|
|
import { SendCustomerSmsModal, type SmsComposeTarget } from '../components/SendCustomerSmsModal'
|
|
import { ToggleSwitch } from '../components/ToggleSwitch'
|
|
import { Tooltip } from '../components/Tooltip'
|
|
import { useAuth } from '../context/AuthContext'
|
|
import { useTenantBranding } from '../context/TenantBrandingContext'
|
|
import { useToast } from '../context/ToastContext'
|
|
import { useT } from '../i18n/useT'
|
|
import type { BusinessMessageKey } from '../i18n/messages'
|
|
import { ApiError, isAbortError } from '../lib/api'
|
|
import { getActiveBusinessId } from '../lib/businessContext'
|
|
import { formatCellForDisplay } from '../lib/cellNumber'
|
|
import {
|
|
downloadCustomersExcel,
|
|
importCustomersExcel,
|
|
listCustomers,
|
|
removeCustomer,
|
|
updateCustomerEnabled,
|
|
type BusinessCustomerListItem,
|
|
type CustomerAccessFilter,
|
|
type CustomersListResponse,
|
|
} from '../services/customerService'
|
|
import { hasBusinessModule } from '../utils/businessModules'
|
|
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
|
|
const COLUMN_COUNT = 7
|
|
|
|
function formatDate(value: string, locale: 'en' | 'fa') {
|
|
const d = new Date(value)
|
|
if (Number.isNaN(d.getTime())) return value
|
|
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
|
|
return d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' })
|
|
}
|
|
|
|
function displayName(customer: BusinessCustomerListItem) {
|
|
const name = [customer.firstName, customer.lastName].filter(Boolean).join(' ').trim()
|
|
return name || '—'
|
|
}
|
|
|
|
function selectionLabel(customer: BusinessCustomerListItem) {
|
|
const name = [customer.firstName, customer.lastName].filter(Boolean).join(' ').trim()
|
|
return name || formatCellForDisplay(customer.cellNumber)
|
|
}
|
|
|
|
function formatTransactionTotal(total: number | null | undefined) {
|
|
if (total == null || total === 0) {
|
|
return <span className={styles.subText}>—</span>
|
|
}
|
|
return formatIrtPrice(total)
|
|
}
|
|
|
|
/** Badge under name — owner / admin only. */
|
|
function adminBadgeKey(user: BusinessCustomerListItem): BusinessMessageKey | null {
|
|
if (user.isBusinessOwner) return 'customers.access.badge.owner'
|
|
if (user.teamRole === 'admin') return 'customers.access.badge.admin'
|
|
return null
|
|
}
|
|
|
|
function teamRoleMessageKey(teamRole: string): BusinessMessageKey {
|
|
if (teamRole === 'admin') return 'customers.access.role.admin'
|
|
if (teamRole === 'editor') return 'customers.access.role.editor'
|
|
if (teamRole === 'viewer') return 'customers.access.role.viewer'
|
|
return 'customers.access.badge.manager'
|
|
}
|
|
|
|
export function CustomersPage() {
|
|
const t = useT()
|
|
const navigate = useNavigate()
|
|
const { locale } = useLocale()
|
|
const { user: authUser } = useAuth()
|
|
const { enabledModules } = useTenantBranding()
|
|
const { showToast } = useToast()
|
|
const financeEnabled = hasBusinessModule(enabledModules, 'finance')
|
|
const [data, setData] = useState<CustomersListResponse | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
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 [smsTarget, setSmsTarget] = useState<SmsComposeTarget | null>(null)
|
|
const [selectedPeople, setSelectedPeople] = useState<Map<string, string>>(() => new Map())
|
|
const [excelBusy, setExcelBusy] = useState<'export' | 'import' | null>(null)
|
|
const [reloadToken, setReloadToken] = useState(0)
|
|
const importInputRef = useRef<HTMLInputElement>(null)
|
|
const selectionNamesRef = useRef<HTMLDivElement>(null)
|
|
const [visibleSelectedCount, setVisibleSelectedCount] = useState(0)
|
|
|
|
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()
|
|
|
|
async function load() {
|
|
setLoading(true)
|
|
setError('')
|
|
try {
|
|
const result = await listCustomers(
|
|
{
|
|
page,
|
|
pageSize: PAGE_SIZE,
|
|
...appliedFilters,
|
|
},
|
|
controller.signal,
|
|
)
|
|
if (controller.signal.aborted) return
|
|
setData(result)
|
|
} catch (err) {
|
|
if (isAbortError(err) || controller.signal.aborted) return
|
|
setError(err instanceof ApiError ? err.message : t('customers.error.load'))
|
|
} finally {
|
|
if (!controller.signal.aborted) setLoading(false)
|
|
}
|
|
}
|
|
|
|
void load()
|
|
|
|
return () => {
|
|
controller.abort()
|
|
}
|
|
}, [page, appliedFilters.name, appliedFilters.cellNumber, appliedFilters.access, reloadToken, t])
|
|
|
|
const totalPages = useMemo(() => {
|
|
const total = data?.total ?? 0
|
|
return Math.max(1, Math.ceil(total / PAGE_SIZE))
|
|
}, [data?.total])
|
|
|
|
const showingFrom = useMemo(() => {
|
|
if (!data || data.total === 0) 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])
|
|
|
|
const pageIds = useMemo(() => data?.items.map((item) => item.id) ?? [], [data?.items])
|
|
const selectedIds = useMemo(() => new Set(selectedPeople.keys()), [selectedPeople])
|
|
const selectedNames = useMemo(() => Array.from(selectedPeople.values()), [selectedPeople])
|
|
const allPageSelected =
|
|
pageIds.length > 0 && pageIds.every((id) => selectedPeople.has(id))
|
|
const somePageSelected = pageIds.some((id) => selectedPeople.has(id))
|
|
|
|
useLayoutEffect(() => {
|
|
const el = selectionNamesRef.current
|
|
if (!el || selectedNames.length === 0) {
|
|
setVisibleSelectedCount(0)
|
|
return
|
|
}
|
|
|
|
const probe = document.createElement('span')
|
|
const style = getComputedStyle(el)
|
|
probe.style.cssText = [
|
|
'position:absolute',
|
|
'visibility:hidden',
|
|
'pointer-events:none',
|
|
'white-space:nowrap',
|
|
`font:${style.font}`,
|
|
`letter-spacing:${style.letterSpacing}`,
|
|
`font-weight:${style.fontWeight}`,
|
|
].join(';')
|
|
document.body.appendChild(probe)
|
|
|
|
/** Matches `.selectionSep` (1px line + horizontal padding). */
|
|
const SEP_WIDTH = 17
|
|
|
|
const textWidth = (text: string) => {
|
|
probe.textContent = text
|
|
return probe.offsetWidth
|
|
}
|
|
|
|
const measure = () => {
|
|
const maxWidth = el.clientWidth
|
|
if (maxWidth <= 0) {
|
|
setVisibleSelectedCount(selectedNames.length)
|
|
return
|
|
}
|
|
|
|
const nameWidths = selectedNames.map((name) => textWidth(name))
|
|
const totalNamesWidth =
|
|
nameWidths.reduce((sum, w) => sum + w, 0) +
|
|
Math.max(0, selectedNames.length - 1) * SEP_WIDTH
|
|
|
|
if (totalNamesWidth <= maxWidth) {
|
|
setVisibleSelectedCount(selectedNames.length)
|
|
return
|
|
}
|
|
|
|
let fit = 1
|
|
for (let count = selectedNames.length - 1; count >= 1; count -= 1) {
|
|
const rest = selectedNames.length - count
|
|
const moreLabel = t('customers.selection.andOthers', { count: rest })
|
|
const width =
|
|
nameWidths.slice(0, count).reduce((sum, w) => sum + w, 0) +
|
|
textWidth(moreLabel) +
|
|
count * SEP_WIDTH
|
|
if (width <= maxWidth) {
|
|
fit = count
|
|
break
|
|
}
|
|
}
|
|
setVisibleSelectedCount(fit)
|
|
}
|
|
|
|
measure()
|
|
const ro = new ResizeObserver(measure)
|
|
ro.observe(el)
|
|
return () => {
|
|
ro.disconnect()
|
|
probe.remove()
|
|
}
|
|
}, [selectedNames, locale, t])
|
|
|
|
function toggleSelectAllPage() {
|
|
setSelectedPeople((prev) => {
|
|
const next = new Map(prev)
|
|
if (allPageSelected) {
|
|
for (const id of pageIds) next.delete(id)
|
|
} else {
|
|
for (const customer of data?.items ?? []) {
|
|
next.set(customer.id, selectionLabel(customer))
|
|
}
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
function toggleSelectRow(customer: BusinessCustomerListItem) {
|
|
setSelectedPeople((prev) => {
|
|
const next = new Map(prev)
|
|
if (next.has(customer.id)) next.delete(customer.id)
|
|
else next.set(customer.id, selectionLabel(customer))
|
|
return next
|
|
})
|
|
}
|
|
|
|
function applyFilters() {
|
|
setPage(1)
|
|
setAppliedFilters({
|
|
access: draftAccess,
|
|
...(draftName.trim() ? { name: draftName.trim() } : {}),
|
|
...(draftCell.trim() ? { cellNumber: draftCell.trim() } : {}),
|
|
})
|
|
}
|
|
|
|
function clearFilters() {
|
|
setDraftName('')
|
|
setDraftCell('')
|
|
setDraftAccess('all')
|
|
setPage(1)
|
|
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) {
|
|
setTogglingId(customer.id)
|
|
setError('')
|
|
try {
|
|
await updateCustomerEnabled(customer.id, isEnabled)
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) =>
|
|
item.id === customer.id ? { ...item, isEnabled } : item,
|
|
),
|
|
}
|
|
})
|
|
showToast(
|
|
isEnabled
|
|
? t('customers.toast.enabled', { name: displayName(customer) })
|
|
: t('customers.toast.disabled', { name: displayName(customer) }),
|
|
'success',
|
|
)
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : t('customers.error.update'))
|
|
} finally {
|
|
setTogglingId(null)
|
|
}
|
|
}
|
|
|
|
async function confirmRemove() {
|
|
if (!removeTarget) return
|
|
setRemoving(true)
|
|
setError('')
|
|
try {
|
|
await removeCustomer(removeTarget.id)
|
|
setSelectedPeople((prev) => {
|
|
if (!prev.has(removeTarget.id)) return prev
|
|
const next = new Map(prev)
|
|
next.delete(removeTarget.id)
|
|
return next
|
|
})
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
total: Math.max(0, prev.total - 1),
|
|
items: prev.items.filter((item) => item.id !== removeTarget.id),
|
|
}
|
|
})
|
|
showToast(t('customers.toast.removed', { name: displayName(removeTarget) }), 'success')
|
|
setRemoveTarget(null)
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : t('customers.error.remove'))
|
|
} finally {
|
|
setRemoving(false)
|
|
}
|
|
}
|
|
|
|
function handleViewInvoices(customer: BusinessCustomerListItem) {
|
|
navigate(`/invoices?userId=${customer.id}`, { state: { presetCustomer: customer } })
|
|
}
|
|
|
|
function handleSendSms(customer: BusinessCustomerListItem) {
|
|
setSmsTarget({ mode: 'single', customer })
|
|
}
|
|
|
|
function handleBulkSms() {
|
|
if (selectedIds.size > 0) {
|
|
setSmsTarget({
|
|
mode: 'selected',
|
|
userIds: Array.from(selectedIds),
|
|
count: selectedIds.size,
|
|
})
|
|
return
|
|
}
|
|
setSmsTarget({ mode: 'all', totalHint: data?.total })
|
|
}
|
|
|
|
function handleTickets(customer: BusinessCustomerListItem) {
|
|
showToast(t('customers.toast.ticketsSoon', { name: displayName(customer) }), 'info')
|
|
}
|
|
|
|
function handleCustomerSaved(updated: BusinessCustomerListItem) {
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) => (item.id === updated.id ? updated : item)),
|
|
}
|
|
})
|
|
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(teamRoleMessageKey(updated.teamRole))
|
|
: t('customers.access.customer')
|
|
showToast(t('customers.toast.accessUpdated', { role: roleLabel }), 'success')
|
|
}
|
|
|
|
function handleCustomerCreated(customer: BusinessCustomerListItem) {
|
|
setPage(1)
|
|
setData((prev) => {
|
|
if (!prev) {
|
|
return {
|
|
items: [customer],
|
|
total: 1,
|
|
page: 1,
|
|
pageSize: PAGE_SIZE,
|
|
}
|
|
}
|
|
return {
|
|
...prev,
|
|
total: prev.total + 1,
|
|
page: 1,
|
|
items: [customer, ...prev.items.filter((item) => item.id !== customer.id)].slice(
|
|
0,
|
|
PAGE_SIZE,
|
|
),
|
|
}
|
|
})
|
|
showToast(t('customers.toast.added', { name: displayName(customer) }), 'success')
|
|
}
|
|
|
|
async function handleExportExcel() {
|
|
if (excelBusy) return
|
|
setExcelBusy('export')
|
|
setError('')
|
|
try {
|
|
await downloadCustomersExcel()
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : t('customers.excel.exportError'))
|
|
} finally {
|
|
setExcelBusy(null)
|
|
}
|
|
}
|
|
|
|
function openImportPicker() {
|
|
if (excelBusy) return
|
|
importInputRef.current?.click()
|
|
}
|
|
|
|
async function handleImportExcel(fileList: FileList | null) {
|
|
const file = fileList?.[0]
|
|
if (importInputRef.current) {
|
|
importInputRef.current.value = ''
|
|
}
|
|
if (!file) return
|
|
|
|
const name = file.name.toLowerCase()
|
|
if (!name.endsWith('.xlsx')) {
|
|
setError(t('customers.excel.importEmpty'))
|
|
return
|
|
}
|
|
|
|
setExcelBusy('import')
|
|
setError('')
|
|
try {
|
|
const result = await importCustomersExcel(file)
|
|
showToast(
|
|
t('customers.excel.importSuccess', {
|
|
created: result.created,
|
|
linked: result.linked,
|
|
skipped: result.skipped,
|
|
}),
|
|
'success',
|
|
{ persistent: true, size: 'large' },
|
|
)
|
|
setPage(1)
|
|
setReloadToken((token) => token + 1)
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : t('customers.excel.importError'))
|
|
} finally {
|
|
setExcelBusy(null)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main className={pageStyles.content}>
|
|
<Breadcrumbs
|
|
items={[
|
|
{ label: t('nav.home'), href: '/' },
|
|
{ label: t('customers.title') },
|
|
]}
|
|
/>
|
|
|
|
<div className={pageStyles.pageHeader}>
|
|
<PageTitle en="USERS">{t('customers.title')}</PageTitle>
|
|
</div>
|
|
|
|
<div className={filterStyles.filtersPanel}>
|
|
<form className={filterStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
|
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
|
|
<input
|
|
id="filter-customer-name"
|
|
value={draftName}
|
|
onChange={(e) => setDraftName(e.target.value)}
|
|
placeholder={t('customers.filter.name')}
|
|
aria-label={t('customers.filter.name')}
|
|
/>
|
|
</div>
|
|
<div className={`${filterStyles.field} ${filterStyles.fieldCol2}`}>
|
|
<input
|
|
id="filter-customer-cell"
|
|
value={draftCell}
|
|
onChange={(e) => setDraftCell(e.target.value)}
|
|
placeholder={t('customers.filter.cell')}
|
|
aria-label={t('customers.filter.cell')}
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
<div className={`${filterStyles.field} ${filterStyles.fieldCol2}`}>
|
|
<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.filterActions} ${filterStyles.filterActionsEnd}`}>
|
|
<input
|
|
ref={importInputRef}
|
|
type="file"
|
|
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
hidden
|
|
onChange={(e) => void handleImportExcel(e.target.files)}
|
|
/>
|
|
<Tooltip label={t('customers.excel.export')}>
|
|
<button
|
|
type="button"
|
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
|
onClick={() => void handleExportExcel()}
|
|
disabled={loading || !!excelBusy}
|
|
aria-label={t('customers.excel.export')}
|
|
>
|
|
{excelBusy === 'export' ? (
|
|
<Loader2 size={18} className={styles.spin} />
|
|
) : (
|
|
<Download size={18} />
|
|
)}
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip label={t('customers.excel.import')}>
|
|
<button
|
|
type="button"
|
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
|
onClick={openImportPicker}
|
|
disabled={loading || !!excelBusy}
|
|
aria-label={t('customers.excel.import')}
|
|
>
|
|
{excelBusy === 'import' ? (
|
|
<Loader2 size={18} className={styles.spin} />
|
|
) : (
|
|
<Upload size={18} />
|
|
)}
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip label={t('customers.smsBulk')}>
|
|
<button
|
|
type="button"
|
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
|
onClick={handleBulkSms}
|
|
disabled={loading || !!excelBusy || (!data?.total && selectedIds.size === 0)}
|
|
aria-label={t('customers.smsBulk')}
|
|
>
|
|
<MessageSquare size={18} />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip label={t('customers.search')}>
|
|
<button
|
|
type="submit"
|
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
|
disabled={loading || !!excelBusy}
|
|
aria-label={t('customers.search')}
|
|
>
|
|
<Search size={18} />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip label={t('customers.clearFilters')}>
|
|
<button
|
|
type="button"
|
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
|
onClick={clearFilters}
|
|
disabled={loading || !!excelBusy}
|
|
aria-label={t('customers.clearFilters')}
|
|
>
|
|
<RotateCcw size={18} />
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
{selectedNames.length > 0 ? (
|
|
<div
|
|
className={styles.selectionBar}
|
|
dir={locale === 'fa' ? 'rtl' : 'ltr'}
|
|
aria-live="polite"
|
|
>
|
|
<span className={styles.selectionLabel}>{t('customers.selection.label')}</span>
|
|
<div ref={selectionNamesRef} className={styles.selectionNames}>
|
|
{(visibleSelectedCount > 0
|
|
? selectedNames.slice(0, visibleSelectedCount)
|
|
: selectedNames
|
|
).map((name, index) => (
|
|
<span key={`${name}-${index}`} className={styles.selectionNameItem}>
|
|
{index > 0 ? <span className={styles.selectionSep} aria-hidden /> : null}
|
|
<span className={styles.selectionName}>{name}</span>
|
|
</span>
|
|
))}
|
|
{visibleSelectedCount > 0 && selectedNames.length > visibleSelectedCount ? (
|
|
<span className={styles.selectionNameItem}>
|
|
<span className={styles.selectionSep} aria-hidden />
|
|
<span className={styles.selectionMore}>
|
|
{t('customers.selection.andOthers', {
|
|
count: selectedNames.length - visibleSelectedCount,
|
|
})}
|
|
</span>
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<span className={styles.selectionTotal}>
|
|
{t('customers.selection.total', { count: selectedNames.length })}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className={styles.tablePanel}>
|
|
<div className={styles.tableWrap}>
|
|
<div className={styles.tableHeader}>
|
|
<div className={styles.meta}>
|
|
{data ? (
|
|
data.total > 0 ? (
|
|
t('customers.showing', {
|
|
from: showingFrom,
|
|
to: showingTo,
|
|
total: data.total,
|
|
})
|
|
) : (
|
|
t('customers.none')
|
|
)
|
|
) : (
|
|
' '
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{error && <div className={styles.errorBanner}>{error}</div>}
|
|
|
|
<table
|
|
className={`${styles.table}${locale === 'fa' ? ` ${styles.tableFa}` : ''}`}
|
|
dir={locale === 'fa' ? 'rtl' : 'ltr'}
|
|
>
|
|
<colgroup>
|
|
<col className={styles.colCheck} />
|
|
<col className={styles.colName} />
|
|
<col className={styles.colCell} />
|
|
<col className={styles.colOrders} />
|
|
<col className={styles.colTransactions} />
|
|
<col className={styles.colDate} />
|
|
<col className={styles.colActions} />
|
|
</colgroup>
|
|
<thead>
|
|
<tr>
|
|
<th className={`${styles.th} ${styles.thCheck}`}>
|
|
<input
|
|
type="checkbox"
|
|
className={styles.rowCheck}
|
|
checked={allPageSelected}
|
|
ref={(el) => {
|
|
if (el) el.indeterminate = somePageSelected && !allPageSelected
|
|
}}
|
|
onChange={toggleSelectAllPage}
|
|
disabled={loading || pageIds.length === 0}
|
|
aria-label={t('customers.selectAll')}
|
|
/>
|
|
</th>
|
|
<th className={styles.th}>{t('customers.col.name')}</th>
|
|
<th className={styles.th}>{t('customers.col.cell')}</th>
|
|
<th className={styles.th}>{t('customers.col.orders')}</th>
|
|
<th className={styles.th}>{t('customers.col.transactions')}</th>
|
|
<th className={styles.th}>{t('customers.col.date')}</th>
|
|
<th className={`${styles.th} ${styles.thActions}`}>{t('customers.col.actions')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading && (
|
|
<tr>
|
|
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
|
{t('customers.loading')}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
|
|
{!loading && data?.items?.length === 0 && (
|
|
<EmptyTableRow
|
|
colSpan={COLUMN_COUNT}
|
|
icon={Users}
|
|
message={t('customers.empty')}
|
|
actionLabel={t('customers.add')}
|
|
onAction={() => setCreateOpen(true)}
|
|
/>
|
|
)}
|
|
|
|
{!loading &&
|
|
data?.items?.map((customer) => {
|
|
const name = displayName(customer)
|
|
const nameLocale = textLocaleAttrs(name)
|
|
const badgeKey = adminBadgeKey(customer)
|
|
const accessLocked = accessChangeDisabledReason(customer)
|
|
return (
|
|
<tr
|
|
key={customer.id}
|
|
className={!customer.isEnabled ? styles.inactiveRow : undefined}
|
|
>
|
|
<td className={`${styles.td} ${styles.tdCheck}`}>
|
|
<input
|
|
type="checkbox"
|
|
className={styles.rowCheck}
|
|
checked={selectedIds.has(customer.id)}
|
|
onChange={() => toggleSelectRow(customer)}
|
|
aria-label={t('customers.selectRow', { name })}
|
|
/>
|
|
</td>
|
|
<td className={styles.td}>
|
|
<div
|
|
className={[
|
|
styles.customerName,
|
|
nameLocale.className,
|
|
locale === 'fa' ? styles.nameAlignFa : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
lang={nameLocale.lang}
|
|
dir={nameLocale.dir}
|
|
>
|
|
{name}
|
|
</div>
|
|
{badgeKey ? (
|
|
<span className={accessStyles.roleBadge}>{t(badgeKey)}</span>
|
|
) : null}
|
|
{!customer.isEnabled && (
|
|
<div className={styles.statusDisabled}>{t('customers.disabled')}</div>
|
|
)}
|
|
</td>
|
|
<td className={styles.td}>{formatCellForDisplay(customer.cellNumber)}</td>
|
|
<td className={styles.td}>
|
|
{customer.orderCount == null || customer.orderCount === 0 ? (
|
|
<span className={styles.subText}>{t('customers.noOrders')}</span>
|
|
) : (
|
|
customer.orderCount
|
|
)}
|
|
</td>
|
|
<td className={styles.td}>
|
|
{formatTransactionTotal(customer.totalTransactionsIrt)}
|
|
</td>
|
|
<td className={`${styles.td} ${styles.dateCell}`}>
|
|
{formatDate(customer.createdAt, locale)}
|
|
</td>
|
|
<td className={`${styles.td} ${styles.tdActions}`}>
|
|
<div className={styles.rowActions}>
|
|
<Tooltip
|
|
label={
|
|
customer.isEnabled
|
|
? t('customers.disable')
|
|
: t('customers.enable')
|
|
}
|
|
>
|
|
<span className={styles.toggleInActions}>
|
|
<ToggleSwitch
|
|
checked={customer.isEnabled}
|
|
disabled={togglingId === customer.id || removing}
|
|
size="compact"
|
|
ariaLabel={
|
|
customer.isEnabled
|
|
? t('customers.disableAria', { name })
|
|
: t('customers.enableAria', { name })
|
|
}
|
|
onChange={(isEnabled) =>
|
|
void handleToggleEnabled(customer, isEnabled)
|
|
}
|
|
/>
|
|
</span>
|
|
</Tooltip>
|
|
<Tooltip label={t('customers.edit')}>
|
|
<button
|
|
type="button"
|
|
className={styles.actionBtn}
|
|
onClick={() => setEditTarget(customer)}
|
|
aria-label={t('customers.edit')}
|
|
>
|
|
<Pencil size={15} />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip label={t('customers.sms')}>
|
|
<button
|
|
type="button"
|
|
className={styles.actionBtn}
|
|
onClick={() => handleSendSms(customer)}
|
|
aria-label={t('customers.sms')}
|
|
>
|
|
<MessageSquare size={15} />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip label={t('customers.remove')}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
|
|
onClick={() => setRemoveTarget(customer)}
|
|
aria-label={t('customers.remove')}
|
|
disabled={removing}
|
|
>
|
|
<Trash2 size={15} />
|
|
</button>
|
|
</Tooltip>
|
|
<CustomerActionsOverflowMenu
|
|
customerName={name}
|
|
canManageAccess={canManageAccess}
|
|
accessLocked={accessLocked}
|
|
financeEnabled={financeEnabled}
|
|
disabled={removing}
|
|
onChangeAccess={() => setAccessTarget(customer)}
|
|
onViewInvoices={() => handleViewInvoices(customer)}
|
|
onTickets={() => handleTickets(customer)}
|
|
/>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
|
|
<div className={styles.pagination}>
|
|
<div>
|
|
{t('customers.pageMeta', {
|
|
page,
|
|
totalPages,
|
|
pageSize: PAGE_SIZE,
|
|
total: data?.total ?? 0,
|
|
})}
|
|
</div>
|
|
<Pagination
|
|
currentPage={page}
|
|
totalPages={totalPages}
|
|
onPageChange={setPage}
|
|
disabled={loading}
|
|
variant="inline"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<AddCustomerModal
|
|
open={createOpen}
|
|
onClose={() => setCreateOpen(false)}
|
|
onCreated={handleCustomerCreated}
|
|
/>
|
|
|
|
<EditCustomerModal
|
|
open={editTarget !== null}
|
|
customer={editTarget}
|
|
onClose={() => setEditTarget(null)}
|
|
onSaved={handleCustomerSaved}
|
|
/>
|
|
|
|
<SendCustomerSmsModal
|
|
open={smsTarget !== null}
|
|
target={smsTarget}
|
|
onClose={() => setSmsTarget(null)}
|
|
onSent={(info) => {
|
|
if (info.mode === 'single' && info.phone) {
|
|
showToast(t('customers.toast.smsSent', { phone: info.phone }), 'success')
|
|
} else if (info.failedCount && info.failedCount > 0 && info.totalCount) {
|
|
showToast(
|
|
t('customers.toast.smsSentBulkPartial', {
|
|
sent: info.count,
|
|
total: info.totalCount,
|
|
failed: info.failedCount,
|
|
}),
|
|
'info',
|
|
)
|
|
if (info.mode === 'selected') {
|
|
setSelectedPeople(new Map())
|
|
}
|
|
} else {
|
|
showToast(t('customers.toast.smsSentBulk', { count: info.count }), 'success')
|
|
if (info.mode === 'selected') {
|
|
setSelectedPeople(new Map())
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<ChangeUserAccessModal
|
|
open={accessTarget !== null}
|
|
user={accessTarget}
|
|
canAssignAdmin={isSuperAdmin}
|
|
onClose={() => setAccessTarget(null)}
|
|
onSaved={handleAccessSaved}
|
|
/>
|
|
|
|
<ConfirmDeleteModal
|
|
open={removeTarget !== null}
|
|
title={t('customers.deleteTitle')}
|
|
message={
|
|
removeTarget
|
|
? t('customers.deleteMessage', { name: displayName(removeTarget) })
|
|
: ''
|
|
}
|
|
onCancel={() => setRemoveTarget(null)}
|
|
onConfirm={() => void confirmRemove()}
|
|
/>
|
|
|
|
<button
|
|
type="button"
|
|
className={styles.fab}
|
|
onClick={() => setCreateOpen(true)}
|
|
aria-label={t('customers.add')}
|
|
>
|
|
<Plus size={24} />
|
|
</button>
|
|
</main>
|
|
)
|
|
}
|