From b77906e2e9fa7fde5ccff3f63f8de6708a29dddd Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Fri, 27 Mar 2026 19:26:32 -0600 Subject: [PATCH] wip --- src/components/planes/PlanEstudiosCard.tsx | 23 +- src/components/plans/PlanCard.tsx | 26 -- src/components/plans/PlanGrid.tsx | 38 --- src/components/plans/StatCard.tsx | 11 - src/components/plans/StatsGrid.tsx | 12 - src/components/plans/StatusBadge.tsx | 19 -- src/routes/planes/$planId/_detalle.tsx | 130 +++++--- .../asignaturas/$asignaturaId/route.tsx | 305 +++++++++++------- src/routes/planes/_lista.tsx | 9 +- 9 files changed, 291 insertions(+), 282 deletions(-) delete mode 100644 src/components/plans/PlanCard.tsx delete mode 100644 src/components/plans/PlanGrid.tsx delete mode 100644 src/components/plans/StatCard.tsx delete mode 100644 src/components/plans/StatsGrid.tsx delete mode 100644 src/components/plans/StatusBadge.tsx diff --git a/src/components/planes/PlanEstudiosCard.tsx b/src/components/planes/PlanEstudiosCard.tsx index 3cd31ef..4592c0e 100644 --- a/src/components/planes/PlanEstudiosCard.tsx +++ b/src/components/planes/PlanEstudiosCard.tsx @@ -14,6 +14,7 @@ interface PlanEstudiosCardProps { facultad: string estado: string claseColorEstado?: string + colorEstadoHex?: string colorFacultad: string onClick?: () => void } @@ -26,9 +27,17 @@ export default function PlanEstudiosCard({ facultad, estado, claseColorEstado = '', + colorEstadoHex, colorFacultad, onClick, }: PlanEstudiosCardProps) { + const badgeStyle = colorEstadoHex + ? ({ + backgroundColor: colorEstadoHex, + borderColor: colorEstadoHex, + } as const) + : undefined + return ( -
+
{/* Círculo del ícono con el color de la facultad */}
- - {estado} + + + {estado} + {/* Flecha animada */} diff --git a/src/components/plans/PlanCard.tsx b/src/components/plans/PlanCard.tsx deleted file mode 100644 index d233a80..0000000 --- a/src/components/plans/PlanCard.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { StatusBadge } from "./StatusBadge"; - -export function PlanCard({ plan }: any) { - return ( -
-
- ⚙ Ingeniería - -
- -

- {plan.title} -

- -

- {plan.subtitle} -

- -
- {plan.cycles} ciclos - {plan.credits} créditos - -
-
- ) -} diff --git a/src/components/plans/PlanGrid.tsx b/src/components/plans/PlanGrid.tsx deleted file mode 100644 index c6bcd65..0000000 --- a/src/components/plans/PlanGrid.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { PlanCard } from './PlanCard' - -const mockPlans = [ - { - id: 1, - name: 'Ingeniería en Sistemas', - level: 'Licenciatura', - status: 'Activo', - }, - { - id: 2, - name: 'Arquitectura', - level: 'Licenciatura', - status: 'Activo', - }, - { - id: 3, - name: 'Maestría en Educación', - level: 'Maestría', - status: 'Inactivo', - }, -] - -export function PlanGrid() { - return ( -
-

- Planes disponibles -

- -
- {mockPlans.map(plan => ( - - ))} -
-
- ) -} diff --git a/src/components/plans/StatCard.tsx b/src/components/plans/StatCard.tsx deleted file mode 100644 index d108612..0000000 --- a/src/components/plans/StatCard.tsx +++ /dev/null @@ -1,11 +0,0 @@ -export function StatCard({ icon, value, label }: any) { - return ( -
-
- {icon} - {value} -
-

{label}

-
- ) -} diff --git a/src/components/plans/StatsGrid.tsx b/src/components/plans/StatsGrid.tsx deleted file mode 100644 index ca1bf96..0000000 --- a/src/components/plans/StatsGrid.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { StatCard } from "./StatCard"; - -export function StatsGrid() { - return ( -
- - - - -
- ) -} diff --git a/src/components/plans/StatusBadge.tsx b/src/components/plans/StatusBadge.tsx deleted file mode 100644 index 9de2053..0000000 --- a/src/components/plans/StatusBadge.tsx +++ /dev/null @@ -1,19 +0,0 @@ -export function StatusBadge({ status }: { status: string }) { - const styles: any = { - revision: 'bg-blue-100 text-blue-700', - aprobado: 'bg-green-100 text-green-700', - borrador: 'bg-gray-200 text-gray-600', - } - - return ( - - {status === 'revision' - ? 'En Revisión' - : status === 'aprobado' - ? 'Aprobado' - : 'Borrador'} - - ) -} diff --git a/src/routes/planes/$planId/_detalle.tsx b/src/routes/planes/$planId/_detalle.tsx index 2c10f06..6c4139b 100644 --- a/src/routes/planes/$planId/_detalle.tsx +++ b/src/routes/planes/$planId/_detalle.tsx @@ -8,18 +8,24 @@ import { } from 'lucide-react' import { useState, useEffect, forwardRef } from 'react' +import type { Database } from '@/types/supabase' + import { Badge } from '@/components/ui/badge' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' import { NotFoundPage } from '@/components/ui/NotFoundPage' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { Skeleton } from '@/components/ui/skeleton' import { plans_get } from '@/data/api/plans.api' import { usePlan, useUpdatePlanFields } from '@/data/hooks/usePlans' import { qk } from '@/data/query/keys' +import { cn } from '@/lib/utils' + +type NivelPlanEstudio = Database['public']['Enums']['nivel_plan_estudio'] export const Route = createFileRoute('/planes/$planId/_detalle')({ loader: async ({ context: { queryClient }, params: { planId } }) => { @@ -54,28 +60,26 @@ function RouteComponent() { // Estados locales para manejar la edición "en vivo" antes de persistir const [nombrePlan, setNombrePlan] = useState('') - const [nivelPlan, setNivelPlan] = useState('') - const [isDirty, setIsDirty] = useState(false) + const [nivelPlan, setNivelPlan] = useState( + undefined, + ) useEffect(() => { if (data) { setNombrePlan(data.nombre || '') - setNivelPlan(data.nivel || '') + setNivelPlan(data.nivel) } }, [data]) - const niveles = [ + const niveles: Array = [ 'Licenciatura', 'Maestría', 'Doctorado', - 'Diplomado', 'Especialidad', + 'Diplomado', + 'Otro', ] - const persistChange = (patch: any) => { - mutate({ planId, patch }) - } - const MAX_CHARACTERS = 200 const handleKeyDown = (e: React.KeyboardEvent) => { @@ -148,18 +152,18 @@ function RouteComponent() { contentEditable suppressContentEditableWarning spellCheck={false} + aria-label="Nombre del plan" + title="Nombre del plan" onKeyDown={handleKeyDown} onPaste={handlePaste} onBlur={(e) => { - const nuevoNombre = - e.currentTarget.textContent?.trim() || '' + const nuevoNombre = e.currentTarget.textContent.trim() setNombrePlan(nuevoNombre) if (nuevoNombre !== data?.nombre) { mutate({ planId, patch: { nombre: nuevoNombre } }) } }} - className="hover:border-input focus:border-primary block w-full cursor-text border-b border-transparent break-words whitespace-pre-wrap transition-colors outline-none select-text sm:inline-block sm:w-auto" - style={{ textDecoration: 'none' }} + className="hover:border-input focus:border-primary block w-full cursor-text border-b border-transparent wrap-break-word whitespace-pre-wrap no-underline transition-colors outline-none select-text sm:inline-block sm:w-auto" > {nombrePlan} @@ -170,42 +174,68 @@ function RouteComponent() {

-
- - {data?.estados_plan?.etiqueta} - -
+ {(() => { + const estadoColorHex = (data?.estados_plan as any)?.color as + | string + | undefined + const badgeStyle = estadoColorHex + ? ({ + backgroundColor: estadoColorHex, + borderColor: estadoColorHex, + } as const) + : undefined + + return ( + + + {data?.estados_plan?.etiqueta} + + + ) + })()}
)} {/* 3. Cards de Información */}
- - - } - label="Nivel" +
+
+ +
+
+

