feat: implement groups risk overview feature with analytics and routes

This commit is contained in:
Jose Selesan
2026-09-24 20:45:11 -03:00
parent 2e184845e1
commit efde1aaf33
11 changed files with 382 additions and 16 deletions

View File

@@ -19,6 +19,8 @@ import type {
GroupDto,
GroupInviteInfoDto,
GroupList,
GroupRiskSummary,
GroupsRiskOverview,
GroupWaitlistEntryDto,
GroupWaitlistList,
HomeSummaryDto,
@@ -223,6 +225,8 @@ export const getGroupAnalytics = (groupId: string) =>
export const getStudentsAtRisk = (groupId: string) =>
apiFetch<StudentsAtRisk>(`/api/v1/groups/${groupId}/students-at-risk`)
export const getGroupsRiskOverview = () => apiFetch<GroupsRiskOverview>('/api/v1/groups-risk/overview')
export const getAttendeeHistory = (groupId: string, attendeeId: string) =>
apiFetch<AttendeeHistory>(`/api/v1/groups/${groupId}/attendees/${attendeeId}/history`)

View File

@@ -127,6 +127,7 @@ export function AnalyticsView() {
void queryClient.invalidateQueries({ queryKey: ['group', groupId] });
void queryClient.invalidateQueries({ queryKey: ['classes-today'] });
void queryClient.invalidateQueries({ queryKey: ['home-summary'] });
void queryClient.invalidateQueries({ queryKey: ['groups-risk-overview'] });
},
onError: (err: Error) => {
toast.error(err.message || 'No pudimos actualizar el estado del alumno.');

View File

@@ -44,6 +44,7 @@ import {
createAttendee,
getGroup,
getGroupAttendees,
getGroupsRiskOverview,
getGroupWaitlist,
getInviteToken,
promoteGroupWaitlistEntry,
@@ -106,6 +107,12 @@ export function GroupDetailView() {
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),
@@ -487,6 +494,9 @@ export function GroupDetailView() {
);
}
const riskLevel =
riskOverviewQuery.data?.items.find((item) => item.groupId === groupId)?.riskLevel ?? 'NONE';
return (
<section className="space-y-6 animate-fade-in">
{/* Navigation & Header */}
@@ -504,17 +514,28 @@ export function GroupDetailView() {
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-primary">{group.name}</h1>
<Badge variant="success">Activo</Badge>
{riskLevel === 'HIGH' || riskLevel === 'MEDIUM' ? (
<Badge variant={riskLevel === 'HIGH' ? 'danger' : 'warning'} className="gap-1.5">
<span
className={`size-2 rounded-full ${
riskLevel === 'HIGH' ? 'bg-danger' : 'bg-warning'
}`}
aria-hidden="true"
/>
{riskLevel === 'HIGH' ? 'Riesgo alto' : 'Riesgo moderado'}
</Badge>
) : null}
</div>
{group.description ? (
<p className="mt-1 text-sm text-foreground/70">{group.description}</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2.5">
<div className="grid w-full grid-cols-3 gap-2 sm:flex sm:w-auto sm:flex-wrap sm:items-center sm:gap-2.5">
<Button
variant="outline"
onClick={() => void navigate({ to: '/groups/$groupId/analytics', params: { groupId } })}
className="gap-2 border-border"
className="w-full justify-center gap-1.5 border-border sm:w-auto"
>
<BarChart3 className="size-4 text-accent" />
<span>Estadísticas</span>
@@ -522,18 +543,22 @@ export function GroupDetailView() {
<Button
variant="outline"
onClick={() => setIsInviteModalOpen(true)}
className="gap-2 border-border"
className="w-full justify-center gap-1.5 border-border sm:w-auto"
>
<Share2 className="size-4 text-accent" />
<span>Compartir Link</span>
<span>
Compartir <span className="hidden sm:inline">Link</span>
</span>
</Button>
<Button
variant="primary"
onClick={() => setIsAddAttendeeModalOpen(true)}
className="gap-2"
className="w-full justify-center gap-1.5 sm:w-auto"
>
<UserPlus className="size-4" />
<span>Agregar Miembros</span>
<span>
Agregar <span className="hidden sm:inline">Miembros</span>
</span>
</Button>
</div>
</div>

View File

@@ -1,14 +1,27 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { CalendarClock, Loader2, Plus, Users } from 'lucide-react'
import type { GroupDto } from '@gruperly/shared'
import type { GroupDto, GroupRiskLevel } from '@gruperly/shared'
import { Badge, Button } from '../components/ui'
import { getGroups } from '../lib/api'
import { getGroups, getGroupsRiskOverview } from '../lib/api'
import { BILLING_LABELS, formatPrice, formatSchedule } from '../lib/format'
import { cn } from '../lib/utils'
function GroupCard({ group }: { group: GroupDto }) {
const RISK_LABELS: Record<Exclude<GroupRiskLevel, 'NONE'>, string> = {
HIGH: 'Riesgo alto',
MEDIUM: 'Riesgo moderado',
}
function GroupCard({
group,
riskLevel,
}: {
group: GroupDto
riskLevel: GroupRiskLevel
}) {
const navigate = useNavigate()
const hasSchedule = (group.days?.length ?? 0) > 0
const hasRisk = riskLevel === 'HIGH' || riskLevel === 'MEDIUM'
return (
<article className="rounded-xl border border-border bg-surface p-5 transition-all hover:border-accent/40">
@@ -19,7 +32,21 @@ function GroupCard({ group }: { group: GroupDto }) {
<p className="mt-0.5 truncate text-sm text-foreground/60">{group.description}</p>
) : null}
</div>
<Badge variant="success">Activo</Badge>
<div className="flex flex-col items-end gap-1.5">
{hasRisk ? (
<Badge variant={riskLevel === 'HIGH' ? 'danger' : 'warning'} className="gap-1.5">
<span
className={cn(
'size-2 rounded-full',
riskLevel === 'HIGH' ? 'bg-danger' : 'bg-warning',
)}
aria-hidden="true"
/>
{RISK_LABELS[riskLevel]}
</Badge>
) : null}
<Badge variant="success">Activo</Badge>
</div>
</div>
{hasSchedule ? (
@@ -62,6 +89,14 @@ export function GroupsView() {
queryKey: ['groups'],
queryFn: getGroups,
})
const riskQuery = useQuery({
queryKey: ['groups-risk-overview'],
queryFn: getGroupsRiskOverview,
})
const riskByGroup = new Map(
(riskQuery.data?.items ?? []).map((item) => [item.groupId, item.riskLevel]),
)
return (
<section>
@@ -111,7 +146,11 @@ export function GroupsView() {
{groupsQuery.isSuccess && groupsQuery.data.data.length > 0 ? (
<div className="mt-6 grid gap-4">
{groupsQuery.data.data.map((group) => (
<GroupCard key={group.id} group={group} />
<GroupCard
key={group.id}
group={group}
riskLevel={riskByGroup.get(group.id) ?? 'NONE'}
/>
))}
</div>
) : null}