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:
@@ -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 }),
|
||||
})
|
||||
@@ -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,
|
||||
]),
|
||||
])
|
||||
|
||||
|
||||
164
apps/web/src/routes/absence-notify.tsx
Normal file
164
apps/web/src/routes/absence-notify.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
472
apps/web/src/routes/analytics.tsx
Normal file
472
apps/web/src/routes/analytics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
259
apps/web/src/routes/class-attendance.tsx
Normal file
259
apps/web/src/routes/class-attendance.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user