Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85cf67c1b5 | ||
|
|
cc0180087c | ||
|
|
ea81e01a0a | ||
|
|
0ff530aa22 | ||
|
|
edcba1a7e4 | ||
|
|
e2bfbab713 | ||
|
|
4ab73ade40 | ||
|
|
b9be17db97 | ||
|
|
787522e4ca |
@@ -0,0 +1,69 @@
|
||||
import Link from "next/link";
|
||||
import type { Kid } from "@/app/data/kids";
|
||||
|
||||
/**
|
||||
* Colores dinámicos → utilities Tailwind con valores exactos.
|
||||
* El set de pares del mock es cerrado; nunca se usan atributos style.
|
||||
*/
|
||||
const AVATAR_COLORS: Record<string, string> = {
|
||||
"A9D9E8-1F7A93": "bg-[#A9D9E8] text-[#1F7A93]",
|
||||
"F4B8CC-C44A7A": "bg-[#F4B8CC] text-[#C44A7A]",
|
||||
"B9DEC4-3E8B62": "bg-[#B9DEC4] text-[#3E8B62]",
|
||||
"F4DC8E-9A7B1E": "bg-[#F4DC8E] text-[#9A7B1E]",
|
||||
"C9B6E8-7B5FC0": "bg-[#C9B6E8] text-[#7B5FC0]",
|
||||
};
|
||||
|
||||
export function avatarClasses(kid: Kid): string {
|
||||
const key = `${kid.avatar.bg.replace("#", "")}-${kid.avatar.text.replace("#", "")}`;
|
||||
return AVATAR_COLORS[key] ?? "";
|
||||
}
|
||||
|
||||
/** "2 padres vinculados" | "1 padre vinculado" | "sin padres vinculados". */
|
||||
function parentsLabel(kid: Kid): string {
|
||||
if (kid.parents.length === 0) return "sin padres vinculados";
|
||||
return `${kid.parents.length} ${kid.parents.length === 1 ? "padre" : "padres"} vinculados`;
|
||||
}
|
||||
|
||||
export default function KidCard({ kid }: { kid: Kid }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/kids/${kid.slug}`}
|
||||
className="flex min-w-0 items-center gap-[14px] rounded-[18px] border border-[#ECE0D0] bg-[#FFFDF9] p-4 shadow-[0_4px_14px_-12px_rgba(120,90,60,.5)] transition duration-150 hover:-translate-y-[2px] hover:border-[#F2A78E]"
|
||||
>
|
||||
<div
|
||||
className={`flex h-12 w-12 flex-none items-center justify-center rounded-full font-display text-[19px] font-semibold ${avatarClasses(kid)}`}
|
||||
>
|
||||
{kid.initial}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-display text-base font-semibold text-[#3F362E]">{kid.name}</div>
|
||||
<div className="text-[13px] text-[#A89A8B]">
|
||||
{kid.age} años · {parentsLabel(kid)}
|
||||
</div>
|
||||
</div>
|
||||
{kid.parents.length === 0 ? (
|
||||
<span className="flex-none rounded-full bg-[#F9D2DE] px-[9px] py-[5px] text-[11px] font-extrabold text-[#C56486]">
|
||||
VINCULAR
|
||||
</span>
|
||||
) : kid.allergies.length > 0 ? (
|
||||
<span className="flex-none rounded-full bg-[#FBD8CC] px-[9px] py-[5px] text-[11px] font-extrabold text-[#D9684A]">
|
||||
{kid.allergies[0]}
|
||||
</span>
|
||||
) : (
|
||||
<svg
|
||||
className="flex-none"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#CBB89F"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Kid } from "@/app/data/kids";
|
||||
import KidCard from "@/app/components/KidCard";
|
||||
|
||||
/** Compara sin mayúsculas ni acentos: "sofia" encuentra a "Sofía Méndez". */
|
||||
function normalize(value: string): string {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/\p{Diacritic}/gu, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export default function KidsBrowser({ kids }: { kids: Kid[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const filtered = kids.filter((kid) => normalize(kid.name).includes(normalize(query)));
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Buscador */}
|
||||
<div className="mb-[22px] flex items-center gap-[11px] rounded-[14px] border border-[#ECE0D0] bg-[#FFFDF9] px-4 py-3">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#B0A290"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Buscar niño…"
|
||||
className="flex-1 border-none bg-transparent text-[15px] text-[#3F362E] outline-none placeholder:text-[#B6A99B]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Encabezado de sección: sala + contador + divisor */}
|
||||
<div className="mb-[14px] flex items-center gap-3">
|
||||
<span className="text-[12.5px] font-extrabold tracking-[.8px] text-[#3F362E]">SALA SOLES</span>
|
||||
<span className="text-[13px] text-[#A89A8B]">
|
||||
{filtered.length} {filtered.length === 1 ? "niño" : "niños"}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-[#E7DAC8]" />
|
||||
</div>
|
||||
|
||||
{/* Grilla filtrada o estado vacío */}
|
||||
{filtered.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-[14px]">
|
||||
{filtered.map((kid) => (
|
||||
<KidCard key={kid.slug} kid={kid} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[18px] border border-dashed border-[#DBCDBA] bg-[#FFFDF9] px-4 py-10 text-center text-[14.5px] text-[#94887B]">
|
||||
No hay niños que coincidan con la búsqueda.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ const NAV_ITEMS: NavItem[] = [
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/ninos",
|
||||
href: "/kids",
|
||||
label: "Niños",
|
||||
icon: (
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
export type ParentStatus = "activa" | "pendiente";
|
||||
|
||||
export interface Parent {
|
||||
name: string;
|
||||
/** "Mamá" | "Papá" */
|
||||
role: string;
|
||||
/** Inicial para el avatar circular. */
|
||||
initial: string;
|
||||
/** Color de fondo del avatar, exacto a la plantilla. */
|
||||
bg: string;
|
||||
/** Texto tras "Rol · " en el perfil: "activa" | "invitación enviada". */
|
||||
note: string;
|
||||
/** Define el badge ACTIVA (verde) / PENDIENTE (amarillo). */
|
||||
status: ParentStatus;
|
||||
}
|
||||
|
||||
export interface Kid {
|
||||
slug: string;
|
||||
name: string;
|
||||
/** Inicial para el avatar circular. */
|
||||
initial: string;
|
||||
/** Colores del avatar, exactos a la plantilla. */
|
||||
avatar: { bg: string; text: string };
|
||||
/** Se renderiza como "X años". */
|
||||
age: number;
|
||||
/** Badges de la lista; vacío = chevron. La primera se muestra en la tarjeta. */
|
||||
allergies: string[];
|
||||
/** Texto del recuadro naranja del perfil (si no hay, no se muestra). */
|
||||
allergyNote?: string;
|
||||
birthDate: string;
|
||||
room: string;
|
||||
admission: string;
|
||||
parents: Parent[];
|
||||
}
|
||||
|
||||
export const kids: Kid[] = [
|
||||
{
|
||||
slug: "mateo-fernandez",
|
||||
name: "Mateo Fernández",
|
||||
initial: "M",
|
||||
avatar: { bg: "#A9D9E8", text: "#1F7A93" },
|
||||
age: 3,
|
||||
allergies: ["MANÍ"],
|
||||
allergyNote: "Alergia al maní. Evitar frutos secos. Lleva inhalador en la mochila.",
|
||||
birthDate: "12 mar 2022",
|
||||
room: "Soles",
|
||||
admission: "feb 2025",
|
||||
parents: [
|
||||
{ name: "Lucía Fernández", role: "Mamá", initial: "L", bg: "#C9B6E8", note: "activa", status: "activa" },
|
||||
{ name: "Diego Fernández", role: "Papá", initial: "D", bg: "#A9C7E8", note: "invitación enviada", status: "pendiente" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "sofia-mendez",
|
||||
name: "Sofía Méndez",
|
||||
initial: "S",
|
||||
avatar: { bg: "#F4B8CC", text: "#C44A7A" },
|
||||
age: 2,
|
||||
allergies: [],
|
||||
birthDate: "3 jul 2023",
|
||||
room: "Soles",
|
||||
admission: "mar 2025",
|
||||
parents: [
|
||||
{ name: "Valeria Méndez", role: "Mamá", initial: "V", bg: "#F4B8CC", note: "activa", status: "activa" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "benjamin-ruiz",
|
||||
name: "Benjamín Ruiz",
|
||||
initial: "B",
|
||||
avatar: { bg: "#B9DEC4", text: "#3E8B62" },
|
||||
age: 3,
|
||||
allergies: [],
|
||||
birthDate: "28 nov 2022",
|
||||
room: "Soles",
|
||||
admission: "ago 2024",
|
||||
parents: [
|
||||
{ name: "Marcela Ruiz", role: "Mamá", initial: "M", bg: "#B9DEC4", note: "activa", status: "activa" },
|
||||
{ name: "Javier Ruiz", role: "Papá", initial: "J", bg: "#A9C7E8", note: "activa", status: "activa" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "valentina-soto",
|
||||
name: "Valentina Soto",
|
||||
initial: "V",
|
||||
avatar: { bg: "#F4DC8E", text: "#9A7B1E" },
|
||||
age: 2,
|
||||
allergies: [],
|
||||
birthDate: "9 may 2023",
|
||||
room: "Soles",
|
||||
admission: "feb 2025",
|
||||
parents: [],
|
||||
},
|
||||
{
|
||||
slug: "tomas-diaz",
|
||||
name: "Tomás Díaz",
|
||||
initial: "T",
|
||||
avatar: { bg: "#C9B6E8", text: "#7B5FC0" },
|
||||
age: 3,
|
||||
allergies: ["LACTOSA"],
|
||||
allergyNote: "Intolerancia a la lactosa. Toma leche deslactosada y evita los lácteos en la merienda.",
|
||||
birthDate: "17 sep 2022",
|
||||
room: "Soles",
|
||||
admission: "sep 2023",
|
||||
parents: [
|
||||
{ name: "Andrés Díaz", role: "Papá", initial: "A", bg: "#C9B6E8", note: "activa", status: "activa" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "emma-castro",
|
||||
name: "Emma Castro",
|
||||
initial: "E",
|
||||
avatar: { bg: "#F4B8CC", text: "#C44A7A" },
|
||||
age: 2,
|
||||
allergies: [],
|
||||
birthDate: "2 ene 2023",
|
||||
room: "Soles",
|
||||
admission: "mar 2025",
|
||||
parents: [
|
||||
{ name: "Camila Castro", role: "Mamá", initial: "C", bg: "#F2937A", note: "activa", status: "activa" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "lucas-romero",
|
||||
name: "Lucas Romero",
|
||||
initial: "L",
|
||||
avatar: { bg: "#A9D9E8", text: "#1F7A93" },
|
||||
age: 3,
|
||||
allergies: [],
|
||||
birthDate: "30 abr 2022",
|
||||
room: "Soles",
|
||||
admission: "feb 2024",
|
||||
parents: [
|
||||
{ name: "Martín Romero", role: "Papá", initial: "M", bg: "#A9C7E8", note: "activa", status: "activa" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "olivia-vega",
|
||||
name: "Olivia Vega",
|
||||
initial: "O",
|
||||
avatar: { bg: "#B9DEC4", text: "#3E8B62" },
|
||||
age: 2,
|
||||
allergies: [],
|
||||
birthDate: "21 oct 2023",
|
||||
room: "Soles",
|
||||
admission: "ago 2025",
|
||||
parents: [
|
||||
{ name: "Julieta Vega", role: "Mamá", initial: "J", bg: "#F4DC8E", note: "activa", status: "activa" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,212 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import Sidebar from "@/app/components/Sidebar";
|
||||
import { avatarClasses } from "@/app/components/KidCard";
|
||||
import type { Kid, Parent, ParentStatus } from "@/app/data/kids";
|
||||
import { kids } from "@/app/data/kids";
|
||||
|
||||
/** Página 100% estática que deriva de params: permitido bloquear, sin Suspense. */
|
||||
export const instant = false;
|
||||
|
||||
/** Los 8 slugs del mock se prerenderizan; cualquier otro cae en notFound(). */
|
||||
export function generateStaticParams() {
|
||||
return kids.map((kid) => ({ slug: kid.slug }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Colores dinámicos → utilities Tailwind con valores exactos.
|
||||
* El set de fondos del mock es cerrado; nunca se usan atributos style.
|
||||
*/
|
||||
const PARENT_BG: Record<string, string> = {
|
||||
C9B6E8: "bg-[#C9B6E8]",
|
||||
A9C7E8: "bg-[#A9C7E8]",
|
||||
F4B8CC: "bg-[#F4B8CC]",
|
||||
B9DEC4: "bg-[#B9DEC4]",
|
||||
F2937A: "bg-[#F2937A]",
|
||||
F4DC8E: "bg-[#F4DC8E]",
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<ParentStatus, { label: string; classes: string }> = {
|
||||
activa: { label: "ACTIVA", classes: "bg-[#CFEBD8] text-[#3E9B6C]" },
|
||||
pendiente: { label: "PENDIENTE", classes: "bg-[#F7E7A6] text-[#9A7B1E]" },
|
||||
};
|
||||
|
||||
function parentClasses(parent: Parent): string {
|
||||
return PARENT_BG[parent.bg.replace("#", "")] ?? "";
|
||||
}
|
||||
|
||||
function ParentRow({ parent }: { parent: Parent }) {
|
||||
const badge = STATUS_BADGE[parent.status];
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-10 w-10 flex-none items-center justify-center rounded-full font-display text-base font-semibold text-white ${parentClasses(parent)}`}
|
||||
>
|
||||
{parent.initial}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[14.5px] font-extrabold text-[#3F362E]">{parent.name}</div>
|
||||
<div className="text-[12.5px] text-[#A89A8B]">
|
||||
{parent.role} · {parent.note}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`flex-none rounded-full px-[9px] py-1 text-[10.5px] font-extrabold ${badge.classes}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function KidProfilePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const kid: Kid | undefined = kids.find((candidate) => candidate.slug === slug);
|
||||
if (!kid) notFound();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#F6ECDF]">
|
||||
<Sidebar activeHref="/kids" />
|
||||
<main className="h-screen min-w-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-[820px] px-10 pb-20 pt-[34px]">
|
||||
{/* Breadcrumb */}
|
||||
<Link
|
||||
href="/kids"
|
||||
className="mb-5 flex items-center gap-[7px] text-[14px] font-bold text-[#94887B]"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
Volver a Niños
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-wrap items-start gap-[26px]">
|
||||
{/* Columna izquierda */}
|
||||
<div className="flex min-w-[300px] flex-1 flex-col gap-[18px]">
|
||||
{/* Identidad */}
|
||||
<div className="flex items-center gap-[18px]">
|
||||
<div
|
||||
className={`flex h-[84px] w-[84px] flex-none items-center justify-center rounded-full font-display text-[34px] font-semibold ${avatarClasses(kid)}`}
|
||||
>
|
||||
{kid.initial}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="font-display text-[28px] font-semibold leading-[1.2] text-[#3F362E]">{kid.name}</h1>
|
||||
<p className="mt-[3px] text-[15px] text-[#94887B]">
|
||||
{kid.age} años · Sala {kid.room}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/agregar-nino"
|
||||
className="rounded-[12px] border-[1.5px] border-[#ECE0D0] bg-[#FFFDF9] px-4 py-[9px] text-sm font-bold text-[#6E6359]"
|
||||
>
|
||||
Editar
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Alergias y notas (solo si tiene) */}
|
||||
{kid.allergyNote && (
|
||||
<div className="flex gap-[14px] rounded-[16px] bg-[#FBDAD6] px-[18px] py-4">
|
||||
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-[11px] bg-[#F4A8A0]">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" />
|
||||
<path d="M12 9v4M12 17h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-[2px] text-[15px] font-extrabold text-[#C5413A]">Alergias y notas</div>
|
||||
<div className="text-[14.5px] leading-[1.5] text-[#B25249]">{kid.allergyNote}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Datos */}
|
||||
<div className="overflow-hidden rounded-[16px] border border-[#ECE0D0] bg-[#FFFDF9]">
|
||||
<div className="flex justify-between border-b border-[#F0E6D8] px-[18px] py-[15px]">
|
||||
<span className="text-[14.5px] text-[#94887B]">Fecha de nacimiento</span>
|
||||
<span className="text-[14.5px] font-extrabold text-[#3F362E]">{kid.birthDate}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-[#F0E6D8] px-[18px] py-[15px]">
|
||||
<span className="text-[14.5px] text-[#94887B]">Sala</span>
|
||||
<span className="text-[14.5px] font-extrabold text-[#3F362E]">{kid.room}</span>
|
||||
</div>
|
||||
<div className="flex justify-between px-[18px] py-[15px]">
|
||||
<span className="text-[14.5px] text-[#94887B]">Ingreso</span>
|
||||
<span className="text-[14.5px] font-extrabold text-[#3F362E]">{kid.admission}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Columna derecha */}
|
||||
<div className="flex w-[300px] flex-none flex-col gap-[14px]">
|
||||
<Link
|
||||
href="/resumen-dia"
|
||||
className="flex w-full items-center justify-center gap-[9px] rounded-[14px] bg-[#3F362E] py-[13px] text-[15px] font-extrabold text-white"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</svg>
|
||||
Resumen del día
|
||||
</Link>
|
||||
|
||||
{/* Padres vinculados */}
|
||||
<div className="rounded-[16px] border border-[#ECE0D0] bg-[#FFFDF9] px-[18px] py-4">
|
||||
<div className="mb-[14px] text-[12.5px] font-extrabold tracking-[.8px] text-[#8A7C6D]">
|
||||
PADRES VINCULADOS
|
||||
</div>
|
||||
<div className="flex flex-col gap-[14px]">
|
||||
{kid.parents.map((parent) => (
|
||||
<ParentRow key={parent.name} parent={parent} />
|
||||
))}
|
||||
<Link href="/vincular-padre" className="flex items-center gap-3 pt-2">
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-full border-[1.5px] border-dashed border-[#D8CBBA] text-[#B0A290]">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-[14.5px] font-extrabold text-[#C5503A]">Vincular otro padre</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Link from "next/link";
|
||||
import KidsBrowser from "@/app/components/KidsBrowser";
|
||||
import Sidebar from "@/app/components/Sidebar";
|
||||
import { kids } from "@/app/data/kids";
|
||||
|
||||
export default function KidsPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#F6ECDF]">
|
||||
<Sidebar activeHref="/kids" />
|
||||
<main className="h-screen min-w-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-[880px] px-10 pb-20 pt-[34px]">
|
||||
{/* Encabezado */}
|
||||
<div className="mb-[22px] flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="mb-1 text-[12.5px] font-extrabold tracking-[.8px] text-[#D9583C]">GESTIÓN</div>
|
||||
<h1 className="font-display text-[30px] font-semibold leading-[1.2] text-[#3F362E]">Niños</h1>
|
||||
</div>
|
||||
<Link
|
||||
href="/agregar-nino"
|
||||
className="flex items-center gap-2 rounded-[14px] bg-[linear-gradient(180deg,#F4977E,#EE8164)] px-[18px] py-[11px] text-[14.5px] font-extrabold text-white shadow-[0_8px_18px_-8px_rgba(238,129,100,.7)]"
|
||||
>
|
||||
<svg
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
Agregar niño
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<KidsBrowser kids={kids} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# SPEC 02 — Niños: listado y perfil (`/kids` y `/kids/[slug]`)
|
||||
|
||||
> **Estado:** Verificada (2026-10-20)
|
||||
> **Depende de:** SPEC 01
|
||||
> **Fecha:** 2026-10-20
|
||||
> **Objetivo:** Implementar las plantillas `references/pantallas/ninos.dc.html` y `references/pantallas/perfil-nino.dc.html` como `/kids` y `/kids/[slug]`, visualmente idénticas, con mock de 8 niños con ficha completa y buscador funcional.
|
||||
|
||||
## Alcance
|
||||
|
||||
**In:**
|
||||
|
||||
- Página `/kids` (`app/kids/page.tsx`): sidebar con "Niños" activo, encabezado (eyebrow "GESTIÓN" + título "Niños"), botón "Agregar niño" (→ `/agregar-nino`), buscador y grilla de 2 columnas con las 8 tarjetas de la plantilla.
|
||||
- Buscador **funcional** (filtro client-side): filtra por nombre mientras se escribe, el contador "8 niños" se actualiza y muestra estado vacío si no hay coincidencias.
|
||||
- Página `/kids/[slug]` (`app/kids/[slug]/page.tsx`): breadcrumb "Volver a Niños" (→ `/kids`), avatar 84px + nombre + "3 años · Sala Soles", botón "Editar" (→ `/agregar-nino`), recuadro naranja "Alergias y notas" (solo si el niño tiene notas), tarjeta de datos (nacimiento / sala / ingreso), columna derecha con botón "Resumen del día" (→ `/resumen-dia`), tarjeta "PADRES VINCULADOS" (badges ACTIVA/PENDIENTE) y link "Vincular otro padre" (→ `/vincular-padre`).
|
||||
- Las 8 fichas de perfil existen: cada slug renderiza los datos propios del niño (nombre, avatar, alergias, fechas y padres), coherentes con la lista.
|
||||
- Mock tipado en `app/data/kids.ts` con los 8 niños de la plantilla (Mateo con los datos exactos del perfil; los otros 7 con datos inventados coherentes con su fila de la lista).
|
||||
- Componentes nuevos en `app/components/`: `KidCard.tsx` (tarjeta de la lista) y `KidsBrowser.tsx` (cliente: buscador + contador + grilla + estado vacío).
|
||||
- Update de `app/components/Sidebar.tsx`: el link "Niños" pasa de `/ninos` a `/kids` (única referencia existente; SPEC 01 la cableó así).
|
||||
- Hover de tarjeta según plantilla: borde `#F2A78E` + `translateY(-2px)` con transición de 150ms.
|
||||
|
||||
**Out of scope (para specs futuras):**
|
||||
|
||||
- Páginas `/agregar-nino`, `/vincular-padre` y `/resumen-dia` (hoy dan 404 y está bien).
|
||||
- Alta/edición real de niños y vinculación real de padres.
|
||||
- Autenticación, base de datos y persistencia.
|
||||
- Responsive móvil y dark mode.
|
||||
|
||||
## Modelo de datos
|
||||
|
||||
```ts
|
||||
// app/data/kids.ts
|
||||
export type ParentStatus = "activa" | "pendiente";
|
||||
|
||||
export interface Parent {
|
||||
name: string; // "Lucía Fernández"
|
||||
role: string; // "Mamá" | "Papá"
|
||||
initial: string; // "L"
|
||||
bg: string; // "#C9B6E8"
|
||||
status: ParentStatus; // badge ACTIVA (verde) | PENDIENTE (amarillo)
|
||||
}
|
||||
|
||||
export interface Kid {
|
||||
slug: string; // "mateo-fernandez"
|
||||
name: string; // "Mateo Fernández"
|
||||
initial: string; // "M"
|
||||
avatar: { bg: string; text: string }; // { bg: "#A9D9E8", text: "#1F7A93" }
|
||||
age: number; // 3 → se renderiza "3 años"
|
||||
allergies: string[]; // ["MANÍ"] — badges de la lista (vacos = sin badge)
|
||||
allergyNote?: string; // texto del recuadro del perfil (si existe → se muestra)
|
||||
birthDate: string; // "12 mar 2022"
|
||||
room: string; // "Soles"
|
||||
admission: string; // "feb 2025"
|
||||
parents: Parent[];
|
||||
}
|
||||
```
|
||||
|
||||
- Los 8 niños con sus colores exactos de la plantilla: Mateo `#A9D9E8/#1F7A93`, Sofía `#F4B8CC/#C44A7A`, Benjamín `#B9DEC4/#3E8B62`, Valentina `#F4DC8E/#9A7B1E`, Tomás `#C9B6E8/#7B5FC0`, Emma `#F4B8CC/#C44A7A`, Lucas `#A9D9E8/#1F7A93`, Olivia `#B9DEC4/#3E8B62`.
|
||||
- Regla del badge lateral en la lista (deducida de la plantilla): sin padres → badge "VINCULAR" (`#F9D2DE`/`#C56486`); con alergia → primera alergia (`#FBD8CC`/`#D9684A`); resto → chevron `#CBB89F`.
|
||||
- Padres de Mateo (exactos de la plantilla): Lucía Fernández (Mamá, `#C9B6E8`, activa) y Diego Fernández (Papá, `#A9C7E8`, pendiente "invitación enviada").
|
||||
|
||||
Convenciones heredadas de SPEC 01: colores como hex exactos en utilities Tailwind arbitrarias; cero atributos `style`; todo server components salvo `KidsBrowser`.
|
||||
|
||||
## Plan de implementación
|
||||
|
||||
1. **Mock** — crear `app/data/kids.ts` con los tipos y los 8 niños. Test manual: `npm run dev` compila.
|
||||
2. **KidCard** — crear `app/components/KidCard.tsx`: avatar circular, nombre, "X años · N padre(s) vinculado(s)", badge según la regla y link a `/kids/[slug]` con hover de plantilla. Test manual: se ve correcta suelta en cualquier página temporal.
|
||||
3. **KidsBrowser (cliente)** — crear `app/components/KidsBrowser.tsx`: input "Buscar niño…", fila "SALA SOLES + contador + línea divisoria", grilla `grid-cols-2` filtrada por nombre (comparación sin mayúsculas ni acentos vía `normalize("NFD")`) y mensaje de estado vacío.
|
||||
4. **Página /kids** — crear `app/kids/page.tsx`: Sidebar `activeHref="/kids"`, encabezado, botón "Agregar niño" y `<KidsBrowser kids={kids}>`. Test manual: `http://localhost:3000/kids` muestra las 8 tarjetas y el filtro funciona.
|
||||
5. **Sidebar** — en `app/components/Sidebar.tsx` cambiar el href de "Niños" de `/ninos` a `/kids`. Test manual: el link navega y ninguna página queda rota.
|
||||
6. **Página /kids/[slug]** — crear `app/kids/[slug]/page.tsx` con `generateStaticParams` y `notFound()`: en Next.js 16 `params` es una Promise (`const { slug } = await params`). Layout de dos columnas (columna izquierda flexible + derecha fija de 300px), ficha completa, recuadro de alergias condicionado y tarjeta de padres con badges ACTIVA/PENDIENTE. Test manual: `/kids/mateo-fernandez` replica la plantilla y `/kids/valentina-soto` muestra su ficha sin recuadro de alergias ni padres.
|
||||
7. **Fidelidad visual** — comparación lado a lado de ambas pantallas contra las plantillas a ancho ≥1280px; ajustar diferencias. Capturas de verificación en `.playwright-mcp/`.
|
||||
|
||||
## Criterios de aceptación
|
||||
|
||||
- [x] `npm run dev` levanta sin errores y `/kids` muestra eyebrow "GESTIÓN", título "Niños", botón "Agregar niño", buscador y las 8 tarjetas en grilla de 2 columnas. *(Verificado 2026-10-20 en `localhost:3001` a 1440×900; consola limpia tras el opt-out `instant = false`. Computed: h1 "Niños" Fredoka 30px, `grid-template-columns: 393px 393px`, 8 tarjetas. Captura: `.playwright-mcp/verify-kids-final.png`.)*
|
||||
- [x] `npm run build` completa sin errores, incluyendo el prerender de `/kids` y de los 8 slugs. *(Next.js 16.4.2 + Turbopack: compilación y TypeScript OK, 14 páginas estáticas — `/kids` ○ y los 8 slugs listados como estáticos.)*
|
||||
- [x] Escribir "sof" en el buscador deja solo la tarjeta de Sofía Méndez, el contador se actualiza y escribir algo sin coincidencias muestra el estado vacío. *(Computed: "sof" → 1 tarjeta "Sofía Méndez" y contador "1 niño"; "zzzqx" → 0 tarjetas, mensaje "No hay niños que coincidan con la búsqueda." y "0 niños".)*
|
||||
- [x] Escribir "sofia" (sin acento) también encuentra a Sofía Méndez. *(Computed: "sofia" sin acento devuelve exactamente esa tarjeta.)*
|
||||
- [x] Cada tarjeta enlaza con `<Link>` al slug de su niño (8 hrefs distintos) y el badge es correcto: Mateo "MANÍ", Tomás "LACTOSA", Valentina "VINCULAR", resto chevron. *(Computed: 8 hrefs `/kids/<slug>` únicos; badges correctos; `next/link` importado en `KidCard` y 0 `<a>` planos en los archivos nuevos.)*
|
||||
- [x] `/kids/mateo-fernandez` replica la plantilla: breadcrumb, avatar 84px "M", nombre, "3 años · Sala Soles", botón "Editar", recuadro "Alergias y notas" con el texto exacto ("Alergia al maní. Evitar frutos secos. Lleva inhalador en la mochila."), filas nacimiento/sala/ingreso, botón "Resumen del día" y tarjeta "PADRES VINCULADOS" con Lucía (ACTIVA, verde) y Diego (PENDIENTE, amarillo) + "Vincular otro padre". *(Computed: breadcrumb → `/kids`, avatar 84×84 Fredoka "M", texto de alergias literal, filas "12 mar 2022 / Soles / feb 2025", badges `rgb(207,235,216)`/`rgb(62,155,108)` y `rgb(247,231,166)`/`rgb(154,123,30)`. Captura: `.playwright-mcp/verify-perfil-final.png`; comparación visual contra `perfil-nino.dc.html` sin diferencias.)*
|
||||
- [x] Los otros 7 slugs muestran la ficha propia de cada niño (nombre, avatar, edad, padres y alergias coherentes con la lista); Valentina no tiene recuadro de alergias ni padres listados. *(Fetch del HTML SSR de cada slug: nombre, inicial de avatar y edad·sala correctos; recuadro de alergias solo en Tomás; filas de padres 1/2/0/1/1/1/1 según la lista; Valentina sin recuadro y sin badges.)*
|
||||
- [x] `/kids/no-existe` responde 404. *(HTTP 404 real con página de not-found; patrón `generateStaticParams` + `notFound()` tras `await params` validado contra la doc de Next.js (Context7) y `node_modules/next/dist/docs/`.)*
|
||||
- [x] En el sidebar, "Niños" apunta a `/kids` y aparece activo (fondo `#FBE3D8`, texto `#D9583C`) tanto en `/kids` como en `/kids/[slug]`; en `/` sigue activo "Feed". *(Computed en `/kids`, `/kids/tomas-diaz` y `/`: activo "Niños" con `rgb(251,227,216)`/`rgb(217,88,60)` en lista y ficha; en `/` el activo es "Feed" y "Niños" queda inactivo `rgb(110,99,89)`.)*
|
||||
- [x] Los links futuros del perfil navegan con `<Link>`: Editar → `/agregar-nino`, Resumen del día → `/resumen-dia`, Vincular otro padre → `/vincular-padre` (hoy 404, aceptado). *(Computed: los 3 hrefs presentes en el DOM vía `next/link`; destinos 404 según lo acordado.)*
|
||||
- [x] A ancho ≥1280px ambas pantallas coinciden con las plantillas: lista con contenido centrado de 880px y perfil de 820px con columna derecha de 300px. *(Computed a 1440px: lista `max-width: 880px` centrada (left 404 = 248 de sidebar + 156), perfil 820px y columna 300px. Comparación visual lado a lado contra `ninos.dc.html` y `perfil-nino.dc.html` sin diferencias: `.playwright-mcp/spec02-ref-ninos.png` vs `.playwright-mcp/spec02-app-ninos-v2.png`, `.playwright-mcp/spec02-ref-perfil.png` vs `.playwright-mcp/spec02-app-perfil.png`.)*
|
||||
- [x] Los archivos nuevos no usan atributos `style`; todo es Tailwind con valores arbitrarios exactos. *(Grep `style=` en `app/`: 0 resultados; `tsc --noEmit` y `eslint` limpios sobre los 6 archivos.)*
|
||||
|
||||
## Decisiones
|
||||
|
||||
- **Sí:** rutas `/kids` y `/kids/[slug]` — decisión explícita del usuario aunque el sidebar decía `/ninos`; se actualiza `Sidebar.tsx` (única referencia, así ningún criterio de SPEC 01 queda huérfano sin notarlo).
|
||||
- **Sí:** los 8 niños con ficha completa en `app/data/kids.ts` — el perfil es una página por slug real, no un placeholder clonado.
|
||||
- **Sí:** datos inventados para los otros 7 perfiles, pero coherentes con la lista (misma edad, misma cantidad de padres, mismas alergias); solo Mateo conserva los datos literales de la plantilla.
|
||||
- **Sí:** buscador funcional con un único componente cliente (`KidsBrowser`) — es la primera pieza interactiva del repo y queda aislada; el resto sigue siendo server components.
|
||||
- **Sí:** búsqueda solo por nombre, insensible a mayúsculas y acentos — la plantilla dice "Buscar niño…" y en español los acentos son el caso de uso obvio.
|
||||
- **Sí:** regla de badge deducida de la plantilla (sin padres → VINCULAR, con alergia → alergia, resto → chevron) — los 3 casos existen en el diseño original.
|
||||
- **Sí:** recuadro de alergias solo cuando `allergyNote` existe — la plantilla no define cómo se ve un perfil sin alergias y omitirlo es lo más fiel.
|
||||
- **Sí:** perfil compuesto directo en `page.tsx` sin componentes de un solo uso — solo se extraen `KidCard` y `KidsBrowser`, que se reutilizan o aíslan estado.
|
||||
- **Sí:** `generateStaticParams` + `notFound()` — mock estático: los 8 slugs se prerenderizan y cualquier otro slug da 404 real.
|
||||
- **No:** filtrar por sala, edad o alergia — no está en la plantilla.
|
||||
- **No:** páginas `/agregar-nino`, `/vincular-padre` ni `/resumen-dia` — specs futuras.
|
||||
|
||||
## Riesgos
|
||||
|
||||
| Riesgo | Mitigación |
|
||||
| --- | --- |
|
||||
| El buscador cliente rompe la convención server-only del repo | `KidsBrowser` aísla el estado; `KidCard` sigue siendo puro y reutilizable |
|
||||
| Búsqueda lenta o hidratación inconsistente al filtrar | Filtrado en memoria sobre 8 items; el grid se deriva del mismo array tipado |
|
||||
| Links a páginas inexistentes confunden en la verificación | Documentado en Out of scope y en criterios: 404 esperado, igual que SPEC 01 |
|
||||
|
||||
## Qué **no** está en esta spec
|
||||
|
||||
- Páginas agregar/editar niño, vincular padre y resumen del día — cada una en su propia spec.
|
||||
- Autenticación, base de datos y persistencia.
|
||||
- Responsive móvil y dark mode.
|
||||
Reference in New Issue
Block a user