Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
162 lines
4.8 KiB
TypeScript
162 lines
4.8 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
|
|
import Modal from '@/components/modals/Modal'
|
|
import Input from '@/components/formElements/Input'
|
|
import SearchAltIcon from '@/components/icons/SearchAltIcon'
|
|
import { coerceToString, formatPersonName } from '@/helpers'
|
|
import { addToast } from '@/lib/toast'
|
|
import { fetchPreviousAttendees, setPreviousAttendeeGuests, type PreviousAttendee } from '@/services/events'
|
|
import { texts } from '@/texts'
|
|
|
|
interface Step5GuestListProps {
|
|
isOpen: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
eventId: string
|
|
attendees: PreviousAttendee[]
|
|
onFinish: () => void
|
|
}
|
|
|
|
export default function Step5GuestList({ isOpen, onOpenChange, eventId, attendees, onFinish }: Step5GuestListProps) {
|
|
const [search, setSearch] = useState('')
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
|
const [isLinking, setIsLinking] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return
|
|
|
|
setSearch('')
|
|
setSelectedIds([])
|
|
}, [isOpen])
|
|
|
|
const filteredAttendees = useMemo(() => {
|
|
const query = search.trim().toLowerCase()
|
|
|
|
if (!query) return attendees
|
|
|
|
return attendees.filter((attendee) => {
|
|
const name = formatPersonName(attendee.firstName, attendee.lastName, '').toLowerCase()
|
|
|
|
return name.includes(query)
|
|
})
|
|
}, [attendees, search])
|
|
|
|
const allFilteredSelected = filteredAttendees.length > 0 && filteredAttendees.every((attendee) => selectedIds.includes(attendee.userId))
|
|
|
|
const toggleAttendee = (userId: string, checked: boolean) => {
|
|
setSelectedIds((prev) => (checked ? [...prev, userId] : prev.filter((id) => id !== userId)))
|
|
}
|
|
|
|
const toggleSelectAll = (checked: boolean) => {
|
|
const filteredIds = filteredAttendees.map((attendee) => attendee.userId)
|
|
|
|
if (checked) {
|
|
setSelectedIds((prev) => Array.from(new Set([...prev, ...filteredIds])))
|
|
|
|
return
|
|
}
|
|
|
|
setSelectedIds((prev) => prev.filter((id) => !filteredIds.includes(id)))
|
|
}
|
|
|
|
const handleSendInvitations = async () => {
|
|
if (selectedIds.length === 0) {
|
|
onOpenChange(false)
|
|
onFinish()
|
|
|
|
return
|
|
}
|
|
|
|
setIsLinking(true)
|
|
|
|
try {
|
|
await setPreviousAttendeeGuests(eventId, selectedIds)
|
|
addToast({ title: texts.events.guestsSaved, color: 'success' })
|
|
onOpenChange(false)
|
|
onFinish()
|
|
} catch {
|
|
addToast({ title: texts.events.guestsSaveFailed, color: 'danger' })
|
|
} finally {
|
|
setIsLinking(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
acceptBtnDisabled={selectedIds.length === 0}
|
|
acceptBtnText={texts.events.sendInvitation}
|
|
bodyClassName="max-h-[min(60vh,28rem)]"
|
|
isLoading={isLinking}
|
|
isOpen={isOpen}
|
|
rejectBtnText={texts.common.cancel}
|
|
size="lg"
|
|
title={texts.events.invitePreviousGuestsTitle}
|
|
onAccept={() => void handleSendInvitations()}
|
|
onClose={onFinish}
|
|
onOpenChange={onOpenChange}
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
<p className="text-sm leading-6 text-secondary-30">{texts.events.invitePreviousGuestsDescription}</p>
|
|
|
|
<Input
|
|
isClearable
|
|
ariaLabel={texts.events.guestSearchAria}
|
|
generalType="input"
|
|
iconStart={<SearchAltIcon className="size-5 text-secondary-30" />}
|
|
name="guestSearch"
|
|
placeholder={texts.events.guestSearchPlaceholder}
|
|
value={search}
|
|
onClear={() => {
|
|
setSearch('')
|
|
}}
|
|
onValueChange={(next) => {
|
|
setSearch(coerceToString(next))
|
|
}}
|
|
/>
|
|
|
|
<Input
|
|
generalType="checkbox"
|
|
label={texts.events.selectAllGuests}
|
|
name="selectAllGuests"
|
|
value={allFilteredSelected}
|
|
onValueChange={(checked) => {
|
|
toggleSelectAll(Boolean(checked))
|
|
}}
|
|
/>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
{filteredAttendees.length === 0 ? (
|
|
<p className="rounded-xl bg-default-100 p-4 text-sm text-secondary-30">{texts.common.emptyChoice}</p>
|
|
) : (
|
|
filteredAttendees.map((attendee) => (
|
|
<div
|
|
key={attendee.userId}
|
|
className="rounded-xl border border-default-200 bg-white p-3"
|
|
>
|
|
<Input
|
|
generalType="checkbox"
|
|
label={formatPersonName(attendee.firstName, attendee.lastName, texts.events.guest)}
|
|
name={`guest-${attendee.userId}`}
|
|
value={selectedIds.includes(attendee.userId)}
|
|
onValueChange={(checked) => {
|
|
toggleAttendee(attendee.userId, Boolean(checked))
|
|
}}
|
|
/>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
export async function loadPreviousAttendeesForStep5(): Promise<PreviousAttendee[]> {
|
|
try {
|
|
return await fetchPreviousAttendees()
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|