feat: implement groups risk overview feature with analytics and routes
This commit is contained in:
@@ -10,7 +10,10 @@ import { requestLoggerMiddleware } from '@/http/request-logger';
|
|||||||
import { corsMiddleware, securityHeadersMiddleware } from '@/http/security-headers';
|
import { corsMiddleware, securityHeadersMiddleware } from '@/http/security-headers';
|
||||||
import { sessionAuthMiddleware } from '@/http/session-auth';
|
import { sessionAuthMiddleware } from '@/http/session-auth';
|
||||||
import { logger } from '@/logger';
|
import { logger } from '@/logger';
|
||||||
import { studentStatusRoutes } from './modules/analytics';
|
import {
|
||||||
|
groupsRiskOverviewRoute,
|
||||||
|
studentStatusRoutes,
|
||||||
|
} from './modules/analytics';
|
||||||
import { attendeesRoutes } from './modules/attendees';
|
import { attendeesRoutes } from './modules/attendees';
|
||||||
import { authRoutes } from './modules/auth';
|
import { authRoutes } from './modules/auth';
|
||||||
import { classesRoutes } from './modules/classes';
|
import { classesRoutes } from './modules/classes';
|
||||||
@@ -46,6 +49,7 @@ api.route('/payments', paymentsRoutes);
|
|||||||
api.route('/waitlist', waitlistRoutes);
|
api.route('/waitlist', waitlistRoutes);
|
||||||
api.route('/classes', classesRoutes);
|
api.route('/classes', classesRoutes);
|
||||||
api.route('/students', studentStatusRoutes);
|
api.route('/students', studentStatusRoutes);
|
||||||
|
api.route('/groups-risk', groupsRiskOverviewRoute);
|
||||||
|
|
||||||
app.notFound((c) => {
|
app.notFound((c) => {
|
||||||
return problemJson(c, notFoundProblem(c.req.path));
|
return problemJson(c, notFoundProblem(c.req.path));
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||||
|
import { GetGroupsRiskOverview } from './use-case';
|
||||||
|
|
||||||
|
const route = new Hono();
|
||||||
|
|
||||||
|
route.get('/overview', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
if (!user) {
|
||||||
|
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCase = new GetGroupsRiskOverview();
|
||||||
|
const result = await useCase.execute(user.id);
|
||||||
|
|
||||||
|
return resultJson(c, result);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default route;
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { Prisma } from '@generated/prisma/client';
|
||||||
|
import type {
|
||||||
|
AttendanceStatus,
|
||||||
|
GroupRiskLevel,
|
||||||
|
GroupRiskSummary,
|
||||||
|
GroupsRiskOverview,
|
||||||
|
ProblemDetails,
|
||||||
|
Result,
|
||||||
|
} from '@gruperly/shared';
|
||||||
|
import { ok } from '@gruperly/shared';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
import { buildGroupWhereForUser } from '@/modules/groups/lib';
|
||||||
|
import {
|
||||||
|
classifyRisk,
|
||||||
|
monthlyAttendanceRate,
|
||||||
|
trailingAbsentStreak,
|
||||||
|
} from '../../lib/risk';
|
||||||
|
import { ANALYTICS_WINDOW_MS } from '../get-overview/use-case';
|
||||||
|
|
||||||
|
type GetGroupsRiskOverviewDeps = {
|
||||||
|
db?: {
|
||||||
|
group: Prisma.GroupDelegate;
|
||||||
|
classSession: Prisma.ClassSessionDelegate;
|
||||||
|
attendee: Prisma.AttendeeDelegate;
|
||||||
|
attendance: Prisma.AttendanceDelegate;
|
||||||
|
};
|
||||||
|
now?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GroupRow = { id: string; name: string };
|
||||||
|
type AttendeeRow = { id: string; groupId: string };
|
||||||
|
type SessionRow = { id: string; groupId: string };
|
||||||
|
type AttendanceRow = {
|
||||||
|
attendeeId: string;
|
||||||
|
status: AttendanceStatus;
|
||||||
|
classSession: { startsAt: Date };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resumen de semáforo de riesgo por grupo para la lista de grupos.
|
||||||
|
// Un grupo es HIGH si tiene al menos un alumno en riesgo alto, MEDIUM si
|
||||||
|
// alguno en riesgo moderado y NONE en caso contrario.
|
||||||
|
export class GetGroupsRiskOverview {
|
||||||
|
constructor(private readonly deps: GetGroupsRiskOverviewDeps = {}) {}
|
||||||
|
|
||||||
|
async execute(userId: string): Promise<Result<GroupsRiskOverview, ProblemDetails>> {
|
||||||
|
const db = this.deps.db ?? prisma;
|
||||||
|
const now = this.deps.now ?? new Date();
|
||||||
|
const since = new Date(now.getTime() - ANALYTICS_WINDOW_MS);
|
||||||
|
|
||||||
|
const groups = (await db.group.findMany({
|
||||||
|
where: buildGroupWhereForUser(userId) as Prisma.GroupWhereInput,
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})) as GroupRow[];
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return ok({ items: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupIds = groups.map((group) => group.id);
|
||||||
|
const attendeeGroup = new Map<string, string>();
|
||||||
|
const sessionCountByGroup = new Map<string, number>();
|
||||||
|
|
||||||
|
const [attendees, sessions, attendances] = await Promise.all([
|
||||||
|
db.attendee.findMany({
|
||||||
|
where: { groupId: { in: groupIds }, status: 'ACTIVE' },
|
||||||
|
select: { id: true, groupId: true },
|
||||||
|
}) as Promise<AttendeeRow[]>,
|
||||||
|
db.classSession.findMany({
|
||||||
|
where: { groupId: { in: groupIds }, startsAt: { gte: since } },
|
||||||
|
select: { id: true, groupId: true },
|
||||||
|
}) as Promise<SessionRow[]>,
|
||||||
|
db.attendance.findMany({
|
||||||
|
where: { attendee: { groupId: { in: groupIds } } },
|
||||||
|
select: {
|
||||||
|
attendeeId: true,
|
||||||
|
status: true,
|
||||||
|
classSession: { select: { startsAt: true } },
|
||||||
|
},
|
||||||
|
orderBy: { classSession: { startsAt: 'desc' } },
|
||||||
|
}) as Promise<AttendanceRow[]>,
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const attendee of attendees) {
|
||||||
|
attendeeGroup.set(attendee.id, attendee.groupId);
|
||||||
|
}
|
||||||
|
for (const session of sessions) {
|
||||||
|
sessionCountByGroup.set(session.groupId, (sessionCountByGroup.get(session.groupId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamByAttendee = groupAttendanceByAttendee(attendances);
|
||||||
|
|
||||||
|
const worstRiskByGroup = new Map<string, GroupRiskLevel>();
|
||||||
|
for (const attendee of attendees) {
|
||||||
|
const groupId = attendeeGroup.get(attendee.id)!;
|
||||||
|
const stream = (streamByAttendee.get(attendee.id) ?? []).map((row) => ({
|
||||||
|
status: row.status,
|
||||||
|
occurredAt: row.classSession.startsAt,
|
||||||
|
}));
|
||||||
|
const streak = trailingAbsentStreak(stream);
|
||||||
|
const monthlyRate = monthlyAttendanceRate(
|
||||||
|
stream.filter((entry) => entry.occurredAt.getTime() >= since.getTime()),
|
||||||
|
sessionCountByGroup.get(groupId) ?? 0,
|
||||||
|
);
|
||||||
|
const riskLevel = classifyRisk(streak, monthlyRate);
|
||||||
|
if (!riskLevel) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = worstRiskByGroup.get(groupId) ?? 'NONE';
|
||||||
|
if (
|
||||||
|
riskLevel === 'HIGH' ||
|
||||||
|
(riskLevel === 'MEDIUM' && current === 'NONE')
|
||||||
|
) {
|
||||||
|
worstRiskByGroup.set(groupId, riskLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: GroupRiskSummary[] = groups.map((group) => ({
|
||||||
|
groupId: group.id,
|
||||||
|
groupName: group.name,
|
||||||
|
riskLevel: worstRiskByGroup.get(group.id) ?? 'NONE',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return ok({ items });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupAttendanceByAttendee(rows: AttendanceRow[]): Map<string, AttendanceRow[]> {
|
||||||
|
const byAttendee = new Map<string, AttendanceRow[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = byAttendee.get(row.attendeeId) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
byAttendee.set(row.attendeeId, list);
|
||||||
|
}
|
||||||
|
return byAttendee;
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
export { default as groupsRiskOverviewRoute } from './features/get-groups-risk-overview/route';
|
||||||
export { default as studentStatusRoutes } from './features/update-attendee-status/route';
|
export { default as studentStatusRoutes } from './features/update-attendee-status/route';
|
||||||
export { default as analyticsRoutes } from './routes';
|
export { default as analyticsRoutes } from './routes';
|
||||||
114
apps/backend/test/analytics-groups-risk.test.ts
Normal file
114
apps/backend/test/analytics-groups-risk.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
const db = {
|
||||||
|
group: {
|
||||||
|
findMany: mock(),
|
||||||
|
},
|
||||||
|
attendee: {
|
||||||
|
findMany: mock(),
|
||||||
|
},
|
||||||
|
classSession: {
|
||||||
|
findMany: mock(),
|
||||||
|
},
|
||||||
|
attendance: {
|
||||||
|
findMany: mock(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module('@/lib/prisma', () => ({
|
||||||
|
default: db,
|
||||||
|
getPrismaClient: mock(),
|
||||||
|
UnitOfWork: class {
|
||||||
|
executeResult = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
|
||||||
|
execute = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { groupsRiskOverviewRoute } from '@/modules/analytics';
|
||||||
|
|
||||||
|
const userId = 'user-1';
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const now = Date.now();
|
||||||
|
const at = (offsetDays: number) => new Date(now - offsetDays * DAY_MS);
|
||||||
|
|
||||||
|
function makeApp(userValue: unknown) {
|
||||||
|
const app = new Hono();
|
||||||
|
app.use('*', async (c, next) => {
|
||||||
|
c.set('user', userValue as never);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
app.route('/groups-risk', groupsRiskOverviewRoute);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /groups-risk/overview', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('devuelve items vacíos si el usuario no tiene grupos', async () => {
|
||||||
|
prismaFetchGroupFindMany([]);
|
||||||
|
prismaFetchAttendeeFindMany([]);
|
||||||
|
prismaFetchClassSessionFindMany([]);
|
||||||
|
prismaFetchAttendanceFindMany([]);
|
||||||
|
|
||||||
|
const res = await makeApp({ id: userId }).request('/groups-risk/overview');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ items: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marca HIGH a un grupo con un alumno con 3 ausencias consecutivas', async () => {
|
||||||
|
prismaFetchGroupFindMany([
|
||||||
|
{ id: 'group-1', name: 'Funcional' },
|
||||||
|
{ id: 'group-2', name: 'Yoga' },
|
||||||
|
]);
|
||||||
|
prismaFetchAttendeeFindMany([
|
||||||
|
{ id: 'a-1', groupId: 'group-1' },
|
||||||
|
{ id: 'a-2', groupId: 'group-2' },
|
||||||
|
]);
|
||||||
|
prismaFetchClassSessionFindMany([
|
||||||
|
{ id: 's1', groupId: 'group-1', startsAt: at(-1) },
|
||||||
|
{ id: 's2', groupId: 'group-1', startsAt: at(-2) },
|
||||||
|
{ id: 's3', groupId: 'group-1', startsAt: at(-3) },
|
||||||
|
{ id: 's4', groupId: 'group-2', startsAt: at(-1) },
|
||||||
|
]);
|
||||||
|
prismaFetchAttendanceFindMany([
|
||||||
|
attendance('a-1', 'ABSENT', at(-1)),
|
||||||
|
attendance('a-1', 'ABSENT', at(-2)),
|
||||||
|
attendance('a-1', 'ABSENT', at(-3)),
|
||||||
|
attendance('a-2', 'PRESENT', at(-1)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await makeApp({ id: userId }).request('/groups-risk/overview');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
items: [
|
||||||
|
{ groupId: 'group-1', groupName: 'Funcional', riskLevel: 'HIGH' },
|
||||||
|
{ groupId: 'group-2', groupName: 'Yoga', riskLevel: 'NONE' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function prismaFetchGroupFindMany(rows: unknown[]) {
|
||||||
|
db.group.findMany.mockResolvedValue(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prismaFetchAttendeeFindMany(rows: unknown[]) {
|
||||||
|
db.attendee.findMany.mockResolvedValue(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prismaFetchClassSessionFindMany(rows: unknown[]) {
|
||||||
|
db.classSession.findMany.mockResolvedValue(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prismaFetchAttendanceFindMany(rows: unknown[]) {
|
||||||
|
db.attendance.findMany.mockResolvedValue(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attendance(attendeeId: string, status: string, startsAt: string) {
|
||||||
|
return { attendeeId, status, classSession: { startsAt } };
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ function makeSession(overrides: Record<string, unknown> = {}) {
|
|||||||
return {
|
return {
|
||||||
id: 'session-1',
|
id: 'session-1',
|
||||||
groupId: 'group-1',
|
groupId: 'group-1',
|
||||||
startsAt: new Date('2026-09-23T19:00:00.000Z'),
|
startsAt: todayAtUtc(),
|
||||||
group: {
|
group: {
|
||||||
id: 'group-1',
|
id: 'group-1',
|
||||||
name: 'Funcional',
|
name: 'Funcional',
|
||||||
@@ -77,6 +77,13 @@ function makeSession(overrides: Record<string, unknown> = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fecha "hoy" a las 19:00 UTC, del día real del runner.
|
||||||
|
function todayAtUtc() {
|
||||||
|
const date = new Date();
|
||||||
|
date.setUTCHours(19, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
describe('classes routes', () => {
|
describe('classes routes', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -85,7 +92,7 @@ describe('classes routes', () => {
|
|||||||
|
|
||||||
describe('GET /classes/today', () => {
|
describe('GET /classes/today', () => {
|
||||||
it('materializa y lista las clases de hoy del profesor', async () => {
|
it('materializa y lista las clases de hoy del profesor', async () => {
|
||||||
const startsAt = new Date('2026-09-23T19:00:00.000Z');
|
const startsAt = todayAtUtc();
|
||||||
prisma.group.findMany.mockResolvedValue([
|
prisma.group.findMany.mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 'group-1',
|
id: 'group-1',
|
||||||
@@ -432,7 +439,7 @@ describe('classes routes', () => {
|
|||||||
})
|
})
|
||||||
.format(new Date())
|
.format(new Date())
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
const startsAt = new Date('2026-09-23T19:00:00.000Z');
|
const startsAt = todayAtUtc();
|
||||||
|
|
||||||
prisma.attendee.findUnique.mockResolvedValue({
|
prisma.attendee.findUnique.mockResolvedValue({
|
||||||
id: 'attendee-1',
|
id: 'attendee-1',
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import type {
|
|||||||
GroupDto,
|
GroupDto,
|
||||||
GroupInviteInfoDto,
|
GroupInviteInfoDto,
|
||||||
GroupList,
|
GroupList,
|
||||||
|
GroupRiskSummary,
|
||||||
|
GroupsRiskOverview,
|
||||||
GroupWaitlistEntryDto,
|
GroupWaitlistEntryDto,
|
||||||
GroupWaitlistList,
|
GroupWaitlistList,
|
||||||
HomeSummaryDto,
|
HomeSummaryDto,
|
||||||
@@ -223,6 +225,8 @@ export const getGroupAnalytics = (groupId: string) =>
|
|||||||
export const getStudentsAtRisk = (groupId: string) =>
|
export const getStudentsAtRisk = (groupId: string) =>
|
||||||
apiFetch<StudentsAtRisk>(`/api/v1/groups/${groupId}/students-at-risk`)
|
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) =>
|
export const getAttendeeHistory = (groupId: string, attendeeId: string) =>
|
||||||
apiFetch<AttendeeHistory>(`/api/v1/groups/${groupId}/attendees/${attendeeId}/history`)
|
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: ['group', groupId] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ['classes-today'] });
|
void queryClient.invalidateQueries({ queryKey: ['classes-today'] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ['home-summary'] });
|
void queryClient.invalidateQueries({ queryKey: ['home-summary'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['groups-risk-overview'] });
|
||||||
},
|
},
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast.error(err.message || 'No pudimos actualizar el estado del alumno.');
|
toast.error(err.message || 'No pudimos actualizar el estado del alumno.');
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
createAttendee,
|
createAttendee,
|
||||||
getGroup,
|
getGroup,
|
||||||
getGroupAttendees,
|
getGroupAttendees,
|
||||||
|
getGroupsRiskOverview,
|
||||||
getGroupWaitlist,
|
getGroupWaitlist,
|
||||||
getInviteToken,
|
getInviteToken,
|
||||||
promoteGroupWaitlistEntry,
|
promoteGroupWaitlistEntry,
|
||||||
@@ -106,6 +107,12 @@ export function GroupDetailView() {
|
|||||||
enabled: Boolean(groupId),
|
enabled: Boolean(groupId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const riskOverviewQuery = useQuery({
|
||||||
|
queryKey: ['groups-risk-overview'],
|
||||||
|
queryFn: getGroupsRiskOverview,
|
||||||
|
enabled: Boolean(groupId),
|
||||||
|
});
|
||||||
|
|
||||||
const inviteTokenQuery = useQuery({
|
const inviteTokenQuery = useQuery({
|
||||||
queryKey: ['invite-token', groupId],
|
queryKey: ['invite-token', groupId],
|
||||||
queryFn: () => getInviteToken(groupId),
|
queryFn: () => getInviteToken(groupId),
|
||||||
@@ -487,6 +494,9 @@ export function GroupDetailView() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const riskLevel =
|
||||||
|
riskOverviewQuery.data?.items.find((item) => item.groupId === groupId)?.riskLevel ?? 'NONE';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6 animate-fade-in">
|
<section className="space-y-6 animate-fade-in">
|
||||||
{/* Navigation & Header */}
|
{/* Navigation & Header */}
|
||||||
@@ -504,17 +514,28 @@ export function GroupDetailView() {
|
|||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<h1 className="text-2xl font-bold text-primary">{group.name}</h1>
|
<h1 className="text-2xl font-bold text-primary">{group.name}</h1>
|
||||||
<Badge variant="success">Activo</Badge>
|
<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>
|
</div>
|
||||||
{group.description ? (
|
{group.description ? (
|
||||||
<p className="mt-1 text-sm text-foreground/70">{group.description}</p>
|
<p className="mt-1 text-sm text-foreground/70">{group.description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => void navigate({ to: '/groups/$groupId/analytics', params: { groupId } })}
|
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" />
|
<BarChart3 className="size-4 text-accent" />
|
||||||
<span>Estadísticas</span>
|
<span>Estadísticas</span>
|
||||||
@@ -522,18 +543,22 @@ export function GroupDetailView() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setIsInviteModalOpen(true)}
|
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" />
|
<Share2 className="size-4 text-accent" />
|
||||||
<span>Compartir Link</span>
|
<span>
|
||||||
|
Compartir <span className="hidden sm:inline">Link</span>
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => setIsAddAttendeeModalOpen(true)}
|
onClick={() => setIsAddAttendeeModalOpen(true)}
|
||||||
className="gap-2"
|
className="w-full justify-center gap-1.5 sm:w-auto"
|
||||||
>
|
>
|
||||||
<UserPlus className="size-4" />
|
<UserPlus className="size-4" />
|
||||||
<span>Agregar Miembros</span>
|
<span>
|
||||||
|
Agregar <span className="hidden sm:inline">Miembros</span>
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { CalendarClock, Loader2, Plus, Users } from 'lucide-react'
|
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 { 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 { 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 navigate = useNavigate()
|
||||||
const hasSchedule = (group.days?.length ?? 0) > 0
|
const hasSchedule = (group.days?.length ?? 0) > 0
|
||||||
|
const hasRisk = riskLevel === 'HIGH' || riskLevel === 'MEDIUM'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="rounded-xl border border-border bg-surface p-5 transition-all hover:border-accent/40">
|
<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>
|
<p className="mt-0.5 truncate text-sm text-foreground/60">{group.description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{hasSchedule ? (
|
{hasSchedule ? (
|
||||||
@@ -62,6 +89,14 @@ export function GroupsView() {
|
|||||||
queryKey: ['groups'],
|
queryKey: ['groups'],
|
||||||
queryFn: getGroups,
|
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 (
|
return (
|
||||||
<section>
|
<section>
|
||||||
@@ -111,7 +146,11 @@ export function GroupsView() {
|
|||||||
{groupsQuery.isSuccess && groupsQuery.data.data.length > 0 ? (
|
{groupsQuery.isSuccess && groupsQuery.data.data.length > 0 ? (
|
||||||
<div className="mt-6 grid gap-4">
|
<div className="mt-6 grid gap-4">
|
||||||
{groupsQuery.data.data.map((group) => (
|
{groupsQuery.data.data.map((group) => (
|
||||||
<GroupCard key={group.id} group={group} />
|
<GroupCard
|
||||||
|
key={group.id}
|
||||||
|
group={group}
|
||||||
|
riskLevel={riskByGroup.get(group.id) ?? 'NONE'}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -64,4 +64,20 @@ export const UpdateStudentStatusResultSchema = z.object({
|
|||||||
attendeeId: z.string(),
|
attendeeId: z.string(),
|
||||||
status: attendeeStatusSchema,
|
status: attendeeStatusSchema,
|
||||||
});
|
});
|
||||||
export type UpdateStudentStatusResult = z.output<typeof UpdateStudentStatusResultSchema>;
|
export type UpdateStudentStatusResult = z.output<typeof UpdateStudentStatusResultSchema>;
|
||||||
|
|
||||||
|
export const groupRiskLevelSchema = z.enum(['NONE', 'MEDIUM', 'HIGH']);
|
||||||
|
export type GroupRiskLevel = z.output<typeof groupRiskLevelSchema>;
|
||||||
|
|
||||||
|
export const GroupRiskSummarySchema = z.object({
|
||||||
|
groupId: z.string(),
|
||||||
|
groupName: z.string(),
|
||||||
|
riskLevel: groupRiskLevelSchema,
|
||||||
|
});
|
||||||
|
export type GroupRiskSummary = z.output<typeof GroupRiskSummarySchema>;
|
||||||
|
|
||||||
|
export const GroupsRiskOverviewSchema = z.object({
|
||||||
|
items: z.array(GroupRiskSummarySchema),
|
||||||
|
});
|
||||||
|
export type GroupsRiskOverview = z.output<typeof GroupsRiskOverviewSchema>;
|
||||||
|
export type GroupsRiskOverviewDto = GroupsRiskOverview;
|
||||||
Reference in New Issue
Block a user