feat: add absence notification and analytics features

- Implemented AbsenceNotifyView for notifying absence with session details.
- Created AnalyticsView for displaying group analytics and managing students at risk.
- Added ClassAttendanceView for marking attendance in classes.
- Defined new schemas for analytics and attendance in shared package.
This commit is contained in:
Jose Selesan
2026-09-23 16:24:45 -03:00
parent a363e9e8fc
commit 2e184845e1
59 changed files with 3854 additions and 23 deletions

View File

@@ -1,8 +1,12 @@
import type {
AbsenceDetails,
AttendeeDto,
AttendeeHistory,
AttendeeList,
AttendeeStatus,
BulkCreateAttendees,
BulkCreateAttendeesResult,
ClassTodayList,
ConnectPayment,
ConnectPaymentResult,
CreateAttendee,
@@ -11,6 +15,7 @@ import type {
CreateFirstGroupResult,
CreateGroupResult,
CreateGroupWaitlistEntry,
GroupAnalytics,
GroupDto,
GroupInviteInfoDto,
GroupList,
@@ -20,11 +25,19 @@ import type {
InviteTokenResult,
JoinGroupViaInvite,
JoinGroupViaInviteResult,
MarkAttendance,
MarkAttendanceResult,
NotifyAbsence,
NotifyAbsenceResult,
OnboardingStatusDto,
ProblemDetails,
PromoteGroupWaitlistEntryResult,
PublicNotifyAbsence,
RemoveAttendeeResult,
RemoveGroupWaitlistEntryResult,
SessionStudents,
StudentsAtRisk,
UpdateStudentStatusResult,
} from '@gruperly/shared'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
@@ -89,18 +102,18 @@ export const createGroup = (payload: CreateFirstGroup) =>
export const getGroups = () => apiFetch<GroupList>('/api/v1/groups')
export const getHomeSummary = () => {
let timeZone: string | undefined
function timeZoneParam(): string {
try {
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
return timeZone ? `?timeZone=${encodeURIComponent(timeZone)}` : ''
} catch {
// Sin zona horaria disponible: el backend usa su valor por defecto.
timeZone = undefined
return ''
}
const params = timeZone ? `?timeZone=${encodeURIComponent(timeZone)}` : ''
return apiFetch<HomeSummaryDto>(`/api/v1/home${params}`)
}
export const getHomeSummary = () =>
apiFetch<HomeSummaryDto>(`/api/v1/home${timeZoneParam()}`)
export const getGroup = (groupId: string) =>
apiFetch<GroupDto>(`/api/v1/groups/${groupId}`)
@@ -171,4 +184,50 @@ export const removeGroupAttendee = (
{
method: 'DELETE',
},
)
)
// — Día a Día: clases y asistencia —
export const getClassesToday = () =>
apiFetch<ClassTodayList>(`/api/v1/classes/today${timeZoneParam()}`)
export const getSessionStudents = (sessionId: string) =>
apiFetch<SessionStudents>(`/api/v1/classes/${sessionId}/students${timeZoneParam()}`)
export const markAttendance = (sessionId: string, payload: MarkAttendance) =>
apiFetch<MarkAttendanceResult>(`/api/v1/classes/${sessionId}/attendance`, {
method: 'POST',
body: JSON.stringify(payload),
})
export const notifyAbsence = (sessionId: string, payload: NotifyAbsence) =>
apiFetch<NotifyAbsenceResult>(`/api/v1/classes/${sessionId}/notify-absence`, {
method: 'POST',
body: JSON.stringify(payload),
})
export const getAbsenceDetails = (token: string) =>
apiFetch<AbsenceDetails>(`/api/v1/classes/absence?token=${encodeURIComponent(token)}`)
export const publicNotifyAbsence = (payload: PublicNotifyAbsence) =>
apiFetch<NotifyAbsenceResult>('/api/v1/classes/notify-absence', {
method: 'POST',
body: JSON.stringify(payload),
})
// — Analíticas y gestión de riesgo —
export const getGroupAnalytics = (groupId: string) =>
apiFetch<GroupAnalytics>(`/api/v1/groups/${groupId}/analytics`)
export const getStudentsAtRisk = (groupId: string) =>
apiFetch<StudentsAtRisk>(`/api/v1/groups/${groupId}/students-at-risk`)
export const getAttendeeHistory = (groupId: string, attendeeId: string) =>
apiFetch<AttendeeHistory>(`/api/v1/groups/${groupId}/attendees/${attendeeId}/history`)
export const updateAttendeeStatus = (attendeeId: string, status: AttendeeStatus) =>
apiFetch<UpdateStudentStatusResult>(`/api/v1/students/${attendeeId}/status`, {
method: 'POST',
body: JSON.stringify({ status }),
})

View File

@@ -13,6 +13,9 @@ import { OnboardingView } from './routes/onboarding'
import { GroupDetailView } from './routes/group-detail'
import { CreateGroupView } from './routes/create-group'
import { JoinGroupView } from './routes/join-group'
import { ClassAttendanceView } from './routes/class-attendance'
import { AbsenceNotifyView } from './routes/absence-notify'
import { AnalyticsView } from './routes/analytics'
const rootRoute = createRootRoute({
component: () => <Outlet />,
@@ -48,6 +51,13 @@ const joinGroupRoute = createRoute({
component: JoinGroupView,
})
// Página pública: el alumno avisa su ausencia con su token personal.
const absenceNotifyRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/avisar-ausencia/$token',
component: AbsenceNotifyView,
})
// Capa con la navegación de la app autenticada (Sidebar + BottomNav).
// El guard redirige a /onboarding a quien no completó el onboarding.
const appLayoutRoute = createRoute({
@@ -80,6 +90,12 @@ const groupDetailRoute = createRoute({
component: GroupDetailView,
})
const groupAnalyticsRoute = createRoute({
getParentRoute: () => appLayoutRoute,
path: '/groups/$groupId/analytics',
component: AnalyticsView,
})
const paymentsRoute = createRoute({
getParentRoute: () => appLayoutRoute,
path: '/payments',
@@ -104,21 +120,30 @@ const profileRoute = createRoute({
component: ProfileView,
})
const classAttendanceRoute = createRoute({
getParentRoute: () => appLayoutRoute,
path: '/class/$sessionId/attendance',
component: ClassAttendanceView,
})
const routeTree = rootRoute.addChildren([
loginRoute,
signupRoute,
verifyEmailRoute,
onboardingRoute,
joinGroupRoute,
absenceNotifyRoute,
appLayoutRoute.addChildren([
indexRoute,
groupsRoute,
createGroupRoute,
groupDetailRoute,
groupAnalyticsRoute,
paymentsRoute,
settingsRoute,
securityRoute,
profileRoute,
classAttendanceRoute,
]),
])

View File

@@ -0,0 +1,164 @@
import { useState } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { Link, useParams } from '@tanstack/react-router'
import { CalendarX2, CheckCircle2, Loader2, Moon, Sun } from 'lucide-react'
import { Badge, Button, useToast } from '../components/ui'
import { Logo } from '../components/brand'
import { useTheme } from '../context/ThemeProvider'
import { ApiError, getAbsenceDetails, publicNotifyAbsence } from '../lib/api'
export function AbsenceNotifyView() {
const { token } = useParams({ strict: false }) as { token: string }
const { setTheme, isDark } = useTheme()
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 details = detailsQuery.data
return (
<div className="flex min-h-screen flex-col justify-between bg-background text-primary selection:bg-accent selection:text-white">
<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>
<button
type="button"
onClick={() => setTheme(isDark ? 'light' : 'dark')}
className="rounded-xl p-2 text-foreground/70 transition-colors hover:bg-primary-soft hover:text-primary"
title={isDark ? 'Cambiar a tema claro' : 'Cambiar a tema oscuro'}
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
</header>
<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">
{detailsQuery.isPending ? (
<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}
{detailsQuery.isError || !details ? (
<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) => {
const time = new Date(session.startsAt).toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
const notified =
session.notified || locallyNotified.includes(session.sessionId)
return (
<li
key={session.sessionId}
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>
{notified ? (
<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={notifyMutation.isPending}
onClick={() => notifyMutation.mutate(session.sessionId)}
>
{notifyMutation.isPending &&
notifyMutation.variables === session.sessionId ? (
<Loader2 className="size-4 animate-spin" />
) : null}
Avisar ausencia
</Button>
)}
</li>
)
})}
</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>
<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>
</div>
)
}

View File

@@ -0,0 +1,472 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useParams } from '@tanstack/react-router';
import type { AttendeeStatus, StudentAtRiskDto } from '@gruperly/shared';
import {
ArrowLeft,
CalendarCheck,
DoorOpen,
Loader2,
MessageCircle,
MoreVertical,
Pause,
Percent,
UserMinus,
Users,
} from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { Avatar, Badge, Button, Modal, useToast } from '../components/ui';
import {
getAttendeeHistory,
getGroupAnalytics,
getStudentsAtRisk,
updateAttendeeStatus,
} from '../lib/api';
import { cn } from '../lib/utils';
type StatusAction = { student: StudentAtRiskDto; status: AttendeeStatus } | null;
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>
);
}
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';
}
}
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';
}
}
function formatSessionDate(iso: string): string {
return new Date(iso).toLocaleDateString('es-MX', {
weekday: 'short',
day: 'numeric',
month: 'short',
});
}
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'] });
},
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>
{/* KPIs */}
<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>
{/* Alumnos en riesgo */}
<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) => (
<li
key={student.attendeeId}
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={() => setHistoryStudent(student)}
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={() => void handleReengage(student, groupName ?? 'tu clase')}
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={() =>
setMenuOpenFor(menuOpenFor === student.attendeeId ? null : student.attendeeId)
}
>
<MoreVertical className="size-4" />
</Button>
{menuOpenFor === student.attendeeId ? (
<>
<div
className="fixed inset-0 z-20"
onClick={() => setMenuOpenFor(null)}
aria-hidden="true"
/>
<div className="absolute right-0 z-30 mt-1 w-44 rounded-xl border border-border bg-surface p-1.5 shadow-xl animate-fade-in">
<button
type="button"
onClick={() => {
setStatusAction({ student, status: 'PAUSED' });
setMenuOpenFor(null);
}}
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={() => {
setStatusAction({ student, status: 'DROPPED' });
setMenuOpenFor(null);
}}
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>
))}
</ul>
)}
</div>
{/* Modal: historial del alumno */}
<Modal
isOpen={historyStudent !== null}
onClose={() => setHistoryStudent(null)}
title={historyStudent?.fullName ?? 'Historial'}
description={historyStudent ? 'Historial de presentismo de las últimas clases.' : undefined}
maxWidth="md"
>
{historyStudent ? (
<div>
{historyQuery.isPending ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="size-6 animate-spin text-accent" />
</div>
) : historyQuery.isError || !historyQuery.data ? (
<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">
{historyQuery.data.attendanceRate}%
</span>
<span className="text-xs font-medium uppercase text-foreground/60">
Presentismo general ({historyQuery.data.sessions.length} clases)
</span>
</div>
<ul className="mt-4 space-y-2">
{historyQuery.data.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 ?? null),
)}
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',
session.status === 'PRESENT'
? 'text-success'
: session.status === 'ABSENT'
? 'text-danger'
: session.status === 'EXCUSED'
? 'text-warning'
: 'text-foreground/50',
)}
>
{statusLabel(session.status)}
</span>
</li>
))}
</ul>
</div>
)}
</div>
) : null}
</Modal>
{/* Modal: confirmar cambio de estado */}
<Modal
isOpen={statusAction !== null}
onClose={() => setStatusAction(null)}
title={statusAction?.status === 'DROPPED' ? 'Dar de baja' : 'Pausar vacante'}
maxWidth="md"
>
{statusAction ? (
<div className="space-y-4">
<p className="text-sm text-foreground/70">
{statusAction.student.fullName}{' '}
{statusAction.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={() => setStatusAction(null)}>
Cancelar
</Button>
<Button
variant="primary"
className={cn(
statusAction.status === 'DROPPED' &&
'border border-danger/20 bg-danger text-white hover:bg-danger/90',
)}
disabled={statusMutation.isPending}
onClick={() => statusMutation.mutate(statusAction)}
>
{statusMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : statusAction.status === 'DROPPED' ? (
<UserMinus className="size-4" />
) : (
<Pause className="size-4" />
)}
<span>{statusAction.status === 'DROPPED' ? 'Dar de baja' : 'Pausar vacante'}</span>
</Button>
</div>
</div>
) : null}
</Modal>
</section>
);
}

