10 Commits

Author SHA1 Message Date
37fab3ead6 refactor: Update CriterioEvaluacionRow structure and related logic for consistency 2026-03-04 15:49:43 -06:00
fa200acbfd fix #148: Refactorización para limpieza y generalidad 2026-03-04 14:53:23 -06:00
020caf4e68 Sistema de Evaluación AHora está ligado a Criterios de evaluación
- En los datos generales se renderizan como texto plano los criterios de evaluación.
- Si le picas a editar los Criterios de evaluación te dirige a Sistema de evaluación y lo pone en modo de edición.
- La infocard de SIstema de evaluación se edita adecuadamente y persiste en la BDD
2026-03-04 14:15:22 -06:00
896c694a85 Merge pull request 'Documento de asignaturas #155' (#156) from issue/155-documento-de-asignaturas into main
Reviewed-on: #156
2026-03-04 17:05:49 +00:00
990daf5786 Documento de asignaturas
fix #155
2026-03-04 11:04:30 -06:00
c1197413db Merge pull request 'Se corrige bueg en hook de asignaturas en columna version' (#154) from issue/152-ajuste-de-vista-para-las-tablas into main
Reviewed-on: #154
2026-03-04 15:40:23 +00:00
bf2b8a9b6e Se corrige bug en version en hook de asignaturas 2026-03-04 09:39:31 -06:00
d6ecee7549 Merge pull request 'Ajuste de vista para las tablas #152' (#153) from issue/152-ajuste-de-vista-para-las-tablas into main
Reviewed-on: #153
2026-03-04 15:25:44 +00:00
66bbf8ae17 Ajuste de vista para las tablas
fix #152
2026-03-04 09:25:10 -06:00
6012d0ced8 Ajuste de vista para las tablas #152 2026-03-04 09:24:27 -06:00
10 changed files with 916 additions and 304 deletions

View File

@@ -1,11 +1,12 @@
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { Pencil, Sparkles } from 'lucide-react' import { Minus, Pencil, Plus, Sparkles } from 'lucide-react'
import { useState, useEffect } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import type { AsignaturaDetail } from '@/data' import type { AsignaturaDetail } from '@/data'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { import {
Tooltip, Tooltip,
@@ -37,54 +38,15 @@ export interface AsignaturaResponse {
datos: AsignaturaDatos datos: AsignaturaDatos
} }
function isRecord(value: unknown): value is Record<string, unknown> { type CriterioEvaluacionRow = {
return typeof value === 'object' && value !== null && !Array.isArray(value) criterio: string
porcentaje: number
} }
function parseContenidoTematicoToPlainText(value: unknown): string { type CriterioEvaluacionRowDraft = {
if (!Array.isArray(value)) return '' id: string
criterio: string
const blocks: Array<string> = [] porcentaje: string // allow empty while editing
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,
} }
export const Route = createFileRoute( export const Route = createFileRoute(
@@ -132,11 +94,19 @@ function DatosGenerales({
}: { }: {
onPersistDato: (clave: string, value: string) => void onPersistDato: (clave: string, value: string) => void
}) { }) {
const { asignaturaId } = useParams({ const { asignaturaId, planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
}) })
const navigate = useNavigate()
const { data: data, isLoading: isLoading } = useSubject(asignaturaId) 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) // 1. Extraemos la definición de la estructura (los metadatos)
const definicionRaw = data?.estructuras_asignatura?.definicion const definicionRaw = data?.estructuras_asignatura?.definicion
@@ -154,6 +124,56 @@ function DatosGenerales({
const valoresActuales = isRecord(datosRaw) const valoresActuales = isRecord(datosRaw)
? (datosRaw as Record<string, any>) ? (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> if (isLoading) return <p>Cargando información...</p>
return ( return (
@@ -209,10 +229,29 @@ function DatosGenerales({
clave={key} clave={key}
title={cardTitle} title={cardTitle}
initialContent={currentContent} initialContent={currentContent}
xColumn={xColumn}
placeholder={placeholder} placeholder={placeholder}
description={description} 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 <InfoCard
title="Sistema de Evaluación" title="Sistema de Evaluación"
type="evaluation" type="evaluation"
initialContent={[ initialContent={criteriosEvaluacion}
{ label: 'Exámenes parciales', value: '30%' }, containerRef={evaluationCardRef}
{ label: 'Proyecto integrador', value: '35%' }, forceEditToken={evaluationForceEditToken}
{ label: 'Prácticas de laboratorio', value: '20%' }, highlightToken={evaluationHighlightToken}
{ label: 'Participación', value: '15%' }, onPersist={({ value }) => persistCriteriosEvaluacion(value)}
]}
/> />
</div> </div>
</div> </div>
@@ -265,11 +303,19 @@ interface InfoCardProps {
initialContent: any initialContent: any
placeholder?: string placeholder?: string
description?: string description?: string
xColumn?: string
required?: boolean // Nueva prop para el asterisco required?: boolean // Nueva prop para el asterisco
type?: 'text' | 'requirements' | 'evaluation' type?: 'text' | 'requirements' | 'evaluation'
onEnhanceAI?: (content: any) => void 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({ function InfoCard({
@@ -279,14 +325,22 @@ function InfoCard({
initialContent, initialContent,
placeholder, placeholder,
description, description,
xColumn,
required, required,
type = 'text', type = 'text',
onPersist, onPersist,
onClickEditButton,
containerRef,
forceEditToken,
highlightToken,
}: InfoCardProps) { }: InfoCardProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [isHighlighted, setIsHighlighted] = useState(false)
const [data, setData] = useState(initialContent) const [data, setData] = useState(initialContent)
const [tempText, setTempText] = useState(initialContent) const [tempText, setTempText] = useState(initialContent)
const [evalRows, setEvalRows] = useState<Array<CriterioEvaluacionRowDraft>>(
[],
)
const navigate = useNavigate() const navigate = useNavigate()
const { planId } = useParams({ const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
@@ -295,16 +349,85 @@ function InfoCard({
useEffect(() => { useEffect(() => {
setData(initialContent) setData(initialContent)
setTempText(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 = () => { const handleSave = () => {
console.log('clave, valor:', clave, String(tempText ?? '')) 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) setData(tempText)
setIsEditing(false) setIsEditing(false)
if (type === 'text' && clave && onPersist) { if (type === 'text') {
onPersist(clave, String(tempText ?? '')) void onPersist?.({ type, clave, value: String(tempText ?? '') })
} }
} }
@@ -325,122 +448,300 @@ function InfoCard({
}) })
} }
return ( const evaluationTotal = useMemo(() => {
<Card className="overflow-hidden transition-all hover:border-slate-300"> if (type !== 'evaluation') return 0
<TooltipProvider> return evalRows.reduce((acc, r) => {
<CardHeader className="border-b bg-slate-50/50 px-5 py-3"> const v = String(r.porcentaje).trim()
<div className="flex items-center justify-between"> if (!v) return acc
<div className="flex items-center gap-2"> const n = Number(v)
<Tooltip> if (!Number.isFinite(n)) return acc
<TooltipTrigger asChild> const porcentaje = Math.trunc(n)
<CardTitle className="cursor-help text-sm font-bold text-slate-700"> if (porcentaje < 1 || porcentaje > 100) return acc
{title} return acc + porcentaje
</CardTitle> }, 0)
</TooltipTrigger> }, [type, evalRows])
<TooltipContent side="top" className="max-w-xs text-xs">
{description || 'Información del campo'}
</TooltipContent>
</Tooltip>
{required && ( return (
<span <div ref={containerRef as any}>
className="text-sm font-bold text-red-500" <Card
title="Requerido" className={
> 'overflow-hidden transition-all hover:border-slate-300 ' +
* (isHighlighted ? 'ring-primary/40 ring-2' : '')
</span> }
>
<TooltipProvider>
<CardHeader className="border-b bg-slate-50/50 px-5 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<CardTitle className="cursor-help text-sm font-bold text-slate-700">
{title}
</CardTitle>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-xs">
{description || 'Información del campo'}
</TooltipContent>
</Tooltip>
{required && (
<span
className="text-sm font-bold text-red-500"
title="Requerido"
>
*
</span>
)}
</div>
{!isEditing && (
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
onClick={() => clave && handleIARequest(clave)}
>
<Sparkles className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Mejorar con IA</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400"
onClick={() => {
const startEditing = () => setIsEditing(true)
if (onClickEditButton) {
onClickEditButton({ startEditing })
return
}
startEditing()
}}
>
<Pencil className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Editar campo</TooltipContent>
</Tooltip>
</div>
)} )}
</div> </div>
</CardHeader>
</TooltipProvider>
{!isEditing && ( <CardContent className="pt-4">
<div className="flex gap-1"> {isEditing ? (
<Tooltip> <div className="space-y-3">
<TooltipTrigger asChild> {type === 'evaluation' ? (
<Button <div className="space-y-3">
variant="ghost" <div className="space-y-2">
size="icon" {evalRows.map((row) => (
className="h-8 w-8 text-blue-500 hover:bg-blue-100" <div
onClick={() => clave && handleIARequest(clave)} 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')
}
> >
<Sparkles className="h-4 w-4" /> Total: {evaluationTotal}/100
</Button> </span>
</TooltipTrigger>
<TooltipContent>Mejorar con IA</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="sm"
className="h-8 w-8 text-slate-400" className="text-emerald-700 hover:bg-emerald-50"
onClick={() => { onClick={() => {
// Si esta InfoCard proviene de una columna externa (ej: contenido_tematico), // Agregar una fila vacía (siempre permitido)
// redirigimos a la pestaña de Contenido en vez de editar inline. setEvalRows((prev) => [
if (xColumn === 'contenido_tematico') { ...prev,
// Agregamos un timestamp para forzar la actualización {
// de la location.state aunque la ruta sea la misma. id: crypto.randomUUID(),
navigate({ criterio: '',
to: '/planes/$planId/asignaturas/$asignaturaId/contenido', porcentaje: '',
params: { planId, asignaturaId: asignaturaId! }, },
}) ])
return
}
setIsEditing(true)
}} }}
> >
<Pencil className="h-3 w-3" /> <Plus className="mr-2 h-4 w-4" /> Agregar renglón
</Button> </Button>
</TooltipTrigger> </div>
<TooltipContent>Editar campo</TooltipContent> </div>
</Tooltip>
</div>
)}
</div>
</CardHeader>
</TooltipProvider>
<CardContent className="pt-4">
{isEditing ? (
<div className="space-y-3">
<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)}
>
Cancelar
</Button>
<Button
size="sm"
className="bg-[#00a878] hover:bg-[#008f66]"
onClick={handleSave}
>
Guardar
</Button>
</div>
</div>
) : (
<div className="text-sm leading-relaxed text-slate-600">
{type === 'text' &&
(data ? (
<p className="whitespace-pre-wrap">{data}</p>
) : ( ) : (
<p className="text-slate-400 italic">Sin información.</p> <Textarea
))} value={tempText}
{type === 'requirements' && <RequirementsView items={data} />} placeholder={placeholder}
{type === 'evaluation' && <EvaluationView items={data} />} onChange={(e) => setTempText(e.target.value)}
</div> className="min-h-30 text-sm leading-relaxed"
)} />
</CardContent> )}
</Card> <div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
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>
<Button
size="sm"
className="bg-[#00a878] hover:bg-[#008f66]"
onClick={handleSave}
disabled={type === 'evaluation' && evaluationTotal > 100}
>
Guardar
</Button>
</div>
</div>
) : (
<div className="text-sm leading-relaxed text-slate-600">
{type === 'text' &&
(data ? (
<p className="whitespace-pre-wrap">{data}</p>
) : (
<p className="text-slate-400 italic">Sin información.</p>
))}
{type === 'requirements' && <RequirementsView 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 // 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 ( return (
<div className="space-y-2"> <div className="space-y-2">
{items.map((item, i) => ( {items.map((item, i) => (
@@ -474,10 +779,92 @@ function EvaluationView({ items }: { items: Array<any> }) {
key={i} key={i}
className="flex justify-between border-b border-slate-50 pb-1.5 text-sm italic" 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="text-slate-500">{item.criterio}</span>
<span className="font-bold text-blue-600">{item.value}</span> <span className="font-bold text-blue-600">{item.porcentaje}%</span>
</div> </div>
))} ))}
{porcentajeTotal < 100 && (
<p className="text-destructive text-sm font-medium">
El porcentaje total es menor a 100%.
</p>
)}
</div> </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,
}

View File

@@ -8,6 +8,7 @@ export const ImprovementCard = ({
suggestions, suggestions,
onApply, onApply,
planId, planId,
dbMessageId,
currentDatos, currentDatos,
activeChatId, activeChatId,
onApplySuccess, onApplySuccess,
@@ -16,6 +17,7 @@ export const ImprovementCard = ({
onApply?: (key: string, value: string) => void onApply?: (key: string, value: string) => void
planId: string planId: string
currentDatos: any currentDatos: any
dbMessageId: string
activeChatId: any activeChatId: any
onApplySuccess?: (key: string) => void onApplySuccess?: (key: string) => void
}) => { }) => {
@@ -53,9 +55,11 @@ export const ImprovementCard = ({
setLocalApplied((prev) => [...prev, key]) setLocalApplied((prev) => [...prev, key])
if (onApplySuccess) onApplySuccess(key) if (onApplySuccess) onApplySuccess(key)
if (activeChatId) {
// --- CAMBIO AQUÍ: Ahora enviamos el ID del mensaje ---
if (dbMessageId) {
updateAppliedStatus.mutate({ updateAppliedStatus.mutate({
conversacionId: activeChatId, conversacionId: dbMessageId, // Cambiamos el nombre de la propiedad si es necesario
campoAfectado: key, campoAfectado: key,
}) })
} }

View File

@@ -100,7 +100,7 @@ export async function library_search(payload: {
export async function create_conversation(planId: string) { export async function create_conversation(planId: string) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke( const { data, error } = await supabase.functions.invoke(
'create-chat-conversation/conversations', 'create-chat-conversation/plan/conversations',
{ {
method: 'POST', method: 'POST',
body: { body: {
@@ -149,7 +149,7 @@ export async function ai_plan_chat_v2(payload: {
}): Promise<{ reply: string; meta?: any }> { }): Promise<{ reply: string; meta?: any }> {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke( const { data, error } = await supabase.functions.invoke(
`create-chat-conversation/conversations/${payload.conversacionId}/messages`, `create-chat-conversation/conversations/plan/${payload.conversacionId}/messages`,
{ {
method: 'POST', method: 'POST',
body: { body: {
@@ -175,6 +175,22 @@ export async function getConversationByPlan(planId: string) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return data ?? [] return data ?? []
} }
export async function getMessagesByConversation(conversationId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('plan_mensajes_ia')
.select('*')
.eq('conversacion_plan_id', conversationId)
.order('fecha_creacion', { ascending: true }) // Ascendente para que el chat fluya en orden cronológico
if (error) {
console.error('Error al obtener mensajes:', error.message)
throw error
}
return data ?? []
}
export async function update_conversation_title( export async function update_conversation_title(
conversacionId: string, conversacionId: string,
@@ -194,45 +210,40 @@ export async function update_conversation_title(
} }
export async function update_recommendation_applied_status( export async function update_recommendation_applied_status(
conversacionId: string, mensajeId: string, // Ahora es más eficiente usar el ID del mensaje directamente
campoAfectado: string, campoAfectado: string,
) { ) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
// 1. Obtener el estado actual del JSON // 1. Obtener la propuesta actual de ese mensaje específico
const { data: conv, error: fetchError } = await supabase const { data: msgData, error: fetchError } = await supabase
.from('conversaciones_plan') .from('plan_mensajes_ia')
.select('conversacion_json') .select('propuesta')
.eq('id', conversacionId) .eq('id', mensajeId)
.single() .single()
if (fetchError) throw fetchError if (fetchError) throw fetchError
if (!conv.conversacion_json) throw new Error('No se encontró la conversación') if (!msgData?.propuesta)
throw new Error('No se encontró la propuesta en el mensaje')
// 2. Transformar el JSON para marcar como aplicada la recomendación específica const propuestaActual = msgData.propuesta as any
// Usamos una transformación inmutable para evitar efectos secundarios
const nuevoJson = (conv.conversacion_json as Array<any>).map((msg) => {
if (msg.user === 'assistant' && Array.isArray(msg.recommendations)) {
return {
...msg,
recommendations: msg.recommendations.map((rec: any) =>
rec.campo_afectado === campoAfectado
? { ...rec, aplicada: true }
: rec,
),
}
}
return msg
})
// 3. Actualizar la base de datos con el nuevo JSON // 2. Modificar el array de recommendations dentro de la propuesta
const { data, error: updateError } = await supabase // Mantenemos el resto de la propuesta (prompt, respuesta, etc.) intacto
.from('conversaciones_plan') const nuevaPropuesta = {
.update({ conversacion_json: nuevoJson }) ...propuestaActual,
.eq('id', conversacionId) recommendations: (propuestaActual.recommendations || []).map((rec: any) =>
.select() rec.campo_afectado === campoAfectado ? { ...rec, aplicada: true } : rec,
.single() ),
}
// 3. Actualizar la base de datos con el nuevo objeto JSON
const { error: updateError } = await supabase
.from('plan_mensajes_ia')
.update({ propuesta: nuevaPropuesta })
.eq('id', mensajeId)
if (updateError) throw updateError if (updateError) throw updateError
return data
return true
} }

View File

@@ -3,9 +3,15 @@
const DOCUMENT_PDF_URL = const DOCUMENT_PDF_URL =
'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434' 'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434'
const DOCUMENT_PDF_ASIGNATURA_URL =
'https://n8n.app.lci.ulsa.mx/webhook/041a68be-7568-46d0-bc08-09ded12d017d'
interface GeneratePdfParams { interface GeneratePdfParams {
plan_estudio_id: string plan_estudio_id: string
} }
interface GeneratePdfParamsAsignatura {
asignatura_id: string
}
export async function fetchPlanPdf({ export async function fetchPlanPdf({
plan_estudio_id, plan_estudio_id,
@@ -25,3 +31,22 @@ export async function fetchPlanPdf({
// n8n devuelve el archivo → lo tratamos como blob // n8n devuelve el archivo → lo tratamos como blob
return await response.blob() return await response.blob()
} }
export async function fetchAsignaturaPdf({
asignatura_id,
}: GeneratePdfParamsAsignatura): Promise<Blob> {
const response = await fetch(DOCUMENT_PDF_ASIGNATURA_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ asignatura_id }),
})
if (!response.ok) {
throw new Error('Error al generar el PDF')
}
// n8n devuelve el archivo → lo tratamos como blob
return await response.blob()
}

View File

@@ -112,12 +112,12 @@ export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
.from('asignaturas') .from('asignaturas')
.select( .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( 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, 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)) carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
), ),
estructuras_asignatura(id,nombre,version,definicion) estructuras_asignatura(id,nombre,definicion)
`, `,
) )
.eq('id', subjectId) .eq('id', subjectId)

View File

@@ -12,6 +12,7 @@ import {
update_conversation_status, update_conversation_status,
update_recommendation_applied_status, update_recommendation_applied_status,
update_conversation_title, update_conversation_title,
getMessagesByConversation,
} from '../api/ai.api' } from '../api/ai.api'
// eslint-disable-next-line node/prefer-node-protocol // eslint-disable-next-line node/prefer-node-protocol
@@ -88,6 +89,25 @@ export function useConversationByPlan(planId: string | null) {
}) })
} }
export function useMessagesByChat(conversationId: string | null) {
return useQuery({
// La queryKey debe ser única; incluimos el ID para que se refresque al cambiar de chat
queryKey: ['conversation-messages', conversationId],
// Solo ejecutamos la función si el ID no es null o undefined
queryFn: () => {
if (!conversationId) throw new Error('Conversation ID is required')
return getMessagesByConversation(conversationId)
},
// Importante: 'enabled' controla que no se dispare la petición si no hay ID
enabled: !!conversationId,
// Opcional: Mantener los datos previos mientras se carga la nueva conversación
placeholderData: (previousData) => previousData,
})
}
export function useUpdateRecommendationApplied() { export function useUpdateRecommendationApplied() {
const qc = useQueryClient() const qc = useQueryClient()

View File

@@ -29,6 +29,7 @@ import { Textarea } from '@/components/ui/textarea'
import { import {
useAIPlanChat, useAIPlanChat,
useConversationByPlan, useConversationByPlan,
useMessagesByChat,
useUpdateConversationStatus, useUpdateConversationStatus,
useUpdateConversationTitle, useUpdateConversationTitle,
} from '@/data' } from '@/data'
@@ -103,6 +104,8 @@ function RouteComponent() {
) )
const { data: lastConversation, isLoading: isLoadingConv } = const { data: lastConversation, isLoading: isLoadingConv } =
useConversationByPlan(planId) useConversationByPlan(planId)
const { data: mensajesDelChat, isLoading: isLoadingMessages } =
useMessagesByChat(activeChatId)
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>( const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
[], [],
) )
@@ -154,53 +157,50 @@ function RouteComponent() {
}, [lastConversation, activeChatId]) }, [lastConversation, activeChatId])
const chatMessages = useMemo(() => { const chatMessages = useMemo(() => {
// 1. Si no hay ID o no hay data del chat, retornamos vacío if (!activeChatId || !mensajesDelChat) return []
if (!activeChatId || !activeChatData) return []
const json = (activeChatData.conversacion_json || // flatMap nos permite devolver 2 elementos (pregunta y respuesta) por cada registro de la BD
[]) as unknown as Array<ChatMessageJSON> return mensajesDelChat.flatMap((msg: any) => {
const messages = []
// 2. Verificamos que 'json' sea realmente un array antes de mapear // 1. Mensaje del Usuario
if (!Array.isArray(json)) return [] messages.push({
id: `${msg.id}-user`,
role: 'user',
content: msg.mensaje,
selectedFields: msg.campos || [], // Aquí están tus campos
})
return json.map((msg, index: number) => { // 2. Mensaje del Asistente (si hay respuesta)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (msg.respuesta) {
if (!msg?.user) { // Extraemos las recomendaciones de la nueva estructura: msg.propuesta.recommendations
return { const rawRecommendations = msg.propuesta?.recommendations || []
id: `err-${index}`,
messages.push({
id: `${msg.id}-ai`,
dbMessageId: msg.id,
role: 'assistant', role: 'assistant',
content: '', content: msg.respuesta,
suggestions: [], isRefusal: msg.is_refusal,
} suggestions: rawRecommendations.map((rec: any) => {
const fieldConfig = availableFields.find(
(f) => f.key === rec.campo_afectado,
)
return {
key: rec.campo_afectado,
label: fieldConfig
? fieldConfig.label
: rec.campo_afectado.replace(/_/g, ' '),
newValue: rec.texto_mejora,
applied: rec.aplicada,
}
}),
})
} }
const isAssistant = msg.user === 'assistant' return messages
return {
id: `${activeChatId}-${index}`,
role: isAssistant ? 'assistant' : 'user',
content: isAssistant ? msg.message || '' : msg.prompt || '', // Agregamos fallback a string vacío
isRefusal: isAssistant && msg.refusal === true,
suggestions:
isAssistant && msg.recommendations
? msg.recommendations.map((rec) => {
const fieldConfig = availableFields.find(
(f) => f.key === rec.campo_afectado,
)
return {
key: rec.campo_afectado,
label: fieldConfig
? fieldConfig.label
: rec.campo_afectado.replace(/_/g, ' '),
newValue: rec.texto_mejora,
applied: rec.aplicada,
}
})
: [],
}
}) })
}, [activeChatData, activeChatId, availableFields]) }, [mensajesDelChat, activeChatId, availableFields])
const scrollToBottom = () => { const scrollToBottom = () => {
if (scrollRef.current) { if (scrollRef.current) {
// Buscamos el viewport interno del ScrollArea de Radix // Buscamos el viewport interno del ScrollArea de Radix
@@ -226,6 +226,8 @@ function RouteComponent() {
}, [lastConversation]) }, [lastConversation])
useEffect(() => { useEffect(() => {
console.log(mensajesDelChat)
scrollToBottom() scrollToBottom()
}, [chatMessages, isLoading]) }, [chatMessages, isLoading])
@@ -242,30 +244,38 @@ function RouteComponent() {
}, [input, selectedFields]) }, [input, selectedFields])
useEffect(() => { useEffect(() => {
if (isLoadingConv || !lastConversation) return if (isLoadingConv || isSending) return
const isChatStillActive = activeChats.some( const currentChatExists = activeChats.some(
(chat) => chat.id === activeChatId, (chat) => chat.id === activeChatId,
) )
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome' const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
// Caso A: El chat actual ya no es válido (fue archivado o borrado) // 1. Si el chat que teníamos seleccionado ya no existe (ej. se archivó)
if (activeChatId && !isChatStillActive && !isCreationMode) { if (activeChatId && !currentChatExists && !isCreationMode) {
setActiveChatId(undefined) setActiveChatId(undefined)
setMessages([]) setMessages([])
return // Salimos para evitar ejecuciones extra en este render return
} }
// Caso B: No hay chat seleccionado y hay chats disponibles (Auto-selección al cargar) // 2. Auto-selección inicial: Solo si no hay ID, no estamos creando y hay chats
if (!activeChatId && activeChats.length > 0 && !isCreationMode) { if (
!activeChatId &&
activeChats.length > 0 &&
!isCreationMode &&
chatMessages.length === 0
) {
setActiveChatId(activeChats[0].id) setActiveChatId(activeChats[0].id)
} }
}, [
activeChats,
activeChatId,
isLoadingConv,
isSending,
messages.length,
chatMessages.length,
])
// Caso C: Si la lista de chats está vacía y no estamos creando uno, limpiar por si acaso
if (activeChats.length === 0 && activeChatId && !isCreationMode) {
setActiveChatId(undefined)
}
}, [activeChats, activeChatId, isLoadingConv, messages.length])
useEffect(() => { useEffect(() => {
const state = routerState.location.state as any const state = routerState.location.state as any
if (!state?.campo_edit || availableFields.length === 0) return if (!state?.campo_edit || availableFields.length === 0) return
@@ -352,13 +362,16 @@ function RouteComponent() {
input: string, input: string,
fields: Array<SelectedField>, fields: Array<SelectedField>,
) => { ) => {
const cleaned = input.replace(/\n?\[Campos:[^\]]*]/g, '').trim() // 1. Limpiamos cualquier rastro anterior de la etiqueta (por si acaso)
// Esta regex ahora también limpia si el texto termina de forma natural
const cleaned = input.replace(/[:\s]+[^:]*$/, '').trim()
if (fields.length === 0) return cleaned if (fields.length === 0) return cleaned
const fieldLabels = fields.map((f) => f.label).join(', ') const fieldLabels = fields.map((f) => f.label).join(', ')
return `${cleaned}\n[Campos: ${fieldLabels}]` // 2. Devolvemos un formato natural: "Mejora este campo: Nombre del Campo"
return `${cleaned}: ${fieldLabels}`
} }
const toggleField = (field: SelectedField) => { const toggleField = (field: SelectedField) => {
@@ -388,42 +401,46 @@ function RouteComponent() {
const handleSend = async (promptOverride?: string) => { const handleSend = async (promptOverride?: string) => {
const rawText = promptOverride || input const rawText = promptOverride || input
if (!rawText.trim() && selectedFields.length === 0) return
if (isSending || (!rawText.trim() && selectedFields.length === 0)) return if (isSending || (!rawText.trim() && selectedFields.length === 0)) return
const currentFields = [...selectedFields] const currentFields = [...selectedFields]
const finalPrompt = buildPrompt(rawText, currentFields)
setIsSending(true) setIsSending(true)
setOptimisticMessage(rawText) setOptimisticMessage(rawText)
setInput('')
setSelectedArchivoIds([])
setSelectedRepositorioIds([])
setUploadedFiles([])
try {
const payload: any = {
planId: planId,
content: finalPrompt,
conversacionId: activeChatId || undefined,
}
if (currentFields.length > 0) { // Limpiar input inmediatamente para feedback visual
payload.campos = currentFields.map((f) => f.key) setInput('')
setSelectedFields([])
try {
const payload = {
planId,
content: buildPrompt(rawText, currentFields),
conversacionId: activeChatId,
campos:
currentFields.length > 0
? currentFields.map((f) => f.key)
: undefined,
} }
const response = await sendChat(payload) const response = await sendChat(payload)
// IMPORTANTE: Si es un chat nuevo, actualizar el ID antes de invalidar
if (response.conversacionId && response.conversacionId !== activeChatId) { if (response.conversacionId && response.conversacionId !== activeChatId) {
setActiveChatId(response.conversacionId) setActiveChatId(response.conversacionId)
} }
await queryClient.invalidateQueries({ // Invalidar ambas para asegurar que la lista de la izquierda y los mensajes se
queryKey: ['conversation-by-plan', planId], await Promise.all([
}) queryClient.invalidateQueries({
setOptimisticMessage(null) queryKey: ['conversation-by-plan', planId],
}),
queryClient.invalidateQueries({
queryKey: ['conversation-messages', response.conversacionId],
}),
])
} catch (error) { } catch (error) {
console.error('Error en el chat:', error) console.error('Error:', error)
// Aquí sí podrías usar un toast o un mensaje de error temporal
} finally { } finally {
// 5. CRÍTICO: Detener el estado de carga SIEMPRE
setIsSending(false) setIsSending(false)
setOptimisticMessage(null) setOptimisticMessage(null)
} }
@@ -666,6 +683,7 @@ function RouteComponent() {
<div className="mt-4"> <div className="mt-4">
<ImprovementCard <ImprovementCard
suggestions={msg.suggestions} suggestions={msg.suggestions}
dbMessageId={msg.dbMessageId}
planId={planId} planId={planId}
currentDatos={data?.datos} currentDatos={data?.datos}
activeChatId={activeChatId} activeChatId={activeChatId}

View File

@@ -2,7 +2,7 @@ import { createFileRoute, useParams } from '@tanstack/react-router'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab' import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
import { fetchPlanPdf } from '@/data/api/document.api' import { fetchAsignaturaPdf } from '@/data/api/document.api'
export const Route = createFileRoute( export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/documento', '/planes/$planId/asignaturas/$asignaturaId/documento',
@@ -11,7 +11,7 @@ export const Route = createFileRoute(
}) })
function RouteComponent() { function RouteComponent() {
const { planId } = useParams({ const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId/documento', from: '/planes/$planId/asignaturas/$asignaturaId/documento',
}) })
@@ -23,8 +23,8 @@ function RouteComponent() {
try { try {
setIsLoading(true) setIsLoading(true)
const pdfBlob = await fetchPlanPdf({ const pdfBlob = await fetchAsignaturaPdf({
plan_estudio_id: planId, asignatura_id: asignaturaId,
}) })
const url = window.URL.createObjectURL(pdfBlob) const url = window.URL.createObjectURL(pdfBlob)
@@ -38,7 +38,7 @@ function RouteComponent() {
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
}, [planId]) }, [asignaturaId])
useEffect(() => { useEffect(() => {
loadPdfPreview() loadPdfPreview()
@@ -49,8 +49,8 @@ function RouteComponent() {
}, [loadPdfPreview]) }, [loadPdfPreview])
const handleDownload = async () => { const handleDownload = async () => {
const pdfBlob = await fetchPlanPdf({ const pdfBlob = await fetchAsignaturaPdf({
plan_estudio_id: planId, asignatura_id: asignaturaId,
}) })
const url = window.URL.createObjectURL(pdfBlob) const url = window.URL.createObjectURL(pdfBlob)

View File

@@ -232,7 +232,7 @@ function AsignaturaLayout() {
{ label: 'Datos', to: '' }, { label: 'Datos', to: '' },
{ label: 'Contenido', to: 'contenido' }, { label: 'Contenido', to: 'contenido' },
{ label: 'Bibliografía', to: 'bibliografia' }, { label: 'Bibliografía', to: 'bibliografia' },
{ label: 'IA', to: 'asignaturaIa' }, { label: 'IA', to: 'iaasignatura' },
{ label: 'Documento SEP', to: 'documento' }, { label: 'Documento SEP', to: 'documento' },
{ label: 'Historial', to: 'historial' }, { label: 'Historial', to: 'historial' },
].map((tab) => { ].map((tab) => {

View File

@@ -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: { asignaturas: {
Row: { Row: {
actualizado_en: string actualizado_en: string
@@ -91,6 +141,7 @@ export type Database = {
creado_en: string creado_en: string
creado_por: string | null creado_por: string | null
creditos: number creditos: number
criterios_de_evaluacion: Json
datos: Json datos: Json
estado: Database['public']['Enums']['estado_asignatura'] estado: Database['public']['Enums']['estado_asignatura']
estructura_id: string | null estructura_id: string | null
@@ -115,6 +166,7 @@ export type Database = {
creado_en?: string creado_en?: string
creado_por?: string | null creado_por?: string | null
creditos: number creditos: number
criterios_de_evaluacion?: Json
datos?: Json datos?: Json
estado?: Database['public']['Enums']['estado_asignatura'] estado?: Database['public']['Enums']['estado_asignatura']
estructura_id?: string | null estructura_id?: string | null
@@ -139,6 +191,7 @@ export type Database = {
creado_en?: string creado_en?: string
creado_por?: string | null creado_por?: string | null
creditos?: number creditos?: number
criterios_de_evaluacion?: Json
datos?: Json datos?: Json
estado?: Database['public']['Enums']['estado_asignatura'] estado?: Database['public']['Enums']['estado_asignatura']
estructura_id?: string | null estructura_id?: string | null
@@ -176,6 +229,13 @@ export type Database = {
referencedRelation: 'estructuras_asignatura' referencedRelation: 'estructuras_asignatura'
referencedColumns: ['id'] referencedColumns: ['id']
}, },
{
foreignKeyName: 'asignaturas_estructura_id_fkey'
columns: ['estructura_id']
isOneToOne: false
referencedRelation: 'plantilla_asignatura'
referencedColumns: ['estructura_id']
},
{ {
foreignKeyName: 'asignaturas_linea_plan_fk_compuesta' foreignKeyName: 'asignaturas_linea_plan_fk_compuesta'
columns: ['linea_plan_id', 'plan_estudio_id'] columns: ['linea_plan_id', 'plan_estudio_id']
@@ -241,6 +301,13 @@ export type Database = {
referencedRelation: 'asignaturas' referencedRelation: 'asignaturas'
referencedColumns: ['id'] 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' foreignKeyName: 'bibliografia_asignatura_creado_por_fkey'
columns: ['creado_por'] columns: ['creado_por']
@@ -295,6 +362,13 @@ export type Database = {
referencedRelation: 'asignaturas' referencedRelation: 'asignaturas'
referencedColumns: ['id'] 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' foreignKeyName: 'cambios_asignatura_cambiado_por_fkey'
columns: ['cambiado_por'] columns: ['cambiado_por']
@@ -441,6 +515,13 @@ export type Database = {
referencedRelation: 'asignaturas' referencedRelation: 'asignaturas'
referencedColumns: ['id'] 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' foreignKeyName: 'conversaciones_asignatura_creado_por_fkey'
columns: ['creado_por'] columns: ['creado_por']
@@ -552,7 +633,8 @@ export type Database = {
definicion: Json definicion: Json
id: string id: string
nombre: string nombre: string
version: string | null template_id: string | null
tipo: Database['public']['Enums']['tipo_estructura_plan'] | null
} }
Insert: { Insert: {
actualizado_en?: string actualizado_en?: string
@@ -560,7 +642,8 @@ export type Database = {
definicion?: Json definicion?: Json
id?: string id?: string
nombre: string nombre: string
version?: string | null template_id?: string | null
tipo?: Database['public']['Enums']['tipo_estructura_plan'] | null
} }
Update: { Update: {
actualizado_en?: string actualizado_en?: string
@@ -568,7 +651,8 @@ export type Database = {
definicion?: Json definicion?: Json
id?: string id?: string
nombre?: string nombre?: string
version?: string | null template_id?: string | null
tipo?: Database['public']['Enums']['tipo_estructura_plan'] | null
} }
Relationships: [] Relationships: []
} }
@@ -692,6 +776,13 @@ export type Database = {
referencedRelation: 'asignaturas' referencedRelation: 'asignaturas'
referencedColumns: ['id'] 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' foreignKeyName: 'interacciones_ia_plan_estudio_id_fkey'
columns: ['plan_estudio_id'] 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: { planes_estudio: {
Row: { Row: {
activo: boolean activo: boolean
@@ -934,6 +1075,13 @@ export type Database = {
referencedRelation: 'asignaturas' referencedRelation: 'asignaturas'
referencedColumns: ['id'] 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' foreignKeyName: 'responsables_asignatura_usuario_id_fkey'
columns: ['usuario_id'] columns: ['usuario_id']
@@ -1199,6 +1347,14 @@ export type Database = {
} }
} }
Views: { Views: {
plantilla_asignatura: {
Row: {
asignatura_id: string | null
estructura_id: string | null
template_id: string | null
}
Relationships: []
}
plantilla_plan: { plantilla_plan: {
Row: { Row: {
estructura_id: string | null estructura_id: string | null
@@ -1221,13 +1377,9 @@ export type Database = {
unaccent_immutable: { Args: { '': string }; Returns: string } unaccent_immutable: { Args: { '': string }; Returns: string }
} }
Enums: { Enums: {
estado_asignatura: estado_asignatura: 'borrador' | 'revisada' | 'aprobada' | 'generando'
| 'borrador'
| 'revisada'
| 'aprobada'
| 'generando'
| 'fallida'
estado_conversacion: 'ACTIVA' | 'ARCHIVANDO' | 'ARCHIVADA' | 'ERROR' estado_conversacion: 'ACTIVA' | 'ARCHIVANDO' | 'ARCHIVADA' | 'ERROR'
estado_mensaje_ia: 'PROCESANDO' | 'COMPLETADO' | 'ERROR'
estado_tarea_revision: 'PENDIENTE' | 'COMPLETADA' | 'OMITIDA' estado_tarea_revision: 'PENDIENTE' | 'COMPLETADA' | 'OMITIDA'
fuente_cambio: 'HUMANO' | 'IA' fuente_cambio: 'HUMANO' | 'IA'
nivel_plan_estudio: nivel_plan_estudio:
@@ -1400,14 +1552,9 @@ export const Constants = {
}, },
public: { public: {
Enums: { Enums: {
estado_asignatura: [ estado_asignatura: ['borrador', 'revisada', 'aprobada', 'generando'],
'borrador',
'revisada',
'aprobada',
'generando',
'fallida',
],
estado_conversacion: ['ACTIVA', 'ARCHIVANDO', 'ARCHIVADA', 'ERROR'], estado_conversacion: ['ACTIVA', 'ARCHIVANDO', 'ARCHIVADA', 'ERROR'],
estado_mensaje_ia: ['PROCESANDO', 'COMPLETADO', 'ERROR'],
estado_tarea_revision: ['PENDIENTE', 'COMPLETADA', 'OMITIDA'], estado_tarea_revision: ['PENDIENTE', 'COMPLETADA', 'OMITIDA'],
fuente_cambio: ['HUMANO', 'IA'], fuente_cambio: ['HUMANO', 'IA'],
nivel_plan_estudio: [ nivel_plan_estudio: [