Files
gruperly/apps/web/src/routes/absence-notify.tsx
Jose Selesan 2e184845e1 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.
2026-09-23 16:24:45 -03:00

165 lines
7.2 KiB
TypeScript

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>
)
}