Compare commits
5 Commits
developmen
...
feat/atten
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e736ed3547 | ||
|
|
94c353f4f2 | ||
|
|
336e1af523 | ||
|
|
efde1aaf33 | ||
|
|
2e184845e1 |
@@ -48,7 +48,8 @@ bun --filter @gruperly/backend db:* # db:generate / db:migrate / db:push
|
||||
- **Base de datos**: todas las tablas usan **snake_case** vía `@@map` en `prisma/models/*.prisma` (p. ej. `User` → `users`, `WaitlistEntry` → `waitlist_entries`). Al agregar un modelo nuevo, incluir siempre `@@map("nombre_tabla")`.
|
||||
- **API** responde errores en **Problem Details RFC 7807** (`application/problem+json`) y resultados como `{ data, pagination }`; los "use cases" devuelven `Result` y nunca lanzan excepciones de dominio.
|
||||
- **Tailwind v4** con tokens en `@theme` dentro de `apps/web/src/index.css` (p. ej. `--color-accent: #1e90ff`). Se usan como clases auto-generadas: `text-accent`, `bg-success-soft`, etc. Radius por defecto `0.75rem` (`rounded-xl`).
|
||||
- **Router NO es file-based** (aunque `stack.md` lo diga): las rutas se declaran manualmente en `apps/web/src/router.tsx` con `createRoute` + `addChildren` y se registran vía module augmentation. **Cada vista nueva debe añadirse ahí.**
|
||||
- **Router ES file-based** (TanStack Router + `@tanstack/router-plugin`): las rutas viven en `apps/web/src/routes/` y solo contienen definiciones (`createFileRoute` + `component`), sin lógica de UI. `src/router.tsx` solo crea el router con el `routeTree` importado de `src/routeTree.gen.ts` (**archivo generado por el plugin en build/dev, versionado en git**: si agregás/renombrás una ruta, corré `bun --filter @gruperly/web build` para regenerarlo). Las rutas autenticadas cuelgan del layout pathless `_authenticated.tsx` (`AppLayoutGuard` + `RootLayout` con `<Outlet />`).
|
||||
- **Vistas en `apps/web/src/features/<feature>/`**: un componente por archivo, nombre de archivo en PascalCase igual al componente, imports relativos (sin alias). El estado compartido padre-hijo vive en un `*Provider.tsx` del feature (contexto + queries/mutations de React Query) que se monta en el route file; los componentes hijos consumen `use<Feature>()` y las forms locales mantienen su propio estado.
|
||||
- Nav (Inicio/Grupos/Cobros/Ajustes) vive en `apps/web/src/components/layout/nav-items.ts`; es la fuente única para `BottomNav` (móvil) y `Sidebar` (desktop) — no dupliques la lista.
|
||||
- **UI**: primitivos propios en `apps/web/src/components/ui/` (avatar, button, badge) más helper `cn()` en `src/lib/utils.ts` (clsx + tailwind-merge). Aunque `stack.md` mencione Shadcn, **todavía no está instalado** (sin Radix); úsalos directos.
|
||||
- Layout mobile-first: `RootLayout` usa columna `max-w-md` en móvil y dos columnas (Sidebar + contenido `max-w-6xl`) en `lg+`.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AttendanceStatus" AS ENUM ('PRESENT', 'ABSENT', 'EXCUSED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "attendees" ADD COLUMN "notifyToken" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "class_sessions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"startsAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "class_sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "attendances" (
|
||||
"id" TEXT NOT NULL,
|
||||
"attendeeId" TEXT NOT NULL,
|
||||
"classSessionId" TEXT NOT NULL,
|
||||
"status" "AttendanceStatus" NOT NULL,
|
||||
"markedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "attendances_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "slot_releases" (
|
||||
"id" TEXT NOT NULL,
|
||||
"attendeeId" TEXT NOT NULL,
|
||||
"classSessionId" TEXT NOT NULL,
|
||||
"isClaimed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "slot_releases_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "class_sessions_groupId_startsAt_key" ON "class_sessions"("groupId", "startsAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "attendances_attendeeId_classSessionId_key" ON "attendances"("attendeeId", "classSessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "slot_releases_attendeeId_classSessionId_key" ON "slot_releases"("attendeeId", "classSessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "attendees_notifyToken_key" ON "attendees"("notifyToken");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "class_sessions" ADD CONSTRAINT "class_sessions_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "attendances" ADD CONSTRAINT "attendances_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "attendees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "attendances" ADD CONSTRAINT "attendances_classSessionId_fkey" FOREIGN KEY ("classSessionId") REFERENCES "class_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "slot_releases" ADD CONSTRAINT "slot_releases_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "attendees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "slot_releases" ADD CONSTRAINT "slot_releases_classSessionId_fkey" FOREIGN KEY ("classSessionId") REFERENCES "class_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Backfill: tokens de aviso de ausencia para alumnos existentes
|
||||
UPDATE "attendees" SET "notifyToken" = gen_random_uuid()::text WHERE "notifyToken" IS NULL;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AttendeeStatus" AS ENUM ('ACTIVE', 'PAUSED', 'DROPPED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "attendees" ADD COLUMN "status" "AttendeeStatus" NOT NULL DEFAULT 'ACTIVE';
|
||||
50
apps/backend/prisma/models/attendance.prisma
Normal file
50
apps/backend/prisma/models/attendance.prisma
Normal file
@@ -0,0 +1,50 @@
|
||||
// Attendance & class sessions
|
||||
|
||||
enum AttendanceStatus {
|
||||
PRESENT
|
||||
ABSENT
|
||||
EXCUSED
|
||||
}
|
||||
|
||||
model ClassSession {
|
||||
id String @id @default(cuid())
|
||||
groupId String
|
||||
startsAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
attendances Attendance[]
|
||||
slotReleases SlotRelease[]
|
||||
|
||||
@@unique([groupId, startsAt])
|
||||
@@map("class_sessions")
|
||||
}
|
||||
|
||||
model Attendance {
|
||||
id String @id @default(cuid())
|
||||
attendeeId String
|
||||
classSessionId String
|
||||
status AttendanceStatus
|
||||
markedAt DateTime @default(now())
|
||||
|
||||
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
|
||||
classSession ClassSession @relation(fields: [classSessionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([attendeeId, classSessionId])
|
||||
@@map("attendances")
|
||||
}
|
||||
|
||||
model SlotRelease {
|
||||
id String @id @default(cuid())
|
||||
attendeeId String
|
||||
classSessionId String
|
||||
isClaimed Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
|
||||
classSession ClassSession @relation(fields: [classSessionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([attendeeId, classSessionId])
|
||||
@@map("slot_releases")
|
||||
}
|
||||
@@ -20,6 +20,7 @@ model Group {
|
||||
attendees Attendee[]
|
||||
payments Payment[]
|
||||
waitlist GroupWaitlistEntry[]
|
||||
classSessions ClassSession[]
|
||||
|
||||
@@map("groups")
|
||||
}
|
||||
@@ -59,8 +60,14 @@ enum Role {
|
||||
MEMBER
|
||||
}
|
||||
|
||||
enum AttendeeStatus {
|
||||
ACTIVE
|
||||
PAUSED
|
||||
DROPPED
|
||||
}
|
||||
|
||||
model Attendee {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
groupId String
|
||||
fullName String
|
||||
email String?
|
||||
@@ -68,11 +75,15 @@ model Attendee {
|
||||
guardianName String?
|
||||
guardianPhone String?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
notifyToken String? @unique @default(cuid())
|
||||
status AttendeeStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
payments Payment[]
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
payments Payment[]
|
||||
attendances Attendance[]
|
||||
slotReleases SlotRelease[]
|
||||
|
||||
@@index([groupId])
|
||||
@@map("attendees")
|
||||
|
||||
@@ -10,8 +10,13 @@ import { requestLoggerMiddleware } from '@/http/request-logger';
|
||||
import { corsMiddleware, securityHeadersMiddleware } from '@/http/security-headers';
|
||||
import { sessionAuthMiddleware } from '@/http/session-auth';
|
||||
import { logger } from '@/logger';
|
||||
import {
|
||||
groupsRiskOverviewRoute,
|
||||
studentStatusRoutes,
|
||||
} from './modules/analytics';
|
||||
import { attendeesRoutes } from './modules/attendees';
|
||||
import { authRoutes } from './modules/auth';
|
||||
import { classesRoutes } from './modules/classes';
|
||||
import { groupsRoutes } from './modules/groups';
|
||||
import { healthCheckRoutes } from './modules/health-check';
|
||||
import { homeRoutes } from './modules/home';
|
||||
@@ -42,6 +47,9 @@ api.route('/onboarding', onboardingRoutes);
|
||||
api.route('/attendees', attendeesRoutes);
|
||||
api.route('/payments', paymentsRoutes);
|
||||
api.route('/waitlist', waitlistRoutes);
|
||||
api.route('/classes', classesRoutes);
|
||||
api.route('/students', studentStatusRoutes);
|
||||
api.route('/groups-risk', groupsRiskOverviewRoute);
|
||||
|
||||
app.notFound((c) => {
|
||||
return problemJson(c, notFoundProblem(c.req.path));
|
||||
|
||||
@@ -27,6 +27,14 @@ export function isPublicApiRequest(method: string, path: string): boolean {
|
||||
|
||||
const normalizedPath = normalizePath(path);
|
||||
|
||||
// Aviso de ausencia con token personal del alumno (sin sesión).
|
||||
if (method === 'GET' && normalizedPath === '/api/v1/classes/absence') {
|
||||
return true;
|
||||
}
|
||||
if (method === 'POST' && normalizedPath === '/api/v1/classes/notify-absence') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
normalizedPath === '/api/v1/health'
|
||||
|| matchesPublicPrefix(normalizedPath, '/api/v1/auth')
|
||||
@@ -40,5 +48,8 @@ function matchesPublicPrefix(path: string, prefix: string): boolean {
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path;
|
||||
const withoutQuery = path.split('?')[0]?.split('#')[0] ?? path;
|
||||
return withoutQuery.length > 1 && withoutQuery.endsWith('/')
|
||||
? withoutQuery.slice(0, -1)
|
||||
: withoutQuery;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { GetAttendeeHistory } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.get('/:groupId/attendees/:attendeeId/history', async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
|
||||
const groupId = c.req.param('groupId');
|
||||
const attendeeId = c.req.param('attendeeId');
|
||||
const useCase = new GetAttendeeHistory();
|
||||
const result = await useCase.execute(groupId, attendeeId, user.id);
|
||||
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type {
|
||||
AttendanceStatus,
|
||||
AttendeeHistory,
|
||||
ProblemDetails,
|
||||
Result,
|
||||
} from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { notFoundResourceProblem } from '@/http/problem-builders';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { type AnalyticsAccessDb, findGroupForUser } from '../../lib/access';
|
||||
|
||||
export const HISTORY_SESSIONS_LIMIT = 20;
|
||||
|
||||
type GetAttendeeHistoryDeps = {
|
||||
db?: AnalyticsAccessDb & Pick<PrismaClient, 'classSession' | 'attendee' | 'attendance'>;
|
||||
};
|
||||
|
||||
export class GetAttendeeHistory {
|
||||
constructor(private readonly deps: GetAttendeeHistoryDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
groupId: string,
|
||||
attendeeId: string,
|
||||
userId: string,
|
||||
): Promise<Result<AttendeeHistory, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
|
||||
const access = await findGroupForUser(db, groupId, userId);
|
||||
if (!access.ok) {
|
||||
return access;
|
||||
}
|
||||
|
||||
const attendee = await db.attendee.findFirst({
|
||||
where: { id: attendeeId, groupId },
|
||||
select: { id: true, fullName: true },
|
||||
});
|
||||
if (!attendee) {
|
||||
return err(notFoundResourceProblem('Attendee', attendeeId));
|
||||
}
|
||||
|
||||
const sessions = await db.classSession.findMany({
|
||||
where: { groupId },
|
||||
orderBy: { startsAt: 'desc' },
|
||||
take: HISTORY_SESSIONS_LIMIT,
|
||||
select: { id: true, startsAt: true },
|
||||
});
|
||||
const sessionIds = sessions.map((session) => session.id);
|
||||
|
||||
const attendances = sessionIds.length
|
||||
? await db.attendance.findMany({
|
||||
where: { attendeeId, classSessionId: { in: sessionIds } },
|
||||
select: { classSessionId: true, status: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const statusBySession = new Map<string, AttendanceStatus>(
|
||||
attendances.map((record) => [record.classSessionId, record.status]),
|
||||
);
|
||||
|
||||
const orderedSessions = sessions
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((session) => ({
|
||||
classSessionId: session.id,
|
||||
startsAt: session.startsAt.toISOString(),
|
||||
status: statusBySession.get(session.id) ?? null,
|
||||
}));
|
||||
|
||||
const presentCount = attendances.filter((record) => record.status === 'PRESENT').length;
|
||||
const attendanceRate =
|
||||
sessions.length > 0 ? Math.round((presentCount / sessions.length) * 100) : 0;
|
||||
|
||||
return ok({
|
||||
attendeeId: attendee.id,
|
||||
fullName: attendee.fullName,
|
||||
groupId,
|
||||
groupName: access.value.name,
|
||||
attendanceRate,
|
||||
sessions: orderedSessions,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { GetGroupAnalytics } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.get('/:groupId/analytics', async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
|
||||
const groupId = c.req.param('groupId');
|
||||
const useCase = new GetGroupAnalytics();
|
||||
const result = await useCase.execute(groupId, user.id);
|
||||
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type { GroupAnalytics, ProblemDetails, Result } from '@gruperly/shared';
|
||||
import { ok } from '@gruperly/shared';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { type AnalyticsAccessDb, findGroupForUser } from '../../lib/access';
|
||||
|
||||
export const ANALYTICS_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type GetGroupAnalyticsDeps = {
|
||||
db?: AnalyticsAccessDb & Pick<PrismaClient, 'classSession' | 'attendance' | 'slotRelease'>;
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
export class GetGroupAnalytics {
|
||||
constructor(private readonly deps: GetGroupAnalyticsDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
groupId: string,
|
||||
userId: string,
|
||||
): Promise<Result<GroupAnalytics, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
const now = this.deps.now ?? new Date();
|
||||
const since = new Date(now.getTime() - ANALYTICS_WINDOW_MS);
|
||||
|
||||
const access = await findGroupForUser(db, groupId, userId);
|
||||
if (!access.ok) {
|
||||
return access;
|
||||
}
|
||||
|
||||
const [sessions, attendances, recoveredSlots] = await Promise.all([
|
||||
db.classSession.findMany({
|
||||
where: { groupId, startsAt: { gte: since } },
|
||||
select: { id: true },
|
||||
}),
|
||||
db.attendance.findMany({
|
||||
where: { classSession: { groupId, startsAt: { gte: since } } },
|
||||
select: { status: true },
|
||||
}),
|
||||
db.slotRelease.count({
|
||||
where: { classSession: { groupId, startsAt: { gte: since } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalClasses = sessions.length;
|
||||
const totalMarked = attendances.length;
|
||||
const totalPresent = attendances.filter((record) => record.status === 'PRESENT').length;
|
||||
|
||||
return ok({
|
||||
attendanceRate: totalMarked > 0 ? Math.round((totalPresent / totalMarked) * 100) : 0,
|
||||
totalPresent,
|
||||
totalClasses,
|
||||
recoveredSlots,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { GetStudentsAtRisk } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.get('/:groupId/students-at-risk', async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
|
||||
const groupId = c.req.param('groupId');
|
||||
const useCase = new GetStudentsAtRisk();
|
||||
const result = await useCase.execute(groupId, user.id);
|
||||
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type {
|
||||
AttendanceStatus,
|
||||
ProblemDetails,
|
||||
Result,
|
||||
StudentAtRisk,
|
||||
StudentsAtRisk,
|
||||
} from '@gruperly/shared';
|
||||
import { ok } from '@gruperly/shared';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { type AnalyticsAccessDb, findGroupForUser } from '../../lib/access';
|
||||
import {
|
||||
classifyRisk,
|
||||
lastAttendedAt,
|
||||
monthlyAttendanceRate,
|
||||
trailingAbsentStreak,
|
||||
} from '../../lib/risk';
|
||||
import { ANALYTICS_WINDOW_MS } from '../get-overview/use-case';
|
||||
|
||||
type GetStudentsAtRiskDeps = {
|
||||
db?: AnalyticsAccessDb & Pick<PrismaClient, 'classSession' | 'attendee' | 'attendance'>;
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
type AttendanceStreamRow = {
|
||||
attendeeId: string;
|
||||
status: AttendanceStatus;
|
||||
classSession: { startsAt: Date };
|
||||
};
|
||||
|
||||
export class GetStudentsAtRisk {
|
||||
constructor(private readonly deps: GetStudentsAtRiskDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
groupId: string,
|
||||
userId: string,
|
||||
): Promise<Result<StudentsAtRisk, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
const now = this.deps.now ?? new Date();
|
||||
const since = new Date(now.getTime() - ANALYTICS_WINDOW_MS);
|
||||
|
||||
const access = await findGroupForUser(db, groupId, userId);
|
||||
if (!access.ok) {
|
||||
return access;
|
||||
}
|
||||
|
||||
const [windowSessions, attendees, attendances] = await Promise.all([
|
||||
db.classSession.findMany({
|
||||
where: { groupId, startsAt: { gte: since } },
|
||||
select: { id: true },
|
||||
}),
|
||||
db.attendee.findMany({
|
||||
where: { groupId, status: 'ACTIVE' },
|
||||
select: { id: true, fullName: true, phone: true },
|
||||
}),
|
||||
db.attendance.findMany({
|
||||
where: { attendee: { groupId } },
|
||||
select: {
|
||||
attendeeId: true,
|
||||
status: true,
|
||||
classSession: { select: { startsAt: true } },
|
||||
},
|
||||
orderBy: { classSession: { startsAt: 'desc' } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalWindowSessions = windowSessions.length;
|
||||
const streamByAttendee = groupAttendanceByAttendee(attendances);
|
||||
|
||||
const atRisk: StudentAtRisk[] = [];
|
||||
for (const attendee of attendees) {
|
||||
const stream = (streamByAttendee.get(attendee.id) ?? []).map((row) => ({
|
||||
status: row.status,
|
||||
occurredAt: row.classSession.startsAt,
|
||||
}));
|
||||
const consecutiveAbsences = trailingAbsentStreak(stream);
|
||||
const attendedAt = lastAttendedAt(stream);
|
||||
const monthlyRate = monthlyAttendanceRate(
|
||||
stream.filter((entry) => entry.occurredAt.getTime() >= since.getTime()),
|
||||
totalWindowSessions,
|
||||
);
|
||||
const riskLevel = classifyRisk(consecutiveAbsences, monthlyRate);
|
||||
if (!riskLevel) {
|
||||
continue;
|
||||
}
|
||||
atRisk.push({
|
||||
attendeeId: attendee.id,
|
||||
fullName: attendee.fullName,
|
||||
phone: attendee.phone,
|
||||
riskLevel,
|
||||
consecutiveAbsences,
|
||||
lastAttendedAt: attendedAt ? attendedAt.toISOString() : null,
|
||||
monthlyAttendanceRate: monthlyRate,
|
||||
});
|
||||
}
|
||||
|
||||
atRisk.sort((a, b) => {
|
||||
if (a.riskLevel !== b.riskLevel) {
|
||||
return a.riskLevel === 'HIGH' ? -1 : 1;
|
||||
}
|
||||
if (a.consecutiveAbsences !== b.consecutiveAbsences) {
|
||||
return b.consecutiveAbsences - a.consecutiveAbsences;
|
||||
}
|
||||
return a.monthlyAttendanceRate - b.monthlyAttendanceRate;
|
||||
});
|
||||
|
||||
return ok({
|
||||
groupName: access.value.name,
|
||||
data: atRisk,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function groupAttendanceByAttendee(rows: AttendanceStreamRow[]): Map<string, AttendanceStreamRow[]> {
|
||||
const byAttendee = new Map<string, AttendanceStreamRow[]>();
|
||||
for (const row of rows) {
|
||||
const list = byAttendee.get(row.attendeeId) ?? [];
|
||||
list.push(row);
|
||||
byAttendee.set(row.attendeeId, list);
|
||||
}
|
||||
return byAttendee;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { UpdateStudentStatus } from '@gruperly/shared';
|
||||
import { UpdateStudentStatusSchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { UpdateAttendeeStatus } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.post('/:studentId/status', validate.json(UpdateStudentStatusSchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
|
||||
const studentId = c.req.param('studentId');
|
||||
const payload = c.req.valid('json') as UpdateStudentStatus;
|
||||
const useCase = new UpdateAttendeeStatus();
|
||||
const result = await useCase.execute(studentId, user.id, payload);
|
||||
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type {
|
||||
ProblemDetails,
|
||||
Result,
|
||||
UpdateStudentStatus,
|
||||
UpdateStudentStatusResult,
|
||||
} from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
type UpdateAttendeeStatusDeps = {
|
||||
db?: Pick<PrismaClient, 'attendee'>;
|
||||
};
|
||||
|
||||
export class UpdateAttendeeStatus {
|
||||
constructor(private readonly deps: UpdateAttendeeStatusDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
studentId: string,
|
||||
userId: string,
|
||||
payload: UpdateStudentStatus,
|
||||
): Promise<Result<UpdateStudentStatusResult, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
|
||||
const attendee = await db.attendee.findUnique({
|
||||
where: { id: studentId },
|
||||
include: { group: { include: { members: { where: { userId } } } } },
|
||||
});
|
||||
|
||||
if (!attendee) {
|
||||
return err(notFoundResourceProblem('Attendee', studentId));
|
||||
}
|
||||
|
||||
const isOwner = attendee.group.createdById === userId;
|
||||
const isMember = attendee.group.members.length > 0;
|
||||
if (!isOwner && !isMember) {
|
||||
return err(noGroupAccessProblem());
|
||||
}
|
||||
|
||||
if (attendee.status === payload.status) {
|
||||
return ok({ attendeeId: studentId, status: payload.status });
|
||||
}
|
||||
|
||||
const updated = await db.attendee.update({
|
||||
where: { id: studentId },
|
||||
data: { status: payload.status },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
return ok({ attendeeId: updated.id, status: updated.status });
|
||||
}
|
||||
}
|
||||
3
apps/backend/src/modules/analytics/index.ts
Normal file
3
apps/backend/src/modules/analytics/index.ts
Normal file
@@ -0,0 +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 analyticsRoutes } from './routes';
|
||||
37
apps/backend/src/modules/analytics/lib/access.ts
Normal file
37
apps/backend/src/modules/analytics/lib/access.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type { ProblemDetails, Result } from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders';
|
||||
|
||||
export type AnalyticsAccessDb = Pick<PrismaClient, 'group'>;
|
||||
|
||||
type GroupWithAccess = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdById: string;
|
||||
members: { userId: string }[];
|
||||
};
|
||||
|
||||
// Verifica que el usuario sea owner o miembro del grupo. Devuelve el grupo si tiene acceso.
|
||||
export async function findGroupForUser(
|
||||
db: AnalyticsAccessDb,
|
||||
groupId: string,
|
||||
userId: string,
|
||||
): Promise<Result<GroupWithAccess, ProblemDetails>> {
|
||||
const group = await db.group.findUnique({
|
||||
where: { id: groupId },
|
||||
include: { members: { where: { userId } } },
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
return err(notFoundResourceProblem('Group', groupId));
|
||||
}
|
||||
|
||||
const isOwner = group.createdById === userId;
|
||||
const isMember = group.members.length > 0;
|
||||
if (!isOwner && !isMember) {
|
||||
return err(noGroupAccessProblem());
|
||||
}
|
||||
|
||||
return ok(group);
|
||||
}
|
||||
56
apps/backend/src/modules/analytics/lib/risk.ts
Normal file
56
apps/backend/src/modules/analytics/lib/risk.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export type AttendanceStreamEntry = {
|
||||
status: 'PRESENT' | 'ABSENT' | 'EXCUSED';
|
||||
occurredAt: Date;
|
||||
};
|
||||
|
||||
export type RiskLevel = 'HIGH' | 'MEDIUM';
|
||||
|
||||
// Cuenta las ausencias consecutivas sin justificar desde la clase más reciente.
|
||||
// Recibe la lista ordenada de más reciente a más antigua; PRESENT y EXCUSED rompen la cadena.
|
||||
export function trailingAbsentStreak(entries: AttendanceStreamEntry[]): number {
|
||||
let streak = 0;
|
||||
for (const entry of entries) {
|
||||
if (entry.status !== 'ABSENT') {
|
||||
break;
|
||||
}
|
||||
streak += 1;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
|
||||
// Fecha de la última clase a la que el alumno asistió (PRESENT), o null.
|
||||
export function lastAttendedAt(entries: AttendanceStreamEntry[]): Date | null {
|
||||
for (const entry of entries) {
|
||||
if (entry.status === 'PRESENT') {
|
||||
return entry.occurredAt;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Porcentaje de asistencia sobre el total de sesiones del período.
|
||||
// Las sesiones sin registrar cuentan como no asistidas.
|
||||
export function monthlyAttendanceRate(
|
||||
entries: AttendanceStreamEntry[],
|
||||
totalSessions: number,
|
||||
): number {
|
||||
if (totalSessions <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const present = entries.filter((entry) => entry.status === 'PRESENT').length;
|
||||
return Math.round((present / totalSessions) * 100);
|
||||
}
|
||||
|
||||
// HIGH >= 3 ausencias consecutivas; MEDIUM == 2 consecutivas o asistencia mensual < 50%.
|
||||
export function classifyRisk(
|
||||
consecutiveAbsences: number,
|
||||
monthlyRate: number,
|
||||
): RiskLevel | null {
|
||||
if (consecutiveAbsences >= 3) {
|
||||
return 'HIGH';
|
||||
}
|
||||
if (consecutiveAbsences === 2 || monthlyRate < 50) {
|
||||
return 'MEDIUM';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
12
apps/backend/src/modules/analytics/routes.ts
Normal file
12
apps/backend/src/modules/analytics/routes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Hono } from 'hono';
|
||||
import getAttendeeHistoryRoute from './features/get-attendee-history/route';
|
||||
import getOverviewRoute from './features/get-overview/route';
|
||||
import getStudentsAtRiskRoute from './features/get-students-at-risk/route';
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
routes.route('/', getOverviewRoute);
|
||||
routes.route('/', getStudentsAtRiskRoute);
|
||||
routes.route('/', getAttendeeHistoryRoute);
|
||||
|
||||
export default routes;
|
||||
@@ -70,7 +70,9 @@ export class CreateAttendeeUseCase {
|
||||
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
|
||||
|
||||
if (group.capacity !== null) {
|
||||
const currentCount = await db.attendee.count({ where: { groupId } });
|
||||
const currentCount = await db.attendee.count({
|
||||
where: { groupId, status: 'ACTIVE' },
|
||||
});
|
||||
|
||||
if (currentCount >= group.capacity) {
|
||||
if (isOwner && !options.allowOverflow) {
|
||||
|
||||
@@ -9,6 +9,7 @@ export type AttendeeRecord = {
|
||||
guardianName: string | null;
|
||||
guardianPhone: string | null;
|
||||
notes: string | null;
|
||||
notifyToken?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
@@ -23,6 +24,7 @@ export function toAttendeeDto(record: AttendeeRecord): AttendeeDto {
|
||||
guardianName: record.guardianName,
|
||||
guardianPhone: record.guardianPhone,
|
||||
notes: record.notes,
|
||||
notifyToken: record.notifyToken ?? null,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { MarkAttendance } from '@gruperly/shared';
|
||||
import { MarkAttendanceSchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { MarkSessionAttendance } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.post('/:sessionId/attendance', validate.json(MarkAttendanceSchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
|
||||
const sessionId = c.req.param('sessionId');
|
||||
const payload = c.req.valid('json') as MarkAttendance;
|
||||
const useCase = new MarkSessionAttendance();
|
||||
const result = await useCase.execute(sessionId, payload, user.id);
|
||||
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { AttendanceStatus } from '@generated/prisma/client';
|
||||
import type {
|
||||
MarkAttendance,
|
||||
MarkAttendanceResult,
|
||||
ProblemDetails,
|
||||
Result,
|
||||
} from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { validationProblem } from '@/http/problem-builders';
|
||||
import prisma, { UnitOfWork } from '@/lib/prisma';
|
||||
import { type ClassesDb, ensureSessionAccess, sessionNotFound } from '../../lib';
|
||||
|
||||
type MarkSessionAttendanceDeps = {
|
||||
db?: Pick<ClassesDb, 'classSession' | 'attendee'>;
|
||||
unitOfWork?: UnitOfWork;
|
||||
};
|
||||
|
||||
export class MarkSessionAttendance {
|
||||
constructor(private readonly deps: MarkSessionAttendanceDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
sessionId: string,
|
||||
payload: MarkAttendance,
|
||||
userId: string,
|
||||
): Promise<Result<MarkAttendanceResult, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
|
||||
|
||||
if (payload.classSessionId !== sessionId) {
|
||||
return err(
|
||||
validationProblem({
|
||||
detail: 'El identificador de la clase no coincide con la ruta.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const session = await db.classSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
include: {
|
||||
group: { include: { members: { where: { userId } } } },
|
||||
},
|
||||
});
|
||||
if (!session) {
|
||||
return err(sessionNotFound(sessionId));
|
||||
}
|
||||
|
||||
const access = ensureSessionAccess(session, userId);
|
||||
if (!access.ok) {
|
||||
return access;
|
||||
}
|
||||
|
||||
const uniqueRecords = [
|
||||
...new Map(payload.records.map((record) => [record.attendeeId, record])).values(),
|
||||
];
|
||||
|
||||
const attendees = await db.attendee.findMany({
|
||||
where: {
|
||||
groupId: session.groupId,
|
||||
id: { in: uniqueRecords.map((record) => record.attendeeId) },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const attendeeIds = new Set(attendees.map((attendee) => attendee.id));
|
||||
const invalidRecord = uniqueRecords.find((record) => !attendeeIds.has(record.attendeeId));
|
||||
if (invalidRecord) {
|
||||
return err(
|
||||
validationProblem({
|
||||
detail: 'Uno o más alumnos no pertenecen al grupo de esta clase.',
|
||||
errors: { attendeeId: [invalidRecord.attendeeId] },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const markedAt = new Date();
|
||||
return unitOfWork.executeResult(async (tx) => {
|
||||
for (const record of uniqueRecords) {
|
||||
await tx.attendance.upsert({
|
||||
where: {
|
||||
attendeeId_classSessionId: {
|
||||
attendeeId: record.attendeeId,
|
||||
classSessionId: sessionId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
attendeeId: record.attendeeId,
|
||||
classSessionId: sessionId,
|
||||
status:
|
||||
record.status === 'PRESENT'
|
||||
? AttendanceStatus.PRESENT
|
||||
: AttendanceStatus.ABSENT,
|
||||
markedAt,
|
||||
},
|
||||
update: {
|
||||
status:
|
||||
record.status === 'PRESENT'
|
||||
? AttendanceStatus.PRESENT
|
||||
: AttendanceStatus.ABSENT,
|
||||
markedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
return ok({ classSessionId: sessionId, marked: uniqueRecords.length });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { NotifyAbsence } from '@gruperly/shared';
|
||||
import { NotifyAbsenceSchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { NotifySessionAbsence } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.post('/:sessionId/notify-absence', validate.json(NotifyAbsenceSchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
|
||||
const sessionId = c.req.param('sessionId');
|
||||
const payload = c.req.valid('json') as NotifyAbsence;
|
||||
const useCase = new NotifySessionAbsence();
|
||||
const result = await useCase.execute(sessionId, payload, user.id);
|
||||
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
NotifyAbsence,
|
||||
NotifyAbsenceResult,
|
||||
ProblemDetails,
|
||||
Result,
|
||||
} from '@gruperly/shared';
|
||||
import { err } from '@gruperly/shared';
|
||||
import { validationProblem } from '@/http/problem-builders';
|
||||
import prisma, { UnitOfWork } from '@/lib/prisma';
|
||||
import {
|
||||
absenceReleaseHours,
|
||||
type ClassesDb,
|
||||
ensureSessionAccess,
|
||||
registerAbsence,
|
||||
sessionNotFound,
|
||||
} from '../../lib';
|
||||
|
||||
type NotifySessionAbsenceDeps = {
|
||||
db?: Pick<ClassesDb, 'classSession' | 'attendee'>;
|
||||
unitOfWork?: UnitOfWork;
|
||||
now?: Date;
|
||||
releaseHours?: number;
|
||||
};
|
||||
|
||||
export class NotifySessionAbsence {
|
||||
constructor(private readonly deps: NotifySessionAbsenceDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
sessionId: string,
|
||||
payload: NotifyAbsence,
|
||||
userId: string,
|
||||
): Promise<Result<NotifyAbsenceResult, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
|
||||
const now = this.deps.now ?? new Date();
|
||||
const releaseHours = this.deps.releaseHours ?? absenceReleaseHours();
|
||||
|
||||
if (payload.classSessionId !== sessionId) {
|
||||
return err(
|
||||
validationProblem({
|
||||
detail: 'El identificador de la clase no coincide con la ruta.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const session = await db.classSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
include: {
|
||||
group: { include: { members: { where: { userId } } } },
|
||||
},
|
||||
});
|
||||
if (!session) {
|
||||
return err(sessionNotFound(sessionId));
|
||||
}
|
||||
|
||||
const access = ensureSessionAccess(session, userId);
|
||||
if (!access.ok) {
|
||||
return access;
|
||||
}
|
||||
|
||||
return registerAbsence(
|
||||
{ db, unitOfWork },
|
||||
{
|
||||
attendeeId: payload.attendeeId,
|
||||
classSessionId: sessionId,
|
||||
now,
|
||||
releaseHours,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AbsenceQuery } from '@gruperly/shared';
|
||||
import { AbsenceQuerySchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { resultJson } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { GetAbsenceDetails } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
// Ruta pública (whitelistada en session-auth): el alumno entra con su token personal.
|
||||
route.get('/absence', validate.query(AbsenceQuerySchema), async (c) => {
|
||||
const query = c.req.valid('query') as AbsenceQuery;
|
||||
const useCase = new GetAbsenceDetails();
|
||||
const result = await useCase.execute(query);
|
||||
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { AbsenceDetails, AbsenceQuery, ProblemDetails, Result } from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { notFoundResourceProblem } from '@/http/problem-builders';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { DEFAULT_TIME_ZONE } from '@/modules/home/lib/scheduler';
|
||||
import {
|
||||
buildTodayCandidates,
|
||||
type ClassesDb,
|
||||
ensureTodaySessions,
|
||||
} from '../../lib';
|
||||
|
||||
type GetAbsenceDetailsDeps = {
|
||||
db?: Pick<
|
||||
ClassesDb,
|
||||
'attendee' | 'group' | 'classSession' | 'attendance' | 'slotRelease'
|
||||
>;
|
||||
now?: Date;
|
||||
timeZone?: string;
|
||||
};
|
||||
|
||||
export class GetAbsenceDetails {
|
||||
constructor(private readonly deps: GetAbsenceDetailsDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
query: AbsenceQuery,
|
||||
): Promise<Result<AbsenceDetails, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
const now = this.deps.now ?? new Date();
|
||||
const timeZone = this.deps.timeZone ?? DEFAULT_TIME_ZONE;
|
||||
|
||||
const attendee = await db.attendee.findUnique({
|
||||
where: { notifyToken: query.token },
|
||||
include: {
|
||||
group: { select: { id: true, name: true, days: true, time: true } },
|
||||
},
|
||||
});
|
||||
if (!attendee) {
|
||||
return err(notFoundResourceProblem('Attendee', 'notify token'));
|
||||
}
|
||||
|
||||
const candidates = buildTodayCandidates(
|
||||
[{ id: attendee.group.id, days: attendee.group.days, time: attendee.group.time }],
|
||||
now,
|
||||
timeZone,
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
return ok({
|
||||
attendeeId: attendee.id,
|
||||
fullName: attendee.fullName,
|
||||
groupName: attendee.group.name,
|
||||
sessions: [],
|
||||
});
|
||||
}
|
||||
|
||||
const sessionIds = await ensureTodaySessions(db, candidates);
|
||||
const [sessions, attendances, releases] = await Promise.all([
|
||||
db.classSession.findMany({
|
||||
where: { id: { in: sessionIds } },
|
||||
orderBy: { startsAt: 'asc' },
|
||||
}),
|
||||
db.attendance.findMany({
|
||||
where: { classSessionId: { in: sessionIds }, attendeeId: attendee.id },
|
||||
select: { classSessionId: true, status: true },
|
||||
}),
|
||||
db.slotRelease.findMany({
|
||||
where: { classSessionId: { in: sessionIds }, attendeeId: attendee.id },
|
||||
select: { classSessionId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const attendanceBySession = new Map(
|
||||
attendances.map((attendance) => [attendance.classSessionId, attendance.status]),
|
||||
);
|
||||
const releasedSessionIds = new Set(releases.map((release) => release.classSessionId));
|
||||
|
||||
return ok({
|
||||
attendeeId: attendee.id,
|
||||
fullName: attendee.fullName,
|
||||
groupName: attendee.group.name,
|
||||
sessions: sessions.map((session) => {
|
||||
const attendanceStatus = attendanceBySession.get(session.id) ?? null;
|
||||
return {
|
||||
sessionId: session.id,
|
||||
groupName: attendee.group.name,
|
||||
startsAt: session.startsAt.toISOString(),
|
||||
notified:
|
||||
attendanceStatus === 'EXCUSED' || releasedSessionIds.has(session.id),
|
||||
attendanceStatus,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { PublicNotifyAbsence } from '@gruperly/shared';
|
||||
import { PublicNotifyAbsenceSchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { resultJson } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { PublicNotifyAbsenceUseCase } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
// Ruta pública (whitelistada en session-auth): el alumno avisa su ausencia con token.
|
||||
route.post('/notify-absence', validate.json(PublicNotifyAbsenceSchema), async (c) => {
|
||||
const payload = c.req.valid('json') as PublicNotifyAbsence;
|
||||
const useCase = new PublicNotifyAbsenceUseCase();
|
||||
const result = await useCase.execute(payload);
|
||||
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,48 @@
|
||||
import type {
|
||||
NotifyAbsenceResult,
|
||||
ProblemDetails,
|
||||
PublicNotifyAbsence,
|
||||
Result,
|
||||
} from '@gruperly/shared';
|
||||
import { err } from '@gruperly/shared';
|
||||
import { notFoundResourceProblem } from '@/http/problem-builders';
|
||||
import prisma, { UnitOfWork } from '@/lib/prisma';
|
||||
import { absenceReleaseHours, type ClassesDb, registerAbsence } from '../../lib';
|
||||
|
||||
type PublicNotifyAbsenceDeps = {
|
||||
db?: Pick<ClassesDb, 'attendee' | 'classSession'>;
|
||||
unitOfWork?: UnitOfWork;
|
||||
now?: Date;
|
||||
releaseHours?: number;
|
||||
};
|
||||
|
||||
export class PublicNotifyAbsenceUseCase {
|
||||
constructor(private readonly deps: PublicNotifyAbsenceDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
payload: PublicNotifyAbsence,
|
||||
): Promise<Result<NotifyAbsenceResult, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
|
||||
const now = this.deps.now ?? new Date();
|
||||
const releaseHours = this.deps.releaseHours ?? absenceReleaseHours();
|
||||
|
||||
const attendee = await db.attendee.findUnique({
|
||||
where: { notifyToken: payload.token },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!attendee) {
|
||||
return err(notFoundResourceProblem('Attendee', 'notify token'));
|
||||
}
|
||||
|
||||
return registerAbsence(
|
||||
{ db, unitOfWork },
|
||||
{
|
||||
attendeeId: attendee.id,
|
||||
classSessionId: payload.classSessionId,
|
||||
now,
|
||||
releaseHours,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { TodayClassesQuery } from '@gruperly/shared';
|
||||
import { TodayClassesQuerySchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { GetSessionStudents } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.get('/:sessionId/students', validate.query(TodayClassesQuerySchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
|
||||
const sessionId = c.req.param('sessionId');
|
||||
const query = c.req.valid('query') as TodayClassesQuery;
|
||||
const useCase = new GetSessionStudents();
|
||||
const result = await useCase.execute(sessionId, user.id, query);
|
||||
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type {
|
||||
ProblemDetails,
|
||||
Result,
|
||||
SessionStudents,
|
||||
TodayClassesQuery,
|
||||
} from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { DEFAULT_TIME_ZONE, startOfDayInTimeZone } from '@/modules/home/lib/scheduler';
|
||||
import {
|
||||
type ClassesDb,
|
||||
ensureSessionAccess,
|
||||
resolvePaymentStatus,
|
||||
sessionNotFound,
|
||||
} from '../../lib';
|
||||
|
||||
type GetSessionStudentsDeps = {
|
||||
db?: Pick<
|
||||
ClassesDb,
|
||||
'classSession' | 'attendee' | 'payment' | 'attendance' | 'slotRelease'
|
||||
> &
|
||||
Pick<PrismaClient, 'group'>;
|
||||
now?: Date;
|
||||
timeZone?: string;
|
||||
};
|
||||
|
||||
export class GetSessionStudents {
|
||||
constructor(private readonly deps: GetSessionStudentsDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
query: TodayClassesQuery = {},
|
||||
): Promise<Result<SessionStudents, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
const now = this.deps.now ?? new Date();
|
||||
const timeZone = this.deps.timeZone ?? query.timeZone ?? DEFAULT_TIME_ZONE;
|
||||
|
||||
const session = await db.classSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
include: {
|
||||
group: {
|
||||
include: { members: { where: { userId } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return err(sessionNotFound(sessionId));
|
||||
}
|
||||
|
||||
const access = ensureSessionAccess(session, userId);
|
||||
if (!access.ok) {
|
||||
return access;
|
||||
}
|
||||
|
||||
const startOfToday = startOfDayInTimeZone(now, timeZone);
|
||||
|
||||
const [attendees, payments, attendances, releases] = await Promise.all([
|
||||
db.attendee.findMany({
|
||||
where: { groupId: session.groupId },
|
||||
orderBy: { fullName: 'asc' },
|
||||
}),
|
||||
db.payment.findMany({
|
||||
where: { groupId: session.groupId },
|
||||
select: { attendeeId: true, status: true, dueDate: true },
|
||||
}),
|
||||
db.attendance.findMany({
|
||||
where: { classSessionId: sessionId },
|
||||
select: { attendeeId: true, status: true },
|
||||
}),
|
||||
db.slotRelease.findMany({
|
||||
where: { classSessionId: sessionId },
|
||||
select: { attendeeId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const paymentsByAttendee = new Map<string, typeof payments>();
|
||||
for (const payment of payments) {
|
||||
const list = paymentsByAttendee.get(payment.attendeeId) ?? [];
|
||||
list.push(payment);
|
||||
paymentsByAttendee.set(payment.attendeeId, list);
|
||||
}
|
||||
|
||||
const attendanceByAttendee = new Map(
|
||||
attendances.map((attendance) => [attendance.attendeeId, attendance.status]),
|
||||
);
|
||||
const releasedAttendeeIds = new Set(releases.map((release) => release.attendeeId));
|
||||
|
||||
return ok({
|
||||
sessionId: session.id,
|
||||
groupId: session.groupId,
|
||||
groupName: session.group.name,
|
||||
startsAt: session.startsAt.toISOString(),
|
||||
students: attendees.map((attendee) => {
|
||||
const attendanceStatus = attendanceByAttendee.get(attendee.id) ?? null;
|
||||
return {
|
||||
attendeeId: attendee.id,
|
||||
fullName: attendee.fullName,
|
||||
paymentStatus: resolvePaymentStatus(
|
||||
paymentsByAttendee.get(attendee.id) ?? [],
|
||||
startOfToday,
|
||||
),
|
||||
attendanceStatus,
|
||||
notifiedAbsence:
|
||||
attendanceStatus === 'EXCUSED' || releasedAttendeeIds.has(attendee.id),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
23
apps/backend/src/modules/classes/features/today/route.ts
Normal file
23
apps/backend/src/modules/classes/features/today/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { TodayClassesQuery } from '@gruperly/shared';
|
||||
import { TodayClassesQuerySchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { GetTodayClasses } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.get('/today', validate.query(TodayClassesQuerySchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
|
||||
const query = c.req.valid('query') as TodayClassesQuery;
|
||||
const useCase = new GetTodayClasses();
|
||||
const result = await useCase.execute(user.id, query);
|
||||
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
82
apps/backend/src/modules/classes/features/today/use-case.ts
Normal file
82
apps/backend/src/modules/classes/features/today/use-case.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { ClassTodayList, ProblemDetails, Result, TodayClassesQuery } from '@gruperly/shared';
|
||||
import { ok } from '@gruperly/shared';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { buildGroupWhereForUser } from '@/modules/groups/lib/helpers';
|
||||
import { DEFAULT_TIME_ZONE } from '@/modules/home/lib/scheduler';
|
||||
import {
|
||||
buildTodayCandidates,
|
||||
type ClassesDb,
|
||||
ensureTodaySessions,
|
||||
toClassToday,
|
||||
} from '../../lib';
|
||||
|
||||
type GetTodayClassesDeps = {
|
||||
db?: Pick<ClassesDb, 'group' | 'classSession' | 'attendance'>;
|
||||
now?: Date;
|
||||
timeZone?: string;
|
||||
};
|
||||
|
||||
export class GetTodayClasses {
|
||||
constructor(private readonly deps: GetTodayClassesDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
userId: string,
|
||||
query: TodayClassesQuery = {},
|
||||
): Promise<Result<ClassTodayList, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
const now = this.deps.now ?? new Date();
|
||||
const timeZone = this.deps.timeZone ?? query.timeZone ?? DEFAULT_TIME_ZONE;
|
||||
|
||||
const groups = await db.group.findMany({
|
||||
where: buildGroupWhereForUser(userId),
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
days: true,
|
||||
time: true,
|
||||
capacity: true,
|
||||
_count: {
|
||||
select: { attendees: { where: { status: 'ACTIVE' } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const candidates = buildTodayCandidates(groups, now, timeZone);
|
||||
if (candidates.length === 0) {
|
||||
return ok({ data: [] });
|
||||
}
|
||||
|
||||
const sessionIds = await ensureTodaySessions(db, candidates);
|
||||
|
||||
const [sessions, markedAttendances] = await Promise.all([
|
||||
db.classSession.findMany({
|
||||
where: { id: { in: sessionIds } },
|
||||
include: {
|
||||
group: { select: { id: true, name: true, capacity: true } },
|
||||
},
|
||||
orderBy: { startsAt: 'asc' },
|
||||
}),
|
||||
db.attendance.findMany({
|
||||
where: {
|
||||
classSessionId: { in: sessionIds },
|
||||
status: { in: ['PRESENT', 'ABSENT'] },
|
||||
},
|
||||
select: { classSessionId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const markedSessionIds = new Set(markedAttendances.map((a) => a.classSessionId));
|
||||
const enrolledByGroup = new Map(groups.map((group) => [group.id, group._count.attendees]));
|
||||
|
||||
return ok({
|
||||
data: sessions.map((session) =>
|
||||
toClassToday(
|
||||
session,
|
||||
session.group,
|
||||
enrolledByGroup.get(session.groupId) ?? 0,
|
||||
markedSessionIds.has(session.id),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
1
apps/backend/src/modules/classes/index.ts
Normal file
1
apps/backend/src/modules/classes/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as classesRoutes } from './routes';
|
||||
92
apps/backend/src/modules/classes/lib/helpers.ts
Normal file
92
apps/backend/src/modules/classes/lib/helpers.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type {
|
||||
AttendeePaymentStatus,
|
||||
ClassToday,
|
||||
ProblemDetails,
|
||||
Result,
|
||||
} from '@gruperly/shared';
|
||||
import { err } from '@gruperly/shared';
|
||||
import { noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders';
|
||||
|
||||
export type ClassesDb = Pick<
|
||||
PrismaClient,
|
||||
'group' | 'classSession' | 'attendee' | 'payment' | 'attendance' | 'slotRelease'
|
||||
>;
|
||||
|
||||
type PaymentLike = {
|
||||
status: 'PENDING' | 'PAID' | 'OVERDUE' | 'CANCELLED';
|
||||
dueDate: Date;
|
||||
};
|
||||
|
||||
// Estado de cobro calculado en tiempo real (sin columna denormalizada):
|
||||
// PENDIENTE si hay un pago vencido (OVERDUE) o un PENDING cuyo vencimiento ya pasó.
|
||||
export function resolvePaymentStatus(
|
||||
payments: PaymentLike[],
|
||||
startOfToday: Date,
|
||||
): AttendeePaymentStatus {
|
||||
const hasUnpaid = payments.some(
|
||||
(payment) =>
|
||||
payment.status === 'OVERDUE'
|
||||
|| (payment.status === 'PENDING' && payment.dueDate.getTime() < startOfToday.getTime()),
|
||||
);
|
||||
return hasUnpaid ? 'PENDING' : 'UP_TO_DATE';
|
||||
}
|
||||
|
||||
export function absenceReleaseHours(): number {
|
||||
const raw = process.env.ABSENCE_RELEASE_HOURS;
|
||||
if (raw == null || raw.trim() === '') {
|
||||
return 12;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 12;
|
||||
}
|
||||
|
||||
export type ClassSessionRecord = {
|
||||
id: string;
|
||||
groupId: string;
|
||||
startsAt: Date;
|
||||
};
|
||||
|
||||
type GroupWithAccess = {
|
||||
createdById: string;
|
||||
members: { userId: string }[];
|
||||
};
|
||||
|
||||
// Verifica que el usuario sea owner o miembro del grupo dueño de la sesión.
|
||||
export function ensureSessionAccess(
|
||||
session: ClassSessionRecord & { group: GroupWithAccess },
|
||||
userId: string,
|
||||
): Result<ClassSessionRecord, ProblemDetails> {
|
||||
const isOwner = session.group.createdById === userId;
|
||||
const isMember = session.group.members.length > 0;
|
||||
if (!isOwner && !isMember) {
|
||||
return err(noGroupAccessProblem());
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: { id: session.id, groupId: session.groupId, startsAt: session.startsAt },
|
||||
};
|
||||
}
|
||||
|
||||
export function sessionNotFound(sessionId: string): ProblemDetails {
|
||||
return notFoundResourceProblem('Class session', sessionId);
|
||||
}
|
||||
|
||||
export function toClassToday(
|
||||
session: { id: string; startsAt: Date },
|
||||
group: { id: string; name: string; capacity: number | null },
|
||||
enrolledCount: number,
|
||||
hasAttendance: boolean,
|
||||
): ClassToday {
|
||||
return {
|
||||
sessionId: session.id,
|
||||
groupId: group.id,
|
||||
groupName: group.name,
|
||||
startsAt: session.startsAt.toISOString(),
|
||||
enrolledCount,
|
||||
capacity: group.capacity,
|
||||
availableSlots:
|
||||
group.capacity == null ? null : Math.max(0, group.capacity - enrolledCount),
|
||||
hasAttendance,
|
||||
};
|
||||
}
|
||||
3
apps/backend/src/modules/classes/lib/index.ts
Normal file
3
apps/backend/src/modules/classes/lib/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './helpers';
|
||||
export * from './notify';
|
||||
export * from './sessions';
|
||||
91
apps/backend/src/modules/classes/lib/notify.ts
Normal file
91
apps/backend/src/modules/classes/lib/notify.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { AttendanceStatus } from '@generated/prisma/client';
|
||||
import type { NotifyAbsenceResult, ProblemDetails, Result } from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { notFoundResourceProblem, validationProblem } from '@/http/problem-builders';
|
||||
import type { UnitOfWork } from '@/lib/prisma';
|
||||
import { type ClassesDb, sessionNotFound } from './helpers';
|
||||
|
||||
type RegisterAbsenceDeps = {
|
||||
db: Pick<ClassesDb, 'classSession' | 'attendee'>;
|
||||
unitOfWork: UnitOfWork;
|
||||
};
|
||||
|
||||
type RegisterAbsenceInput = {
|
||||
attendeeId: string;
|
||||
classSessionId: string;
|
||||
now: Date;
|
||||
releaseHours: number;
|
||||
};
|
||||
|
||||
// Núcleo compartido del aviso de ausencia (profesor autenticado y alumno con token):
|
||||
// - Marca la asistencia como EXCUSED (upsert idempotente).
|
||||
// - Si la anticipación alcanza `releaseHours`, crea/conserva un SlotRelease para liberar el cupo.
|
||||
export async function registerAbsence(
|
||||
deps: RegisterAbsenceDeps,
|
||||
input: RegisterAbsenceInput,
|
||||
): Promise<Result<NotifyAbsenceResult, ProblemDetails>> {
|
||||
const { db, unitOfWork } = deps;
|
||||
const { attendeeId, classSessionId, now, releaseHours } = input;
|
||||
|
||||
const session = await db.classSession.findUnique({ where: { id: classSessionId } });
|
||||
if (!session) {
|
||||
return err(sessionNotFound(classSessionId));
|
||||
}
|
||||
|
||||
const attendee = await db.attendee.findUnique({ where: { id: attendeeId } });
|
||||
if (!attendee) {
|
||||
return err(notFoundResourceProblem('Attendee', attendeeId));
|
||||
}
|
||||
if (attendee.groupId !== session.groupId) {
|
||||
return err(
|
||||
validationProblem({ detail: 'El alumno no pertenece al grupo de esta clase.' }),
|
||||
);
|
||||
}
|
||||
|
||||
const anticipationMs = session.startsAt.getTime() - now.getTime();
|
||||
const slotReleased = anticipationMs >= releaseHours * 60 * 60 * 1000;
|
||||
|
||||
return unitOfWork.executeResult(async (tx) => {
|
||||
await tx.attendance.upsert({
|
||||
where: {
|
||||
attendeeId_classSessionId: {
|
||||
attendeeId,
|
||||
classSessionId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
attendeeId,
|
||||
classSessionId,
|
||||
status: AttendanceStatus.EXCUSED,
|
||||
markedAt: now,
|
||||
},
|
||||
update: {
|
||||
status: AttendanceStatus.EXCUSED,
|
||||
markedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
if (slotReleased) {
|
||||
await tx.slotRelease.upsert({
|
||||
where: {
|
||||
attendeeId_classSessionId: {
|
||||
attendeeId,
|
||||
classSessionId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
attendeeId,
|
||||
classSessionId,
|
||||
isClaimed: false,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
return ok({
|
||||
classSessionId,
|
||||
notified: true,
|
||||
slotReleased,
|
||||
});
|
||||
});
|
||||
}
|
||||
64
apps/backend/src/modules/classes/lib/sessions.ts
Normal file
64
apps/backend/src/modules/classes/lib/sessions.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { WeekDay } from '@gruperly/shared';
|
||||
import { todayOccurrence } from '@/modules/home/lib/scheduler';
|
||||
import type { ClassesDb } from './helpers';
|
||||
|
||||
export type TodaySessionCandidate = {
|
||||
groupId: string;
|
||||
startsAt: Date;
|
||||
};
|
||||
|
||||
type ScheduledGroup = {
|
||||
id: string;
|
||||
days: WeekDay[] | null;
|
||||
time: string | null;
|
||||
};
|
||||
|
||||
// Calcula qué grupos tienen clase hoy (según days/time y la zona horaria del profesor).
|
||||
export function buildTodayCandidates(
|
||||
groups: ScheduledGroup[],
|
||||
now: Date,
|
||||
timeZone: string,
|
||||
): TodaySessionCandidate[] {
|
||||
const candidates: TodaySessionCandidate[] = [];
|
||||
for (const group of groups) {
|
||||
if (!group.days || group.days.length === 0 || !group.time) {
|
||||
continue;
|
||||
}
|
||||
const startsAt = todayOccurrence({
|
||||
days: group.days,
|
||||
time: group.time,
|
||||
now,
|
||||
timeZone,
|
||||
});
|
||||
if (startsAt) {
|
||||
candidates.push({ groupId: group.id, startsAt });
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
// Materializa (upsert idempotente) las ClassSession del día y devuelve sus ids.
|
||||
export async function ensureTodaySessions(
|
||||
db: Pick<ClassesDb, 'classSession'>,
|
||||
candidates: TodaySessionCandidate[],
|
||||
): Promise<string[]> {
|
||||
const sessionIds: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const session = await db.classSession.upsert({
|
||||
where: {
|
||||
groupId_startsAt: {
|
||||
groupId: candidate.groupId,
|
||||
startsAt: candidate.startsAt,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
groupId: candidate.groupId,
|
||||
startsAt: candidate.startsAt,
|
||||
},
|
||||
update: {},
|
||||
select: { id: true },
|
||||
});
|
||||
sessionIds.push(session.id);
|
||||
}
|
||||
return sessionIds;
|
||||
}
|
||||
18
apps/backend/src/modules/classes/routes.ts
Normal file
18
apps/backend/src/modules/classes/routes.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Hono } from 'hono';
|
||||
import markAttendanceRoute from './features/mark-attendance/route';
|
||||
import notifyAbsenceRoute from './features/notify-absence/route';
|
||||
import publicDetailsRoute from './features/public-details/route';
|
||||
import publicNotifyRoute from './features/public-notify/route';
|
||||
import sessionStudentsRoute from './features/session-students/route';
|
||||
import todayRoute from './features/today/route';
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
routes.route('/', todayRoute);
|
||||
routes.route('/', sessionStudentsRoute);
|
||||
routes.route('/', markAttendanceRoute);
|
||||
routes.route('/', notifyAbsenceRoute);
|
||||
routes.route('/', publicDetailsRoute);
|
||||
routes.route('/', publicNotifyRoute);
|
||||
|
||||
export default routes;
|
||||
@@ -29,7 +29,9 @@ export async function promoteWaitlistEntryRecord(
|
||||
entry: GroupWaitlistEntryRecord,
|
||||
): Promise<Result<AttendeeDto, ProblemDetails>> {
|
||||
if (group.capacity !== null) {
|
||||
const currentCount = await db.attendee.count({ where: { groupId: group.id } });
|
||||
const currentCount = await db.attendee.count({
|
||||
where: { groupId: group.id, status: 'ACTIVE' },
|
||||
});
|
||||
if (currentCount >= group.capacity) {
|
||||
return err(capacityReachedProblem(group.capacity));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Hono } from 'hono';
|
||||
import analyticsRoutes from '../analytics/routes';
|
||||
import addToGroupWaitlistRoute from '../attendees/features/add-to-waitlist/route';
|
||||
import bulkCreateAttendeesRoute from '../attendees/features/bulk-create/route';
|
||||
import createAttendeeRoute from '../attendees/features/create/route';
|
||||
@@ -21,6 +22,7 @@ routes.route('/', bulkCreateAttendeesRoute);
|
||||
routes.route('/', getByIdRoute);
|
||||
routes.route('/', addToGroupWaitlistRoute);
|
||||
routes.route('/', groupWaitlistRoutes);
|
||||
routes.route('/', analyticsRoutes);
|
||||
routes.route('/', removeAttendeeRoute);
|
||||
|
||||
export default routes;
|
||||
@@ -22,7 +22,7 @@ export type NextClassOccurrence = {
|
||||
};
|
||||
|
||||
// Convierte un tiempo de "pared" (wall clock) expresado en `timeZone` al instante absoluto.
|
||||
function zonedWallClockToUtc(wall: string, timeZone: string): Date {
|
||||
export function zonedWallClockToUtc(wall: string, timeZone: string): Date {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(wall);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid wall clock: ${wall}`);
|
||||
@@ -64,7 +64,7 @@ function zonedWallClockToUtc(wall: string, timeZone: string): Date {
|
||||
return new Date(asUtc.getTime() + offsetMs);
|
||||
}
|
||||
|
||||
function wallDateInTimeZone(now: Date, timeZone: string): { year: number; month: number; day: number } {
|
||||
export function wallDateInTimeZone(now: Date, timeZone: string): { year: number; month: number; day: number } {
|
||||
const wall = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
@@ -143,4 +143,46 @@ export function nextClassOccurrence(opt: {
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
// Instante absoluto de la clase de HOY (aunque la hora ya haya pasado), o null si el grupo
|
||||
// no tiene clase programada para el día de `now` en `timeZone`.
|
||||
export function todayOccurrence(opt: {
|
||||
days: WeekDay[];
|
||||
time: string;
|
||||
now: Date;
|
||||
timeZone: string;
|
||||
}): Date | null {
|
||||
const { days, time, now, timeZone } = opt;
|
||||
|
||||
if (days.length === 0 || !time) {
|
||||
return null;
|
||||
}
|
||||
const [hour, minute] = time.split(':').map(Number);
|
||||
if (
|
||||
Number.isNaN(hour) ||
|
||||
Number.isNaN(minute) ||
|
||||
hour < 0 ||
|
||||
hour > 23 ||
|
||||
minute < 0 ||
|
||||
minute > 59
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { year, month, day } = wallDateInTimeZone(now, timeZone);
|
||||
const dow = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
|
||||
const dayMatches = days.some((d) => WEEKDAY_TO_DOW[d] === dow);
|
||||
if (!dayMatches) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wall = `${year}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}`;
|
||||
return zonedWallClockToUtc(wall, timeZone);
|
||||
}
|
||||
|
||||
// Primer instante del día de `now` en `timeZone` (medianoche local).
|
||||
export function startOfDayInTimeZone(now: Date, timeZone: string): Date {
|
||||
const { year, month, day } = wallDateInTimeZone(now, timeZone);
|
||||
return zonedWallClockToUtc(`${year}-${pad(month)}-${pad(day)}T00:00`, timeZone);
|
||||
}
|
||||
@@ -55,7 +55,9 @@ export class JoinViaInvite {
|
||||
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
|
||||
|
||||
if (group.capacity !== null) {
|
||||
const currentCount = await db.attendee.count({ where: { groupId: group.id } });
|
||||
const currentCount = await db.attendee.count({
|
||||
where: { groupId: group.id, status: 'ACTIVE' },
|
||||
});
|
||||
|
||||
if (currentCount >= group.capacity) {
|
||||
const existingWaitlistEntry = await db.groupWaitlistEntry.findFirst({
|
||||
|
||||
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 } };
|
||||
}
|
||||
79
apps/backend/test/analytics-risk.test.ts
Normal file
79
apps/backend/test/analytics-risk.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
classifyRisk,
|
||||
lastAttendedAt,
|
||||
monthlyAttendanceRate,
|
||||
trailingAbsentStreak,
|
||||
} from '@/modules/analytics/lib/risk';
|
||||
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
const at = (offsetDays: number) => new Date(Date.now() - offsetDays * day);
|
||||
|
||||
function entry(status: 'PRESENT' | 'ABSENT' | 'EXCUSED', offsetDays: number) {
|
||||
return { status, occurredAt: at(offsetDays) };
|
||||
}
|
||||
|
||||
describe('trailingAbsentStreak', () => {
|
||||
it('devuelve 0 sin registros', () => {
|
||||
expect(trailingAbsentStreak([])).toBe(0);
|
||||
});
|
||||
|
||||
it('cuenta ausencias consecutivas sin justificar desde la más reciente', () => {
|
||||
expect(trailingAbsentStreak([entry('ABSENT', 0)])).toBe(1);
|
||||
expect(trailingAbsentStreak([entry('ABSENT', 0), entry('ABSENT', 7), entry('ABSENT', 14)])).toBe(3);
|
||||
});
|
||||
|
||||
it('rompe la cadena con PRESENT o EXCUSED', () => {
|
||||
expect(
|
||||
trailingAbsentStreak([entry('ABSENT', 0), entry('ABSENT', 7), entry('PRESENT', 14)]),
|
||||
).toBe(2);
|
||||
expect(trailingAbsentStreak([entry('EXCUSED', 0), entry('ABSENT', 7)])).toBe(0);
|
||||
expect(trailingAbsentStreak([entry('PRESENT', 0)])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lastAttendedAt', () => {
|
||||
it('devuelve la clase PRESENT más reciente', () => {
|
||||
const result = lastAttendedAt([
|
||||
entry('PRESENT', 0),
|
||||
entry('ABSENT', 7),
|
||||
entry('ABSENT', 14),
|
||||
]);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.getTime()).toBe(at(0).getTime());
|
||||
});
|
||||
|
||||
it('devuelve null si nunca asistió', () => {
|
||||
expect(lastAttendedAt([entry('ABSENT', 0), entry('EXCUSED', 7)])).toBeNull();
|
||||
expect(lastAttendedAt([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthlyAttendanceRate', () => {
|
||||
it('razón de PRESENT sobre el total de sesiones', () => {
|
||||
expect(monthlyAttendanceRate([entry('PRESENT', 0), entry('PRESENT', 7), entry('ABSENT', 14)], 5)).toBe(40);
|
||||
});
|
||||
|
||||
it('devuelve 0 sin sesiones o sin presentes', () => {
|
||||
expect(monthlyAttendanceRate([], 3)).toBe(0);
|
||||
expect(monthlyAttendanceRate([entry('ABSENT', 0)], 3)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyRisk', () => {
|
||||
it('HIGH con 3 o más ausencias consecutivas', () => {
|
||||
expect(classifyRisk(3, 80)).toBe('HIGH');
|
||||
expect(classifyRisk(5, 20)).toBe('HIGH');
|
||||
});
|
||||
|
||||
it('MEDIUM con 2 ausencias consecutivas o asistencia menor al 50%', () => {
|
||||
expect(classifyRisk(2, 90)).toBe('MEDIUM');
|
||||
expect(classifyRisk(1, 40)).toBe('MEDIUM');
|
||||
expect(classifyRisk(2, 40)).toBe('MEDIUM');
|
||||
});
|
||||
|
||||
it('sin riesgo si no cumple ningún umbral', () => {
|
||||
expect(classifyRisk(1, 60)).toBeNull();
|
||||
expect(classifyRisk(0, 60)).toBeNull();
|
||||
});
|
||||
});
|
||||
325
apps/backend/test/analytics.test.ts
Normal file
325
apps/backend/test/analytics.test.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
const db = {
|
||||
group: {
|
||||
findUnique: mock(),
|
||||
},
|
||||
classSession: {
|
||||
findMany: mock(),
|
||||
},
|
||||
attendee: {
|
||||
findMany: mock(),
|
||||
findFirst: mock(),
|
||||
findUnique: mock(),
|
||||
update: mock(),
|
||||
},
|
||||
attendance: {
|
||||
findMany: mock(),
|
||||
},
|
||||
slotRelease: {
|
||||
count: 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 prisma from '@/lib/prisma';
|
||||
import { analyticsRoutes, studentStatusRoutes } 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).toISOString();
|
||||
|
||||
function makeApp(userValue: unknown) {
|
||||
const app = new Hono();
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('user', userValue as never);
|
||||
await next();
|
||||
});
|
||||
app.route('/groups', analyticsRoutes);
|
||||
app.route('/students', studentStatusRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
function makeGroup(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'group-1',
|
||||
name: 'Funcional',
|
||||
createdById: userId,
|
||||
members: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('analytics routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /groups/:groupId/analytics', () => {
|
||||
it('calcula métricas de asistencia de los últimos 30 días', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(makeGroup());
|
||||
prisma.classSession.findMany.mockResolvedValue([{ id: 's-1' }, { id: 's-2' }]);
|
||||
prisma.attendance.findMany.mockResolvedValue([
|
||||
{ status: 'PRESENT' },
|
||||
{ status: 'PRESENT' },
|
||||
{ status: 'ABSENT' },
|
||||
{ status: 'EXCUSED' },
|
||||
]);
|
||||
prisma.slotRelease.count.mockResolvedValue(2);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/groups/group-1/analytics');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
attendanceRate: 50,
|
||||
totalPresent: 2,
|
||||
totalClasses: 2,
|
||||
recoveredSlots: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('devuelve 0 en todas las métricas sin clases en el período', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(makeGroup());
|
||||
prisma.classSession.findMany.mockResolvedValue([]);
|
||||
prisma.attendance.findMany.mockResolvedValue([]);
|
||||
prisma.slotRelease.count.mockResolvedValue(0);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/groups/group-1/analytics');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
attendanceRate: 0,
|
||||
totalPresent: 0,
|
||||
totalClasses: 0,
|
||||
recoveredSlots: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('prohíbe el acceso a quien no es owner ni miembro', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(
|
||||
makeGroup({ createdById: 'someone-else' }),
|
||||
);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/groups/group-1/analytics');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ code: 'group_access_denied' });
|
||||
});
|
||||
|
||||
it('devuelve 404 si el grupo no existe', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(null);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/groups/group-1/analytics');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.json()).toMatchObject({ code: 'not_found' });
|
||||
});
|
||||
|
||||
it('rechaza requests sin sesión', async () => {
|
||||
const res = await makeApp(null).request('/groups/group-1/analytics');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /groups/:groupId/students-at-risk', () => {
|
||||
const sessions = [at(0), at(7), at(14), at(21)];
|
||||
|
||||
it('clasifica HIGH y MEDIUM según el historial de asistencia', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(makeGroup());
|
||||
prisma.classSession.findMany.mockResolvedValue(sessions.map((startsAt) => ({ id: 's', startsAt })));
|
||||
prisma.attendee.findMany.mockResolvedValue([
|
||||
{ id: 'a-1', fullName: 'Ana Pérez', phone: '+521111111111' },
|
||||
{ id: 'a-2', fullName: 'Bruno Díaz', phone: null },
|
||||
]);
|
||||
prisma.attendance.findMany.mockResolvedValue([
|
||||
{ attendeeId: 'a-1', status: 'ABSENT', classSession: { startsAt: new Date(at(0)) } },
|
||||
{ attendeeId: 'a-1', status: 'ABSENT', classSession: { startsAt: new Date(at(7)) } },
|
||||
{ attendeeId: 'a-1', status: 'ABSENT', classSession: { startsAt: new Date(at(14)) } },
|
||||
{ attendeeId: 'a-1', status: 'PRESENT', classSession: { startsAt: new Date(at(21)) } },
|
||||
{ attendeeId: 'a-2', status: 'ABSENT', classSession: { startsAt: new Date(at(0)) } },
|
||||
{ attendeeId: 'a-2', status: 'ABSENT', classSession: { startsAt: new Date(at(7)) } },
|
||||
{ attendeeId: 'a-2', status: 'PRESENT', classSession: { startsAt: new Date(at(14)) } },
|
||||
{ attendeeId: 'a-2', status: 'PRESENT', classSession: { startsAt: new Date(at(21)) } },
|
||||
]);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/groups/group-1/students-at-risk');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
groupName: 'Funcional',
|
||||
data: [
|
||||
{
|
||||
attendeeId: 'a-1',
|
||||
fullName: 'Ana Pérez',
|
||||
phone: '+521111111111',
|
||||
riskLevel: 'HIGH',
|
||||
consecutiveAbsences: 3,
|
||||
lastAttendedAt: at(21),
|
||||
monthlyAttendanceRate: 25,
|
||||
},
|
||||
{
|
||||
attendeeId: 'a-2',
|
||||
fullName: 'Bruno Díaz',
|
||||
phone: null,
|
||||
riskLevel: 'MEDIUM',
|
||||
consecutiveAbsences: 2,
|
||||
lastAttendedAt: at(14),
|
||||
monthlyAttendanceRate: 50,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('devuelve lista vacía cuando nadie está en riesgo', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(makeGroup());
|
||||
prisma.classSession.findMany.mockResolvedValue(sessions.map((startsAt) => ({ id: 's', startsAt })));
|
||||
prisma.attendee.findMany.mockResolvedValue([
|
||||
{ id: 'a-1', fullName: 'Ana Pérez', phone: null },
|
||||
]);
|
||||
prisma.attendance.findMany.mockResolvedValue([
|
||||
{ attendeeId: 'a-1', status: 'PRESENT', classSession: { startsAt: new Date(at(0)) } },
|
||||
{ attendeeId: 'a-1', status: 'PRESENT', classSession: { startsAt: new Date(at(7)) } },
|
||||
]);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/groups/group-1/students-at-risk');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ groupName: 'Funcional', data: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /groups/:groupId/attendees/:attendeeId/history', () => {
|
||||
it('devuelve el historial de presentismo con su porcentaje', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(makeGroup());
|
||||
prisma.attendee.findFirst.mockResolvedValue({ id: 'a-1', fullName: 'Ana Pérez' });
|
||||
prisma.classSession.findMany.mockResolvedValue([
|
||||
{ id: 's-2', startsAt: new Date(at(7)) },
|
||||
{ id: 's-1', startsAt: new Date(at(0)) },
|
||||
]);
|
||||
prisma.attendance.findMany.mockResolvedValue([
|
||||
{ classSessionId: 's-1', status: 'PRESENT' },
|
||||
{ classSessionId: 's-2', status: 'ABSENT' },
|
||||
]);
|
||||
|
||||
const res = await makeApp({ id: userId }).request(
|
||||
'/groups/group-1/attendees/a-1/history',
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
attendeeId: 'a-1',
|
||||
fullName: 'Ana Pérez',
|
||||
groupId: 'group-1',
|
||||
groupName: 'Funcional',
|
||||
attendanceRate: 50,
|
||||
sessions: [
|
||||
{ classSessionId: 's-1', startsAt: at(0), status: 'PRESENT' },
|
||||
{ classSessionId: 's-2', startsAt: at(7), status: 'ABSENT' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('devuelve 404 si el alumno no pertenece al grupo', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(makeGroup());
|
||||
prisma.attendee.findFirst.mockResolvedValue(null);
|
||||
|
||||
const res = await makeApp({ id: userId }).request(
|
||||
'/groups/group-1/attendees/nope/history',
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.json()).toMatchObject({ code: 'not_found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /students/:studentId/status', () => {
|
||||
function makeAttendee(status: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'a-1',
|
||||
status,
|
||||
group: { createdById: userId, members: [] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('cambia el estado del alumno', async () => {
|
||||
prisma.attendee.findUnique.mockResolvedValue(makeAttendee('ACTIVE'));
|
||||
prisma.attendee.update.mockResolvedValue({ id: 'a-1', status: 'DROPPED' });
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/students/a-1/status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'DROPPED' }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ attendeeId: 'a-1', status: 'DROPPED' });
|
||||
expect(prisma.attendee.update).toHaveBeenCalledWith({
|
||||
where: { id: 'a-1' },
|
||||
data: { status: 'DROPPED' },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('es idempotente si el estado no cambia', async () => {
|
||||
prisma.attendee.findUnique.mockResolvedValue(makeAttendee('PAUSED'));
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/students/a-1/status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'PAUSED' }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ attendeeId: 'a-1', status: 'PAUSED' });
|
||||
expect(prisma.attendee.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prohíbe el acceso a quien no es owner ni miembro', async () => {
|
||||
prisma.attendee.findUnique.mockResolvedValue(
|
||||
makeAttendee('ACTIVE', { group: { createdById: 'someone-else', members: [] } }),
|
||||
);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/students/a-1/status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'PAUSED' }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ code: 'group_access_denied' });
|
||||
});
|
||||
|
||||
it('rechaza estados fuera del contrato', async () => {
|
||||
const res = await makeApp({ id: userId }).request('/students/a-1/status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'HOLD' }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(prisma.attendee.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rechaza requests sin sesión', async () => {
|
||||
const res = await makeApp(null).request('/students/a-1/status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'ACTIVE' }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ const attendee = {
|
||||
guardianName: 'Luis Pérez',
|
||||
guardianPhone: null,
|
||||
notes: null,
|
||||
notifyToken: null,
|
||||
createdAt: new Date('2026-08-01T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
|
||||
};
|
||||
|
||||
514
apps/backend/test/classes.test.ts
Normal file
514
apps/backend/test/classes.test.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
const db = {
|
||||
group: {
|
||||
findMany: mock(),
|
||||
},
|
||||
classSession: {
|
||||
upsert: mock(),
|
||||
findMany: mock(),
|
||||
findUnique: mock(),
|
||||
},
|
||||
attendee: {
|
||||
findMany: mock(),
|
||||
findUnique: mock(),
|
||||
},
|
||||
payment: {
|
||||
findMany: mock(),
|
||||
},
|
||||
attendance: {
|
||||
findMany: mock(),
|
||||
upsert: mock(),
|
||||
},
|
||||
slotRelease: {
|
||||
findMany: mock(),
|
||||
upsert: 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 prisma from '@/lib/prisma';
|
||||
import { classesRoutes } from '@/modules/classes';
|
||||
|
||||
const userId = 'user-1';
|
||||
const UTC_WEEKDAYS = [
|
||||
'SUNDAY',
|
||||
'MONDAY',
|
||||
'TUESDAY',
|
||||
'WEDNESDAY',
|
||||
'THURSDAY',
|
||||
'FRIDAY',
|
||||
'SATURDAY',
|
||||
] as const;
|
||||
const todayUtc = UTC_WEEKDAYS[new Date().getUTCDay()];
|
||||
|
||||
function makeApp(userValue: unknown) {
|
||||
const app = new Hono();
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('user', userValue as never);
|
||||
await next();
|
||||
});
|
||||
app.route('/classes', classesRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
function makeSession(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'session-1',
|
||||
groupId: 'group-1',
|
||||
startsAt: todayAtUtc(),
|
||||
group: {
|
||||
id: 'group-1',
|
||||
name: 'Funcional',
|
||||
createdById: userId,
|
||||
members: [],
|
||||
capacity: 10,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// 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', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.ABSENCE_RELEASE_HOURS;
|
||||
});
|
||||
|
||||
describe('GET /classes/today', () => {
|
||||
it('materializa y lista las clases de hoy del profesor', async () => {
|
||||
const startsAt = todayAtUtc();
|
||||
prisma.group.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'group-1',
|
||||
name: 'Funcional',
|
||||
days: [todayUtc],
|
||||
time: '19:00',
|
||||
capacity: 10,
|
||||
_count: { attendees: 8 },
|
||||
},
|
||||
]);
|
||||
prisma.classSession.upsert.mockResolvedValue({ id: 'session-1' });
|
||||
prisma.classSession.findMany.mockResolvedValue([
|
||||
{ id: 'session-1', groupId: 'group-1', startsAt, group: { id: 'group-1', name: 'Funcional', capacity: 10 } },
|
||||
]);
|
||||
prisma.attendance.findMany.mockResolvedValue([{ classSessionId: 'session-1' }]);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/classes/today?timeZone=UTC');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
data: [
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
groupId: 'group-1',
|
||||
groupName: 'Funcional',
|
||||
startsAt: startsAt.toISOString(),
|
||||
enrolledCount: 8,
|
||||
capacity: 10,
|
||||
availableSlots: 2,
|
||||
hasAttendance: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(prisma.classSession.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.classSession.upsert).toHaveBeenCalledWith({
|
||||
where: {
|
||||
groupId_startsAt: { groupId: 'group-1', startsAt },
|
||||
},
|
||||
create: { groupId: 'group-1', startsAt },
|
||||
update: {},
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('devuelve lista vacía cuando ningún grupo tiene clase hoy', async () => {
|
||||
prisma.group.findMany.mockResolvedValue([]);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/classes/today?timeZone=UTC');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ data: [] });
|
||||
expect(prisma.classSession.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rechaza requests sin sesión', async () => {
|
||||
const res = await makeApp(null).request('/classes/today');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.json()).toMatchObject({ code: 'unauthorized' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /classes/:sessionId/students', () => {
|
||||
it('incluye estado de pago dinámico, asistencia y ausencia avisada', async () => {
|
||||
prisma.classSession.findUnique.mockResolvedValue(makeSession());
|
||||
prisma.attendee.findMany.mockResolvedValue([
|
||||
{ id: 'attendee-1', fullName: 'Ana Pérez' },
|
||||
{ id: 'attendee-2', fullName: 'Bruno Díaz' },
|
||||
]);
|
||||
prisma.payment.findMany.mockResolvedValue([
|
||||
{ attendeeId: 'attendee-1', status: 'OVERDUE', dueDate: new Date('2026-09-01T00:00:00.000Z') },
|
||||
{ attendeeId: 'attendee-2', status: 'PAID', dueDate: new Date('2026-09-01T00:00:00.000Z') },
|
||||
]);
|
||||
prisma.attendance.findMany.mockResolvedValue([
|
||||
{ attendeeId: 'attendee-2', status: 'EXCUSED' },
|
||||
]);
|
||||
prisma.slotRelease.findMany.mockResolvedValue([]);
|
||||
|
||||
const res = await makeApp({ id: userId }).request(
|
||||
'/classes/session-1/students?timeZone=UTC',
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toMatchObject({
|
||||
sessionId: 'session-1',
|
||||
groupId: 'group-1',
|
||||
groupName: 'Funcional',
|
||||
students: [
|
||||
{
|
||||
attendeeId: 'attendee-1',
|
||||
fullName: 'Ana Pérez',
|
||||
paymentStatus: 'PENDING',
|
||||
attendanceStatus: null,
|
||||
notifiedAbsence: false,
|
||||
},
|
||||
{
|
||||
attendeeId: 'attendee-2',
|
||||
fullName: 'Bruno Díaz',
|
||||
paymentStatus: 'UP_TO_DATE',
|
||||
attendanceStatus: 'EXCUSED',
|
||||
notifiedAbsence: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('prohíbe el acceso a quien no es owner ni miembro del grupo', async () => {
|
||||
prisma.classSession.findUnique.mockResolvedValue(
|
||||
makeSession({
|
||||
group: {
|
||||
id: 'group-1',
|
||||
name: 'Funcional',
|
||||
createdById: 'someone-else',
|
||||
members: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/classes/session-1/students');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ code: 'group_access_denied' });
|
||||
});
|
||||
|
||||
it('devuelve 404 si la sesión no existe', async () => {
|
||||
prisma.classSession.findUnique.mockResolvedValue(null);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/classes/nope/students');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.json()).toMatchObject({ code: 'not_found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /classes/:sessionId/attendance', () => {
|
||||
const validBody = {
|
||||
classSessionId: 'session-1',
|
||||
records: [
|
||||
{ attendeeId: 'attendee-1', status: 'PRESENT' },
|
||||
{ attendeeId: 'attendee-2', status: 'ABSENT' },
|
||||
],
|
||||
};
|
||||
|
||||
it('guarda la asistencia masiva con upsert', async () => {
|
||||
prisma.classSession.findUnique.mockResolvedValue(makeSession());
|
||||
prisma.attendee.findMany.mockResolvedValue([
|
||||
{ id: 'attendee-1' },
|
||||
{ id: 'attendee-2' },
|
||||
]);
|
||||
prisma.attendance.upsert.mockResolvedValue({});
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/classes/session-1/attendance', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(validBody),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ classSessionId: 'session-1', marked: 2 });
|
||||
expect(prisma.attendance.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.attendance.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
attendeeId_classSessionId: {
|
||||
attendeeId: 'attendee-1',
|
||||
classSessionId: 'session-1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rechaza cuando el classSessionId del body no coincide con la ruta', async () => {
|
||||
const res = await makeApp({ id: userId }).request('/classes/session-1/attendance', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...validBody, classSessionId: 'other-session' }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('rechaza alumnos que no pertenecen al grupo', async () => {
|
||||
prisma.classSession.findUnique.mockResolvedValue(makeSession());
|
||||
prisma.attendee.findMany.mockResolvedValue([{ id: 'attendee-1' }]);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/classes/session-1/attendance', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(validBody),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(prisma.attendance.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rechaza registros con status fuera del contrato', async () => {
|
||||
const res = await makeApp({ id: userId }).request('/classes/session-1/attendance', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
classSessionId: 'session-1',
|
||||
records: [{ attendeeId: 'attendee-1', status: 'EXCUSED' }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rechaza requests sin sesión', async () => {
|
||||
const res = await makeApp(null).request('/classes/session-1/attendance', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(validBody),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /classes/:sessionId/notify-absence (profesor)', () => {
|
||||
const notifyBody = {
|
||||
attendeeId: 'attendee-1',
|
||||
classSessionId: 'session-1',
|
||||
};
|
||||
|
||||
it('libera el cupo cuando la anticipación alcanza las 12 horas', async () => {
|
||||
const startsAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
prisma.classSession.findUnique.mockResolvedValue(makeSession({ startsAt }));
|
||||
prisma.attendee.findUnique.mockResolvedValue({
|
||||
id: 'attendee-1',
|
||||
groupId: 'group-1',
|
||||
});
|
||||
prisma.attendance.upsert.mockResolvedValue({});
|
||||
prisma.slotRelease.upsert.mockResolvedValue({});
|
||||
|
||||
const res = await makeApp({ id: userId }).request(
|
||||
'/classes/session-1/notify-absence',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(notifyBody),
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
classSessionId: 'session-1',
|
||||
notified: true,
|
||||
slotReleased: true,
|
||||
});
|
||||
expect(prisma.attendance.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.slotRelease.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marca EXCUSED sin liberar cupo si avisa con menos de 12 horas', async () => {
|
||||
const startsAt = new Date(Date.now() + 60 * 60 * 1000);
|
||||
prisma.classSession.findUnique.mockResolvedValue(makeSession({ startsAt }));
|
||||
prisma.attendee.findUnique.mockResolvedValue({
|
||||
id: 'attendee-1',
|
||||
groupId: 'group-1',
|
||||
});
|
||||
prisma.attendance.upsert.mockResolvedValue({});
|
||||
prisma.slotRelease.upsert.mockResolvedValue({});
|
||||
|
||||
const res = await makeApp({ id: userId }).request(
|
||||
'/classes/session-1/notify-absence',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(notifyBody),
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ slotReleased: false });
|
||||
expect(prisma.slotRelease.upsert).not.toHaveBeenCalled();
|
||||
expect(prisma.attendance.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('respeta el override configurable ABSENCE_RELEASE_HOURS', async () => {
|
||||
process.env.ABSENCE_RELEASE_HOURS = '4';
|
||||
const startsAt = new Date(Date.now() + 6 * 60 * 60 * 1000);
|
||||
prisma.classSession.findUnique.mockResolvedValue(makeSession({ startsAt }));
|
||||
prisma.attendee.findUnique.mockResolvedValue({
|
||||
id: 'attendee-1',
|
||||
groupId: 'group-1',
|
||||
});
|
||||
prisma.attendance.upsert.mockResolvedValue({});
|
||||
prisma.slotRelease.upsert.mockResolvedValue({});
|
||||
|
||||
const res = await makeApp({ id: userId }).request(
|
||||
'/classes/session-1/notify-absence',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(notifyBody),
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ slotReleased: true });
|
||||
expect(prisma.slotRelease.upsert).toHaveBeenCalledTimes(1);
|
||||
delete process.env.ABSENCE_RELEASE_HOURS;
|
||||
});
|
||||
|
||||
it('rechaza un alumno de otro grupo', async () => {
|
||||
prisma.classSession.findUnique.mockResolvedValue(makeSession());
|
||||
prisma.attendee.findUnique.mockResolvedValue({
|
||||
id: 'attendee-1',
|
||||
groupId: 'another-group',
|
||||
});
|
||||
|
||||
const res = await makeApp({ id: userId }).request(
|
||||
'/classes/session-1/notify-absence',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(notifyBody),
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(prisma.attendance.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rutas públicas con token', () => {
|
||||
it('GET /classes/absence devuelve 404 con token inválido', async () => {
|
||||
prisma.attendee.findUnique.mockResolvedValue(null);
|
||||
|
||||
const res = await makeApp(null).request('/classes/absence?token=bad-token');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.json()).toMatchObject({ code: 'not_found' });
|
||||
});
|
||||
|
||||
it('GET /classes/absence devuelve las clases de hoy del alumno', async () => {
|
||||
const mxWeekday = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/Mexico_City',
|
||||
weekday: 'long',
|
||||
})
|
||||
.format(new Date())
|
||||
.toUpperCase();
|
||||
const startsAt = todayAtUtc();
|
||||
|
||||
prisma.attendee.findUnique.mockResolvedValue({
|
||||
id: 'attendee-1',
|
||||
fullName: 'Ana Pérez',
|
||||
group: {
|
||||
id: 'group-1',
|
||||
name: 'Funcional',
|
||||
days: [mxWeekday],
|
||||
time: '19:00',
|
||||
},
|
||||
});
|
||||
prisma.classSession.upsert.mockResolvedValue({ id: 'session-1' });
|
||||
prisma.classSession.findMany.mockResolvedValue([
|
||||
{ id: 'session-1', groupId: 'group-1', startsAt },
|
||||
]);
|
||||
prisma.attendance.findMany.mockResolvedValue([]);
|
||||
prisma.slotRelease.findMany.mockResolvedValue([]);
|
||||
|
||||
const res = await makeApp(null).request('/classes/absence?token=valid-token');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
attendeeId: 'attendee-1',
|
||||
fullName: 'Ana Pérez',
|
||||
groupName: 'Funcinal'.replace('Funcinal', 'Funcional'),
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
groupName: 'Funcional',
|
||||
startsAt: startsAt.toISOString(),
|
||||
notified: false,
|
||||
attendanceStatus: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /classes/notify-absence público registra el aviso con token', async () => {
|
||||
const startsAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
prisma.attendee.findUnique.mockResolvedValue({
|
||||
id: 'attendee-1',
|
||||
groupId: 'group-1',
|
||||
});
|
||||
prisma.classSession.findUnique.mockResolvedValue(makeSession({ startsAt }));
|
||||
prisma.attendance.upsert.mockResolvedValue({});
|
||||
prisma.slotRelease.upsert.mockResolvedValue({});
|
||||
|
||||
const res = await makeApp(null).request('/classes/notify-absence', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: 'valid-token', classSessionId: 'session-1' }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ notified: true, slotReleased: true });
|
||||
expect(prisma.attendance.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('POST /classes/notify-absence público devuelve 404 con token inválido', async () => {
|
||||
prisma.attendee.findUnique.mockResolvedValue(null);
|
||||
|
||||
const res = await makeApp(null).request('/classes/notify-absence', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: 'bad', classSessionId: 'session-1' }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.attendance.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,18 @@ describe('session auth', () => {
|
||||
expect(isPublicApiRequest('GET', '/api/v1/attendees')).toBe(false);
|
||||
});
|
||||
|
||||
it('classifies absence notification endpoints as public (token-based)', () => {
|
||||
expect(isPublicApiRequest('GET', '/api/v1/classes/absence?token=abc')).toBe(true);
|
||||
expect(isPublicApiRequest('POST', '/api/v1/classes/notify-absence')).toBe(true);
|
||||
// Las rutas autenticadas de clases siguen protegidas.
|
||||
expect(isPublicApiRequest('GET', '/api/v1/classes/today')).toBe(false);
|
||||
expect(isPublicApiRequest('GET', '/api/v1/classes/session-1/students')).toBe(false);
|
||||
expect(isPublicApiRequest('POST', '/api/v1/classes/session-1/attendance')).toBe(false);
|
||||
expect(
|
||||
isPublicApiRequest('POST', '/api/v1/classes/session-1/notify-absence'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('allows public requests without a session', async () => {
|
||||
const app = new Hono();
|
||||
app.use('*', sessionAuthMiddleware);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"@gruperly/shared": "workspace:*",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tanstack/react-router": "^1.90.0",
|
||||
"@tanstack/react-router": "^1.170.38",
|
||||
"better-auth": "1.7.2",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.34.0",
|
||||
@@ -27,6 +27,7 @@
|
||||
"devDependencies": {
|
||||
"@gruperly/config": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/router-plugin": "^1.168.40",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { AuthShell } from './AuthShell'
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
@@ -6,7 +7,7 @@ import { useAuth } from '../../context/AuthProvider'
|
||||
import { getOnboardingStatus } from '../../lib/api'
|
||||
import { RootLayout } from './RootLayout'
|
||||
|
||||
export function AppLayoutGuard() {
|
||||
export function AppLayoutGuard({ children }: { children: ReactNode }) {
|
||||
const { user, isPending } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -30,5 +31,5 @@ export function AppLayoutGuard() {
|
||||
)
|
||||
}
|
||||
|
||||
return <RootLayout />
|
||||
return <RootLayout>{children}</RootLayout>
|
||||
}
|
||||
@@ -17,7 +17,7 @@ const BREADCRUMBS: Record<string, Crumb[]> = {
|
||||
{ label: 'Inicio', to: '/' },
|
||||
{ label: 'Ajustes' },
|
||||
],
|
||||
'/seguridad': [
|
||||
'/security': [
|
||||
{ label: 'Inicio', to: '/' },
|
||||
{ label: 'Ajustes', to: '/settings' },
|
||||
{ label: 'Seguridad' },
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTheme } from '../../context/ThemeProvider'
|
||||
import { Logo } from '../brand'
|
||||
import { Breadcrumb } from './Breadcrumb'
|
||||
import { ThemeToggle } from './ThemeToggle'
|
||||
import { UserMenu } from './UserMenu'
|
||||
|
||||
function ThemeToggle() {
|
||||
const { isDark, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||
aria-label={isDark ? 'Cambiar a tema claro' : 'Cambiar a tema oscuro'}
|
||||
className="flex size-9 items-center justify-center rounded-full border border-border text-foreground/70 transition-colors hover:bg-primary-soft hover:text-primary"
|
||||
>
|
||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-40 h-16 w-full border-b border-border bg-background/90 backdrop-blur">
|
||||
|
||||
7
apps/web/src/components/layout/PublicFooter.tsx
Normal file
7
apps/web/src/components/layout/PublicFooter.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export function PublicFooter() {
|
||||
return (
|
||||
<footer className="border-t border-border px-4 py-4 text-center text-xs text-foreground/40">
|
||||
Gruperly — Gestión sencilla de cobros y grupos
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
14
apps/web/src/components/layout/PublicHeader.tsx
Normal file
14
apps/web/src/components/layout/PublicHeader.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Logo } from '../brand'
|
||||
import { ThemeToggle } from './ThemeToggle'
|
||||
|
||||
export function PublicHeader() {
|
||||
return (
|
||||
<header className="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-surface/80 px-4 py-3 backdrop-blur-md sm:px-8">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<Logo className="h-7 w-auto" />
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Outlet } from '@tanstack/react-router'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Header } from './Header'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { BottomNav } from './BottomNav'
|
||||
|
||||
export function RootLayout() {
|
||||
export function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-dvh w-full">
|
||||
<Header />
|
||||
@@ -11,12 +11,10 @@ export function RootLayout() {
|
||||
<div className="mx-auto flex w-full max-w-7xl gap-6 lg:px-6">
|
||||
<Sidebar />
|
||||
|
||||
<main className="min-w-0 flex-1 px-4 pb-24 pt-4 lg:px-0 lg:pb-8 lg:pt-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
<main className="min-w-0 flex-1 px-4 pb-24 pt-4 lg:px-0 lg:pb-8 lg:pt-8">{children}</main>
|
||||
</div>
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
17
apps/web/src/components/layout/ThemeToggle.tsx
Normal file
17
apps/web/src/components/layout/ThemeToggle.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTheme } from '../../context/ThemeProvider'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { isDark, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||
aria-label={isDark ? 'Cambiar a tema claro' : 'Cambiar a tema oscuro'}
|
||||
className="flex size-9 items-center justify-center rounded-full border border-border text-foreground/70 transition-colors hover:bg-primary-soft hover:text-primary"
|
||||
>
|
||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
export { RootLayout } from './RootLayout'
|
||||
export { Header } from './Header'
|
||||
export { ThemeToggle } from './ThemeToggle'
|
||||
export { PublicHeader } from './PublicHeader'
|
||||
export { PublicFooter } from './PublicFooter'
|
||||
export { Sidebar } from './Sidebar'
|
||||
export { BottomNav } from './BottomNav'
|
||||
export { NAV_ITEMS, type NavItem } from './nav-items'
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export { Stepper, type StepperProps } from './Stepper'
|
||||
export { WelcomeStep } from './WelcomeStep'
|
||||
export { PaymentStep } from './PaymentStep'
|
||||
export { FirstGroupStep } from './FirstGroupStep'
|
||||
export { ConfirmationStep } from './ConfirmationStep'
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import type { AbsenceDetails } from '@gruperly/shared'
|
||||
import { useToast } from '../../components/ui'
|
||||
import { ApiError, getAbsenceDetails, publicNotifyAbsence } from '../../lib/api'
|
||||
|
||||
type AbsenceNotifyContextValue = {
|
||||
token: string
|
||||
details: AbsenceDetails | undefined
|
||||
detailsPending: boolean
|
||||
detailsFailed: boolean
|
||||
isNotified: (session: { notified: boolean; sessionId: string }) => boolean
|
||||
isNotifying: (sessionId: string) => boolean
|
||||
notify: (sessionId: string) => void
|
||||
}
|
||||
|
||||
const AbsenceNotifyContext = createContext<AbsenceNotifyContextValue | null>(null)
|
||||
|
||||
export function AbsenceNotifyProvider({ token, children }: { token: string; children: ReactNode }) {
|
||||
const toast = useToast()
|
||||
const [locallyNotified, setLocallyNotified] = useState<string[]>([])
|
||||
|
||||
const detailsQuery = useQuery({
|
||||
queryKey: ['absence-details', token],
|
||||
queryFn: () => getAbsenceDetails(token),
|
||||
enabled: Boolean(token),
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
const notifyMutation = useMutation({
|
||||
mutationFn: (sessionId: string) => publicNotifyAbsence({ token, classSessionId: sessionId }),
|
||||
onSuccess: (_data, sessionId) => {
|
||||
setLocallyNotified((prev) => [...prev, sessionId])
|
||||
toast.success('Tu profesor ya tiene registrado el aviso. Gracias por avisar.', 'Ausencia avisada')
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
if (error instanceof ApiError && error.problem?.status === 404) {
|
||||
toast.error('Este enlace ya no es válido. Consultá a tu profesor.')
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof Error ? error.message : 'No pudimos registrar el aviso.')
|
||||
},
|
||||
})
|
||||
|
||||
const value: AbsenceNotifyContextValue = {
|
||||
token,
|
||||
details: detailsQuery.data,
|
||||
detailsPending: detailsQuery.isPending,
|
||||
detailsFailed: detailsQuery.isError,
|
||||
isNotified: (session) => session.notified || locallyNotified.includes(session.sessionId),
|
||||
isNotifying: (sessionId) => notifyMutation.isPending && notifyMutation.variables === sessionId,
|
||||
notify: (sessionId) => notifyMutation.mutate(sessionId),
|
||||
}
|
||||
|
||||
return <AbsenceNotifyContext.Provider value={value}>{children}</AbsenceNotifyContext.Provider>
|
||||
}
|
||||
|
||||
export function useAbsenceNotify() {
|
||||
const context = useContext(AbsenceNotifyContext)
|
||||
if (!context) {
|
||||
throw new Error('useAbsenceNotify debe usarse dentro de <AbsenceNotifyProvider>')
|
||||
}
|
||||
return context
|
||||
}
|
||||
71
apps/web/src/features/absence-notify/AbsenceNotifyView.tsx
Normal file
71
apps/web/src/features/absence-notify/AbsenceNotifyView.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { CalendarX2, Loader2 } from 'lucide-react'
|
||||
import { PublicFooter, PublicHeader } from '../../components/layout'
|
||||
import { Badge } from '../../components/ui'
|
||||
import { AbsenceSessionRow } from './AbsenceSessionRow'
|
||||
import { useAbsenceNotify } from './AbsenceNotifyProvider'
|
||||
|
||||
export function AbsenceNotifyView() {
|
||||
const { details, detailsPending, detailsFailed } = useAbsenceNotify()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col justify-between bg-background text-primary selection:bg-accent selection:text-white">
|
||||
<PublicHeader />
|
||||
|
||||
<main className="flex flex-1 items-center justify-center p-4 sm:p-6 md:p-10">
|
||||
<div className="w-full max-w-lg space-y-6">
|
||||
{detailsPending ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20">
|
||||
<Loader2 className="size-8 animate-spin text-accent" />
|
||||
<p className="text-sm text-foreground/60">Cargando tus clases...</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detailsFailed || (!details && !detailsPending) ? (
|
||||
<div className="space-y-3 rounded-2xl border border-danger/20 bg-danger-soft p-6 text-center sm:p-8">
|
||||
<h2 className="text-xl font-bold text-danger">Enlace no válido</h2>
|
||||
<p className="mx-auto max-w-sm text-sm leading-relaxed text-foreground/70">
|
||||
No pudimos identificarte con este enlace. Consultá con tu profesor para solicitar uno nuevo.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{details ? (
|
||||
<div className="animate-step-enter space-y-5 rounded-2xl border border-border bg-surface p-6 shadow-xl sm:p-8">
|
||||
<div className="border-b border-border pb-5 text-center">
|
||||
<div className="mx-auto mb-3 flex size-14 items-center justify-center rounded-full bg-accent-soft text-accent">
|
||||
<CalendarX2 className="size-7" />
|
||||
</div>
|
||||
<Badge variant="neutral" className="mb-2">
|
||||
Aviso de ausencia
|
||||
</Badge>
|
||||
<h1 className="text-2xl font-bold text-primary">Hola, {details.fullName}</h1>
|
||||
<p className="mt-1 text-sm font-medium text-foreground/80">
|
||||
Grupo: <span className="text-primary">{details.groupName}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{details.sessions.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-border bg-primary-soft/40 px-4 py-8 text-center text-sm text-foreground/60">
|
||||
No tenés clases programadas para hoy. Si necesitás avisar de todas formas, contactá a tu
|
||||
profesor.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{details.sessions.map((session) => (
|
||||
<AbsenceSessionRow key={session.sessionId} session={session} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="text-center text-xs leading-relaxed text-foreground/50">
|
||||
Si avisás con anticipación, tu cupo se libera para una clase de recuperación.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<PublicFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
apps/web/src/features/absence-notify/AbsenceSessionRow.tsx
Normal file
44
apps/web/src/features/absence-notify/AbsenceSessionRow.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { CheckCircle2, Loader2 } from 'lucide-react'
|
||||
import { Button } from '../../components/ui'
|
||||
import { useAbsenceNotify } from './AbsenceNotifyProvider'
|
||||
|
||||
type AbsenceSession = {
|
||||
sessionId: string
|
||||
startsAt: string
|
||||
groupName: string
|
||||
notified: boolean
|
||||
}
|
||||
|
||||
export function AbsenceSessionRow({ session }: { session: AbsenceSession }) {
|
||||
const { isNotified, isNotifying, notify } = useAbsenceNotify()
|
||||
const time = new Date(session.startsAt).toLocaleTimeString('es-MX', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<li className="flex items-center justify-between gap-3 rounded-xl border border-border bg-primary-soft/30 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-primary">Hoy · {time} hs</p>
|
||||
<p className="truncate text-xs text-foreground/60">{session.groupName}</p>
|
||||
</div>
|
||||
{isNotified(session) ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-success-soft px-2.5 py-1 text-xs font-medium text-success">
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
Ya avisaste
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isNotifying(session.sessionId)}
|
||||
onClick={() => notify(session.sessionId)}
|
||||
>
|
||||
{isNotifying(session.sessionId) ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Avisar ausencia
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Logo } from '../brand'
|
||||
import { Logo } from '../../components/brand'
|
||||
|
||||
export type AuthShellProps = {
|
||||
title: string
|
||||
@@ -5,7 +5,7 @@ import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { Fingerprint, Loader2 } from 'lucide-react'
|
||||
import { authClient } from '../../lib/auth-client'
|
||||
import { Button, Input, Label } from '../../components/ui'
|
||||
import { AuthShell } from '../../components/auth'
|
||||
import { AuthShell } from './AuthShell'
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email('Ingresá un email válido'),
|
||||
@@ -5,7 +5,7 @@ import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { authClient } from '../../lib/auth-client'
|
||||
import { Button, Input, Label } from '../../components/ui'
|
||||
import { AuthShell } from '../../components/auth'
|
||||
import { AuthShell } from './AuthShell'
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { MailCheck, MailWarning, Loader2 } from 'lucide-react'
|
||||
import { authClient } from '../../lib/auth-client'
|
||||
import { AuthShell } from '../../components/auth'
|
||||
import { AuthShell } from './AuthShell'
|
||||
|
||||
type SearchParams = {
|
||||
token?: string
|
||||
117
apps/web/src/features/class-attendance/AttendanceProvider.tsx
Normal file
117
apps/web/src/features/class-attendance/AttendanceProvider.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { SessionStudents } from '@gruperly/shared'
|
||||
import { useToast } from '../../components/ui'
|
||||
import { getSessionStudents, markAttendance } from '../../lib/api'
|
||||
import { isAttendanceLocked } from './utils'
|
||||
import type { AttendanceChoice } from './utils'
|
||||
|
||||
type AttendanceContextValue = {
|
||||
sessionId: string
|
||||
sessionInfo: SessionStudents | undefined
|
||||
studentsPending: boolean
|
||||
studentsFailed: boolean
|
||||
retryStudents: () => void
|
||||
startTime: string | null
|
||||
presentCount: number
|
||||
choices: Record<string, AttendanceChoice>
|
||||
choiceFor: (attendeeId: string) => AttendanceChoice
|
||||
toggleStudent: (attendeeId: string) => void
|
||||
saveAttendance: () => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
const AttendanceContext = createContext<AttendanceContextValue | null>(null)
|
||||
|
||||
export function AttendanceProvider({ sessionId, children }: { sessionId: string; children: ReactNode }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
|
||||
const studentsQuery = useQuery({
|
||||
queryKey: ['class-students', sessionId],
|
||||
queryFn: () => getSessionStudents(sessionId),
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const sessionInfo = studentsQuery.data
|
||||
const [choices, setChoices] = useState<Record<string, AttendanceChoice>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionInfo) return
|
||||
const initial: Record<string, AttendanceChoice> = {}
|
||||
for (const student of sessionInfo.students) {
|
||||
if (isAttendanceLocked(student)) continue
|
||||
initial[student.attendeeId] = student.attendanceStatus === 'ABSENT' ? 'ABSENT' : 'PRESENT'
|
||||
}
|
||||
setChoices(initial)
|
||||
}, [sessionInfo])
|
||||
|
||||
const presentCount = useMemo(
|
||||
() =>
|
||||
(sessionInfo?.students ?? []).filter(
|
||||
(student) => !isAttendanceLocked(student) && choices[student.attendeeId] === 'PRESENT',
|
||||
).length,
|
||||
[sessionInfo, choices],
|
||||
)
|
||||
|
||||
const toggleStudent = (attendeeId: string) => {
|
||||
setChoices((prev) => ({
|
||||
...prev,
|
||||
[attendeeId]: prev[attendeeId] === 'ABSENT' ? 'PRESENT' : 'ABSENT',
|
||||
}))
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const records = (sessionInfo?.students ?? [])
|
||||
.filter((student) => !isAttendanceLocked(student))
|
||||
.map((student) => ({
|
||||
attendeeId: student.attendeeId,
|
||||
status: choices[student.attendeeId] ?? 'PRESENT',
|
||||
}))
|
||||
return markAttendance(sessionId, { classSessionId: sessionId, records })
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Asistencia guardada correctamente.', 'Listo')
|
||||
void queryClient.invalidateQueries({ queryKey: ['classes-today'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['class-students', sessionId] })
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || 'No pudimos guardar la asistencia.')
|
||||
},
|
||||
})
|
||||
|
||||
const startTime = sessionInfo
|
||||
? new Date(sessionInfo.startsAt).toLocaleTimeString('es-MX', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
: null
|
||||
|
||||
const value: AttendanceContextValue = {
|
||||
sessionId,
|
||||
sessionInfo,
|
||||
studentsPending: studentsQuery.isPending,
|
||||
studentsFailed: studentsQuery.isError,
|
||||
retryStudents: () => void studentsQuery.refetch(),
|
||||
startTime,
|
||||
presentCount,
|
||||
choices,
|
||||
choiceFor: (attendeeId) => choices[attendeeId] ?? 'PRESENT',
|
||||
toggleStudent,
|
||||
saveAttendance: () => saveMutation.mutate(),
|
||||
isSaving: saveMutation.isPending,
|
||||
}
|
||||
|
||||
return <AttendanceContext.Provider value={value}>{children}</AttendanceContext.Provider>
|
||||
}
|
||||
|
||||
export function useAttendance() {
|
||||
const context = useContext(AttendanceContext)
|
||||
if (!context) {
|
||||
throw new Error('useAttendance debe usarse dentro de <AttendanceProvider>')
|
||||
}
|
||||
return context
|
||||
}
|
||||
45
apps/web/src/features/class-attendance/AttendanceToggle.tsx
Normal file
45
apps/web/src/features/class-attendance/AttendanceToggle.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Check, X } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import type { AttendanceChoice } from './utils'
|
||||
|
||||
export function AttendanceToggle({
|
||||
choice,
|
||||
disabled,
|
||||
onToggle,
|
||||
fullName,
|
||||
}: {
|
||||
choice: AttendanceChoice
|
||||
disabled?: boolean
|
||||
onToggle: () => void
|
||||
fullName: string
|
||||
}) {
|
||||
const isPresent = choice === 'PRESENT'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isPresent}
|
||||
aria-label={`${isPresent ? 'Presente' : 'Ausente'}: ${fullName}`}
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
'relative inline-flex h-11 w-14 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface disabled:cursor-not-allowed disabled:opacity-70',
|
||||
isPresent ? 'bg-success' : 'bg-danger',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-9 items-center justify-center rounded-full bg-white shadow-sm transition-transform duration-150',
|
||||
isPresent ? 'translate-x-3' : 'translate-x-1',
|
||||
)}
|
||||
>
|
||||
{isPresent ? (
|
||||
<Check className="size-5 text-success" aria-hidden />
|
||||
) : (
|
||||
<X className="size-5 text-danger" aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import { Badge, Button } from '../../components/ui'
|
||||
import { useAttendance } from './AttendanceProvider'
|
||||
import { StudentRow } from './StudentRow'
|
||||
|
||||
export function ClassAttendanceView() {
|
||||
const {
|
||||
sessionInfo,
|
||||
studentsPending,
|
||||
studentsFailed,
|
||||
retryStudents,
|
||||
startTime,
|
||||
presentCount,
|
||||
saveAttendance,
|
||||
isSaving,
|
||||
} = useAttendance()
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Link
|
||||
to="/"
|
||||
className="mb-3 hidden items-center gap-1 text-sm text-foreground/60 transition-colors hover:text-primary lg:inline-flex"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
<span>Volver al inicio</span>
|
||||
</Link>
|
||||
|
||||
{studentsPending ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="size-6 animate-spin text-foreground/40" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{studentsFailed ? (
|
||||
<div className="mt-4 space-y-3 rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
|
||||
<p>No pudimos cargar la lista de alumnos.</p>
|
||||
<Button variant="outline" size="sm" onClick={retryStudents}>
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sessionInfo ? (
|
||||
<>
|
||||
<header className="sticky top-16 z-30 -mx-4 border-b border-border bg-background/95 px-4 py-3 backdrop-blur lg:mx-0 lg:rounded-xl lg:border lg:px-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-lg font-bold text-primary">{sessionInfo.groupName}</h1>
|
||||
<p className="text-xs text-foreground/60">{startTime ? `Hoy · ${startTime} hs` : 'Clase de hoy'}</p>
|
||||
</div>
|
||||
<Badge variant="success" className="shrink-0 text-sm">
|
||||
{presentCount}/{sessionInfo.students.length} presentes
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{sessionInfo.students.length === 0 ? (
|
||||
<div className="mt-6 rounded-xl border border-dashed border-border bg-surface px-4 py-10 text-center">
|
||||
<p className="text-sm font-medium text-primary">Este grupo no tiene alumnos</p>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Agregá miembros desde la ficha del grupo para tomar asistencia.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-4 space-y-3 pb-2">
|
||||
{sessionInfo.students.map((student) => (
|
||||
<StudentRow key={student.attendeeId} student={student} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{sessionInfo.students.length > 0 ? (
|
||||
<div className="sticky bottom-20 z-30 mt-6 lg:bottom-6">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="h-12 w-full text-base font-semibold shadow-lg"
|
||||
disabled={isSaving}
|
||||
onClick={saveAttendance}
|
||||
>
|
||||
{isSaving ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Guardar Asistencia
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
37
apps/web/src/features/class-attendance/StudentRow.tsx
Normal file
37
apps/web/src/features/class-attendance/StudentRow.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { SessionStudentDto } from '@gruperly/shared'
|
||||
import { Avatar, Badge } from '../../components/ui'
|
||||
import { AttendanceToggle } from './AttendanceToggle'
|
||||
import { useAttendance } from './AttendanceProvider'
|
||||
import { isAttendanceLocked } from './utils'
|
||||
|
||||
function PaymentBadge({ status }: { status: SessionStudentDto['paymentStatus'] }) {
|
||||
return status === 'UP_TO_DATE' ? <Badge variant="success">Al día</Badge> : <Badge variant="danger">Pendiente</Badge>
|
||||
}
|
||||
|
||||
export function StudentRow({ student }: { student: SessionStudentDto }) {
|
||||
const { choiceFor, toggleStudent } = useAttendance()
|
||||
const locked = isAttendanceLocked(student)
|
||||
|
||||
return (
|
||||
<li
|
||||
className={`flex items-center gap-3 rounded-xl border bg-surface px-4 py-3 ${
|
||||
locked ? 'border-warning/30 bg-warning-soft/30' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<Avatar name={student.fullName} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-primary">{student.fullName}</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<PaymentBadge status={student.paymentStatus} />
|
||||
{locked ? <Badge variant="warning">Avisó ausencia</Badge> : null}
|
||||
</div>
|
||||
</div>
|
||||
<AttendanceToggle
|
||||
choice={choiceFor(student.attendeeId)}
|
||||
disabled={locked}
|
||||
onToggle={() => toggleStudent(student.attendeeId)}
|
||||
fullName={student.fullName}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
7
apps/web/src/features/class-attendance/utils.ts
Normal file
7
apps/web/src/features/class-attendance/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { SessionStudentDto } from '@gruperly/shared'
|
||||
|
||||
export type AttendanceChoice = 'PRESENT' | 'ABSENT'
|
||||
|
||||
export function isAttendanceLocked(student: SessionStudentDto): boolean {
|
||||
return student.notifiedAbsence || student.attendanceStatus === 'EXCUSED'
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate, Link } from '@tanstack/react-router'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import type { CreateFirstGroup } from '@gruperly/shared'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { GroupForm } from '../components/groups/GroupForm'
|
||||
import { createGroup } from '../lib/api'
|
||||
import { createGroup } from '../../lib/api'
|
||||
import { GroupForm } from './GroupForm'
|
||||
|
||||
export function CreateGroupView() {
|
||||
const navigate = useNavigate()
|
||||
@@ -19,7 +19,10 @@ export function CreateGroupView() {
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-md">
|
||||
<Link to="/groups" className="hidden mb-6 items-center gap-1 text-sm font-medium text-foreground/70 transition-colors hover:text-primary lg:inline-flex">
|
||||
<Link
|
||||
to="/groups"
|
||||
className="mb-6 hidden items-center gap-1 text-sm font-medium text-foreground/70 transition-colors hover:text-primary lg:inline-flex"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Grupos
|
||||
</Link>
|
||||
78
apps/web/src/features/groups/GroupCard.tsx
Normal file
78
apps/web/src/features/groups/GroupCard.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import type { GroupDto, GroupRiskLevel } from '@gruperly/shared'
|
||||
import { CalendarClock, Users } from 'lucide-react'
|
||||
import { Badge, Button } from '../../components/ui'
|
||||
import { BILLING_LABELS, formatPrice, formatSchedule } from '../../lib/format'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { RISK_LABELS } from './constants'
|
||||
|
||||
export 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">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-primary">{group.name}</h3>
|
||||
{group.description ? (
|
||||
<p className="mt-0.5 truncate text-sm text-foreground/60">{group.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<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 ? (
|
||||
<dl className="mt-4 space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-foreground/70">
|
||||
<CalendarClock className="size-4 shrink-0 text-accent" />
|
||||
<span>{formatSchedule(group.days ?? [], group.time)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground/70">
|
||||
<Users className="size-4 shrink-0 text-accent" />
|
||||
<span>
|
||||
Cupo {group.capacity ?? '—'} miembros
|
||||
{group.price != null
|
||||
? ` · ${formatPrice(group.price)}${group.billingType ? ` · ${BILLING_LABELS[group.billingType]}` : ''}`
|
||||
: ''}
|
||||
{group.dueDay != null ? ` · vence el día ${group.dueDay}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="mt-4 text-sm text-foreground/50">Aún sin plan de cobro configurado.</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void navigate({ to: `/groups/${group.id}` })}
|
||||
>
|
||||
Gestionar Miembros
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { BillingType, CreateFirstGroup, WeekDay } from '@gruperly/shared'
|
||||
import { CreateFirstGroupSchema } from '@gruperly/shared'
|
||||
import { ArrowLeft, Check, Loader2 } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { Button, Input, Label } from '../ui'
|
||||
import { Button, Input, Label } from '../../components/ui'
|
||||
import { BILLING_TYPES, WEEK_DAY_CHIPS } from '../onboarding/constants'
|
||||
|
||||
const defaultValues: CreateFirstGroup = {
|
||||
81
apps/web/src/features/groups/GroupsView.tsx
Normal file
81
apps/web/src/features/groups/GroupsView.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2, Plus } from 'lucide-react'
|
||||
import { Button } from '../../components/ui'
|
||||
import { getGroups, getGroupsRiskOverview } from '../../lib/api'
|
||||
import { GroupCard } from './GroupCard'
|
||||
|
||||
export function GroupsView() {
|
||||
const navigate = useNavigate()
|
||||
const groupsQuery = useQuery({
|
||||
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>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Grupos</h1>
|
||||
<p className="mt-2 text-sm text-foreground/60">Tus grupos de cobranza.</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => void navigate({ to: '/groups/new' })}>
|
||||
<Plus className="size-4" />
|
||||
Crear
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{groupsQuery.isPending ? (
|
||||
<div className="mt-8 flex items-center justify-center py-16">
|
||||
<Loader2 className="size-6 animate-spin text-foreground/40" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{groupsQuery.isError ? (
|
||||
<div className="mt-8 space-y-3 rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
|
||||
<p>No pudimos cargar tus grupos.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void groupsQuery.refetch()}>
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{groupsQuery.isSuccess && groupsQuery.data.data.length === 0 ? (
|
||||
<div className="mt-8 rounded-xl border border-dashed border-border bg-surface px-4 py-12 text-center">
|
||||
<p className="font-medium text-primary">Todavía no tenés grupos</p>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Crea tu primer grupo para empezar a cobrar.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-5"
|
||||
onClick={() => void navigate({ to: '/groups/new' })}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Crear tu primer grupo
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{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}
|
||||
riskLevel={riskByGroup.get(group.id) ?? 'NONE'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
219
apps/web/src/features/groups/analytics/AnalyticsView.tsx
Normal file
219
apps/web/src/features/groups/analytics/AnalyticsView.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useParams } from '@tanstack/react-router'
|
||||
import type { StudentAtRiskDto } from '@gruperly/shared'
|
||||
import { ArrowLeft, CalendarCheck, DoorOpen, Loader2, Percent, Users } from 'lucide-react'
|
||||
import { Badge, Button, useToast } from '../../../components/ui'
|
||||
import { getAttendeeHistory, getGroupAnalytics, getStudentsAtRisk, updateAttendeeStatus } from '../../../lib/api'
|
||||
import { KpiCard } from './KpiCard'
|
||||
import { AtRiskStudentCard, type StatusAction } from './AtRiskStudentCard'
|
||||
import { AttendeeHistoryModal } from './AttendeeHistoryModal'
|
||||
import { StatusActionModal } from './StatusActionModal'
|
||||
|
||||
export function AnalyticsView() {
|
||||
const { groupId } = useParams({ strict: false }) as { groupId: string }
|
||||
const queryClient = useQueryClient()
|
||||
const toast = useToast()
|
||||
|
||||
const [historyStudent, setHistoryStudent] = useState<StudentAtRiskDto | null>(null)
|
||||
const [statusAction, setStatusAction] = useState<StatusAction>(null)
|
||||
const [menuOpenFor, setMenuOpenFor] = useState<string | null>(null)
|
||||
|
||||
const analyticsQuery = useQuery({
|
||||
queryKey: ['group-analytics', groupId],
|
||||
queryFn: () => getGroupAnalytics(groupId),
|
||||
enabled: Boolean(groupId),
|
||||
})
|
||||
|
||||
const riskQuery = useQuery({
|
||||
queryKey: ['students-at-risk', groupId],
|
||||
queryFn: () => getStudentsAtRisk(groupId),
|
||||
enabled: Boolean(groupId),
|
||||
})
|
||||
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ['attendee-history', groupId, historyStudent?.attendeeId],
|
||||
queryFn: () => getAttendeeHistory(groupId, historyStudent!.attendeeId!),
|
||||
enabled: Boolean(groupId && historyStudent),
|
||||
})
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (action: Exclude<StatusAction, null>) =>
|
||||
updateAttendeeStatus(action.student.attendeeId, action.status),
|
||||
onSuccess: (_result, action) => {
|
||||
const name = action.student.fullName.split(' ')[0]
|
||||
toast.success(
|
||||
action.status === 'DROPPED'
|
||||
? `${name} fue dado de baja del grupo. Su vacante quedó disponible.`
|
||||
: `La vacante de ${name} quedó pausada.`,
|
||||
)
|
||||
setStatusAction(null)
|
||||
void queryClient.invalidateQueries({ queryKey: ['students-at-risk', groupId] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] })
|
||||
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.')
|
||||
},
|
||||
})
|
||||
|
||||
const handleReengage = async (student: StudentAtRiskDto, groupName: string) => {
|
||||
if (!student.phone) return
|
||||
const firstName = student.fullName.split(' ')[0] ?? student.fullName
|
||||
const message =
|
||||
`¡Hola ${firstName}! 👋 Te extrañamos en ${groupName}. ` +
|
||||
'Notamos que no asististe a las últimas clases y queremos que vuelvas. ' +
|
||||
'Si tenés algún problema con tus horarios o tu membresía, escribinos y lo resolvemos juntos.'
|
||||
const waUrl = `https://wa.me/${student.phone.replace(/\D/g, '')}?text=${encodeURIComponent(message)}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(message)
|
||||
toast.success('¡Abriendo WhatsApp! Mensaje copiado al portapapeles.')
|
||||
} catch {
|
||||
// Si clipboard falla, igual abrimos WhatsApp con el mensaje.
|
||||
}
|
||||
window.open(waUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const groupName = riskQuery.data?.groupName
|
||||
|
||||
if (analyticsQuery.isPending || riskQuery.isPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="size-8 animate-spin text-accent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (analyticsQuery.isError || riskQuery.isError || !analyticsQuery.data) {
|
||||
return (
|
||||
<div className="rounded-xl border border-danger/20 bg-danger-soft p-6 text-danger">
|
||||
<h2 className="text-lg font-semibold">No pudimos cargar las estadísticas</h2>
|
||||
<p className="mt-2 text-sm">Verificá tu conexión e intentá de nuevo.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
void analyticsQuery.refetch()
|
||||
void riskQuery.refetch()
|
||||
}}
|
||||
>
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const analytics = analyticsQuery.data
|
||||
const atRisk = riskQuery.data?.data ?? []
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-md lg:max-w-6xl">
|
||||
<Link
|
||||
to="/groups/$groupId"
|
||||
params={{ groupId }}
|
||||
className="mb-3 hidden items-center gap-1 text-sm text-foreground/60 transition-colors hover:text-primary lg:inline-flex"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Volver a {groupName ?? 'el grupo'}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Estadísticas</h1>
|
||||
<p className="mt-2 text-sm text-foreground/60">
|
||||
Asistencia y gestión de riesgo de {groupName ?? 'tu grupo'}.
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-xl bg-primary-soft px-3 py-1.5 text-xs font-semibold text-foreground/70">
|
||||
Últimos 30 días
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<KpiCard
|
||||
icon={<Percent className="size-5" />}
|
||||
label="Asistencia promedio"
|
||||
value={`${analytics.attendanceRate}%`}
|
||||
hint="últimos 30 días"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<CalendarCheck className="size-5" />}
|
||||
label="Asistencias del mes"
|
||||
value={analytics.totalPresent.toLocaleString('es-MX')}
|
||||
hint={`${analytics.totalClasses} clases dictadas`}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<DoorOpen className="size-5" />}
|
||||
label="Cupos recuperados"
|
||||
value={analytics.recoveredSlots.toLocaleString('es-MX')}
|
||||
hint="por ausencias avisadas"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="size-5 text-accent" />
|
||||
<h2 className="text-lg font-semibold text-primary">Alumnos en riesgo</h2>
|
||||
{atRisk.length > 0 ? (
|
||||
<Badge variant={atRisk.some((s) => s.riskLevel === 'HIGH') ? 'danger' : 'warning'}>
|
||||
{atRisk.length} {atRisk.length === 1 ? 'alumno' : 'alumnos'}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Alumnos con ausencias consecutivas o baja asistencia mensual.
|
||||
</p>
|
||||
|
||||
{atRisk.length === 0 ? (
|
||||
<div className="mt-4 rounded-xl border border-dashed border-border bg-surface px-4 py-10 text-center">
|
||||
<p className="font-medium text-primary">¡Sin alumnos en riesgo!</p>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
No hay ausencias consecutivas ni asistencia por debajo del 50%.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-4 space-y-3">
|
||||
{atRisk.map((student) => (
|
||||
<AtRiskStudentCard
|
||||
key={student.attendeeId}
|
||||
student={student}
|
||||
menuOpen={menuOpenFor === student.attendeeId}
|
||||
onToggleMenu={() =>
|
||||
setMenuOpenFor(menuOpenFor === student.attendeeId ? null : student.attendeeId)
|
||||
}
|
||||
onViewHistory={() => setHistoryStudent(student)}
|
||||
onReengage={() => void handleReengage(student, groupName ?? 'tu clase')}
|
||||
onRequestStatusAction={(action) => {
|
||||
setStatusAction(action)
|
||||
setMenuOpenFor(null)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AttendeeHistoryModal
|
||||
student={historyStudent}
|
||||
sessions={historyQuery.data?.sessions}
|
||||
attendanceRate={historyQuery.data?.attendanceRate}
|
||||
isPending={historyQuery.isPending}
|
||||
isError={historyQuery.isError}
|
||||
onClose={() => setHistoryStudent(null)}
|
||||
/>
|
||||
|
||||
<StatusActionModal
|
||||
action={statusAction}
|
||||
isPending={statusMutation.isPending}
|
||||
groupName={groupName}
|
||||
onCancel={() => setStatusAction(null)}
|
||||
onConfirm={() => {
|
||||
if (statusAction) statusMutation.mutate(statusAction)
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
100
apps/web/src/features/groups/analytics/AtRiskStudentCard.tsx
Normal file
100
apps/web/src/features/groups/analytics/AtRiskStudentCard.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { StudentAtRiskDto } from '@gruperly/shared'
|
||||
import { MessageCircle, MoreVertical, Pause, UserMinus } from 'lucide-react'
|
||||
import { Avatar, Badge, Button } from '../../../components/ui'
|
||||
import { cn } from '../../../lib/utils'
|
||||
|
||||
export type StatusAction = { student: StudentAtRiskDto; status: 'PAUSED' | 'DROPPED' } | null
|
||||
|
||||
export function AtRiskStudentCard({
|
||||
student,
|
||||
menuOpen,
|
||||
onToggleMenu,
|
||||
onViewHistory,
|
||||
onReengage,
|
||||
onRequestStatusAction,
|
||||
}: {
|
||||
student: StudentAtRiskDto
|
||||
menuOpen: boolean
|
||||
onToggleMenu: () => void
|
||||
onViewHistory: () => void
|
||||
onReengage: () => void
|
||||
onRequestStatusAction: (action: Exclude<StatusAction, null>) => void
|
||||
}) {
|
||||
return (
|
||||
<li className="rounded-xl border border-border bg-surface p-4 transition-colors hover:border-accent/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewHistory}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
>
|
||||
<Avatar name={student.fullName} />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-primary">{student.fullName}</p>
|
||||
<p className="mt-0.5 text-xs text-foreground/50">
|
||||
{student.phone ?? 'Sin teléfono'} · {student.monthlyAttendanceRate}% de asistencia
|
||||
mensual
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{student.riskLevel === 'HIGH' ? (
|
||||
<Badge variant="danger">{student.consecutiveAbsences} faltas seguidas</Badge>
|
||||
) : (
|
||||
<Badge variant="warning">Baja asistencia</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!student.phone}
|
||||
onClick={onReengage}
|
||||
className="flex-1 gap-2 border-0 bg-[#25D366] text-white hover:bg-[#1EBE5D] disabled:bg-foreground/10 disabled:text-foreground/40"
|
||||
>
|
||||
<MessageCircle className="size-4" />
|
||||
Reenganchar por WhatsApp
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Gestión de la vacante"
|
||||
onClick={onToggleMenu}
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
</Button>
|
||||
|
||||
{menuOpen ? (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-20"
|
||||
onClick={onToggleMenu}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute right-0 z-30 mt-1 w-44 animate-fade-in rounded-xl border border-border bg-surface p-1.5 shadow-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRequestStatusAction({ student, status: 'PAUSED' })}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-primary transition-colors hover:bg-primary-soft"
|
||||
>
|
||||
<Pause className="size-4" />
|
||||
Pausar vacante
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRequestStatusAction({ student, status: 'DROPPED' })}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-danger transition-colors hover:bg-danger-soft"
|
||||
>
|
||||
<UserMinus className="size-4" />
|
||||
Dar de baja
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { StudentAtRiskDto } from '@gruperly/shared'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Modal } from '../../../components/ui'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { dotClass, formatSessionDate, sessionStatusTextClass, statusLabel } from './format'
|
||||
|
||||
export function AttendeeHistoryModal({
|
||||
student,
|
||||
sessions,
|
||||
attendanceRate,
|
||||
isPending,
|
||||
isError,
|
||||
onClose,
|
||||
}: {
|
||||
student: StudentAtRiskDto | null
|
||||
sessions:
|
||||
| {
|
||||
classSessionId: string
|
||||
startsAt: string
|
||||
status: string | null
|
||||
}[]
|
||||
| undefined
|
||||
attendanceRate: number | undefined
|
||||
isPending: boolean
|
||||
isError: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={student !== null}
|
||||
onClose={onClose}
|
||||
title={student?.fullName ?? 'Historial'}
|
||||
description={student ? 'Historial de presentismo de las últimas clases.' : undefined}
|
||||
maxWidth="md"
|
||||
>
|
||||
{student ? (
|
||||
<div>
|
||||
{isPending ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="size-6 animate-spin text-accent" />
|
||||
</div>
|
||||
) : isError || !sessions ? (
|
||||
<div className="rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
|
||||
No pudimos cargar el historial de este alumno.
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-primary-soft/50 p-3">
|
||||
<span className="text-2xl font-bold text-primary">{attendanceRate}%</span>
|
||||
<span className="text-xs font-medium uppercase text-foreground/60">
|
||||
Presentismo general ({sessions.length} clases)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 space-y-2">
|
||||
{sessions.map((session) => (
|
||||
<li
|
||||
key={session.classSessionId}
|
||||
className="flex items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3"
|
||||
>
|
||||
<span
|
||||
className={cn('size-2.5 shrink-0 rounded-full', dotClass(session.status))}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 text-sm font-medium text-primary">
|
||||
{formatSessionDate(session.startsAt)}
|
||||
</span>
|
||||
<span className={cn('text-xs font-semibold', sessionStatusTextClass(session.status))}>
|
||||
{statusLabel(session.status)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
26
apps/web/src/features/groups/analytics/KpiCard.tsx
Normal file
26
apps/web/src/features/groups/analytics/KpiCard.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function KpiCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
value: string
|
||||
hint?: string
|
||||
}) {
|
||||
return (
|
||||
<article className="flex items-center gap-3 rounded-xl border border-border bg-surface p-4">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium uppercase text-foreground/50">{label}</p>
|
||||
<p className="truncate text-2xl font-bold text-primary">{value}</p>
|
||||
{hint ? <p className="text-xs text-foreground/50">{hint}</p> : null}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
72
apps/web/src/features/groups/analytics/StatusActionModal.tsx
Normal file
72
apps/web/src/features/groups/analytics/StatusActionModal.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Loader2, Pause, UserMinus } from 'lucide-react'
|
||||
import { Button, Modal } from '../../../components/ui'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import type { StatusAction } from './AtRiskStudentCard'
|
||||
|
||||
export function StatusActionModal({
|
||||
action,
|
||||
isPending,
|
||||
groupName,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
action: StatusAction
|
||||
isPending: boolean
|
||||
groupName: string | undefined
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={action !== null}
|
||||
onClose={onCancel}
|
||||
title={action?.status === 'DROPPED' ? 'Dar de baja' : 'Pausar vacante'}
|
||||
maxWidth="md"
|
||||
>
|
||||
{action ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-foreground/70">
|
||||
{action.student.fullName}{' '}
|
||||
{action.status === 'DROPPED' ? (
|
||||
<>
|
||||
dejará de asistir al grupo y <strong className="text-primary">{groupName}</strong>. Su
|
||||
vacante quedará <strong className="text-primary">disponible</strong> para otro alumno, y
|
||||
su historial de asistencia se conservará.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
será <strong className="text-primary">pausado</strong> en{' '}
|
||||
<strong className="text-primary">{groupName}</strong>. Su vacante quedará{' '}
|
||||
<strong className="text-primary">libre</strong> y podés reactivarla en cualquier
|
||||
momento.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={cn(
|
||||
action.status === 'DROPPED' &&
|
||||
'border border-danger/20 bg-danger text-white hover:bg-danger/90',
|
||||
)}
|
||||
disabled={isPending}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : action.status === 'DROPPED' ? (
|
||||
<UserMinus className="size-4" />
|
||||
) : (
|
||||
<Pause className="size-4" />
|
||||
)}
|
||||
<span>{action.status === 'DROPPED' ? 'Dar de baja' : 'Pausar vacante'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
46
apps/web/src/features/groups/analytics/format.ts
Normal file
46
apps/web/src/features/groups/analytics/format.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export function statusLabel(status: string | null): string {
|
||||
switch (status) {
|
||||
case 'PRESENT':
|
||||
return 'Presente'
|
||||
case 'ABSENT':
|
||||
return 'Ausente'
|
||||
case 'EXCUSED':
|
||||
return 'Avisó ausencia'
|
||||
default:
|
||||
return 'Sin registrar'
|
||||
}
|
||||
}
|
||||
|
||||
export function dotClass(status: string | null): string {
|
||||
switch (status) {
|
||||
case 'PRESENT':
|
||||
return 'bg-success'
|
||||
case 'ABSENT':
|
||||
return 'bg-danger'
|
||||
case 'EXCUSED':
|
||||
return 'bg-warning'
|
||||
default:
|
||||
return 'bg-border'
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionStatusTextClass(status: string | null): string {
|
||||
switch (status) {
|
||||
case 'PRESENT':
|
||||
return 'text-success'
|
||||
case 'ABSENT':
|
||||
return 'text-danger'
|
||||
case 'EXCUSED':
|
||||
return 'text-warning'
|
||||
default:
|
||||
return 'text-foreground/50'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSessionDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('es-MX', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
6
apps/web/src/features/groups/constants.ts
Normal file
6
apps/web/src/features/groups/constants.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { GroupRiskLevel } from '@gruperly/shared'
|
||||
|
||||
export const RISK_LABELS: Record<Exclude<GroupRiskLevel, 'NONE'>, string> = {
|
||||
HIGH: 'Riesgo alto',
|
||||
MEDIUM: 'Riesgo moderado',
|
||||
}
|
||||
46
apps/web/src/features/groups/detail/AddAttendeeModal.tsx
Normal file
46
apps/web/src/features/groups/detail/AddAttendeeModal.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Modal } from '../../../components/ui'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { BulkImportPanel } from './BulkImportPanel'
|
||||
import { QuickAddForm } from './QuickAddForm'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function AddAttendeeModal() {
|
||||
const { isAddAttendeeModalOpen, closeAddAttendeeModal, activeTab, setActiveTab } = useGroupDetail()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isAddAttendeeModalOpen}
|
||||
onClose={closeAddAttendeeModal}
|
||||
title="Agregar Miembros al Grupo"
|
||||
description="Agrega miembros rápidamente completando sus datos o importa una lista de contactos."
|
||||
maxWidth="lg"
|
||||
>
|
||||
<div>
|
||||
<div className="mb-5 flex items-center rounded-xl bg-primary-soft p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('quick')}
|
||||
className={cn(
|
||||
'flex-1 rounded-lg py-2 text-xs font-semibold transition-all',
|
||||
activeTab === 'quick' ? 'bg-surface text-primary shadow-xs' : 'text-foreground/60 hover:text-primary',
|
||||
)}
|
||||
>
|
||||
Carga Rápida (Individual)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('bulk')}
|
||||
className={cn(
|
||||
'flex-1 rounded-lg py-2 text-xs font-semibold transition-all',
|
||||
activeTab === 'bulk' ? 'bg-surface text-primary shadow-xs' : 'text-foreground/60 hover:text-primary',
|
||||
)}
|
||||
>
|
||||
Carga Masiva (CSV / Excel)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'quick' ? <QuickAddForm /> : <BulkImportPanel />}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
150
apps/web/src/features/groups/detail/AttendeeDetailModal.tsx
Normal file
150
apps/web/src/features/groups/detail/AttendeeDetailModal.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Copy, Mail, MessageCircle, Phone, ShieldCheck, StickyNote, UserMinus } from 'lucide-react'
|
||||
import { Button, Modal } from '../../../components/ui'
|
||||
import { DetailRow } from './DetailRow'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function AttendeeDetailModal() {
|
||||
const { selectedAttendee, setSelectedAttendee, requestRemoveAttendee, copyAbsenceNotifyUrl } =
|
||||
useGroupDetail()
|
||||
const absenceNotifyUrl = selectedAttendee?.notifyToken
|
||||
? `${window.location.origin}/report-absence/${selectedAttendee.notifyToken}`
|
||||
: null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={selectedAttendee !== null}
|
||||
onClose={() => setSelectedAttendee(null)}
|
||||
title="Detalle del Participante"
|
||||
maxWidth="sm"
|
||||
>
|
||||
{selectedAttendee ? (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-accent/10 text-lg font-bold text-accent">
|
||||
{selectedAttendee.fullName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-lg font-semibold text-primary">{selectedAttendee.fullName}</p>
|
||||
<p className="text-xs text-foreground/50">
|
||||
Incorporado el{' '}
|
||||
{new Date(selectedAttendee.createdAt).toLocaleDateString('es-ES', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 divide-y divide-border rounded-xl border border-border bg-primary-soft/30">
|
||||
<DetailRow icon={<Phone className="size-4 text-accent" />} label="Teléfono">
|
||||
{selectedAttendee.phone ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-primary">{selectedAttendee.phone}</span>
|
||||
<a
|
||||
href={`https://wa.me/${selectedAttendee.phone.replace(/\D/g, '')}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-success transition-colors hover:text-success/80"
|
||||
>
|
||||
<MessageCircle className="size-3.5" />
|
||||
<span>WhatsApp</span>
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-foreground/40">Sin teléfono</span>
|
||||
)}
|
||||
</DetailRow>
|
||||
|
||||
<DetailRow icon={<Mail className="size-4 text-accent" />} label="Email">
|
||||
{selectedAttendee.email ? (
|
||||
<a
|
||||
href={`mailto:${selectedAttendee.email}`}
|
||||
className="text-primary transition-colors hover:text-accent"
|
||||
>
|
||||
{selectedAttendee.email}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-foreground/40">Sin email</span>
|
||||
)}
|
||||
</DetailRow>
|
||||
|
||||
{selectedAttendee.guardianName || selectedAttendee.guardianPhone ? (
|
||||
<>
|
||||
<DetailRow icon={<ShieldCheck className="size-4 text-accent" />} label="Responsable">
|
||||
<span className="text-primary">
|
||||
{selectedAttendee.guardianName || <span className="text-foreground/40">—</span>}
|
||||
</span>
|
||||
</DetailRow>
|
||||
{selectedAttendee.guardianPhone ? (
|
||||
<DetailRow icon={<Phone className="size-4 text-accent" />} label="Tel. Responsable">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-primary">{selectedAttendee.guardianPhone}</span>
|
||||
<a
|
||||
href={`https://wa.me/${selectedAttendee.guardianPhone.replace(/\D/g, '')}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-success transition-colors hover:text-success/80"
|
||||
>
|
||||
<MessageCircle className="size-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</DetailRow>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<DetailRow icon={<StickyNote className="size-4 text-accent" />} label="Notas">
|
||||
{selectedAttendee.notes ? (
|
||||
<span className="text-xs leading-relaxed text-primary">{selectedAttendee.notes}</span>
|
||||
) : (
|
||||
<span className="text-foreground/40">Sin notas</span>
|
||||
)}
|
||||
</DetailRow>
|
||||
</div>
|
||||
|
||||
{absenceNotifyUrl ? (
|
||||
<div className="space-y-2 border-t border-border pt-4">
|
||||
<p className="text-xs font-medium text-foreground/60">Link para avisar ausencias</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded-lg bg-primary-soft px-2 py-1.5 text-xs text-primary">
|
||||
{absenceNotifyUrl}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyAbsenceNotifyUrl(absenceNotifyUrl)}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
Copiar
|
||||
</Button>
|
||||
</div>
|
||||
{selectedAttendee.phone ? (
|
||||
<a
|
||||
href={`https://wa.me/${selectedAttendee.phone.replace(/\D/g, '')}?text=${encodeURIComponent(`Hola ${selectedAttendee.fullName.split(' ')[0]}, desde este link podés avisar tus ausencias en ${absenceNotifyUrl}`)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-success transition-colors hover:text-success/80"
|
||||
>
|
||||
<MessageCircle className="size-3.5" />
|
||||
<span>Enviar por WhatsApp</span>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="border-t border-border pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => requestRemoveAttendee(selectedAttendee)}
|
||||
className="w-full gap-2 border border-danger/20 text-danger hover:bg-danger/10 hover:text-danger"
|
||||
>
|
||||
<UserMinus className="size-4" />
|
||||
Quitar del grupo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
41
apps/web/src/features/groups/detail/BulkCapacityModal.tsx
Normal file
41
apps/web/src/features/groups/detail/BulkCapacityModal.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import { Button, Modal } from '../../../components/ui'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function BulkCapacityModal() {
|
||||
const {
|
||||
isBulkCapacityModalOpen,
|
||||
closeBulkCapacityModal,
|
||||
confirmBulkImportOverCapacity,
|
||||
validRowsCount,
|
||||
attendeesTotal,
|
||||
group,
|
||||
} = useGroupDetail()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isBulkCapacityModalOpen}
|
||||
onClose={closeBulkCapacityModal}
|
||||
title="Aviso de cupo"
|
||||
description="La carga supera el cupo del grupo."
|
||||
maxWidth="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-foreground/70">
|
||||
Estás por importar <strong className="text-primary">{validRowsCount}</strong> miembro(s), pero el
|
||||
grupo ya tiene <strong className="text-primary">{attendeesTotal}</strong> inscrito(s) y su cupo es
|
||||
de <strong className="text-primary">{group?.capacity}</strong>. ¿Deseas importarlos de todos modos?
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-2.5 pt-2">
|
||||
<Button variant="ghost" onClick={closeBulkCapacityModal}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="primary" onClick={confirmBulkImportOverCapacity} className="gap-2">
|
||||
<Check className="size-4" />
|
||||
Importar de todos modos
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
228
apps/web/src/features/groups/detail/BulkImportPanel.tsx
Normal file
228
apps/web/src/features/groups/detail/BulkImportPanel.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useRef } from 'react'
|
||||
import { Check, Download, FileSpreadsheet, Loader2, Trash2, UploadCloud } from 'lucide-react'
|
||||
import { Badge, Button, useToast } from '../../../components/ui'
|
||||
import {
|
||||
downloadAttendeeTemplateCsv,
|
||||
normalizeRowsToContacts,
|
||||
parseCsvContent,
|
||||
parseXlsxContent,
|
||||
} from '../../../lib/file-parser'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function BulkImportPanel() {
|
||||
const {
|
||||
parsedContacts,
|
||||
setParsedContacts,
|
||||
removeParsedContact,
|
||||
fileName,
|
||||
setFileName,
|
||||
isParsingFile,
|
||||
setIsParsingFile,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
validRowsCount,
|
||||
invalidRowsCount,
|
||||
isSubmittingBulkImport,
|
||||
closeAddAttendeeModal,
|
||||
requestBulkImport,
|
||||
} = useGroupDetail()
|
||||
const toast = useToast()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const processFile = async (file: File) => {
|
||||
const fileExtension = file.name.split('.').pop()?.toLowerCase()
|
||||
if (!['csv', 'xlsx', 'xls', 'txt'].includes(fileExtension ?? '')) {
|
||||
toast.error('Formato no compatible. Sube un archivo .csv o .xlsx')
|
||||
return
|
||||
}
|
||||
|
||||
setIsParsingFile(true)
|
||||
setFileName(file.name)
|
||||
try {
|
||||
const rawRows =
|
||||
fileExtension === 'xlsx' || fileExtension === 'xls'
|
||||
? await parseXlsxContent(await file.arrayBuffer())
|
||||
: parseCsvContent(await file.text())
|
||||
|
||||
const contacts = normalizeRowsToContacts(rawRows)
|
||||
if (contacts.length === 0) {
|
||||
toast.error('No se detectaron contactos en el archivo.')
|
||||
} else {
|
||||
setParsedContacts(contacts)
|
||||
const validCount = contacts.filter((c) => c.isValid).length
|
||||
toast.info(`Se detectaron ${contacts.length} filas (${validCount} válidas).`)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Error al procesar el archivo.'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setIsParsingFile(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) {
|
||||
void processFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
void processFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleFileDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-all',
|
||||
isDragging
|
||||
? 'border-accent bg-accent/5'
|
||||
: 'border-border bg-primary-soft/30 hover:border-accent/60',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls,.txt"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<UploadCloud className="mb-2 size-10 text-accent" />
|
||||
<p className="text-sm font-semibold text-primary">
|
||||
Arrastra tu archivo aquí o haz clic para seleccionarlo
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-foreground/60">Formatos soportados: CSV (.csv) o Excel (.xlsx)</p>
|
||||
|
||||
{fileName ? (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium text-primary">
|
||||
<FileSpreadsheet className="size-4 text-accent" />
|
||||
<span>{fileName}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-1 text-xs">
|
||||
<span className="text-foreground/60">¿No tienes el archivo listo? Usa nuestra plantilla.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadAttendeeTemplateCsv}
|
||||
className="inline-flex items-center gap-1 font-medium text-accent transition-colors hover:text-accent-strong"
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
<span>Descargar plantilla CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isParsingFile ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-foreground/60">
|
||||
<Loader2 className="size-4 animate-spin text-accent" />
|
||||
<span>Leyendo y analizando archivo...</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{parsedContacts.length > 0 ? (
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-semibold text-primary">Vista Previa:</span>
|
||||
<Badge variant="success">{validRowsCount} listos para importar</Badge>
|
||||
{invalidRowsCount > 0 ? <Badge variant="danger">{invalidRowsCount} con errores</Badge> : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setParsedContacts([])
|
||||
setFileName(null)
|
||||
}}
|
||||
className="h-7 px-2 text-xs text-foreground/50 hover:text-danger"
|
||||
>
|
||||
Limpiar lista
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto rounded-xl border border-border">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="sticky top-0 border-b border-border bg-surface font-semibold text-foreground/70">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Nombre</th>
|
||||
<th className="px-3 py-2">Teléfono</th>
|
||||
<th className="px-3 py-2">Email</th>
|
||||
<th className="px-3 py-2">Estado</th>
|
||||
<th className="px-2 py-2 text-right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{parsedContacts.map((contact) => (
|
||||
<tr
|
||||
key={contact.id}
|
||||
className={!contact.isValid ? 'bg-danger-soft/30' : 'hover:bg-primary-soft/30'}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium text-primary">
|
||||
{contact.fullName || <span className="italic text-danger">Sin nombre</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-foreground/80">
|
||||
{contact.phone || <span className="italic text-danger">Sin teléfono</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-foreground/60">{contact.email || '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
{contact.isValid ? (
|
||||
<Badge variant="success" className="py-0 text-[10px]">
|
||||
Válido
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="danger" className="py-0 text-[10px]" title={contact.error}>
|
||||
{contact.error}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeParsedContact(contact.id)}
|
||||
className="rounded p-1 text-foreground/40 transition-colors hover:text-danger"
|
||||
title="Eliminar fila"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2.5 border-t border-border pt-3">
|
||||
<Button variant="ghost" onClick={closeAddAttendeeModal}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={requestBulkImport}
|
||||
disabled={isSubmittingBulkImport || validRowsCount === 0}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSubmittingBulkImport ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||||
<span>Confirmar e Importar ({validRowsCount})</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
apps/web/src/features/groups/detail/CapacityModal.tsx
Normal file
61
apps/web/src/features/groups/detail/CapacityModal.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Clock, Loader2, UserPlus } from 'lucide-react'
|
||||
import { Button, Modal } from '../../../components/ui'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function CapacityModal() {
|
||||
const {
|
||||
isCapacityModalOpen,
|
||||
closeCapacityModal,
|
||||
capacityPayload,
|
||||
group,
|
||||
submitForcedAttendee,
|
||||
submitWaitlistEntry,
|
||||
isSubmittingForcedAttendee,
|
||||
isSubmittingWaitlistEntry,
|
||||
} = useGroupDetail()
|
||||
const isBusy = isSubmittingForcedAttendee || isSubmittingWaitlistEntry
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isCapacityModalOpen}
|
||||
onClose={closeCapacityModal}
|
||||
title="Cupo alcanzado"
|
||||
description="El grupo llegó a su cupo máximo de miembros."
|
||||
maxWidth="md"
|
||||
>
|
||||
{capacityPayload ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-foreground/70">
|
||||
El grupo <strong className="text-primary">{group?.name}</strong> ya alcanzó su cupo de{' '}
|
||||
<strong className="text-primary">{group?.capacity}</strong> miembros. ¿Qué deseas hacer con{' '}
|
||||
<strong className="text-primary">
|
||||
{`${capacityPayload.firstName.trim()} ${(capacityPayload.lastName ?? '').trim()}`.trim()}
|
||||
</strong>
|
||||
?
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<Button variant="primary" onClick={submitForcedAttendee} disabled={isBusy} className="gap-2">
|
||||
{isSubmittingForcedAttendee ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<UserPlus className="size-4" />
|
||||
)}
|
||||
<span>Agregar de todos modos</span>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={submitWaitlistEntry} disabled={isBusy} className="gap-2">
|
||||
{isSubmittingWaitlistEntry ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Clock className="size-4" />
|
||||
)}
|
||||
<span>Sumar a la lista de espera</span>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-center text-[11px] text-foreground/50">
|
||||
Si eliges agregarlo de todos modos, el grupo quedará por encima de su cupo.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
15
apps/web/src/features/groups/detail/DetailRow.tsx
Normal file
15
apps/web/src/features/groups/detail/DetailRow.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function DetailRow({ icon, label, children }: { icon: ReactNode; label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="mt-0.5 shrink-0">{icon}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="mb-0.5 text-[11px] font-medium uppercase tracking-wide text-foreground/50">
|
||||
{label}
|
||||
</p>
|
||||
<div className="text-sm">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
537
apps/web/src/features/groups/detail/GroupDetailProvider.tsx
Normal file
537
apps/web/src/features/groups/detail/GroupDetailProvider.tsx
Normal 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
|
||||
}
|
||||
55
apps/web/src/features/groups/detail/GroupDetailView.tsx
Normal file
55
apps/web/src/features/groups/detail/GroupDetailView.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button } from '../../../components/ui'
|
||||
import { AddAttendeeModal } from './AddAttendeeModal'
|
||||
import { AttendeeDetailModal } from './AttendeeDetailModal'
|
||||
import { BulkCapacityModal } from './BulkCapacityModal'
|
||||
import { CapacityModal } from './CapacityModal'
|
||||
import { GroupHeader } from './GroupHeader'
|
||||
import { GroupInfoCard } from './GroupInfoCard'
|
||||
import { InviteLinkModal } from './InviteLinkModal'
|
||||
import { MembersWaitlistSection } from './MembersWaitlistSection'
|
||||
import { RemoveAttendeeModal } from './RemoveAttendeeModal'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
import { WaitlistEntryDetailModal } from './WaitlistEntryDetailModal'
|
||||
|
||||
export function GroupDetailView() {
|
||||
const { group, groupQueryPending } = useGroupDetail()
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (groupQueryPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="size-8 animate-spin text-accent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<div className="rounded-xl border border-danger/20 bg-danger-soft p-6 text-danger">
|
||||
<h2 className="text-lg font-semibold">Grupo no encontrado</h2>
|
||||
<p className="mt-2 text-sm">No se pudo cargar la información de este grupo.</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => void navigate({ to: '/groups' })}>
|
||||
Volver a Grupos
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="animate-fade-in space-y-6">
|
||||
<GroupHeader />
|
||||
<GroupInfoCard />
|
||||
<MembersWaitlistSection />
|
||||
|
||||
<InviteLinkModal />
|
||||
<AddAttendeeModal />
|
||||
<CapacityModal />
|
||||
<BulkCapacityModal />
|
||||
<AttendeeDetailModal />
|
||||
<RemoveAttendeeModal />
|
||||
<WaitlistEntryDetailModal />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
77
apps/web/src/features/groups/detail/GroupHeader.tsx
Normal file
77
apps/web/src/features/groups/detail/GroupHeader.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { ArrowLeft, BarChart3, Share2, UserPlus } from 'lucide-react'
|
||||
import { Badge, Button } from '../../../components/ui'
|
||||
import { RISK_LABELS } from '../constants'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function GroupHeader() {
|
||||
const { group, groupId, riskLevel, openInviteModal, openAddAttendeeModal } = useGroupDetail()
|
||||
const navigate = useNavigate()
|
||||
const hasRisk = riskLevel === 'HIGH' || riskLevel === 'MEDIUM'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
to="/groups"
|
||||
className="mb-3 hidden items-center gap-1 text-sm text-foreground/60 transition-colors hover:text-primary lg:inline-flex"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
<span>Volver a grupos</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-2xl font-bold text-primary">{group?.name}</h1>
|
||||
<Badge variant="success">Activo</Badge>
|
||||
{hasRisk ? (
|
||||
<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"
|
||||
/>
|
||||
{RISK_LABELS[riskLevel]}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{group?.description ? (
|
||||
<p className="mt-1 text-sm text-foreground/70">{group.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<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="w-full justify-center gap-1.5 border-border sm:w-auto"
|
||||
>
|
||||
<BarChart3 className="size-4 text-accent" />
|
||||
<span>Estadísticas</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={openInviteModal}
|
||||
className="w-full justify-center gap-1.5 border-border sm:w-auto"
|
||||
>
|
||||
<Share2 className="size-4 text-accent" />
|
||||
<span>
|
||||
Compartir <span className="hidden sm:inline">Link</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={openAddAttendeeModal}
|
||||
className="w-full justify-center gap-1.5 sm:w-auto"
|
||||
>
|
||||
<UserPlus className="size-4" />
|
||||
<span>
|
||||
Agregar <span className="hidden sm:inline">Miembros</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
apps/web/src/features/groups/detail/GroupInfoCard.tsx
Normal file
47
apps/web/src/features/groups/detail/GroupInfoCard.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { CalendarClock, Users } from 'lucide-react'
|
||||
import { BILLING_LABELS, formatPrice, formatSchedule } from '../../../lib/format'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function GroupInfoCard() {
|
||||
const { group, attendees, waitlistTotal } = useGroupDetail()
|
||||
if (!group) return null
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 rounded-xl border border-border bg-surface p-4 text-sm md:grid-cols-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarClock className="size-5 shrink-0 text-accent" />
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase text-foreground/50">Horario</p>
|
||||
<p className="font-medium text-primary">
|
||||
{(group.days?.length ?? 0) > 0
|
||||
? formatSchedule(group.days ?? [], group.time)
|
||||
: 'Sin horario definido'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="size-5 shrink-0 text-accent" />
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase text-foreground/50">Miembros & Cupo</p>
|
||||
<p className="font-medium text-primary">
|
||||
{attendees.length} inscritos {waitlistTotal > 0 ? ` · ${waitlistTotal} en espera` : ''}
|
||||
{group.capacity ? ` · Cupo de ${group.capacity}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-5 shrink-0 items-center justify-center font-bold text-accent">$</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase text-foreground/50">Cobro</p>
|
||||
<p className="font-medium text-primary">
|
||||
{group.price != null ? formatPrice(group.price) : 'Sin precio'}
|
||||
{group.billingType ? ` · ${BILLING_LABELS[group.billingType]}` : ''}
|
||||
{group.dueDay ? ` · Vence día ${group.dueDay}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
117
apps/web/src/features/groups/detail/InviteLinkModal.tsx
Normal file
117
apps/web/src/features/groups/detail/InviteLinkModal.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Copy, Loader2, MessageCircle, RefreshCw } from 'lucide-react'
|
||||
import { Button, Input, Label, Modal } from '../../../components/ui'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function InviteLinkModal() {
|
||||
const {
|
||||
isInviteModalOpen,
|
||||
closeInviteModal,
|
||||
group,
|
||||
inviteUrl,
|
||||
invitePending,
|
||||
isRegeneratingInvite,
|
||||
regenerateInvite,
|
||||
copyInviteLink,
|
||||
copyWhatsappMessage,
|
||||
openWhatsapp,
|
||||
} = useGroupDetail()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isInviteModalOpen}
|
||||
onClose={closeInviteModal}
|
||||
title="Compartir Link de Invitación"
|
||||
description="Envía este enlace a tus miembros para que completen sus datos e ingresen directamente al grupo."
|
||||
maxWidth="md"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{invitePending ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="size-6 animate-spin text-accent" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="invite-link-input" className="mb-1.5 block">
|
||||
Enlace único del grupo
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="invite-link-input"
|
||||
readOnly
|
||||
value={inviteUrl ?? ''}
|
||||
className="select-text bg-primary-soft/50 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void copyInviteLink()}
|
||||
className="shrink-0 gap-1.5 px-3"
|
||||
title="Copiar al portapapeles"
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
<span>Copiar</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 rounded-xl border border-accent/20 bg-accent-soft p-4 text-xs text-foreground/80">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-semibold text-primary">Vista previa del mensaje para WhatsApp:</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void copyWhatsappMessage()}
|
||||
className="h-7 shrink-0 gap-1.5 px-2 text-xs text-primary hover:text-primary/80"
|
||||
title="Copiar mensaje completo al portapapeles"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
<span>Copiar mensaje</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="whitespace-pre-line rounded-lg border border-accent/10 bg-surface/70 p-3 font-sans text-xs leading-relaxed text-foreground">
|
||||
{`¡Hola! 👋 Te invito a unirte al grupo `}
|
||||
<strong>{group?.name}</strong>.
|
||||
{'\n\n'}
|
||||
Completa tus datos de inscripción en el siguiente enlace:
|
||||
{'\n'}
|
||||
<span className="break-all text-accent underline">{inviteUrl}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2.5 pt-2 sm:flex-row">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void openWhatsapp()}
|
||||
className="w-full gap-2 border-0 bg-[#25D366] text-white hover:bg-[#1EBE5D] sm:flex-1"
|
||||
>
|
||||
<MessageCircle className="size-4" />
|
||||
<span>Abrir en WhatsApp</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={regenerateInvite}
|
||||
disabled={isRegeneratingInvite}
|
||||
className="w-full gap-1.5 text-xs text-foreground/60 hover:text-danger sm:w-auto"
|
||||
title="Invalida el enlace anterior y genera uno nuevo"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', isRegeneratingInvite && 'animate-spin')} />
|
||||
<span>Regenerar enlace</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[11px] font-normal leading-normal text-foreground/50 sm:text-left">
|
||||
Al hacer clic en <strong>Abrir en WhatsApp</strong>, el mensaje completo se copia
|
||||
automáticamente al portapapeles. Si WhatsApp Web solo carga el enlace, podés pegarlo
|
||||
directamente con <kbd className="rounded bg-primary-soft px-1 py-0.5 font-mono text-[10px] text-primary">Ctrl+V</kbd>{' '}
|
||||
o <kbd className="rounded bg-primary-soft px-1 py-0.5 font-mono text-[10px] text-primary">Cmd+V</kbd>.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
45
apps/web/src/features/groups/detail/MembersTable.tsx
Normal file
45
apps/web/src/features/groups/detail/MembersTable.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { AttendeeDto } from '@gruperly/shared'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function MembersTable({ attendees }: { attendees: AttendeeDto[] }) {
|
||||
const { setSelectedAttendee } = useGroupDetail()
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-border text-xs font-semibold uppercase text-foreground/60">
|
||||
<tr>
|
||||
<th className="px-3 pb-3">Nombre</th>
|
||||
<th className="px-3 pb-3">Teléfono</th>
|
||||
<th className="w-8 px-3 pb-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{attendees.map((attendee) => (
|
||||
<tr
|
||||
key={attendee.id}
|
||||
className="cursor-pointer transition-colors hover:bg-primary-soft/50"
|
||||
onClick={() => setSelectedAttendee(attendee)}
|
||||
>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-accent/10 text-xs font-semibold text-accent">
|
||||
{attendee.fullName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="font-medium text-primary">{attendee.fullName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-foreground/70">
|
||||
{attendee.phone || <span className="text-foreground/40">—</span>}
|
||||
</td>
|
||||
<td className="px-1 py-3 text-foreground/30">
|
||||
<ChevronRight className="size-4" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
146
apps/web/src/features/groups/detail/MembersWaitlistSection.tsx
Normal file
146
apps/web/src/features/groups/detail/MembersWaitlistSection.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Clock, Loader2, Plus, Search, Share2, Users } from 'lucide-react'
|
||||
import { Button, Input } from '../../../components/ui'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
import { MembersTable } from './MembersTable'
|
||||
import { WaitlistTable } from './WaitlistTable'
|
||||
|
||||
export function MembersWaitlistSection() {
|
||||
const {
|
||||
attendees,
|
||||
attendeesTotal,
|
||||
attendeesPending,
|
||||
waitlistEntries,
|
||||
waitlistTotal,
|
||||
waitlistPending,
|
||||
listSection,
|
||||
setListSection,
|
||||
searchFilter,
|
||||
setSearchFilter,
|
||||
openInviteModal,
|
||||
openAddAttendeeModal,
|
||||
} = useGroupDetail()
|
||||
|
||||
const query = searchFilter.toLowerCase()
|
||||
const filteredAttendees = attendees.filter(
|
||||
(a) =>
|
||||
a.fullName.toLowerCase().includes(query) ||
|
||||
(a.phone && a.phone.includes(query)) ||
|
||||
(a.email && a.email.toLowerCase().includes(query)),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-border bg-surface p-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
|
||||
<div className="flex w-full items-center rounded-xl bg-primary-soft p-1 sm:w-[26rem]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setListSection('members')}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-semibold transition-all',
|
||||
listSection === 'members'
|
||||
? 'bg-surface text-primary shadow-xs'
|
||||
: 'text-foreground/60 hover:text-primary',
|
||||
)}
|
||||
>
|
||||
<Users className="size-4 shrink-0" />
|
||||
<span className="whitespace-nowrap">Miembros ({attendeesTotal})</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setListSection('waitlist')}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-semibold transition-all',
|
||||
listSection === 'waitlist'
|
||||
? 'bg-surface text-primary shadow-xs'
|
||||
: 'text-foreground/60 hover:text-primary',
|
||||
)}
|
||||
>
|
||||
<Clock className="size-4 shrink-0" />
|
||||
<span className="whitespace-nowrap">Lista de espera ({waitlistTotal})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{listSection === 'members' ? (
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="absolute left-3 top-2.5 size-4 text-foreground/40" />
|
||||
<Input
|
||||
placeholder="Buscar por nombre o teléfono..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
className="h-9 pl-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-foreground/60">
|
||||
{listSection === 'members'
|
||||
? 'Listado de todos los miembros incorporados al grupo.'
|
||||
: 'Personas que esperan un cupo libre en el grupo. Al pasar a alguien al grupo, se crea automáticamente su registro como miembro.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{listSection === 'members' ? (
|
||||
<>
|
||||
{attendeesPending ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-accent" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!attendeesPending && attendees.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border px-4 py-12 text-center">
|
||||
<Users className="mx-auto mb-2 size-10 text-foreground/30" />
|
||||
<p className="font-medium text-primary">Todavía no hay miembros en este grupo</p>
|
||||
<p className="mx-auto mt-1 max-w-sm text-sm text-foreground/60">
|
||||
Puedes compartir el enlace de invitación único o agregar miembros de forma manual o
|
||||
masiva.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-3">
|
||||
<Button variant="outline" size="sm" onClick={openInviteModal} className="gap-2">
|
||||
<Share2 className="size-4" />
|
||||
Compartir Enlace
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={openAddAttendeeModal} className="gap-2">
|
||||
<Plus className="size-4" />
|
||||
Agregar Miembros
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{attendees.length > 0 && filteredAttendees.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-foreground/60">
|
||||
No se encontraron miembros que coincidan con la búsqueda.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{filteredAttendees.length > 0 ? <MembersTable attendees={filteredAttendees} /> : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{waitlistPending ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-accent" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!waitlistPending && waitlistEntries.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border px-4 py-12 text-center">
|
||||
<Clock className="mx-auto mb-2 size-10 text-foreground/30" />
|
||||
<p className="font-medium text-primary">No hay nadie en la lista de espera</p>
|
||||
<p className="mx-auto mt-1 max-w-sm text-sm text-foreground/60">
|
||||
Cuando el grupo alcance su cupo, las personas podrán sumarse a la espera y podrás
|
||||
pasarlas al grupo desde aquí.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{waitlistEntries.length > 0 ? <WaitlistTable entries={waitlistEntries} /> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
apps/web/src/features/groups/detail/QuickAddForm.tsx
Normal file
124
apps/web/src/features/groups/detail/QuickAddForm.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react'
|
||||
import { Check, Loader2 } from 'lucide-react'
|
||||
import { Button, Input, Label, useToast } from '../../../components/ui'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function QuickAddForm() {
|
||||
const { submitQuickAttendee, isSubmittingAttendee, closeAddAttendeeModal } = useGroupDetail()
|
||||
const toast = useToast()
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!firstName.trim() || !phone.trim()) {
|
||||
toast.error('Nombre y teléfono son obligatorios.')
|
||||
return
|
||||
}
|
||||
submitQuickAttendee({
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
phone: phone.trim(),
|
||||
email: email.trim() || undefined,
|
||||
notes: notes.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setFirstName('')
|
||||
setLastName('')
|
||||
setPhone('')
|
||||
setEmail('')
|
||||
setNotes('')
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="quick-first-name" className="mb-1 block text-xs">
|
||||
Nombre <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="quick-first-name"
|
||||
placeholder="Ej. Lucas"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="quick-last-name" className="mb-1 block text-xs">
|
||||
Apellido
|
||||
</Label>
|
||||
<Input
|
||||
id="quick-last-name"
|
||||
placeholder="Ej. González"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="quick-phone" className="mb-1 block text-xs">
|
||||
Teléfono / WhatsApp <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="quick-phone"
|
||||
placeholder="Ej. +54 9 11 2345-6789"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-foreground/50">
|
||||
Usado para contacto y validación de duplicados.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="quick-email" className="mb-1 block text-xs">
|
||||
Email (opcional)
|
||||
</Label>
|
||||
<Input
|
||||
id="quick-email"
|
||||
type="email"
|
||||
placeholder="alumno@ejemplo.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="quick-notes" className="mb-1 block text-xs">
|
||||
Notas u Observaciones (opcional)
|
||||
</Label>
|
||||
<Input
|
||||
id="quick-notes"
|
||||
placeholder="Ej. Nivel intermedio, trae materiales propios"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2.5 border-t border-border pt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
resetForm()
|
||||
closeAddAttendeeModal()
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" disabled={isSubmittingAttendee} className="gap-2">
|
||||
{isSubmittingAttendee ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||||
<span>Guardar Miembro</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
99
apps/web/src/features/groups/detail/RemoveAttendeeModal.tsx
Normal file
99
apps/web/src/features/groups/detail/RemoveAttendeeModal.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Clock, Loader2, Trash2, UserCheck, UserMinus } from 'lucide-react'
|
||||
import { Button, Modal } from '../../../components/ui'
|
||||
import { useGroupDetail } from './GroupDetailProvider'
|
||||
|
||||
export function RemoveAttendeeModal() {
|
||||
const {
|
||||
attendeeToRemove,
|
||||
cancelRemoveAttendee,
|
||||
confirmRemoveAttendee,
|
||||
group,
|
||||
firstWaitlistEntry,
|
||||
waitlistTotal,
|
||||
isSubmittingRemoval,
|
||||
} = useGroupDetail()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={attendeeToRemove !== null}
|
||||
onClose={cancelRemoveAttendee}
|
||||
title="Quitar del grupo"
|
||||
maxWidth="md"
|
||||
>
|
||||
{attendeeToRemove ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-foreground/70">
|
||||
{attendeeToRemove.fullName} dejará de ser miembro del grupo{' '}
|
||||
<strong className="text-primary">{group?.name}</strong>
|
||||
{firstWaitlistEntry ? ' y el cupo quedará libre.' : '.'}
|
||||
{attendeeToRemove.phone ? (
|
||||
<span className="mt-1 block text-xs text-foreground/50">Teléfono: {attendeeToRemove.phone}</span>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
{firstWaitlistEntry ? (
|
||||
<div className="space-y-3 rounded-xl border border-accent/20 bg-accent-soft p-4">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-primary">
|
||||
<Clock className="size-4 text-accent" />
|
||||
<span>
|
||||
Hay {waitlistTotal} persona{waitlistTotal === 1 ? '' : 's'} esperando un cupo
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground/80">
|
||||
¿Quieres pasar a <strong className="text-primary">{firstWaitlistEntry.fullName}</strong>, el
|
||||
primero de la lista de espera, al grupo?
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5 pt-1">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => confirmRemoveAttendee(true)}
|
||||
disabled={isSubmittingRemoval}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSubmittingRemoval ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<UserCheck className="size-4" />
|
||||
)}
|
||||
<span>Quitar y pasar a {firstWaitlistEntry.fullName} al grupo</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => confirmRemoveAttendee(false)}
|
||||
disabled={isSubmittingRemoval}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSubmittingRemoval ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<UserMinus className="size-4" />
|
||||
)}
|
||||
<span>Solo quitar del grupo</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-2.5 border-t border-border pt-2">
|
||||
<Button variant="ghost" onClick={cancelRemoveAttendee}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => confirmRemoveAttendee(false)}
|
||||
disabled={isSubmittingRemoval}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSubmittingRemoval ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4" />
|
||||
)}
|
||||
<span>Quitar del grupo</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user