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 — } 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(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('all') const [togglingId, setTogglingId] = useState(null) const [editTarget, setEditTarget] = useState(null) const [accessTarget, setAccessTarget] = useState(null) const [createOpen, setCreateOpen] = useState(false) const [removeTarget, setRemoveTarget] = useState(null) const [removing, setRemoving] = useState(false) const [smsTarget, setSmsTarget] = useState(null) const [selectedPeople, setSelectedPeople] = useState>(() => new Map()) const [excelBusy, setExcelBusy] = useState<'export' | 'import' | null>(null) const [reloadToken, setReloadToken] = useState(0) const importInputRef = useRef(null) const selectionNamesRef = useRef(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 (
{t('customers.title')}
{ e.preventDefault(); applyFilters() }}>
setDraftName(e.target.value)} placeholder={t('customers.filter.name')} aria-label={t('customers.filter.name')} />
setDraftCell(e.target.value)} placeholder={t('customers.filter.cell')} aria-label={t('customers.filter.cell')} autoComplete="off" />
void handleImportExcel(e.target.files)} />
{selectedNames.length > 0 ? (
{t('customers.selection.label')}
{(visibleSelectedCount > 0 ? selectedNames.slice(0, visibleSelectedCount) : selectedNames ).map((name, index) => ( {index > 0 ? : null} {name} ))} {visibleSelectedCount > 0 && selectedNames.length > visibleSelectedCount ? ( {t('customers.selection.andOthers', { count: selectedNames.length - visibleSelectedCount, })} ) : null}
{t('customers.selection.total', { count: selectedNames.length })}
) : null}
{data ? ( data.total > 0 ? ( t('customers.showing', { from: showingFrom, to: showingTo, total: data.total, }) ) : ( t('customers.none') ) ) : ( ' ' )}
{error &&
{error}
} {loading && ( )} {!loading && data?.items?.length === 0 && ( setCreateOpen(true)} /> )} {!loading && data?.items?.map((customer) => { const name = displayName(customer) const nameLocale = textLocaleAttrs(name) const badgeKey = adminBadgeKey(customer) const accessLocked = accessChangeDisabledReason(customer) return ( ) })}
{ if (el) el.indeterminate = somePageSelected && !allPageSelected }} onChange={toggleSelectAllPage} disabled={loading || pageIds.length === 0} aria-label={t('customers.selectAll')} /> {t('customers.col.name')} {t('customers.col.cell')} {t('customers.col.orders')} {t('customers.col.transactions')} {t('customers.col.date')} {t('customers.col.actions')}
{t('customers.loading')}
toggleSelectRow(customer)} aria-label={t('customers.selectRow', { name })} />
{name}
{badgeKey ? ( {t(badgeKey)} ) : null} {!customer.isEnabled && (
{t('customers.disabled')}
)}
{formatCellForDisplay(customer.cellNumber)} {customer.orderCount == null || customer.orderCount === 0 ? ( {t('customers.noOrders')} ) : ( customer.orderCount )} {formatTransactionTotal(customer.totalTransactionsIrt)} {formatDate(customer.createdAt, locale)}
void handleToggleEnabled(customer, isEnabled) } /> setAccessTarget(customer)} onViewInvoices={() => handleViewInvoices(customer)} onTickets={() => handleTickets(customer)} />
{t('customers.pageMeta', { page, totalPages, pageSize: PAGE_SIZE, total: data?.total ?? 0, })}
setCreateOpen(false)} onCreated={handleCustomerCreated} /> setEditTarget(null)} onSaved={handleCustomerSaved} /> 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()) } } }} /> setAccessTarget(null)} onSaved={handleAccessSaved} /> setRemoveTarget(null)} onConfirm={() => void confirmRemove()} />
) }