+ Nivel +

+ +
+
} @@ -220,7 +250,7 @@ function RouteComponent() { } label="Creación" - value={data?.creado_en?.split('T')[0]} + value={data?.creado_en.split('T')[0]} />
diff --git a/src/routes/planes/$planId/asignaturas/$asignaturaId/route.tsx b/src/routes/planes/$planId/asignaturas/$asignaturaId/route.tsx index 31f4465..c710f1f 100644 --- a/src/routes/planes/$planId/asignaturas/$asignaturaId/route.tsx +++ b/src/routes/planes/$planId/asignaturas/$asignaturaId/route.tsx @@ -6,12 +6,21 @@ import { useParams, useRouterState, } from '@tanstack/react-router' -import { ArrowLeft, GraduationCap } from 'lucide-react' +import { + ArrowLeft, + GraduationCap, + Pencil, + Hash, + BookOpen, + CalendarDays, + Tag, +} from 'lucide-react' import { useEffect, useState } from 'react' import { Badge } from '@/components/ui/badge' import { lateralConfetti } from '@/components/ui/lateral-confetti' import { useSubject, useUpdateAsignatura } from '@/data' +import { cn } from '@/lib/utils' export const Route = createFileRoute( '/planes/$planId/asignaturas/$asignaturaId', @@ -19,65 +28,139 @@ export const Route = createFileRoute( component: AsignaturaLayout, }) -function EditableHeaderField({ +// --- 1. COMPONENTE PARA EDITAR EL TÍTULO (h1) --- +function InlineEditTitle({ value, onSave, - className, }: { - value: string | number + value: string onSave: (val: string) => void - className?: string }) { - const textValue = String(value) + const [isEditing, setIsEditing] = useState(false) + const [tempVal, setTempVal] = useState(value) - // Manejador para cuando el usuario termina de editar (pierde el foco) - const handleBlur = (e: React.FocusEvent) => { - const newValue = e.currentTarget.innerText - if (newValue !== textValue) { - onSave(newValue) - } + useEffect(() => setTempVal(value), [value]) + + const handleSave = () => { + setIsEditing(false) + if (tempVal.trim() && tempVal !== value) onSave(tempVal.trim()) + else setTempVal(value) // Revertir si está vacío } - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - e.currentTarget.blur() // Forzamos el guardado al presionar Enter - } + if (isEditing) { + return ( + setTempVal(e.target.value)} + onBlur={handleSave} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave() + if (e.key === 'Escape') { + setTempVal(value) + setIsEditing(false) + } + }} + className="bg-background text-foreground border-primary focus:ring-primary/20 w-full rounded-md border-2 px-2 py-1 text-3xl font-bold shadow-sm outline-none focus:ring-4" + /> + ) } return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions - setIsEditing(true)} + className="group text-foreground hover:bg-muted/50 flex cursor-pointer items-center gap-3 rounded-md px-2 py-1 text-3xl font-bold transition-colors" > - {textValue} - + {value} + + ) } + +// --- 2. COMPONENTE PARA EDITAR LOS BADGES (Código, Créditos, Semestre) --- +function InlineEditBadge({ + icon, + label, + value, + suffix = '', + type = 'text', + onSave, +}: { + icon: React.ReactNode + label: string + value: string | number + suffix?: string + type?: 'text' | 'number' + onSave: (val: string) => void +}) { + const [isEditing, setIsEditing] = useState(false) + const [tempVal, setTempVal] = useState(value) + + useEffect(() => setTempVal(value), [value]) + + const handleSave = () => { + setIsEditing(false) + if (String(tempVal).trim() !== String(value)) { + onSave(String(tempVal)) + } + } + + if (isEditing) { + return ( +
+ + {label}: + + setTempVal(e.target.value)} + onBlur={handleSave} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave() + if (e.key === 'Escape') { + setTempVal(value) + setIsEditing(false) + } + }} + className="text-foreground w-16 bg-transparent text-sm font-semibold outline-none" + /> +
+ ) + } + + return ( + + ) +} + interface DatosPlan { nombre?: string } function AsignaturaLayout() { const location = useLocation() - const { asignaturaId } = useParams({ - from: '/planes/$planId/asignaturas/$asignaturaId', - }) - const { planId } = useParams({ + const { asignaturaId, planId } = useParams({ from: '/planes/$planId/asignaturas/$asignaturaId', }) + const { data: asignaturaApi, isLoading: loadingAsig } = useSubject(asignaturaId) - // 1. Asegúrate de tener estos estados en tu componente principal - const updateAsignatura = useUpdateAsignatura() - // Dentro de AsignaturaDetailPage const [headerData, setHeaderData] = useState({ codigo: '', nombre: '', @@ -85,7 +168,6 @@ function AsignaturaLayout() { ciclo: 0, }) - // Sincronizar cuando llegue la API useEffect(() => { if (asignaturaApi) { setHeaderData({ @@ -102,23 +184,15 @@ function AsignaturaLayout() { setHeaderData(newData) const patch: Record = - key === 'ciclo' - ? { numero_ciclo: value } - : { - [key]: value, - } + key === 'ciclo' ? { numero_ciclo: value } : { [key]: value } - updateAsignatura.mutate({ - asignaturaId, - patch, - }) + updateAsignatura.mutate({ asignaturaId, patch }) } const pathname = useRouterState({ select: (state) => state.location.pathname, }) - // Confetti al llegar desde creación IA useEffect(() => { if ((location.state as any)?.showConfetti) { lateralConfetti() @@ -128,101 +202,93 @@ function AsignaturaLayout() { if (loadingAsig) { return ( -
+
Cargando asignatura...
) } - // Si no hay datos y no está cargando, algo falló if (!asignaturaApi) return null return ( -
-
-
+
+ {/* HEADER DE LA ASIGNATURA */} +
+
Volver al plan -
-
- {/* CÓDIGO EDITABLE */} - - handleUpdateHeader('codigo', val)} - /> - - - {/* NOMBRE EDITABLE */} -