View File

@@ -0,0 +1,259 @@
import { useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, useParams } from '@tanstack/react-router'
import type { SessionStudentDto } from '@gruperly/shared'
import { ArrowLeft, Check, Loader2, X } from 'lucide-react'
import { Avatar, Badge, Button, useToast } from '../components/ui'
import { getSessionStudents, markAttendance } from '../lib/api'
import { cn } from '../lib/utils'
type AttendanceChoice = 'PRESENT' | 'ABSENT'
function isLocked(student: SessionStudentDto): boolean {
return student.notifiedAbsence || student.attendanceStatus === 'EXCUSED'
}
function PaymentBadge({ status }: { status: SessionStudentDto['paymentStatus'] }) {
return status === 'UP_TO_DATE' ? (
<Badge variant="success">Al día</Badge>
) : (
<Badge variant="danger">Pendiente</Badge>
)
}
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>
)
}
function StudentRow({
student,
choice,
onToggle,
}: {
student: SessionStudentDto
choice: AttendanceChoice
onToggle: () => void
}) {
const locked = isLocked(student)
return (
<li
className={cn(
'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={choice}
disabled={locked}
onToggle={onToggle}
fullName={student.fullName}
/>
</li>
)
}
export function ClassAttendanceView() {
const { sessionId } = useParams({ strict: false }) as { sessionId: string }
const queryClient = useQueryClient()
const toast = useToast()
const studentsQuery = useQuery({
queryKey: ['class-students', sessionId],
queryFn: () => getSessionStudents(sessionId),
staleTime: 0,
})
const students = studentsQuery.data?.students
// Estado local de la toma: todos parten PRESENT (1-tap para marcar ausentes).
// Alumnas con ausencia avisada quedan bloqueadas en EXCUSED.
const [choices, setChoices] = useState<Record<string, AttendanceChoice>>({})
useEffect(() => {
if (!students) return
const initial: Record<string, AttendanceChoice> = {}
for (const student of students) {
if (isLocked(student)) continue
initial[student.attendeeId] = student.attendanceStatus === 'ABSENT' ? 'ABSENT' : 'PRESENT'
}
setChoices(initial)
}, [students])
const presentCount = useMemo(
() =>
(students ?? []).filter(
(student) => !isLocked(student) && choices[student.attendeeId] === 'PRESENT',
).length,
[students, choices],
)
const toggleStudent = (attendeeId: string) => {
setChoices((prev) => ({
...prev,
[attendeeId]: prev[attendeeId] === 'ABSENT' ? 'PRESENT' : 'ABSENT',
}))
}
const saveMutation = useMutation({
mutationFn: () => {
const records = (students ?? [])
.filter((student) => !isLocked(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 sessionInfo = studentsQuery.data
const startTime = sessionInfo
? new Date(sessionInfo.startsAt).toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
: null
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>
{studentsQuery.isPending ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin text-foreground/40" />
</div>
) : null}
{studentsQuery.isError ? (
<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={() => void studentsQuery.refetch()}
>
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}
choice={choices[student.attendeeId] ?? 'PRESENT'}
onToggle={() => toggleStudent(student.attendeeId)}
/>
))}
</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={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : null}
Guardar Asistencia
</Button>
</div>
) : null}
</>
) : null}
</section>
)
}

