diff --git a/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx b/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx index 9828078..3d22627 100644 --- a/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx +++ b/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx @@ -1,11 +1,12 @@ import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' -import { Pencil, Sparkles } from 'lucide-react' -import { useState, useEffect } from 'react' +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, @@ -37,54 +38,15 @@ export interface AsignaturaResponse { datos: AsignaturaDatos } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) +type CriterioEvaluacionRow = { + criterio: string + porcentaje: number } -function parseContenidoTematicoToPlainText(value: unknown): string { - if (!Array.isArray(value)) return '' - - const blocks: Array = [] - - for (const item of value) { - if (!isRecord(item)) continue - - const unidad = - typeof item.unidad === 'number' && Number.isFinite(item.unidad) - ? item.unidad - : undefined - const titulo = typeof item.titulo === 'string' ? item.titulo : '' - - const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim() - if (!header) continue - - const lines: Array = [header] - - const temas = Array.isArray(item.temas) ? item.temas : [] - temas.forEach((tema, idx) => { - const temaNombre = - typeof tema === 'string' - ? tema - : isRecord(tema) && typeof tema.nombre === 'string' - ? tema.nombre - : '' - if (!temaNombre) return - - if (unidad != null) { - lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim()) - } else { - lines.push(`${idx + 1}. ${temaNombre}`) - } - }) - - blocks.push(lines.join('\n')) - } - - return blocks.join('\n\n').trimEnd() -} - -const columnParsers: Partial string>> = { - contenido_tematico: parseContenidoTematicoToPlainText, +type CriterioEvaluacionRowDraft = { + id: string + criterio: string + porcentaje: string // allow empty while editing } export const Route = createFileRoute( @@ -132,11 +94,19 @@ function DatosGenerales({ }: { onPersistDato: (clave: string, value: string) => void }) { - const { asignaturaId } = useParams({ + 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 @@ -154,6 +124,56 @@ function DatosGenerales({ 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 ( @@ -209,10 +229,29 @@ function DatosGenerales({ clave={key} title={cardTitle} initialContent={currentContent} - xColumn={xColumn} placeholder={placeholder} description={description} - onPersist={(clave, value) => onPersistDato(clave, value)} + onPersist={({ clave, value }) => + 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() + } + } + }} /> ) }, @@ -244,12 +283,11 @@ function DatosGenerales({ persistCriteriosEvaluacion(value)} /> @@ -265,11 +303,19 @@ interface InfoCardProps { initialContent: any placeholder?: string description?: string - xColumn?: string required?: boolean // Nueva prop para el asterisco type?: 'text' | 'requirements' | 'evaluation' onEnhanceAI?: (content: any) => void - onPersist?: (clave: string, value: string) => 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({ @@ -279,14 +325,22 @@ function InfoCard({ initialContent, placeholder, description, - xColumn, 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', @@ -295,16 +349,85 @@ function InfoCard({ useEffect(() => { setData(initialContent) setTempText(initialContent) - }, [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' && clave && onPersist) { - onPersist(clave, String(tempText ?? '')) + if (type === 'text') { + void onPersist?.({ type, clave, value: String(tempText ?? '') }) } } @@ -325,122 +448,300 @@ function InfoCard({ }) } - return ( - - - -
-
- - - - {title} - - - - {description || 'Información del campo'} - - + 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]) - {required && ( - - * - + return ( +
+ + + +
+
+ + + + {title} + + + + {description || 'Información del campo'} + + + + {required && ( + + * + + )} +
+ + {!isEditing && ( +
+ + + + + Mejorar con IA + + + + + + + Editar campo + +
)}
+
+
- {!isEditing && ( -
- - - +
+ ))} +
+ +
+ - - - - Mejorar con IA - + Total: {evaluationTotal}/100 + - - - - Editar campo - -
- )} -
- - - - - {isEditing ? ( -
-