Files
dashboards/apps/business/src/components/WorkshopRegistrationsModal.tsx
T
Alireza HassaniandCursor d5aca8bea7 Add business workshop management UI with Jalali scheduling and shared dashboard polish.
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>
2026-08-22 12:22:27 +03:30

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,
)
}