- handleUpdateHeader('nombre', val)} - /> -

- { - // console.log(headerData), - - console.log(asignaturaApi.planes_estudio?.nombre) - } -
- - - Pertenece al plan:{' '} - - {(asignaturaApi.planes_estudio as DatosPlan).nombre || ''} - - -
+
+ {/* Título Editable */} +
+ handleUpdateHeader('nombre', val)} + />
-
- {/* CRÉDITOS EDITABLES */} - - - - handleUpdateHeader('creditos', parseInt(val) || 0) - } - /> - - créditos + {/* Fila de Metadatos Alineados */} +
+ {/* Badge Estático del Tipo */} + + + {asignaturaApi.tipo} - {/* SEMESTRE EDITABLE */} - - - handleUpdateHeader('ciclo', parseInt(val) || 0) - } - /> - ° ciclo - + {/* Badges Editables */} + } + label="Código" + value={headerData.codigo} + onSave={(val) => handleUpdateHeader('codigo', val)} + /> - {asignaturaApi.tipo} + } + label="Créditos" + type="number" + value={headerData.creditos} + onSave={(val) => + handleUpdateHeader('creditos', parseInt(val) || 0) + } + /> + + } + label="Semestre" + type="number" + value={headerData.ciclo} + suffix="°" + onSave={(val) => + handleUpdateHeader('ciclo', parseInt(val) || 0) + } + /> +
+ + {/* Subtítulo de contexto */} +
+ + Pertenece al plan: + + {(asignaturaApi.planes_estudio as DatosPlan).nombre || ''} +
- {/* TABS */} - -