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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user