feat: implement groups risk overview feature with analytics and routes
This commit is contained in:
@@ -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`)
|
||||
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user