537 lines
19 KiB
TypeScript
537 lines
19 KiB
TypeScript
import { createContext, useContext, useMemo, useState } from 'react'
|
|
import type { ReactNode } from 'react'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import type {
|
|
AttendeeDto,
|
|
CreateAttendee,
|
|
CreateAttendeeResult,
|
|
CreateGroupWaitlistEntry,
|
|
GroupDto,
|
|
GroupWaitlistEntryDto,
|
|
GroupWaitlistList,
|
|
GroupRiskLevel,
|
|
} from '@gruperly/shared'
|
|
import { useToast } from '../../../components/ui'
|
|
import type { ParsedContactRow } from '../../../lib/file-parser'
|
|
import {
|
|
addToGroupWaitlist,
|
|
ApiError,
|
|
bulkCreateAttendees,
|
|
createAttendee,
|
|
getGroup,
|
|
getGroupAttendees,
|
|
getGroupsRiskOverview,
|
|
getGroupWaitlist,
|
|
getInviteToken,
|
|
promoteGroupWaitlistEntry,
|
|
removeGroupAttendee,
|
|
removeGroupWaitlistEntry,
|
|
} from '../../../lib/api'
|
|
|
|
export type BulkAttendeeRow = {
|
|
firstName: string
|
|
lastName: string
|
|
fullName: string
|
|
phone: string
|
|
email?: string
|
|
notes?: string
|
|
}
|
|
|
|
export type ListSection = 'members' | 'waitlist'
|
|
export type AddAttendeeTab = 'quick' | 'bulk'
|
|
|
|
type GroupDetailContextValue = {
|
|
groupId: string
|
|
group: GroupDto | undefined
|
|
groupQueryPending: boolean
|
|
riskLevel: GroupRiskLevel
|
|
attendees: AttendeeDto[]
|
|
attendeesTotal: number
|
|
attendeesPending: boolean
|
|
waitlistEntries: GroupWaitlistEntryDto[]
|
|
waitlistTotal: number
|
|
waitlistPending: boolean
|
|
firstWaitlistEntry: GroupWaitlistEntryDto | null
|
|
hasFreeCapacity: boolean
|
|
inviteUrl: string | undefined
|
|
invitePending: boolean
|
|
whatsappMessage: string
|
|
searchFilter: string
|
|
setSearchFilter: (value: string) => void
|
|
listSection: ListSection
|
|
setListSection: (value: ListSection) => void
|
|
activeTab: AddAttendeeTab
|
|
setActiveTab: (value: AddAttendeeTab) => void
|
|
isInviteModalOpen: boolean
|
|
openInviteModal: () => void
|
|
closeInviteModal: () => void
|
|
isAddAttendeeModalOpen: boolean
|
|
openAddAttendeeModal: () => void
|
|
closeAddAttendeeModal: () => void
|
|
selectedAttendee: AttendeeDto | null
|
|
setSelectedAttendee: (attendee: AttendeeDto | null) => void
|
|
attendeeToRemove: AttendeeDto | null
|
|
requestRemoveAttendee: (attendee: AttendeeDto) => void
|
|
cancelRemoveAttendee: () => void
|
|
confirmRemoveAttendee: (promoteFromWaitlist: boolean) => void
|
|
selectedWaitlistEntry: GroupWaitlistEntryDto | null
|
|
setSelectedWaitlistEntry: (entry: GroupWaitlistEntryDto | null) => void
|
|
isCapacityModalOpen: boolean
|
|
capacityPayload: CreateAttendee | null
|
|
closeCapacityModal: () => void
|
|
isBulkCapacityModalOpen: boolean
|
|
closeBulkCapacityModal: () => void
|
|
parsedContacts: ParsedContactRow[]
|
|
setParsedContacts: (contacts: ParsedContactRow[]) => void
|
|
removeParsedContact: (id: string) => void
|
|
fileName: string | null
|
|
setFileName: (name: string | null) => void
|
|
isParsingFile: boolean
|
|
setIsParsingFile: (value: boolean) => void
|
|
isDragging: boolean
|
|
setIsDragging: (value: boolean) => void
|
|
validRowsCount: number
|
|
invalidRowsCount: number
|
|
isSubmittingAttendee: boolean
|
|
isSubmittingForcedAttendee: boolean
|
|
isSubmittingWaitlistEntry: boolean
|
|
isSubmittingBulkImport: boolean
|
|
isSubmittingRemoval: boolean
|
|
isSubmittingPromotion: boolean
|
|
isSubmittingWaitlistRemoval: boolean
|
|
isRegeneratingInvite: boolean
|
|
submitQuickAttendee: (payload: CreateAttendee) => void
|
|
submitForcedAttendee: () => void
|
|
submitWaitlistEntry: () => void
|
|
requestBulkImport: () => void
|
|
confirmBulkImportOverCapacity: () => void
|
|
promoteEntry: (entry: GroupWaitlistEntryDto) => void
|
|
removeEntry: (entry: GroupWaitlistEntryDto) => void
|
|
regenerateInvite: () => void
|
|
copyInviteLink: () => Promise<void>
|
|
copyWhatsappMessage: () => Promise<void>
|
|
openWhatsapp: () => Promise<void>
|
|
copyAbsenceNotifyUrl: (url: string) => void
|
|
}
|
|
|
|
const GroupDetailContext = createContext<GroupDetailContextValue | null>(null)
|
|
|
|
export function GroupDetailProvider({ groupId, children }: { groupId: string; children: ReactNode }) {
|
|
const queryClient = useQueryClient()
|
|
const toast = useToast()
|
|
|
|
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
|
|
const [isAddAttendeeModalOpen, setIsAddAttendeeModalOpen] = useState(false)
|
|
const [selectedAttendee, setSelectedAttendee] = useState<AttendeeDto | null>(null)
|
|
const [activeTab, setActiveTab] = useState<AddAttendeeTab>('quick')
|
|
const [listSection, setListSection] = useState<ListSection>('members')
|
|
const [attendeeToRemove, setAttendeeToRemove] = useState<AttendeeDto | null>(null)
|
|
const [selectedWaitlistEntry, setSelectedWaitlistEntry] = useState<GroupWaitlistEntryDto | null>(null)
|
|
const [isCapacityModalOpen, setIsCapacityModalOpen] = useState(false)
|
|
const [pendingCapacityPayload, setPendingCapacityPayload] = useState<CreateAttendee | null>(null)
|
|
const [isBulkCapacityModalOpen, setIsBulkCapacityModalOpen] = useState(false)
|
|
const [searchFilter, setSearchFilter] = useState('')
|
|
const [parsedContacts, setParsedContacts] = useState<ParsedContactRow[]>([])
|
|
const [fileName, setFileName] = useState<string | null>(null)
|
|
const [isParsingFile, setIsParsingFile] = useState(false)
|
|
const [isDragging, setIsDragging] = useState(false)
|
|
|
|
const groupQuery = useQuery({
|
|
queryKey: ['group', groupId],
|
|
queryFn: () => getGroup(groupId),
|
|
enabled: Boolean(groupId),
|
|
})
|
|
|
|
const riskOverviewQuery = useQuery({
|
|
queryKey: ['groups-risk-overview'],
|
|
queryFn: getGroupsRiskOverview,
|
|
enabled: Boolean(groupId),
|
|
})
|
|
|
|
const inviteTokenQuery = useQuery({
|
|
queryKey: ['invite-token', groupId],
|
|
queryFn: () => getInviteToken(groupId),
|
|
enabled: Boolean(groupId) && isInviteModalOpen,
|
|
})
|
|
|
|
const attendeesQuery = useQuery({
|
|
queryKey: ['group-attendees', groupId],
|
|
queryFn: () => getGroupAttendees(groupId, 1, 100),
|
|
enabled: Boolean(groupId),
|
|
})
|
|
|
|
const waitlistQuery = useQuery({
|
|
queryKey: ['group-waitlist', groupId],
|
|
queryFn: () => getGroupWaitlist(groupId, 1, 100),
|
|
enabled: Boolean(groupId),
|
|
})
|
|
|
|
const group = groupQuery.data
|
|
const attendees = useMemo(() => attendeesQuery.data?.data ?? [], [attendeesQuery.data])
|
|
const attendeesTotal = attendeesQuery.data?.pagination?.total ?? attendees.length
|
|
const waitlistEntries = useMemo(() => waitlistQuery.data?.data ?? [], [waitlistQuery.data])
|
|
const waitlistTotal = waitlistQuery.data?.pagination?.total ?? waitlistEntries.length
|
|
const firstWaitlistEntry = waitlistEntries[0] ?? null
|
|
const hasFreeCapacity = group?.capacity == null || attendeesTotal < group.capacity
|
|
|
|
const closeAddAttendeeFlow = () => {
|
|
setPendingCapacityPayload(null)
|
|
setIsCapacityModalOpen(false)
|
|
setIsAddAttendeeModalOpen(false)
|
|
}
|
|
|
|
const invalidateGroupQueries = () => {
|
|
void queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] })
|
|
void queryClient.invalidateQueries({ queryKey: ['group-waitlist', groupId] })
|
|
void queryClient.invalidateQueries({ queryKey: ['group', groupId] })
|
|
}
|
|
|
|
const handleCreateSuccess = (result: CreateAttendeeResult) => {
|
|
if (result.outcome === 'created') {
|
|
toast.success(`Miembro ${result.attendee.fullName} agregado con éxito.`)
|
|
} else {
|
|
toast.info(result.message)
|
|
}
|
|
void queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] })
|
|
closeAddAttendeeFlow()
|
|
}
|
|
|
|
const regenerateTokenMutation = useMutation({
|
|
mutationFn: () => getInviteToken(groupId, true),
|
|
onSuccess: (data) => {
|
|
queryClient.setQueryData(['invite-token', groupId], data)
|
|
toast.success('Se generó un nuevo enlace de invitación.')
|
|
},
|
|
onError: () => {
|
|
toast.error('No se pudo regenerar el enlace de invitación.')
|
|
},
|
|
})
|
|
|
|
const createAttendeeMutation = useMutation({
|
|
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload),
|
|
onMutate: (payload) => setPendingCapacityPayload(payload),
|
|
onSuccess: handleCreateSuccess,
|
|
onError: (err: Error) => {
|
|
if (err instanceof ApiError && err.problem?.code === 'group_capacity_reached') {
|
|
setIsCapacityModalOpen(true)
|
|
return
|
|
}
|
|
toast.error(err.message || 'Error al agregar miembro.')
|
|
},
|
|
})
|
|
|
|
const createAttendeeForceMutation = useMutation({
|
|
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload, { allowOverflow: true }),
|
|
onSuccess: handleCreateSuccess,
|
|
onError: (err: Error) => {
|
|
toast.error(err.message || 'Error al agregar miembro.')
|
|
},
|
|
})
|
|
|
|
const addToWaitlistMutation = useMutation({
|
|
mutationFn: (payload: CreateGroupWaitlistEntry) => addToGroupWaitlist(groupId, payload),
|
|
onMutate: async (payload: CreateGroupWaitlistEntry) => {
|
|
await queryClient.cancelQueries({ queryKey: ['group-waitlist', groupId] })
|
|
const previousWaitlist = queryClient.getQueryData<GroupWaitlistList>(['group-waitlist', groupId])
|
|
|
|
if (previousWaitlist) {
|
|
const optimisticEntry: GroupWaitlistEntryDto = {
|
|
id: `temp-${Date.now()}`,
|
|
groupId,
|
|
fullName: `${payload.firstName} ${payload.lastName ?? ''}`.trim(),
|
|
phone: payload.phone,
|
|
email: payload.email || null,
|
|
notes: payload.notes ?? null,
|
|
status: 'PENDING',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
queryClient.setQueryData<GroupWaitlistList>(['group-waitlist', groupId], {
|
|
data: [optimisticEntry, ...previousWaitlist.data],
|
|
pagination: {
|
|
...previousWaitlist.pagination,
|
|
total: previousWaitlist.pagination.total + 1,
|
|
},
|
|
})
|
|
}
|
|
|
|
return { previousWaitlist }
|
|
},
|
|
onSuccess: (entry) => {
|
|
toast.info(`${entry.fullName} fue agregado a la lista de espera del grupo.`)
|
|
invalidateGroupQueries()
|
|
closeAddAttendeeFlow()
|
|
},
|
|
onError: (err: Error, _payload, context) => {
|
|
if (context?.previousWaitlist) {
|
|
queryClient.setQueryData<GroupWaitlistList>(['group-waitlist', groupId], context.previousWaitlist)
|
|
}
|
|
if (err instanceof ApiError && err.problem?.code === 'already_waitlisted') {
|
|
toast.info('Este número ya está en la lista de espera del grupo.')
|
|
} else {
|
|
toast.error(err.message || 'Error al agregar a la lista de espera.')
|
|
}
|
|
setPendingCapacityPayload(null)
|
|
setIsCapacityModalOpen(false)
|
|
},
|
|
})
|
|
|
|
const bulkImportMutation = useMutation({
|
|
mutationFn: (rows: BulkAttendeeRow[]) => bulkCreateAttendees(groupId, { attendees: rows }),
|
|
onSuccess: (res) => {
|
|
toast.success(res.message)
|
|
setParsedContacts([])
|
|
setFileName(null)
|
|
void queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] })
|
|
setIsAddAttendeeModalOpen(false)
|
|
},
|
|
onError: (err: Error) => {
|
|
toast.error(err.message || 'Error al importar miembros.')
|
|
},
|
|
})
|
|
|
|
const removeAttendeeMutation = useMutation({
|
|
mutationFn: (payload: { attendeeId: string; promoteFromWaitlist: boolean }) =>
|
|
removeGroupAttendee(groupId, payload.attendeeId, { promoteFromWaitlist: payload.promoteFromWaitlist }),
|
|
onSuccess: (result, payload) => {
|
|
const removedName = attendees.find((a) => a.id === payload.attendeeId)?.fullName ?? 'El miembro'
|
|
if (result.promoted) {
|
|
toast.success(
|
|
`${removedName} fue quitado del grupo y ${result.promoted.fullName} pasó de la lista de espera al grupo.`,
|
|
)
|
|
} else {
|
|
toast.success(`${removedName} fue quitado del grupo.`)
|
|
}
|
|
invalidateGroupQueries()
|
|
setSelectedAttendee(null)
|
|
setAttendeeToRemove(null)
|
|
},
|
|
onError: (err: Error) => {
|
|
if (err instanceof ApiError && err.problem?.code === 'attendee_has_payments') {
|
|
toast.error(err.problem.detail ?? 'Este miembro tiene cobros asociados.')
|
|
} else {
|
|
toast.error(err.message || 'Error al quitar al miembro del grupo.')
|
|
}
|
|
setAttendeeToRemove(null)
|
|
},
|
|
})
|
|
|
|
const promoteWaitlistMutation = useMutation({
|
|
mutationFn: (entryId: string) => promoteGroupWaitlistEntry(groupId, entryId),
|
|
onSuccess: (result) => {
|
|
toast.success(`${result.attendee.fullName} pasó de la lista de espera al grupo.`)
|
|
invalidateGroupQueries()
|
|
},
|
|
onError: (err: Error) => {
|
|
if (err instanceof ApiError && err.problem?.code === 'group_capacity_reached') {
|
|
toast.error('El grupo alcanzó su cupo. Quita un miembro o aumenta el cupo primero.')
|
|
} else {
|
|
toast.error(err.message || 'No se pudo pasar al miembro al grupo.')
|
|
}
|
|
},
|
|
})
|
|
|
|
const removeWaitlistEntryMutation = useMutation({
|
|
mutationFn: (entry: GroupWaitlistEntryDto) => removeGroupWaitlistEntry(groupId, entry.id),
|
|
onSuccess: (_result, entry) => {
|
|
toast.success(`${entry.fullName} fue quitado de la lista de espera.`)
|
|
invalidateGroupQueries()
|
|
},
|
|
onError: (err: Error) => {
|
|
toast.error(err.message || 'No se pudo quitar de la lista de espera.')
|
|
},
|
|
})
|
|
|
|
const inviteUrl = inviteTokenQuery.data?.inviteUrl
|
|
const whatsappMessage =
|
|
group && inviteUrl
|
|
? `¡Hola! 👋 Te invito a unirte al grupo *${group.name}*.\n\nCompleta tus datos de inscripción en el siguiente enlace:\n${inviteUrl}`
|
|
: ''
|
|
const whatsappShareUrl = whatsappMessage
|
|
? `https://wa.me/?text=${encodeURIComponent(whatsappMessage)}`
|
|
: ''
|
|
|
|
const copyInviteLink = async () => {
|
|
if (!inviteUrl) return
|
|
try {
|
|
await navigator.clipboard.writeText(inviteUrl)
|
|
toast.success('¡Enlace de invitación copiado al portapapeles!')
|
|
} catch {
|
|
toast.error('No se pudo copiar automáticamente. Copia el texto manualmente.')
|
|
}
|
|
}
|
|
|
|
const copyWhatsappMessage = async () => {
|
|
if (!whatsappMessage) return
|
|
try {
|
|
await navigator.clipboard.writeText(whatsappMessage)
|
|
toast.success('¡Mensaje copiado al portapapeles!')
|
|
} catch {
|
|
toast.error('No se pudo copiar automáticamente. Copia el texto manualmente.')
|
|
}
|
|
}
|
|
|
|
const openWhatsapp = async () => {
|
|
if (!whatsappShareUrl) return
|
|
try {
|
|
if (whatsappMessage) {
|
|
await navigator.clipboard.writeText(whatsappMessage)
|
|
toast.success('¡Abriendo WhatsApp! Mensaje copiado al portapapeles.')
|
|
}
|
|
} catch {
|
|
// Si el portapapeles falla, igual abrimos WhatsApp con el mensaje.
|
|
}
|
|
window.open(whatsappShareUrl, '_blank', 'noopener,noreferrer')
|
|
}
|
|
|
|
const copyAbsenceNotifyUrl = (url: string) => {
|
|
void navigator.clipboard.writeText(url).then(() => {
|
|
toast.success('Link copiado al portapapeles.')
|
|
})
|
|
}
|
|
|
|
const validRows: BulkAttendeeRow[] = useMemo(
|
|
() =>
|
|
parsedContacts
|
|
.filter((contact) => contact.isValid)
|
|
.map((contact) => ({
|
|
firstName: contact.firstName,
|
|
lastName: contact.lastName,
|
|
fullName: contact.fullName,
|
|
phone: contact.phone,
|
|
email: contact.email || undefined,
|
|
notes: contact.notes || undefined,
|
|
})),
|
|
[parsedContacts],
|
|
)
|
|
|
|
const runBulkImport = () => {
|
|
if (validRows.length === 0) {
|
|
toast.error('No hay miembros válidos para importar.')
|
|
return
|
|
}
|
|
bulkImportMutation.mutate(validRows)
|
|
}
|
|
|
|
const requestBulkImport = () => {
|
|
if (validRows.length === 0) {
|
|
toast.error('No hay miembros válidos para importar.')
|
|
return
|
|
}
|
|
if (group?.capacity != null && attendeesTotal + validRows.length > group.capacity) {
|
|
setIsBulkCapacityModalOpen(true)
|
|
return
|
|
}
|
|
runBulkImport()
|
|
}
|
|
|
|
const value: GroupDetailContextValue = {
|
|
groupId,
|
|
group,
|
|
groupQueryPending: groupQuery.isPending,
|
|
riskLevel: riskOverviewQuery.data?.items.find((item) => item.groupId === groupId)?.riskLevel ?? 'NONE',
|
|
attendees,
|
|
attendeesTotal,
|
|
attendeesPending: attendeesQuery.isPending,
|
|
waitlistEntries,
|
|
waitlistTotal,
|
|
waitlistPending: waitlistQuery.isPending,
|
|
firstWaitlistEntry,
|
|
hasFreeCapacity,
|
|
inviteUrl,
|
|
invitePending: inviteTokenQuery.isPending,
|
|
whatsappMessage,
|
|
searchFilter,
|
|
setSearchFilter,
|
|
listSection,
|
|
setListSection,
|
|
activeTab,
|
|
setActiveTab,
|
|
isInviteModalOpen,
|
|
openInviteModal: () => setIsInviteModalOpen(true),
|
|
closeInviteModal: () => setIsInviteModalOpen(false),
|
|
isAddAttendeeModalOpen,
|
|
openAddAttendeeModal: () => setIsAddAttendeeModalOpen(true),
|
|
closeAddAttendeeModal: () => setIsAddAttendeeModalOpen(false),
|
|
selectedAttendee,
|
|
setSelectedAttendee,
|
|
attendeeToRemove,
|
|
requestRemoveAttendee: (attendee) => setAttendeeToRemove(attendee),
|
|
cancelRemoveAttendee: () => setAttendeeToRemove(null),
|
|
confirmRemoveAttendee: (promoteFromWaitlist) => {
|
|
if (!attendeeToRemove) return
|
|
removeAttendeeMutation.mutate({
|
|
attendeeId: attendeeToRemove.id,
|
|
promoteFromWaitlist,
|
|
})
|
|
},
|
|
selectedWaitlistEntry,
|
|
setSelectedWaitlistEntry,
|
|
isCapacityModalOpen,
|
|
capacityPayload: pendingCapacityPayload,
|
|
closeCapacityModal: () => {
|
|
setPendingCapacityPayload(null)
|
|
setIsCapacityModalOpen(false)
|
|
},
|
|
isBulkCapacityModalOpen,
|
|
closeBulkCapacityModal: () => setIsBulkCapacityModalOpen(false),
|
|
parsedContacts,
|
|
setParsedContacts,
|
|
removeParsedContact: (id) => setParsedContacts((prev) => prev.filter((c) => c.id !== id)),
|
|
fileName,
|
|
setFileName,
|
|
isParsingFile,
|
|
setIsParsingFile,
|
|
isDragging,
|
|
setIsDragging,
|
|
validRowsCount: validRows.length,
|
|
invalidRowsCount: parsedContacts.length - validRows.length,
|
|
isSubmittingAttendee: createAttendeeMutation.isPending,
|
|
isSubmittingForcedAttendee: createAttendeeForceMutation.isPending,
|
|
isSubmittingWaitlistEntry: addToWaitlistMutation.isPending,
|
|
isSubmittingBulkImport: bulkImportMutation.isPending,
|
|
isSubmittingRemoval: removeAttendeeMutation.isPending,
|
|
isSubmittingPromotion: promoteWaitlistMutation.isPending,
|
|
isSubmittingWaitlistRemoval: removeWaitlistEntryMutation.isPending,
|
|
isRegeneratingInvite: regenerateTokenMutation.isPending,
|
|
submitQuickAttendee: (payload) => createAttendeeMutation.mutate(payload),
|
|
submitForcedAttendee: () => {
|
|
if (pendingCapacityPayload) {
|
|
createAttendeeForceMutation.mutate({ ...pendingCapacityPayload })
|
|
}
|
|
},
|
|
submitWaitlistEntry: () => {
|
|
if (pendingCapacityPayload) {
|
|
addToWaitlistMutation.mutate({ ...pendingCapacityPayload })
|
|
}
|
|
},
|
|
requestBulkImport,
|
|
confirmBulkImportOverCapacity: () => {
|
|
setIsBulkCapacityModalOpen(false)
|
|
runBulkImport()
|
|
},
|
|
promoteEntry: (entry) => {
|
|
setSelectedWaitlistEntry(null)
|
|
promoteWaitlistMutation.mutate(entry.id)
|
|
},
|
|
removeEntry: (entry) => {
|
|
setSelectedWaitlistEntry(null)
|
|
removeWaitlistEntryMutation.mutate(entry)
|
|
},
|
|
regenerateInvite: () => regenerateTokenMutation.mutate(),
|
|
copyInviteLink,
|
|
copyWhatsappMessage,
|
|
openWhatsapp,
|
|
copyAbsenceNotifyUrl,
|
|
}
|
|
|
|
return <GroupDetailContext.Provider value={value}>{children}</GroupDetailContext.Provider>
|
|
}
|
|
|
|
export function useGroupDetail() {
|
|
const context = useContext(GroupDetailContext)
|
|
if (!context) {
|
|
throw new Error('useGroupDetail debe usarse dentro de <GroupDetailProvider>')
|
|
}
|
|
return context
|
|
} |