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:
co-authored by
Cursor
parent
2a32c46b2e
commit
4c3bf8324e
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user