294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import { authClient } from "@/lib/auth-client"
|
|
import { MetricCard } from "@/components/parent/metric-card"
|
|
import { WeeklyActivityChart } from "@/components/parent/weekly-activity-chart"
|
|
import { SkillMasteryChart } from "@/components/parent/skill-mastery-chart"
|
|
import { MilestonesList } from "@/components/parent/milestones-list"
|
|
import ErrorPatternsChart from "@/components/parent/error-patterns-chart"
|
|
|
|
interface ChildSummary {
|
|
id: string
|
|
name: string | null
|
|
}
|
|
|
|
interface DashboardData {
|
|
totalSessions: number
|
|
todaySessions: number
|
|
todayMinutes: number
|
|
masteryPercent: number
|
|
masteredSkills: number
|
|
totalSkills: number
|
|
milestones: number
|
|
milestoneData: { skillCode: string; reachedAt: string }[]
|
|
streak: number
|
|
weeklyData: { date: string; minutes: number }[]
|
|
skillBreakdown: { skillCode: string; stability: number; reps: number }[]
|
|
stageProgress: { topic: string; stage: number; completedAt: string | null }[]
|
|
errorTypeCount: Record<string, number>
|
|
totalErrors: number
|
|
lastSession: {
|
|
startedAt: string
|
|
durationSec: number
|
|
skillsPracticed: string
|
|
correctCount: number
|
|
totalAttempts: number
|
|
} | null
|
|
}
|
|
|
|
export default function ParentDashboard() {
|
|
const { data: session, isPending } = authClient.useSession()
|
|
const router = useRouter()
|
|
const [children, setChildren] = useState<ChildSummary[]>([])
|
|
const [selectedChildId, setSelectedChildId] = useState<string>("")
|
|
const [data, setData] = useState<DashboardData | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [creating, setCreating] = useState(false)
|
|
const [newChildName, setNewChildName] = useState("")
|
|
const [pairingCode, setPairingCode] = useState("")
|
|
|
|
useEffect(() => {
|
|
fetch("/api/children")
|
|
.then((r) => r.json())
|
|
.then((list) => {
|
|
if (Array.isArray(list)) {
|
|
setChildren(list)
|
|
if (list.length > 0) setSelectedChildId(list[0].id)
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!selectedChildId) {
|
|
setLoading(false)
|
|
return
|
|
}
|
|
setLoading(true)
|
|
fetch(`/api/dashboard/summary?childId=${selectedChildId}`)
|
|
.then((r) => r.json())
|
|
.then(setData)
|
|
.catch(() => {})
|
|
.finally(() => setLoading(false))
|
|
}, [selectedChildId])
|
|
|
|
const handleCreateChild = async () => {
|
|
if (!newChildName.trim()) return
|
|
setCreating(true)
|
|
const res = await fetch("/api/children", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name: newChildName.trim() }),
|
|
})
|
|
if (res.ok) {
|
|
const child = await res.json()
|
|
setChildren((prev) => [...prev, child])
|
|
setSelectedChildId(child.id)
|
|
setNewChildName("")
|
|
}
|
|
setCreating(false)
|
|
}
|
|
|
|
const handleGeneratePairingCode = async () => {
|
|
if (!selectedChildId) return
|
|
const res = await fetch("/api/pairing", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ action: "generate", childId: selectedChildId }),
|
|
})
|
|
const data = await res.json()
|
|
if (data.pairingCode) {
|
|
setPairingCode(data.pairingCode)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!isPending && !session) {
|
|
router.push("/parent/auth/login")
|
|
}
|
|
}, [session, isPending, router])
|
|
|
|
if (isPending || !session) {
|
|
return (
|
|
<div className="p-4 text-center text-muted-foreground">Cargando...</div>
|
|
)
|
|
}
|
|
|
|
const selectedChild = children.find((c) => c.id === selectedChildId)
|
|
|
|
return (
|
|
<div className="p-4 sm:p-6 max-w-2xl mx-auto">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
|
|
<div>
|
|
<h2 className="text-2xl font-display font-bold">Dashboard</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Bienvenida, {session.user?.name || "Mamá"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{children.length === 0 ? (
|
|
<div className="bg-white rounded-2xl p-6 shadow-sm border border-border">
|
|
<h3 className="text-lg font-display font-semibold mb-4">Agregar un niño</h3>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={newChildName}
|
|
onChange={(e) => setNewChildName(e.target.value)}
|
|
placeholder="Nombre de la nena"
|
|
className="flex-1 rounded-xl border border-border bg-white px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-primary"
|
|
/>
|
|
<button
|
|
onClick={handleCreateChild}
|
|
disabled={creating || !newChildName.trim()}
|
|
className="bg-primary text-primary-foreground touch-target rounded-xl px-6 font-display font-semibold disabled:opacity-50"
|
|
>
|
|
{creating ? "..." : "Agregar"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex flex-wrap gap-2 mb-6">
|
|
{children.map((c) => (
|
|
<button
|
|
key={c.id}
|
|
onClick={() => setSelectedChildId(c.id)}
|
|
className={`rounded-xl px-4 py-2 font-display font-medium text-sm transition-colors ${
|
|
c.id === selectedChildId
|
|
? "bg-primary text-primary-foreground"
|
|
: "bg-muted text-foreground"
|
|
}`}
|
|
>
|
|
{c.name || "Sin nombre"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="text-center py-12 text-muted-foreground">Cargando...</div>
|
|
) : data ? (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
<MetricCard title="Racha" value={`${data.streak} días`} variant="primary" />
|
|
<MetricCard
|
|
title="Hoy"
|
|
value={`${data.todayMinutes} min`}
|
|
subtitle={data.todaySessions > 0 ? `${data.todaySessions} sesiones` : undefined}
|
|
variant="success"
|
|
/>
|
|
<MetricCard
|
|
title="Maestría"
|
|
value={`${data.masteryPercent}%`}
|
|
subtitle={`${data.masteredSkills}/${data.totalSkills} skills`}
|
|
variant="accent"
|
|
/>
|
|
<MetricCard title="Logros" value={data.milestones} variant="default" />
|
|
</div>
|
|
|
|
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
|
<h3 className="text-sm font-display font-semibold mb-3">
|
|
Actividad semanal
|
|
</h3>
|
|
<WeeklyActivityChart data={data.weeklyData} />
|
|
</div>
|
|
|
|
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
|
<h3 className="text-sm font-display font-semibold mb-3">
|
|
Maestría por skill
|
|
</h3>
|
|
<SkillMasteryChart data={data.skillBreakdown} />
|
|
</div>
|
|
|
|
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
|
<h3 className="text-sm font-display font-semibold mb-3">Logros</h3>
|
|
<MilestonesList data={data.milestoneData} />
|
|
</div>
|
|
|
|
{data.stageProgress.length > 0 && (
|
|
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
|
<h3 className="text-sm font-display font-semibold mb-3">Progreso curricular</h3>
|
|
<div className="space-y-2 text-sm">
|
|
{data.stageProgress.map((sp) => (
|
|
<div key={`${sp.topic}-${sp.stage}`} className="flex justify-between items-center">
|
|
<span className="font-medium">
|
|
{sp.topic === "lectura" ? "Lectura" : "Números"} — Etapa {sp.stage}
|
|
</span>
|
|
<span className={sp.completedAt ? "text-green-600" : "text-yellow-600"}>
|
|
{sp.completedAt ? "✅ Completada" : "🔵 En curso"}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{data.totalErrors > 0 && (
|
|
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
|
<h3 className="text-sm font-display font-semibold mb-3">Patrones de error</h3>
|
|
<ErrorPatternsChart data={data.errorTypeCount} total={data.totalErrors} />
|
|
</div>
|
|
)}
|
|
|
|
{data.lastSession && (
|
|
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
|
<h3 className="text-sm font-display font-semibold mb-2">Última sesión</h3>
|
|
<div className="text-sm text-muted-foreground">
|
|
<p>{new Date(data.lastSession.startedAt).toLocaleDateString("es-AR")}</p>
|
|
<p>
|
|
{Math.round((data.lastSession.durationSec ?? 0) / 60)} min ·{" "}
|
|
{data.lastSession.correctCount}/{data.lastSession.totalAttempts} correctas
|
|
</p>
|
|
<p className="text-xs mt-1">Skills: {data.lastSession.skillsPracticed || "—"}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col sm:flex-row gap-3 mt-2">
|
|
<button
|
|
onClick={handleGeneratePairingCode}
|
|
className="bg-secondary text-secondary-foreground touch-target rounded-xl px-6 font-display font-semibold"
|
|
>
|
|
Emparejar nuevo iPad
|
|
</button>
|
|
<button
|
|
onClick={() => router.push("/parent/auth/pairing")}
|
|
className="bg-muted text-foreground touch-target rounded-xl font-display"
|
|
>
|
|
Ingresar código
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
Seleccioná un niño para ver su progreso
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{pairingCode && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
|
<div className="bg-white rounded-2xl p-6 shadow-xl max-w-sm w-full text-center space-y-4">
|
|
<h3 className="text-lg font-display font-semibold">Código de emparejamiento</h3>
|
|
<p className="text-muted-foreground text-sm">
|
|
Ingresá este código en el iPad de la nena
|
|
</p>
|
|
<p className="text-3xl tracking-[0.3em] font-bold font-display text-primary select-all">
|
|
{pairingCode}
|
|
</p>
|
|
<button
|
|
onClick={() => setPairingCode("")}
|
|
className="bg-primary text-primary-foreground touch-target rounded-xl px-6 py-3 font-display font-semibold"
|
|
>
|
|
Listo
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|