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>
This commit is contained in:
co-authored by
Cursor
parent
89269dff04
commit
d5aca8bea7
@@ -0,0 +1,378 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user