diff --git a/src/app/public/events/[eventId]/_components/past-event-detail.tsx b/src/app/public/events/[eventId]/_components/past-event-detail.tsx index bfb50bc..db76ad7 100644 --- a/src/app/public/events/[eventId]/_components/past-event-detail.tsx +++ b/src/app/public/events/[eventId]/_components/past-event-detail.tsx @@ -806,6 +806,35 @@ export const PastEventDetail = ({ eventId }: EventDetailProps) => { const isProjectsLoading = projectsQuery.isLoading || projectsQuery.isFetching; const isProjectsError = projectsQuery.isError; + const approvedProjects = useMemo( + () => projects.filter((project) => String(project.state ?? '').toUpperCase() === 'APPROVED'), + [projects], + ); + + const approvedProjectLookup = useMemo(() => { + const byId = new Set(); + const byCode = new Set(); + const byName = new Set(); + + approvedProjects.forEach((project) => { + if (project.id !== undefined && project.id !== null) { + byId.add(String(project.id)); + } + + const projectCode = normalizeText(String(project.projectCode ?? project.eventNumber ?? '')); + if (projectCode) { + byCode.add(projectCode); + } + + const projectName = normalizeText(String(project.name ?? '')); + if (projectName) { + byName.add(projectName); + } + }); + + return { byId, byCode, byName }; + }, [approvedProjects]); + const rankingConfigQuery = useRankingConfig({ eventId: event?.id, queryConfig: { enabled: Boolean(event?.id) } }); const configVisibleFlag = (() => { @@ -853,7 +882,7 @@ export const PastEventDetail = ({ eventId }: EventDetailProps) => { }); // Finally, add categories from projects - projects.forEach((project) => { + approvedProjects.forEach((project) => { const id = Number(project?.categoryId); if (Number.isFinite(id) && !categoryMap.has(id)) { categoryMap.set(id, `Categoría ${id}`); @@ -861,19 +890,19 @@ export const PastEventDetail = ({ eventId }: EventDetailProps) => { }); return Array.from(categoryMap, ([id, name]) => ({ id, name })); - }, [event?.categories, event?.awards, projects]); + }, [event?.categories, event?.awards, approvedProjects]); // Filter out categories that have no projects to avoid showing empty category pills const visibleCategories = useMemo(() => { - if (!projects || projects.length === 0) return []; + if (!approvedProjects || approvedProjects.length === 0) return []; const projectCategoryIds = new Set(); - for (const p of projects) { + for (const p of approvedProjects) { const cid = Number(p?.categoryId); if (Number.isFinite(cid) && cid > 0) projectCategoryIds.add(cid); } return categories.filter((c) => projectCategoryIds.has(c.id)); - }, [categories, projects]); + }, [categories, approvedProjects]); @@ -903,17 +932,40 @@ export const PastEventDetail = ({ eventId }: EventDetailProps) => { const rankingQuery = usePublicEventRankings({ eventId: event?.id, categoryId: selectedCategoryId, + state: 'APPROVED', queryConfig: { enabled: Boolean(event?.id) && Boolean(isPublicRankingEnabled) }, }); const rankingByCategory = useMemo(() => { // Prefer server-provided public rankings when available - const serverRows = rankingQuery.data?.data ?? []; + const serverRows = (rankingQuery.data?.data ?? []).filter((row) => { + const projectId = row.projectId !== undefined && row.projectId !== null ? String(row.projectId) : undefined; + if (projectId && approvedProjectLookup.byId.has(projectId)) { + return true; + } + + const projectCode = normalizeText(String(row.projectCode ?? '')); + if (projectCode && approvedProjectLookup.byCode.has(projectCode)) { + return true; + } + + const projectName = normalizeText(String(row.projectName ?? '')); + if (projectName && approvedProjectLookup.byName.has(projectName)) { + return true; + } + + return false; + }); if (Array.isArray(serverRows) && serverRows.length > 0) { const map = new Map(); for (const row of serverRows) { - const catId = Number(row.category); - const normalizedCategoryId = Number.isFinite(catId) ? catId : 0; + const normalizedCategoryId = Number.isFinite(Number(row.categoryId)) + ? Number(row.categoryId) + : (() => { + const categoryLabel = normalizeText(String(row.category ?? '')); + const matchedCategory = categories.find((category) => normalizeText(category.name) === categoryLabel); + return matchedCategory?.id ?? selectedCategoryId ?? 0; + })(); const list = map.get(normalizedCategoryId) ?? []; list.push({ position: row.position, @@ -941,19 +993,31 @@ export const PastEventDetail = ({ eventId }: EventDetailProps) => { // Fallback to awards-based ranking when server data not available if (!event?.awards?.length) return new Map(); return toAwardRanking(event.awards, projects, categories[0]?.id); - }, [rankingQuery.data, event?.awards, categories, projects]); + }, [approvedProjectLookup, rankingQuery.data, event?.awards, categories, approvedProjects]); + + const rankingCategories = useMemo(() => { + const ids = Array.from(rankingByCategory.keys()); + if (ids.length === 0) return []; + + return ids.map((id) => ({ + id, + name: categories.find((category) => category.id === id)?.name ?? `Categoría ${id}`, + })); + }, [rankingByCategory, categories]); + + const displayCategories = visibleCategories.length > 0 ? visibleCategories : rankingCategories; const isRankingLoading = rankingQuery.isLoading || rankingConfigQuery.isLoading; const shouldRenderWinners = useMemo(() => { if (!isPublicRankingEnabled) return false; - if (!visibleCategories || visibleCategories.length === 0) return false; + if (!displayCategories || displayCategories.length === 0) return false; // Only render winners if at least one visible category has ranking entries - for (const c of visibleCategories) { + for (const c of displayCategories) { if ((rankingByCategory.get(c.id)?.length ?? 0) > 0) return true; } return false; - }, [isPublicRankingEnabled, visibleCategories, rankingByCategory]); + }, [isPublicRankingEnabled, displayCategories, rankingByCategory]); const eventTypeLabel = useMemo( () => normalizeEventType(event?.eventType), @@ -1129,7 +1193,7 @@ export const PastEventDetail = ({ eventId }: EventDetailProps) => { <> { - if (appliedLimit === 0) return []; + if (appliedLimit === 0) return allWinners; return allWinners.filter((w) => w.position >= 1 && w.position <= appliedLimit); }, [allWinners, appliedLimit]); diff --git a/src/features/events/components/past-event-dashboard.tsx b/src/features/events/components/past-event-dashboard.tsx index 87b6c55..5203033 100644 --- a/src/features/events/components/past-event-dashboard.tsx +++ b/src/features/events/components/past-event-dashboard.tsx @@ -101,9 +101,39 @@ export const PastEventDashboard = ({ event, onBack }: Props) => { const rankingQuery = useEventRankings({ eventId: event.id, categoryId: selectedCategoryId, + state: 'APPROVED', queryConfig: { enabled: canFetchRanking }, }); + const approvedProjects = useMemo( + () => projects.filter((project) => String(project.state ?? '').toUpperCase() === 'APPROVED'), + [projects], + ); + + const approvedProjectLookup = useMemo(() => { + const byId = new Set(); + const byCode = new Set(); + const byName = new Set(); + + approvedProjects.forEach((project) => { + if (project.id !== undefined && project.id !== null) { + byId.add(String(project.id)); + } + + const projectCode = normalizeText(String(project.projectCode ?? project.eventNumber ?? '')); + if (projectCode) { + byCode.add(projectCode); + } + + const projectName = normalizeText(String(project.name ?? '')); + if (projectName) { + byName.add(projectName); + } + }); + + return { byId, byCode, byName }; + }, [approvedProjects]); + const chartData = useMemo(() => { const participantsByCategory = new Map>(); const projectsByCategory = new Map(); @@ -303,6 +333,7 @@ export const PastEventDashboard = ({ event, onBack }: Props) => { const { blob, fileName } = await downloadEventRankingsReport({ eventId: event.id, categoryId: selectedCategoryId, + state: 'APPROVED', }); const url = URL.createObjectURL(blob); @@ -340,9 +371,47 @@ export const PastEventDashboard = ({ event, onBack }: Props) => { visibleScore: existingRankingConfig?.visibleScore ?? fetchedRankingConfig?.visibleScore ?? true, }); }, [event, rankingConfigQuery.data]); - const rankingEntries = useMemo(() => { + const rankingEntries = useMemo(() => { const term = normalizeText(rankingSearch); - return rankingRows + const approvedRankingRows = rankingRows + .map((entry) => { + const projectId = entry.projectId !== undefined && entry.projectId !== null ? String(entry.projectId) : undefined; + const matchedProject = approvedProjects.find((project) => { + if (projectId && String(project.id) === projectId) return true; + + const projectCode = normalizeText(String(project.projectCode ?? project.eventNumber ?? '')); + if (projectCode && projectCode === normalizeText(String(entry.projectCode ?? ''))) return true; + + const projectName = normalizeText(String(project.name ?? '')); + return projectName && projectName === normalizeText(String(entry.projectName ?? '')); + }); + + const projectStats = matchedProject?.id !== undefined ? projectStatsById.get(String(matchedProject.id)) : undefined; + const fallbackEvaluationCount = typeof projectStats?.evaluationCount === 'number' ? projectStats.evaluationCount : undefined; + + return { + ...entry, + evaluationCount: entry.evaluationCount > 0 ? entry.evaluationCount : (fallbackEvaluationCount ?? entry.evaluationCount ?? 0), + }; + }) + .filter((entry) => { + const projectId = entry.projectId !== undefined && entry.projectId !== null ? String(entry.projectId) : undefined; + if (projectId && approvedProjectLookup.byId.has(projectId)) { + return true; + } + + const projectCode = normalizeText(String(entry.projectCode ?? '')); + if (projectCode && approvedProjectLookup.byCode.has(projectCode)) { + return true; + } + + const projectName = normalizeText(String(entry.projectName ?? '')); + if (projectName && approvedProjectLookup.byName.has(projectName)) { + return true; + } + + return false; + }) .filter((entry) => { if (!term) return true; @@ -355,9 +424,11 @@ export const PastEventDashboard = ({ event, onBack }: Props) => { ].join(' ')).includes(term); }) .sort((a, b) => a.position - b.position); - }, [rankingRows, rankingSearch]); - const visibleRankingEntries = useMemo(() => { + return approvedRankingRows; + }, [approvedProjects, approvedProjectLookup, projectStatsById, rankingRows, rankingSearch]); + + const visibleRankingEntries = useMemo(() => { if (!visiblePositions || visiblePositions <= 0) { return rankingEntries; } diff --git a/src/features/monitoring/api/get-event-rankings.ts b/src/features/monitoring/api/get-event-rankings.ts index 3623316..ee1d142 100644 --- a/src/features/monitoring/api/get-event-rankings.ts +++ b/src/features/monitoring/api/get-event-rankings.ts @@ -11,6 +11,7 @@ export type EventRankingRow = { projectCode?: string; projectName: string; category?: string; + categoryId?: number; evaluationCount: number; averageGrade?: number; participants: string[]; @@ -19,6 +20,7 @@ export type EventRankingRow = { export type GetEventRankingsParams = { eventId?: number; categoryId?: number; + state?: string; }; export type GetEventRankingsResponse = { @@ -128,6 +130,9 @@ const normalizeRankingRow = (item: unknown, index: number): EventRankingRow => { asString(record.category ?? record.categoryName ?? record.course ?? record.courseName) ?? asString(nestedCategory.name ?? nestedCategory.code ?? nestedCategory.description) ?? undefined, + categoryId: + asNumber(record.categoryId ?? record.category_id ?? project.categoryId ?? project.courseId) ?? + undefined, evaluationCount: asNumber(record.evaluationCount ?? record.evaluationsCount ?? record.count ?? record.totalEvaluations) ?? 0, @@ -137,7 +142,7 @@ const normalizeRankingRow = (item: unknown, index: number): EventRankingRow => { }; }; -export const getEventRankings = async ({ eventId, categoryId }: GetEventRankingsParams): Promise => { +export const getEventRankings = async ({ eventId, categoryId, state }: GetEventRankingsParams): Promise => { if (!eventId) { return { data: [] }; } @@ -147,6 +152,7 @@ export const getEventRankings = async ({ eventId, categoryId }: GetEventRankings format: 'json', categoryId, category_id: categoryId, + state, }, }); @@ -160,27 +166,29 @@ export const getEventRankings = async ({ eventId, categoryId }: GetEventRankings export const getEventRankingsQueryOptions = ({ eventId, categoryId, + state, }: GetEventRankingsParams = {}) => { return queryOptions({ - queryKey: ['event-rankings', { eventId, categoryId }], - queryFn: () => getEventRankings({ eventId, categoryId }), + queryKey: ['event-rankings', { eventId, categoryId, state }], + queryFn: () => getEventRankings({ eventId, categoryId, state }), }); }; type UseEventRankingsOptions = { eventId?: number; categoryId?: number; + state?: string; queryConfig?: QueryConfig; }; -export const useEventRankings = ({ eventId, categoryId, queryConfig }: UseEventRankingsOptions = {}) => { +export const useEventRankings = ({ eventId, categoryId, state, queryConfig }: UseEventRankingsOptions = {}) => { return useQuery({ - ...getEventRankingsQueryOptions({ eventId, categoryId }), + ...getEventRankingsQueryOptions({ eventId, categoryId, state }), ...queryConfig, }); }; -export const getPublicEventRankings = async ({ eventId, categoryId }: GetEventRankingsParams): Promise => { +export const getPublicEventRankings = async ({ eventId, categoryId, state }: GetEventRankingsParams): Promise => { if (!eventId) { return { data: [] }; } @@ -190,6 +198,7 @@ export const getPublicEventRankings = async ({ eventId, categoryId }: GetEventRa format: 'json', categoryId, category_id: categoryId, + state, }, }); @@ -203,22 +212,24 @@ export const getPublicEventRankings = async ({ eventId, categoryId }: GetEventRa export const getPublicEventRankingsQueryOptions = ({ eventId, categoryId, + state, }: GetEventRankingsParams = {}) => { return queryOptions({ - queryKey: ['public-event-rankings', { eventId, categoryId }], - queryFn: () => getPublicEventRankings({ eventId, categoryId }), + queryKey: ['public-event-rankings', { eventId, categoryId, state }], + queryFn: () => getPublicEventRankings({ eventId, categoryId, state }), }); }; type UsePublicEventRankingsOptions = { eventId?: number; categoryId?: number; + state?: string; queryConfig?: QueryConfig; }; -export const usePublicEventRankings = ({ eventId, categoryId, queryConfig }: UsePublicEventRankingsOptions = {}) => { +export const usePublicEventRankings = ({ eventId, categoryId, state, queryConfig }: UsePublicEventRankingsOptions = {}) => { return useQuery({ - ...getPublicEventRankingsQueryOptions({ eventId, categoryId }), + ...getPublicEventRankingsQueryOptions({ eventId, categoryId, state }), ...queryConfig, }); }; @@ -239,7 +250,7 @@ const parseContentDispositionFileName = (headerValue: string | null) => { return basicMatch?.[1] ?? null; }; -export const downloadEventRankingsReport = async ({ eventId, categoryId }: GetEventRankingsParams) => { +export const downloadEventRankingsReport = async ({ eventId, categoryId, state }: GetEventRankingsParams) => { if (!eventId) { throw new Error('Se requiere un evento para descargar el ranking'); } @@ -251,6 +262,10 @@ export const downloadEventRankingsReport = async ({ eventId, categoryId }: GetEv params.set('category_id', String(categoryId)); } + if (state) { + params.set('state', state); + } + const response = await fetch(`/api/events/${eventId}/rankings?${params.toString()}`, { method: 'GET', credentials: 'include', diff --git a/src/features/monitoring/api/get-ranking-config.ts b/src/features/monitoring/api/get-ranking-config.ts index 0598991..78cdb1c 100644 --- a/src/features/monitoring/api/get-ranking-config.ts +++ b/src/features/monitoring/api/get-ranking-config.ts @@ -5,6 +5,7 @@ import { QueryConfig } from '@/lib/react-query'; export type RankingConfigRecord = { id?: number; + rankingConfigId?: number; eventId?: number; positions?: number; visiblePositions?: number; @@ -40,7 +41,12 @@ const normalizeRankingConfig = (response: GetRankingConfigResponse | null | unde } const record = candidate as RankingConfigRecord; - const id = typeof record.id === 'number' ? record.id : undefined; + const id = + typeof record.id === 'number' + ? record.id + : typeof record.rankingConfigId === 'number' + ? record.rankingConfigId + : undefined; const positions = typeof record.positions === 'number' ? record.positions : record.visiblePositions; const visibleInLanding = typeof (record as Record).visibleInLanding === 'boolean' @@ -57,6 +63,7 @@ const normalizeRankingConfig = (response: GetRankingConfigResponse | null | unde return { id, + rankingConfigId: id, eventId: typeof record.eventId === 'number' ? record.eventId : undefined, positions, visiblePositions: positions, diff --git a/src/features/monitoring/api/upsert-ranking-config.ts b/src/features/monitoring/api/upsert-ranking-config.ts index 227ba4e..2699e77 100644 --- a/src/features/monitoring/api/upsert-ranking-config.ts +++ b/src/features/monitoring/api/upsert-ranking-config.ts @@ -13,6 +13,7 @@ export type UpsertRankingConfigInput = { export type RankingConfigResponse = { id?: number; + rankingConfigId?: number; eventId?: number; positions?: number; visiblePositions?: number; @@ -22,6 +23,7 @@ export type RankingConfigResponse = { gradeVisible?: boolean; data?: { id?: number; + rankingConfigId?: number; eventId?: number; positions?: number; visiblePositions?: number; @@ -69,7 +71,7 @@ export const upsertRankingConfig = async ({ // Try to get existing config for this event and patch it try { const existing = await getRankingConfig(eventId); - const existingId = existing?.data?.id ?? existing?.data?.eventId ?? undefined; + const existingId = existing?.data?.id ?? existing?.data?.rankingConfigId ?? undefined; if (typeof existingId === 'number') { return api.patch(`/events/update-ranking-config/${existingId}`, payload); } @@ -110,10 +112,8 @@ export const useUpsertRankingConfig = ({ mutationConfig }: UseUpsertRankingConfi await Promise.all([ queryClient.invalidateQueries({ queryKey: ['ranking-config', eventId] }), queryClient.invalidateQueries({ queryKey: ['events', eventId] }), - queryClient.invalidateQueries({ queryKey: ['event-rankings'] }), queryClient.refetchQueries({ queryKey: ['ranking-config', eventId] }), queryClient.refetchQueries({ queryKey: ['events', eventId] }), - queryClient.refetchQueries({ queryKey: ['event-rankings'] }), ]); }