- 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.
165 lines
7.2 KiB
TypeScript
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>
|
|
)
|
|
}
|