Added routes

This commit is contained in:
Jose Selesan
2026-09-25 10:05:56 -03:00
parent 336e1af523
commit 94c353f4f2
100 changed files with 6126 additions and 183 deletions

View File

@@ -0,0 +1,537 @@
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
}