Includes workshop list/edit/details, registrations modal, category tree, shared EmptyState/RichTextEditor/toasts, and fixes for the Jalali date picker and thumbnail field layout. Co-authored-by: Cursor <cursoragent@cursor.com>
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { Plus, Trash2, X, Clock, Check, XCircle } from 'lucide-react'
|
|
import { useLocale } from '@meshkee/dashboard-ui'
|
|
import { ApiError } from '../lib/api'
|
|
import { formatCellForDisplay } from '../lib/cellNumber'
|
|
import {
|
|
addWorkshopRegistration,
|
|
formatWorkshopDate,
|
|
formatWorkshopRegistrationName,
|
|
listWorkshopRegistrations,
|
|
removeWorkshopRegistration,
|
|
updateWorkshopRegistrationStatus,
|
|
type WorkshopRegistration,
|
|
type WorkshopRegistrationStatus,
|
|
} from '../services/workshopService'
|
|
import type { BusinessCustomer } from '../services/customerService'
|
|
import { useToast } from '../context/ToastContext'
|
|
import { useT } from '../i18n/useT'
|
|
import { AddWorkshopRegistrationModal } from './AddWorkshopRegistrationModal'
|
|
import { Tooltip } from './Tooltip'
|
|
import modalStyles from './VariationsModal.module.css'
|
|
import styles from './WorkshopRegistrationsModal.module.css'
|
|
|
|
interface WorkshopRegistrationsModalProps {
|
|
open: boolean
|
|
workshopId: string
|
|
workshopTitle: string
|
|
onClose: () => void
|
|
onRegistrationAdded?: () => void
|
|
onRegistrationRemoved?: () => void
|
|
}
|
|
|
|
const ANIMATION_MS = 220
|
|
|
|
const STATUS_CONTROLS: {
|
|
status: WorkshopRegistrationStatus
|
|
icon: typeof Clock
|
|
labelKey: 'workshop.registrations.status.pending' | 'workshop.registrations.status.approved' | 'workshop.registrations.status.rejected'
|
|
className: 'statusBtnPending' | 'statusBtnApprove' | 'statusBtnReject'
|
|
}[] = [
|
|
{
|
|
status: 'pending',
|
|
icon: Clock,
|
|
labelKey: 'workshop.registrations.status.pending',
|
|
className: 'statusBtnPending',
|
|
},
|
|
{
|
|
status: 'approved',
|
|
icon: Check,
|
|
labelKey: 'workshop.registrations.status.approved',
|
|
className: 'statusBtnApprove',
|
|
},
|
|
{
|
|
status: 'rejected',
|
|
icon: XCircle,
|
|
labelKey: 'workshop.registrations.status.rejected',
|
|
className: 'statusBtnReject',
|
|
},
|
|
]
|
|
|
|
export function WorkshopRegistrationsModal({
|
|
open,
|
|
workshopId,
|
|
workshopTitle,
|
|
onClose,
|
|
onRegistrationAdded,
|
|
onRegistrationRemoved,
|
|
}: WorkshopRegistrationsModalProps) {
|
|
const t = useT()
|
|
const { showToast } = useToast()
|
|
const { locale } = useLocale()
|
|
const dateLocale = locale === 'fa' ? 'fa' : 'en'
|
|
const [mounted, setMounted] = useState(open)
|
|
const [closing, setClosing] = useState(false)
|
|
const [registrations, setRegistrations] = useState<WorkshopRegistration[]>([])
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [addError, setAddError] = useState('')
|
|
const [addOpen, setAddOpen] = useState(false)
|
|
const [isAdding, setIsAdding] = useState(false)
|
|
const [updatingId, setUpdatingId] = useState<string | null>(null)
|
|
const [removingId, setRemovingId] = useState<string | null>(null)
|
|
|
|
const registeredUserIds = useMemo(
|
|
() => registrations.map((registration) => registration.userId),
|
|
[registrations],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setMounted(true)
|
|
setClosing(false)
|
|
} else if (mounted) {
|
|
setClosing(true)
|
|
const timer = setTimeout(() => {
|
|
setMounted(false)
|
|
setClosing(false)
|
|
setAddOpen(false)
|
|
setAddError('')
|
|
}, ANIMATION_MS)
|
|
return () => clearTimeout(timer)
|
|
}
|
|
}, [open, mounted])
|
|
|
|
useEffect(() => {
|
|
if (!open || !workshopId) return
|
|
|
|
const controller = new AbortController()
|
|
void loadRegistrations(controller.signal)
|
|
return () => controller.abort()
|
|
}, [open, workshopId])
|
|
|
|
async function loadRegistrations(signal?: AbortSignal) {
|
|
setIsLoading(true)
|
|
setError('')
|
|
|
|
try {
|
|
const data = await listWorkshopRegistrations(workshopId, signal)
|
|
setRegistrations(data.items)
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
|
if (err instanceof ApiError) {
|
|
setError(err.message)
|
|
} else {
|
|
setError(t('workshop.registrations.errorLoad'))
|
|
}
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
async function handleAddCustomer(customer: BusinessCustomer) {
|
|
setIsAdding(true)
|
|
setAddError('')
|
|
|
|
try {
|
|
const result = await addWorkshopRegistration(workshopId, customer.id)
|
|
setRegistrations((current) => [result.registration, ...current])
|
|
setAddOpen(false)
|
|
showToast(t('workshop.registrations.toast.added'), 'success')
|
|
onRegistrationAdded?.()
|
|
} catch (err) {
|
|
if (err instanceof ApiError) {
|
|
setAddError(err.message)
|
|
} else {
|
|
setAddError(t('workshop.registrations.errorAdd'))
|
|
}
|
|
} finally {
|
|
setIsAdding(false)
|
|
}
|
|
}
|
|
|
|
function openAddModal() {
|
|
setAddError('')
|
|
setAddOpen(true)
|
|
}
|
|
|
|
function closeAddModal() {
|
|
if (isAdding) return
|
|
setAddOpen(false)
|
|
setAddError('')
|
|
}
|
|
|
|
async function handleStatusChange(
|
|
registrationId: string,
|
|
status: WorkshopRegistrationStatus,
|
|
) {
|
|
setUpdatingId(registrationId)
|
|
setError('')
|
|
|
|
try {
|
|
const result = await updateWorkshopRegistrationStatus(
|
|
workshopId,
|
|
registrationId,
|
|
status,
|
|
)
|
|
setRegistrations((current) =>
|
|
current.map((item) =>
|
|
item.id === registrationId ? result.registration : item,
|
|
),
|
|
)
|
|
showToast(t('workshop.registrations.toast.statusUpdated'), 'success')
|
|
} catch (err) {
|
|
if (err instanceof ApiError) {
|
|
setError(err.message)
|
|
} else {
|
|
setError(t('workshop.registrations.errorStatus'))
|
|
}
|
|
} finally {
|
|
setUpdatingId(null)
|
|
}
|
|
}
|
|
|
|
async function handleRemove(registrationId: string) {
|
|
setRemovingId(registrationId)
|
|
setError('')
|
|
|
|
try {
|
|
await removeWorkshopRegistration(workshopId, registrationId)
|
|
setRegistrations((current) => current.filter((item) => item.id !== registrationId))
|
|
showToast(t('workshop.registrations.toast.removed'), 'success')
|
|
onRegistrationRemoved?.()
|
|
} catch (err) {
|
|
if (err instanceof ApiError) {
|
|
setError(err.message)
|
|
} else {
|
|
setError(t('workshop.registrations.errorRemove'))
|
|
}
|
|
} finally {
|
|
setRemovingId(null)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!mounted || closing) return
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && !addOpen) onClose()
|
|
}
|
|
document.addEventListener('keydown', onKey)
|
|
return () => document.removeEventListener('keydown', onKey)
|
|
}, [mounted, closing, addOpen, onClose])
|
|
|
|
if (!mounted) return null
|
|
|
|
return createPortal(
|
|
<>
|
|
<div
|
|
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="workshop-registrations-title"
|
|
lang={locale === 'fa' ? 'fa' : undefined}
|
|
dir={locale === 'fa' ? 'rtl' : undefined}
|
|
>
|
|
<div className={modalStyles.header}>
|
|
<div>
|
|
<h2 id="workshop-registrations-title" className={modalStyles.title}>
|
|
{t('workshop.registrations.title')}
|
|
</h2>
|
|
<p className={modalStyles.subtitle}>{workshopTitle}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={modalStyles.closeBtn}
|
|
onClick={onClose}
|
|
aria-label={t('common.close')}
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className={styles.content}>
|
|
<div className={styles.body}>
|
|
{error && (
|
|
<p className={styles.error} role="alert">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{isLoading ? (
|
|
<p className={styles.loading}>{t('workshop.registrations.loading')}</p>
|
|
) : registrations.length === 0 ? (
|
|
<p className={styles.empty}>{t('workshop.registrations.empty')}</p>
|
|
) : (
|
|
<div className={styles.tableWrap}>
|
|
<table className={styles.table}>
|
|
<thead>
|
|
<tr>
|
|
<th>{t('workshop.registrations.col.name')}</th>
|
|
<th className={styles.cellCol}>{t('workshop.registrations.col.cell')}</th>
|
|
<th>{t('workshop.registrations.col.date')}</th>
|
|
<th className={styles.statusCol}>{t('workshop.registrations.col.status')}</th>
|
|
<th className={styles.actionsCol} />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{registrations.map((registration) => (
|
|
<tr key={registration.id}>
|
|
<td>
|
|
{formatWorkshopRegistrationName(registration.user, dateLocale)}
|
|
</td>
|
|
<td className={styles.cellCol} dir="ltr">
|
|
{registration.user.cellNumber
|
|
? formatCellForDisplay(registration.user.cellNumber)
|
|
: '—'}
|
|
</td>
|
|
<td>{formatWorkshopDate(registration.createdAt, dateLocale)}</td>
|
|
<td className={styles.statusCell}>
|
|
<div className={styles.statusControls}>
|
|
{STATUS_CONTROLS.map(({ status, icon: Icon, labelKey, className }) => {
|
|
const isActive = registration.status === status
|
|
const isBusy =
|
|
updatingId === registration.id || removingId === registration.id
|
|
return (
|
|
<Tooltip key={status} label={t(labelKey)}>
|
|
<button
|
|
type="button"
|
|
className={[
|
|
styles.statusBtn,
|
|
styles[className],
|
|
isActive ? styles.statusBtnActive : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
disabled={isBusy || isActive}
|
|
onClick={() => handleStatusChange(registration.id, status)}
|
|
aria-label={t(labelKey)}
|
|
aria-pressed={isActive}
|
|
>
|
|
<Icon size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
)
|
|
})}
|
|
</div>
|
|
</td>
|
|
<td className={styles.actionsCell}>
|
|
<Tooltip label={t('workshop.registrations.remove')}>
|
|
<button
|
|
type="button"
|
|
className={modalStyles.removeRowBtn}
|
|
onClick={() => handleRemove(registration.id)}
|
|
disabled={
|
|
removingId === registration.id || updatingId === registration.id
|
|
}
|
|
aria-label={t('workshop.registrations.remove')}
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</Tooltip>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.fabDock}>
|
|
<Tooltip label={t('workshop.registrations.add')}>
|
|
<button
|
|
type="button"
|
|
className={[styles.addFab, addOpen ? styles.addFabActive : '']
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
onClick={openAddModal}
|
|
disabled={isAdding}
|
|
aria-label={t('workshop.registrations.add')}
|
|
aria-haspopup="dialog"
|
|
aria-expanded={addOpen}
|
|
>
|
|
<Plus size={22} />
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<AddWorkshopRegistrationModal
|
|
open={addOpen}
|
|
excludeIds={registeredUserIds}
|
|
isAdding={isAdding}
|
|
error={addError}
|
|
onClose={closeAddModal}
|
|
onPick={handleAddCustomer}
|
|
/>
|
|
</>,
|
|
document.body,
|
|
)
|
|
}
|