import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' import { Minus, Pencil, Plus, Sparkles } from 'lucide-react' import { useEffect, useMemo, useRef, useState } from 'react' import type { AsignaturaDetail } from '@/data' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip' import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects' export interface BibliografiaEntry { id: string tipo: 'BASICA' | 'COMPLEMENTARIA' cita: string fuenteBibliotecaId?: string fuenteBiblioteca?: any } export interface BibliografiaTabProps { id: string bibliografia: Array onSave: (bibliografia: Array) => void isSaving: boolean } export interface AsignaturaDatos { [key: string]: string } export interface AsignaturaResponse { datos: AsignaturaDatos } type CriterioEvaluacionRow = { criterio: string porcentaje: number } type CriterioEvaluacionRowDraft = { id: string criterio: string porcentaje: string // allow empty while editing } export const Route = createFileRoute( '/planes/$planId/asignaturas/$asignaturaId', )({ component: AsignaturaDetailPage, }) export default function AsignaturaDetailPage() { const { asignaturaId } = useParams({ from: '/planes/$planId/asignaturas/$asignaturaId', }) const { data: asignaturaApi } = useSubject(asignaturaId) const [asignatura, setAsignatura] = useState(null) const updateAsignatura = useUpdateAsignatura() const handlePersistDatoGeneral = (clave: string, value: string) => { const baseDatos = asignatura?.datos ?? (asignaturaApi as any)?.datos ?? {} const mergedDatos = { ...baseDatos, [clave]: value } // Mantener estado local coherente para merges posteriores. setAsignatura((prev) => ({ ...((prev ?? asignaturaApi ?? {}) as any), datos: mergedDatos, })) updateAsignatura.mutate({ asignaturaId, patch: { datos: mergedDatos, }, }) } /* ---------- sincronizar API ---------- */ useEffect(() => { if (asignaturaApi) setAsignatura(asignaturaApi) }, [asignaturaApi]) return } function DatosGenerales({ onPersistDato, }: { onPersistDato: (clave: string, value: string) => void }) { const { asignaturaId, planId } = useParams({ from: '/planes/$planId/asignaturas/$asignaturaId', }) const navigate = useNavigate() const { data: data, isLoading: isLoading } = useSubject(asignaturaId) const updateAsignatura = useUpdateAsignatura() const evaluationCardRef = useRef(null) const [evaluationForceEditToken, setEvaluationForceEditToken] = useState(0) const [evaluationHighlightToken, setEvaluationHighlightToken] = useState(0) // 1. Extraemos la definición de la estructura (los metadatos) const definicionRaw = data?.estructuras_asignatura?.definicion const definicion = isRecord(definicionRaw) ? (definicionRaw as Record) : null const propertiesRaw = definicion ? (definicion as any).properties : undefined const structureProps = isRecord(propertiesRaw) ? (propertiesRaw as Record) : {} // 2. Extraemos los valores reales (el contenido redactado) const datosRaw = data?.datos const valoresActuales = isRecord(datosRaw) ? (datosRaw as Record) : {} const criteriosEvaluacion: Array = useMemo(() => { const raw = (data as any)?.criterios_de_evaluacion console.log(raw) if (!Array.isArray(raw)) return [] const rows: Array = [] for (const item of raw) { if (!isRecord(item)) continue const criterio = typeof item.criterio === 'string' ? item.criterio : '' const porcentajeNum = typeof item.porcentaje === 'number' ? item.porcentaje : typeof item.porcentaje === 'string' ? Number(item.porcentaje) : NaN if (!criterio.trim()) continue if (!Number.isFinite(porcentajeNum)) continue const porcentaje = Math.trunc(porcentajeNum) if (porcentaje < 1 || porcentaje > 100) continue rows.push({ criterio: criterio.trim(), porcentaje: porcentaje }) } return rows }, [data]) const openEvaluationEditor = () => { evaluationCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start', }) const now = Date.now() setEvaluationForceEditToken(now) setEvaluationHighlightToken(now) } const persistCriteriosEvaluacion = async ( rows: Array, ) => { await updateAsignatura.mutateAsync({ asignaturaId: asignaturaId as any, patch: { criterios_de_evaluacion: rows, } as any, }) } if (isLoading) return

Cargando información...

