El sistema de evaluación es editable y se puede ligar con un dato general. En esta caso está ligado a criterios_de_evaluación #157
@@ -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<string, unknown> {
|
||||
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<string> = []
|
||||
|
||||
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<string> = [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<Record<string, (value: unknown) => 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<HTMLDivElement | null>(null)
|
||||
const [evaluationForceEditToken, setEvaluationForceEditToken] =
|
||||
useState<number>(0)
|
||||
const [evaluationHighlightToken, setEvaluationHighlightToken] =
|
||||
useState<number>(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<string, any>)
|
||||
: {}
|
||||
|
||||
const criteriosEvaluacion: Array<CriterioEvaluacionRow> = useMemo(() => {
|
||||
const raw = (data as any)?.criterios_de_evaluacion
|
||||
console.log(raw)
|
||||
|
||||
if (!Array.isArray(raw)) return []
|
||||
|
||||
const rows: Array<CriterioEvaluacionRow> = []
|
||||
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<CriterioEvaluacionRow>,
|
||||
) => {
|
||||
await updateAsignatura.mutateAsync({
|
||||
asignaturaId: asignaturaId as any,
|
||||
patch: {
|
||||
criterios_de_evaluacion: rows,
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
if (isLoading) return <p>Cargando información...</p>
|
||||
|
||||
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({
|
||||
<InfoCard
|
||||
title="Sistema de Evaluación"
|
||||
type="evaluation"
|
||||
initialContent={[
|
||||
{ label: 'Exámenes parciales', value: '30%' },
|
||||
{ label: 'Proyecto integrador', value: '35%' },
|
||||
{ label: 'Prácticas de laboratorio', value: '20%' },
|
||||
{ label: 'Participación', value: '15%' },
|
||||
]}
|
||||
initialContent={criteriosEvaluacion}
|
||||
containerRef={evaluationCardRef}
|
||||
forceEditToken={evaluationForceEditToken}
|
||||
highlightToken={evaluationHighlightToken}
|
||||
onPersist={({ value }) => persistCriteriosEvaluacion(value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<InfoCardProps['type']>
|
||||
clave?: string
|
||||
value: any
|
||||
}) => void | Promise<void>
|
||||
onClickEditButton?: (helpers: { startEditing: () => void }) => void
|
||||
|
||||
containerRef?: React.RefObject<HTMLDivElement | null>
|
||||
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<Array<CriterioEvaluacionRowDraft>>(
|
||||
[],
|
||||
)
|
||||
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<CriterioEvaluacionRowDraft> = 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<CriterioEvaluacionRowDraft>
|
||||
|
||||
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<CriterioEvaluacionRow> = []
|
||||
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,8 +448,27 @@ function InfoCard({
|
||||
})
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card className="overflow-hidden transition-all hover:border-slate-300">
|
||||
<div ref={containerRef as any}>
|
||||
<Card
|
||||
className={
|
||||
'overflow-hidden transition-all hover:border-slate-300 ' +
|
||||
(isHighlighted ? 'ring-primary/40 ring-2' : '')
|
||||
}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<CardHeader className="border-b bg-slate-50/50 px-5 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -375,19 +517,14 @@ function InfoCard({
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400"
|
||||
onClick={() => {
|
||||
// Si esta InfoCard proviene de una columna externa (ej: contenido_tematico),
|
||||
// redirigimos a la pestaña de Contenido en vez de editar inline.
|
||||
if (xColumn === 'contenido_tematico') {
|
||||
// Agregamos un timestamp para forzar la actualización
|
||||
// de la location.state aunque la ruta sea la misma.
|
||||
navigate({
|
||||
to: '/planes/$planId/asignaturas/$asignaturaId/contenido',
|
||||
params: { planId, asignaturaId: asignaturaId! },
|
||||
})
|
||||
const startEditing = () => setIsEditing(true)
|
||||
|
||||
if (onClickEditButton) {
|
||||
onClickEditButton({ startEditing })
|
||||
return
|
||||
}
|
||||
|
||||
setIsEditing(true)
|
||||
startEditing()
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
@@ -404,17 +541,177 @@ function InfoCard({
|
||||
<CardContent className="pt-4">
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
{type === 'evaluation' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{evalRows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="grid grid-cols-[2fr_1fr_1ch_32px] items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
value={row.criterio}
|
||||
placeholder="Criterio"
|
||||
onChange={(e) => {
|
||||
const nextCriterio = e.target.value
|
||||
setEvalRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === row.id
|
||||
? { ...r, criterio: nextCriterio }
|
||||
: r,
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={row.porcentaje}
|
||||
placeholder="%"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
onChange={(e) => {
|
||||
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
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex w-[1ch] items-center justify-center text-sm text-slate-600"
|
||||
aria-hidden
|
||||
>
|
||||
%
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-600 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
setEvalRows((prev) =>
|
||||
prev.filter((r) => r.id !== row.id),
|
||||
)
|
||||
}}
|
||||
aria-label="Quitar renglón"
|
||||
title="Quitar"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={
|
||||
'text-sm ' +
|
||||
(evaluationTotal === 100
|
||||
? 'text-muted-foreground'
|
||||
: 'text-destructive font-semibold')
|
||||
}
|
||||
>
|
||||
Total: {evaluationTotal}/100
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-emerald-700 hover:bg-emerald-50"
|
||||
onClick={() => {
|
||||
// Agregar una fila vacía (siempre permitido)
|
||||
setEvalRows((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
criterio: '',
|
||||
porcentaje: '',
|
||||
},
|
||||
])
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" /> Agregar renglón
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={tempText}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setTempText(e.target.value)}
|
||||
className="min-h-30 text-sm leading-relaxed"
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsEditing(false)}
|
||||
onClick={() => {
|
||||
setIsEditing(false)
|
||||
if (type === 'evaluation') {
|
||||
const raw = Array.isArray(data) ? data : []
|
||||
setEvalRows(
|
||||
raw.map((r: CriterioEvaluacionRow) => ({
|
||||
id: crypto.randomUUID(),
|
||||
criterio:
|
||||
typeof r.criterio === 'string' ? r.criterio : '',
|
||||
porcentaje:
|
||||
typeof r.porcentaje === 'number'
|
||||
? String(Math.trunc(r.porcentaje))
|
||||
: typeof r.porcentaje === 'string'
|
||||
? String(Math.trunc(Number(r.porcentaje)))
|
||||
: '',
|
||||
})),
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
@@ -422,6 +719,7 @@ function InfoCard({
|
||||
size="sm"
|
||||
className="bg-[#00a878] hover:bg-[#008f66]"
|
||||
onClick={handleSave}
|
||||
disabled={type === 'evaluation' && evaluationTotal > 100}
|
||||
>
|
||||
Guardar
|
||||
</Button>
|
||||
@@ -436,11 +734,14 @@ function InfoCard({
|
||||
<p className="text-slate-400 italic">Sin información.</p>
|
||||
))}
|
||||
{type === 'requirements' && <RequirementsView items={data} />}
|
||||
{type === 'evaluation' && <EvaluationView items={data} />}
|
||||
{type === 'evaluation' && (
|
||||
<EvaluationView items={data as Array<CriterioEvaluacionRow>} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -466,7 +767,11 @@ function RequirementsView({ items }: { items: Array<any> }) {
|
||||
}
|
||||
|
||||
// Vista de Evaluación
|
||||
function EvaluationView({ items }: { items: Array<any> }) {
|
||||
function EvaluationView({ items }: { items: Array<CriterioEvaluacionRow> }) {
|
||||
const porcentajeTotal = items.reduce(
|
||||
(total, item) => total + Number(item.porcentaje),
|
||||
0,
|
||||
)
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, i) => (
|
||||
@@ -474,10 +779,92 @@ function EvaluationView({ items }: { items: Array<any> }) {
|
||||
key={i}
|
||||
className="flex justify-between border-b border-slate-50 pb-1.5 text-sm italic"
|
||||
>
|
||||
<span className="text-slate-500">{item.label}</span>
|
||||
<span className="font-bold text-blue-600">{item.value}</span>
|
||||
<span className="text-slate-500">{item.criterio}</span>
|
||||
<span className="font-bold text-blue-600">{item.porcentaje}%</span>
|
||||
</div>
|
||||
))}
|
||||
{porcentajeTotal < 100 && (
|
||||
<p className="text-destructive text-sm font-medium">
|
||||
El porcentaje total es menor a 100%.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function parseContenidoTematicoToPlainText(value: unknown): string {
|
||||
if (!Array.isArray(value)) return ''
|
||||
|
||||
const blocks: Array<string> = []
|
||||
|
||||
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<string> = [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()
|
||||
}
|
||||
|
||||
function parseCriteriosEvaluacionToPlainText(value: unknown): string {
|
||||
if (!Array.isArray(value)) return ''
|
||||
|
||||
const lines: Array<string> = []
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) continue
|
||||
const label = typeof item.criterio === 'string' ? item.criterio.trim() : ''
|
||||
const valueNum =
|
||||
typeof item.porcentaje === 'number'
|
||||
? item.porcentaje
|
||||
: typeof item.porcentaje === 'string'
|
||||
? Number(item.porcentaje)
|
||||
: NaN
|
||||
|
||||
if (!label) continue
|
||||
if (!Number.isFinite(valueNum)) continue
|
||||
|
||||
const v = Math.trunc(valueNum)
|
||||
if (v < 1 || v > 100) continue
|
||||
|
||||
lines.push(`${label}: ${v}%`)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
|
||||
contenido_tematico: parseContenidoTematicoToPlainText,
|
||||
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
|
||||
.from('asignaturas')
|
||||
.select(
|
||||
`
|
||||
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
||||
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,
|
||||
planes_estudio(
|
||||
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
||||
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
|
||||
|
||||
@@ -81,6 +81,56 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
asignatura_mensajes_ia: {
|
||||
Row: {
|
||||
campos: Array<string>
|
||||
conversacion_asignatura_id: string
|
||||
enviado_por: string
|
||||
estado: Database['public']['Enums']['estado_mensaje_ia']
|
||||
fecha_actualizacion: string
|
||||
fecha_creacion: string
|
||||
id: string
|
||||
is_refusal: boolean
|
||||
mensaje: string
|
||||
propuesta: Json | null
|
||||
respuesta: string | null
|
||||
}
|
||||
Insert: {
|
||||
campos?: Array<string>
|
||||
conversacion_asignatura_id: string
|
||||
enviado_por?: string
|
||||
estado?: Database['public']['Enums']['estado_mensaje_ia']
|
||||
fecha_actualizacion?: string
|
||||
fecha_creacion?: string
|
||||
id?: string
|
||||
is_refusal?: boolean
|
||||
mensaje: string
|
||||
propuesta?: Json | null
|
||||
respuesta?: string | null
|
||||
}
|
||||
Update: {
|
||||
campos?: Array<string>
|
||||
conversacion_asignatura_id?: string
|
||||
enviado_por?: string
|
||||
estado?: Database['public']['Enums']['estado_mensaje_ia']
|
||||
fecha_actualizacion?: string
|
||||
fecha_creacion?: string
|
||||
id?: string
|
||||
is_refusal?: boolean
|
||||
mensaje?: string
|
||||
propuesta?: Json | null
|
||||
respuesta?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'asignatura_mensajes_ia_conversacion_asignatura_id_fkey'
|
||||
columns: ['conversacion_asignatura_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'conversaciones_asignatura'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
]
|
||||
}
|
||||
asignaturas: {
|
||||
Row: {
|
||||
actualizado_en: string
|
||||
@@ -91,6 +141,7 @@ export type Database = {
|
||||
creado_en: string
|
||||
creado_por: string | null
|
||||
creditos: number
|
||||
criterios_de_evaluacion: Json
|
||||
datos: Json
|
||||
estado: Database['public']['Enums']['estado_asignatura']
|
||||
estructura_id: string | null
|
||||
@@ -115,6 +166,7 @@ export type Database = {
|
||||
creado_en?: string
|
||||
creado_por?: string | null
|
||||
creditos: number
|
||||
criterios_de_evaluacion?: Json
|
||||
datos?: Json
|
||||
estado?: Database['public']['Enums']['estado_asignatura']
|
||||
estructura_id?: string | null
|
||||
@@ -139,6 +191,7 @@ export type Database = {
|
||||
creado_en?: string
|
||||
creado_por?: string | null
|
||||
creditos?: number
|
||||
criterios_de_evaluacion?: Json
|
||||
datos?: Json
|
||||
estado?: Database['public']['Enums']['estado_asignatura']
|
||||
estructura_id?: string | null
|
||||
@@ -176,6 +229,13 @@ export type Database = {
|
||||
referencedRelation: 'estructuras_asignatura'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'asignaturas_estructura_id_fkey'
|
||||
columns: ['estructura_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'plantilla_asignatura'
|
||||
referencedColumns: ['estructura_id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'asignaturas_linea_plan_fk_compuesta'
|
||||
columns: ['linea_plan_id', 'plan_estudio_id']
|
||||
@@ -241,6 +301,13 @@ export type Database = {
|
||||
referencedRelation: 'asignaturas'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'bibliografia_asignatura_asignatura_id_fkey'
|
||||
columns: ['asignatura_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'plantilla_asignatura'
|
||||
referencedColumns: ['asignatura_id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'bibliografia_asignatura_creado_por_fkey'
|
||||
columns: ['creado_por']
|
||||
@@ -295,6 +362,13 @@ export type Database = {
|
||||
referencedRelation: 'asignaturas'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'cambios_asignatura_asignatura_id_fkey'
|
||||
columns: ['asignatura_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'plantilla_asignatura'
|
||||
referencedColumns: ['asignatura_id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'cambios_asignatura_cambiado_por_fkey'
|
||||
columns: ['cambiado_por']
|
||||
@@ -441,6 +515,13 @@ export type Database = {
|
||||
referencedRelation: 'asignaturas'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'conversaciones_asignatura_asignatura_id_fkey'
|
||||
columns: ['asignatura_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'plantilla_asignatura'
|
||||
referencedColumns: ['asignatura_id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'conversaciones_asignatura_creado_por_fkey'
|
||||
columns: ['creado_por']
|
||||
@@ -552,7 +633,8 @@ export type Database = {
|
||||
definicion: Json
|
||||
id: string
|
||||
nombre: string
|
||||
version: string | null
|
||||
template_id: string | null
|
||||
tipo: Database['public']['Enums']['tipo_estructura_plan'] | null
|
||||
}
|
||||
Insert: {
|
||||
actualizado_en?: string
|
||||
@@ -560,7 +642,8 @@ export type Database = {
|
||||
definicion?: Json
|
||||
id?: string
|
||||
nombre: string
|
||||
version?: string | null
|
||||
template_id?: string | null
|
||||
tipo?: Database['public']['Enums']['tipo_estructura_plan'] | null
|
||||
}
|
||||
Update: {
|
||||
actualizado_en?: string
|
||||
@@ -568,7 +651,8 @@ export type Database = {
|
||||
definicion?: Json
|
||||
id?: string
|
||||
nombre?: string
|
||||
version?: string | null
|
||||
template_id?: string | null
|
||||
tipo?: Database['public']['Enums']['tipo_estructura_plan'] | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
@@ -692,6 +776,13 @@ export type Database = {
|
||||
referencedRelation: 'asignaturas'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'interacciones_ia_asignatura_id_fkey'
|
||||
columns: ['asignatura_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'plantilla_asignatura'
|
||||
referencedColumns: ['asignatura_id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'interacciones_ia_plan_estudio_id_fkey'
|
||||
columns: ['plan_estudio_id']
|
||||
@@ -798,6 +889,56 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
plan_mensajes_ia: {
|
||||
Row: {
|
||||
campos: Array<string>
|
||||
conversacion_plan_id: string
|
||||
enviado_por: string
|
||||
estado: Database['public']['Enums']['estado_mensaje_ia']
|
||||
fecha_actualizacion: string
|
||||
fecha_creacion: string
|
||||
id: string
|
||||
is_refusal: boolean
|
||||
mensaje: string
|
||||
propuesta: Json | null
|
||||
respuesta: string | null
|
||||
}
|
||||
Insert: {
|
||||
campos?: Array<string>
|
||||
conversacion_plan_id: string
|
||||
enviado_por?: string
|
||||
estado?: Database['public']['Enums']['estado_mensaje_ia']
|
||||
fecha_actualizacion?: string
|
||||
fecha_creacion?: string
|
||||
id?: string
|
||||
is_refusal?: boolean
|
||||
mensaje: string
|
||||
propuesta?: Json | null
|
||||
respuesta?: string | null
|
||||
}
|
||||
Update: {
|
||||
campos?: Array<string>
|
||||
conversacion_plan_id?: string
|
||||
enviado_por?: string
|
||||
estado?: Database['public']['Enums']['estado_mensaje_ia']
|
||||
fecha_actualizacion?: string
|
||||
fecha_creacion?: string
|
||||
id?: string
|
||||
is_refusal?: boolean
|
||||
mensaje?: string
|
||||
propuesta?: Json | null
|
||||
respuesta?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'plan_mensajes_ia_conversacion_plan_id_fkey'
|
||||
columns: ['conversacion_plan_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'conversaciones_plan'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
]
|
||||
}
|
||||
planes_estudio: {
|
||||
Row: {
|
||||
activo: boolean
|
||||
@@ -934,6 +1075,13 @@ export type Database = {
|
||||
referencedRelation: 'asignaturas'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'responsables_asignatura_asignatura_id_fkey'
|
||||
columns: ['asignatura_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'plantilla_asignatura'
|
||||
referencedColumns: ['asignatura_id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'responsables_asignatura_usuario_id_fkey'
|
||||
columns: ['usuario_id']
|
||||
@@ -1199,6 +1347,14 @@ export type Database = {
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
plantilla_asignatura: {
|
||||
Row: {
|
||||
asignatura_id: string | null
|
||||
estructura_id: string | null
|
||||
template_id: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
plantilla_plan: {
|
||||
Row: {
|
||||
estructura_id: string | null
|
||||
@@ -1221,13 +1377,9 @@ export type Database = {
|
||||
unaccent_immutable: { Args: { '': string }; Returns: string }
|
||||
}
|
||||
Enums: {
|
||||
estado_asignatura:
|
||||
| 'borrador'
|
||||
| 'revisada'
|
||||
| 'aprobada'
|
||||
| 'generando'
|
||||
| 'fallida'
|
||||
estado_asignatura: 'borrador' | 'revisada' | 'aprobada' | 'generando'
|
||||
estado_conversacion: 'ACTIVA' | 'ARCHIVANDO' | 'ARCHIVADA' | 'ERROR'
|
||||
estado_mensaje_ia: 'PROCESANDO' | 'COMPLETADO' | 'ERROR'
|
||||
estado_tarea_revision: 'PENDIENTE' | 'COMPLETADA' | 'OMITIDA'
|
||||
fuente_cambio: 'HUMANO' | 'IA'
|
||||
nivel_plan_estudio:
|
||||
@@ -1400,14 +1552,9 @@ export const Constants = {
|
||||
},
|
||||
public: {
|
||||
Enums: {
|
||||
estado_asignatura: [
|
||||
'borrador',
|
||||
'revisada',
|
||||
'aprobada',
|
||||
'generando',
|
||||
'fallida',
|
||||
],
|
||||
estado_asignatura: ['borrador', 'revisada', 'aprobada', 'generando'],
|
||||
estado_conversacion: ['ACTIVA', 'ARCHIVANDO', 'ARCHIVADA', 'ERROR'],
|
||||
estado_mensaje_ia: ['PROCESANDO', 'COMPLETADO', 'ERROR'],
|
||||
estado_tarea_revision: ['PENDIENTE', 'COMPLETADA', 'OMITIDA'],
|
||||
fuente_cambio: ['HUMANO', 'IA'],
|
||||
nivel_plan_estudio: [
|
||||
|
||||
Reference in New Issue
Block a user