View File

@@ -11,6 +11,7 @@ import type {
} from '@gruperly/shared';
import {
ArrowLeft,
BarChart3,
CalendarClock,
Check,
ChevronRight,
@@ -68,6 +69,9 @@ export function GroupDetailView() {
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [isAddAttendeeModalOpen, setIsAddAttendeeModalOpen] = useState(false);
const [selectedAttendee, setSelectedAttendee] = useState<AttendeeDto | null>(null);
const absenceNotifyUrl = selectedAttendee?.notifyToken
? `${window.location.origin}/avisar-ausencia/${selectedAttendee.notifyToken}`
: null;
const [activeTab, setActiveTab] = useState<'quick' | 'bulk'>('quick');
const [listSection, setListSection] = useState<'members' | 'waitlist'>('members');
const [attendeeToRemove, setAttendeeToRemove] = useState<AttendeeDto | null>(null);
@@ -507,6 +511,14 @@ export function GroupDetailView() {
</div>
<div className="flex flex-wrap items-center gap-2.5">
<Button
variant="outline"
onClick={() => void navigate({ to: '/groups/$groupId/analytics', params: { groupId } })}
className="gap-2 border-border"
>
<BarChart3 className="size-4 text-accent" />
<span>Estadísticas</span>
</Button>
<Button
variant="outline"
onClick={() => setIsInviteModalOpen(true)}
@@ -1360,6 +1372,41 @@ export function GroupDetailView() {
</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={() => {
void navigator.clipboard.writeText(absenceNotifyUrl);
toast.success('Link copiado al portapapeles.');
}}
>
<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="pt-2 border-t border-border">
<Button
variant="ghost"

View File

@@ -1,9 +1,24 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { CalendarClock, Loader2, Plus, UserCheck, Users, Wallet, type LucideIcon } from 'lucide-react'
import type { HomeSummaryDto, NextClassDto, PaymentDto, UpcomingPaymentDto } from '@gruperly/shared'
import {
CalendarClock,
ClipboardCheck,
Loader2,
Plus,
UserCheck,
Users,
Wallet,
type LucideIcon,
} from 'lucide-react'
import type {
ClassTodayDto,
HomeSummaryDto,
NextClassDto,
PaymentDto,
UpcomingPaymentDto,
} from '@gruperly/shared'
import { Badge, Button } from '../components/ui'
import { getHomeSummary } from '../lib/api'
import { getClassesToday, getHomeSummary } from '../lib/api'
import {
formatPrice,
formatRelativeDateTime,
@@ -22,6 +37,58 @@ const PAYMENT_BADGE_VARIANT: Record<
CANCELLED: 'neutral',
}
function TodayClassCard({ item }: { item: ClassTodayDto }) {
const navigate = useNavigate()
const time = new Date(item.startsAt).toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
return (
<article className="relative overflow-hidden rounded-xl border border-accent/30 bg-surface p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-wide text-foreground/50">
Clase de hoy
</p>
<h2 className="mt-1 truncate text-xl font-bold text-primary">{item.groupName}</h2>
<div className="mt-2 flex items-center gap-2 text-sm text-foreground/70">
<CalendarClock className="size-4 shrink-0 text-accent" />
<span>Hoy · {time} hs</span>
</div>
<p className="mt-1 text-xs text-foreground/60">
{item.enrolledCount} inscrito{item.enrolledCount === 1 ? '' : 's'}
{item.availableSlots != null ? ` · ${item.availableSlots} cupo${item.availableSlots === 1 ? '' : 's'} disponible${item.availableSlots === 1 ? '' : 's'}` : ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge variant={item.hasAttendance ? 'success' : 'neutral'}>
{item.hasAttendance ? 'Asistencia tomada' : 'Por tomar'}
</Badge>
</div>
</div>
<div className="mt-4 flex justify-end gap-2 border-t border-border pt-3">
<Button
variant="outline"
size="sm"
onClick={() => void navigate({ to: `/groups/${item.groupId}` })}
>
Ver grupo
</Button>
<Button
variant="primary"
size="sm"
onClick={() => void navigate({ to: `/class/${item.sessionId}/attendance` })}
>
<ClipboardCheck className="size-4" />
Tomar Asistencia
</Button>
</div>
</article>
)
}
function NextClassHero({ nextClass }: { nextClass: NextClassDto }) {
const navigate = useNavigate()
const hasSchedule = (nextClass.days?.length ?? 0) > 0
@@ -145,6 +212,12 @@ export function HomeView() {
queryKey: ['home-summary'],
queryFn: getHomeSummary,
})
const classesTodayQuery = useQuery({
queryKey: ['classes-today'],
queryFn: getClassesToday,
staleTime: 60_000,
})
const todayClasses = classesTodayQuery.data?.data ?? []
return (
<section>
@@ -187,7 +260,13 @@ export function HomeView() {
{summaryQuery.isSuccess && summaryQuery.data.stats.groups > 0 ? (
<div className="mt-6 space-y-6">
{summaryQuery.data.nextClass ? (
{todayClasses.length > 0 ? (
<div className="space-y-3">
{todayClasses.map((classItem) => (
<TodayClassCard key={classItem.sessionId} item={classItem} />
))}
</div>
) : summaryQuery.data.nextClass ? (
<NextClassHero nextClass={summaryQuery.data.nextClass} />
) : (
<div className="rounded-xl border border-dashed border-border bg-surface px-4 py-8 text-center">