Add customer SMS/Excel tools, selection bar, and sticky import toasts.

Bulk and single SMS from Users, row selection with overflow summary, persistent large import toasts, and business SMS sender in Super Admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-08 14:12:57 +03:30
co-authored by Cursor
parent 2a32c46b2e
commit 4c3bf8324e
30 changed files with 1597 additions and 149 deletions
+111 -11
View File
@@ -6,6 +6,76 @@
overflow: hidden;
}
.selectionBar {
margin-top: 10px;
display: flex;
align-items: center;
gap: 10px;
min-height: 40px;
padding: 8px 14px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
overflow: hidden;
}
.selectionLabel {
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
white-space: nowrap;
}
.selectionNames {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
font-family: var(--font-ui);
}
.selectionNameItem {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.selectionName,
.selectionMore {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
.selectionMore {
color: var(--text-secondary);
font-weight: 500;
}
.selectionSep {
display: inline-block;
width: 1px;
height: 12px;
margin-inline: 8px;
background: rgba(148, 163, 184, 0.45);
flex-shrink: 0;
}
.selectionTotal {
flex-shrink: 0;
margin-inline-start: auto;
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.tableWrap {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
@@ -40,8 +110,12 @@
table-layout: fixed;
}
.colCheck {
width: 44px;
}
.colName {
width: 24%;
width: 22%;
}
.colCell {
@@ -61,12 +135,12 @@
}
.colActions {
width: 240px;
width: 220px;
}
.th,
.td {
padding: 10px 12px;
padding: 14px 14px;
text-align: start;
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
vertical-align: middle;
@@ -121,38 +195,54 @@
white-space: nowrap;
}
.thCheck,
.tdCheck {
width: 44px;
text-align: center;
padding-inline: 8px;
vertical-align: middle;
}
.rowCheck {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--primary);
cursor: pointer;
}
.tdActions {
text-align: right;
white-space: nowrap;
padding-inline-end: 10px;
padding-inline-end: 14px;
}
.rowActions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0;
gap: 4px;
}
/* FA: عملیات column is on the physical left. */
.tableFa .thActions,
.tableFa .tdActions {
text-align: left;
padding-left: 10px;
padding-right: 10px;
padding-left: 14px;
padding-right: 14px;
}
.toggleInActions {
margin-inline-end: 10px;
margin-inline-end: 8px;
flex-shrink: 0;
display: flex;
align-items: center;
height: 28px;
height: 30px;
}
.actionBtn {
width: 28px;
height: 28px;
width: 30px;
height: 30px;
flex-shrink: 0;
display: flex;
align-items: center;
@@ -278,6 +368,16 @@
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
}
.spin {
animation: customersSpin 0.8s linear infinite;
}
@keyframes customersSpin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.fab {
inset-inline-end: 20px;
+355 -62
View File
@@ -1,15 +1,28 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { MessageSquare, Pencil, Plus, Receipt, RotateCcw, Search, Shield, Ticket, Trash2, Users } from 'lucide-react'
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'
@@ -21,6 +34,8 @@ import { ApiError, isAbortError } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import { formatCellForDisplay } from '../lib/cellNumber'
import {
downloadCustomersExcel,
importCustomersExcel,
listCustomers,
removeCustomer,
updateCustomerEnabled,
@@ -37,7 +52,7 @@ import accessStyles from '../components/ChangeUserAccessModal.module.css'
import styles from './CustomersPage.module.css'
const PAGE_SIZE = 24
const COLUMN_COUNT = 6
const COLUMN_COUNT = 7
function formatDate(value: string, locale: 'en' | 'fa') {
const d = new Date(value)
@@ -51,6 +66,11 @@ function displayName(customer: BusinessCustomerListItem) {
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>
@@ -101,6 +121,13 @@ export function CustomersPage() {
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()
@@ -156,7 +183,7 @@ export function CustomersPage() {
return () => {
controller.abort()
}
}, [page, appliedFilters.name, appliedFilters.cellNumber, appliedFilters.access, t])
}, [page, appliedFilters.name, appliedFilters.cellNumber, appliedFilters.access, reloadToken, t])
const totalPages = useMemo(() => {
const total = data?.total ?? 0
@@ -173,6 +200,106 @@ export function CustomersPage() {
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({
@@ -233,6 +360,12 @@ export function CustomersPage() {
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 {
@@ -255,10 +388,19 @@ export function CustomersPage() {
}
function handleSendSms(customer: BusinessCustomerListItem) {
showToast(
t('customers.toast.smsSoon', { phone: formatCellForDisplay(customer.cellNumber) }),
'info',
)
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) {
@@ -316,6 +458,59 @@ export function CustomersPage() {
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
@@ -364,29 +559,113 @@ export function CustomersPage() {
</select>
</div>
<div className={`${filterStyles.filterActions} ${filterStyles.filterActionsEnd}`}>
<button
type="submit"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
disabled={loading}
aria-label={t('customers.search')}
title={t('customers.search')}
>
<Search size={18} />
</button>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={loading}
aria-label={t('customers.clearFilters')}
title={t('customers.clearFilters')}
>
<RotateCcw size={18} />
</button>
<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}>
@@ -414,6 +693,7 @@ export function CustomersPage() {
dir={locale === 'fa' ? 'rtl' : 'ltr'}
>
<colgroup>
<col className={styles.colCheck} />
<col className={styles.colName} />
<col className={styles.colCell} />
<col className={styles.colOrders} />
@@ -423,6 +703,19 @@ export function CustomersPage() {
</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>
@@ -461,6 +754,15 @@ export function CustomersPage() {
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={[
@@ -521,19 +823,6 @@ 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"
@@ -544,18 +833,6 @@ export function CustomersPage() {
<Pencil size={15} />
</button>
</Tooltip>
{financeEnabled ? (
<Tooltip label={t('customers.invoices')}>
<button
type="button"
className={styles.actionBtn}
onClick={() => handleViewInvoices(customer)}
aria-label={t('customers.invoices')}
>
<Receipt size={15} />
</button>
</Tooltip>
) : null}
<Tooltip label={t('customers.sms')}>
<button
type="button"
@@ -566,16 +843,6 @@ export function CustomersPage() {
<MessageSquare size={15} />
</button>
</Tooltip>
<Tooltip label={t('customers.tickets')}>
<button
type="button"
className={styles.actionBtn}
onClick={() => handleTickets(customer)}
aria-label={t('customers.tickets')}
>
<Ticket size={15} />
</button>
</Tooltip>
<Tooltip label={t('customers.remove')}>
<button
type="button"
@@ -587,6 +854,16 @@ export function CustomersPage() {
<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>
@@ -628,6 +905,22 @@ export function CustomersPage() {
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 {
showToast(t('customers.toast.smsSentBulk', { count: info.count }), 'success')
if (info.mode === 'selected') {
setSelectedPeople(new Map())
}
}
}}
/>
<ChangeUserAccessModal
open={accessTarget !== null}
user={accessTarget}
@@ -304,6 +304,7 @@ function StoreItemsPageContent() {
unchanged: result.unchanged,
}),
'success',
{ persistent: true, size: 'large' },
)
await loadItems()
} catch (err) {