return (
{/* Encabezado de la Sección */}

Datos Generales

Información oficial estructurada bajo los lineamientos de la SEP.

{/* Grid de Información */}
{/* Columna Principal (Más ancha) */}
{Object.entries(structureProps).map( ([key, config]: [string, any]) => { const cardTitle = config.title || key const description = config.description || '' const xColumn = typeof config?.['x-column'] === 'string' ? config['x-column'] : undefined // Obtenemos el placeholder del arreglo 'examples' de la estructura const placeholder = config.examples && config.examples.length > 0 ? config.examples[0] : '' const valActual = valoresActuales[key] let currentContent = valActual ?? '' if (xColumn) { const rawValue = (data as any)?.[xColumn] const parser = columnParsers[xColumn] currentContent = parser ? parser(rawValue) : String(rawValue ?? '') } return ( onPersistDato(String(clave ?? key), String(value ?? '')) } onClickEditButton={({ startEditing }) => { switch (xColumn) { case 'contenido_tematico': { navigate({ to: '/planes/$planId/asignaturas/$asignaturaId/contenido', params: { planId, asignaturaId }, }) return } case 'criterios_de_evaluacion': { openEvaluationEditor() return } default: { startEditing() } } }} /> ) }, )}
{/* Columna Lateral (Información Secundaria) */}
{/* Tarjeta de Requisitos */} {/* Tarjeta de Evaluación */} persistCriteriosEvaluacion(value)} />
) } interface InfoCardProps { asignaturaId?: string clave?: string title: string initialContent: any placeholder?: string description?: string required?: boolean // Nueva prop para el asterisco type?: 'text' | 'requirements' | 'evaluation' onEnhanceAI?: (content: any) => void onPersist?: (payload: { type: NonNullable clave?: string value: any }) => void | Promise onClickEditButton?: (helpers: { startEditing: () => void }) => void containerRef?: React.RefObject forceEditToken?: number highlightToken?: number } function InfoCard({ asignaturaId, clave, title, initialContent, placeholder, description, required, type = 'text', onPersist, onClickEditButton, containerRef, forceEditToken, highlightToken, }: InfoCardProps) { const [isEditing, setIsEditing] = useState(false) const [isHighlighted, setIsHighlighted] = useState(false) const [data, setData] = useState(initialContent) const [tempText, setTempText] = useState(initialContent) const [evalRows, setEvalRows] = useState>( [], ) const navigate = useNavigate() const { planId } = useParams({ from: '/planes/$planId/asignaturas/$asignaturaId', }) useEffect(() => { setData(initialContent) setTempText(initialContent) if (type === 'evaluation') { const raw = Array.isArray(initialContent) ? initialContent : [] const rows: Array = raw .map((r: any): CriterioEvaluacionRowDraft | null => { const criterio = typeof r?.criterio === 'string' ? r.criterio : '' const porcentajeNum = typeof r?.porcentaje === 'number' ? r.porcentaje : typeof r?.porcentaje === 'string' ? Number(r.porcentaje) : NaN const porcentaje = Number.isFinite(porcentajeNum) ? String(Math.trunc(porcentajeNum)) : '' return { id: crypto.randomUUID(), criterio, porcentaje, } }) .filter(Boolean) as Array setEvalRows(rows) } }, [initialContent, type]) useEffect(() => { if (!forceEditToken) return setIsEditing(true) }, [forceEditToken]) useEffect(() => { if (!highlightToken) return setIsHighlighted(true) const t = window.setTimeout(() => setIsHighlighted(false), 900) return () => window.clearTimeout(t) }, [highlightToken]) const handleSave = () => { console.log('clave, valor:', clave, String(tempText ?? '')) if (type === 'evaluation') { const cleaned: Array = [] for (const r of evalRows) { const criterio = String(r.criterio).trim() const porcentajeStr = String(r.porcentaje).trim() if (!criterio) continue if (!porcentajeStr) continue const n = Number(porcentajeStr) if (!Number.isFinite(n)) continue const porcentaje = Math.trunc(n) if (porcentaje < 1 || porcentaje > 100) continue cleaned.push({ criterio, porcentaje }) } setData(cleaned) setEvalRows( cleaned.map((x) => ({ id: crypto.randomUUID(), criterio: x.criterio, porcentaje: String(x.porcentaje), })), ) setIsEditing(false) void onPersist?.({ type, clave, value: cleaned }) return } setData(tempText) setIsEditing(false) if (type === 'text') { void onPersist?.({ type, clave, value: String(tempText ?? '') }) } } const handleIARequest = (campoClave: string) => { console.log(placeholder) // Añadimos un timestamp a la state para forzar que la navegación // genere una nueva ubicación incluso si la ruta y los params son iguales. navigate({ to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura', params: { planId, asignaturaId: asignaturaId! }, state: { activeTab: 'ia', prefillCampo: campoClave, prefillContenido: data, _ts: Date.now(), } as any, }) } const evaluationTotal = useMemo(() => { if (type !== 'evaluation') return 0 return evalRows.reduce((acc, r) => { const v = String(r.porcentaje).trim() if (!v) return acc const n = Number(v) if (!Number.isFinite(n)) return acc const porcentaje = Math.trunc(n) if (porcentaje < 1 || porcentaje > 100) return acc return acc + porcentaje }, 0) }, [type, evalRows]) return (
{title} {description || 'Información del campo'} {required && ( * )}
{!isEditing && (
Mejorar con IA Editar campo
)}
{isEditing ? (
{type === 'evaluation' ? (
{evalRows.map((row) => (
{ const nextCriterio = e.target.value setEvalRows((prev) => prev.map((r) => r.id === row.id ? { ...r, criterio: nextCriterio } : r, ), ) }} /> { const raw = e.target.value // Solo permitir '' o dígitos if (raw !== '' && !/^\d+$/.test(raw)) return if (raw === '') { setEvalRows((prev) => prev.map((r) => r.id === row.id ? { id: r.id, criterio: r.criterio, porcentaje: '', } : r, ), ) return } const n = Number(raw) if (!Number.isFinite(n)) return const porcentaje = Math.trunc(n) if (porcentaje < 1 || porcentaje > 100) return // No permitir suma > 100 setEvalRows((prev) => { const next = prev.map((r) => r.id === row.id ? { id: r.id, criterio: r.criterio, porcentaje: raw, } : r, ) const total = next.reduce((acc, r) => { const v = String(r.porcentaje).trim() if (!v) return acc const nn = Number(v) if (!Number.isFinite(nn)) return acc const vv = Math.trunc(nn) if (vv < 1 || vv > 100) return acc return acc + vv }, 0) return total > 100 ? prev : next }) }} />
%
))}
Total: {evaluationTotal}/100
) : (