1 Commits

Author SHA1 Message Date
892d02123e Se añadieron validaciones y mejoras en el modal de nueva bibliografía
-Se implementaron restricciones en SugerenciasStep: el campo de búsqueda se limitó a 200 caracteres y la generación quedó bloqueada si hay 20 o más referencias seleccionadas; se añadió tooltip en el botón de generar cuando la query tiene menos de 3 caracteres.
-Se reforzaron validaciones en FormatoYCitasStep y DatosBasicosManualStep: el título se trim-eó y se forzó a no quedar vacío (max 500 caracteres); si un título queda vacío se hace scroll al input/card, se muestra mensaje de error junto al label y se resalta el input; autores se limitó a 2000 caracteres; editorial a 300 caracteres; ISBN a 20 caracteres; el año se convirtió en input numérico permitiendo vacío o un año de 4 dígitos entre 1450 y el año actual +1.
-Se añadieron checkboxes "Año aproximado" y "En prensa" (mutuamente excluyentes): "En prensa" deshabilita el input de año y se marca el estado para citeproc; "Año aproximado" se envía como circa en issued.
-Al generar CSL se incluyeron las propiedades issued.circa y status ('in press') según los flags del ref.
-En ResumenStep se añadieron advertencias por referencia cuando falte autor(es), año (si no está "en prensa"), editorial o ISBN.
-Se corrigieron detalles de UX en edición de autores para preservar saltos de línea y se añadieron handlers para evitar errores de validación al mover entre pasos.
2026-03-11 16:01:09 -06:00
20 changed files with 1064 additions and 2608 deletions

View File

@@ -1,37 +0,0 @@
name: Deploy to Azure Static Web Apps
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Build
env:
VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ vars.VITE_SUPABASE_ANON_KEY }}
run: bunx --bun vite build
# No hace falta instalar el CLI globalmente, usamos bunx
- name: Deploy to Azure Static Web Apps
env:
AZURE_SWA_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOYMENT_TOKEN }}
run: |
bunx @azure/static-web-apps-cli deploy ./dist \
--env production \
--deployment-token "$AZURE_SWA_DEPLOYMENT_TOKEN"

View File

@@ -4,7 +4,6 @@
"": { "": {
"name": "acad-ia-2", "name": "acad-ia-2",
"dependencies": { "dependencies": {
"@dnd-kit/react": "^0.3.2",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -139,18 +138,6 @@
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
"@dnd-kit/abstract": ["@dnd-kit/abstract@0.3.2", "", { "dependencies": { "@dnd-kit/geometry": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-uvPVK+SZYD6Viddn9M0K0JQdXknuVSxA/EbMlFRanve3P/XTc18oLa5zGftKSGjfQGmuzkZ34E26DSbly1zi3Q=="],
"@dnd-kit/collision": ["@dnd-kit/collision@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/geometry": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-pNmNSLCI8S9fNQ7QJ3fBCDjiT0sqBhUFcKgmyYaGvGCAU+kq0AP8OWlh0JSisc9k5mFyxmRpmFQcnJpILz/RPA=="],
"@dnd-kit/dom": ["@dnd-kit/dom@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/collision": "^0.3.2", "@dnd-kit/geometry": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-cIUAVgt2szQyz6JRy7I+0r+xeyOAGH21Y15hb5bIyHoDEaZBvIDH+OOlD9eoLjCbsxDLN9WloU2CBi3OE6LYDg=="],
"@dnd-kit/geometry": ["@dnd-kit/geometry@0.3.2", "", { "dependencies": { "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-3UBPuIS7E3oGiHxOE8h810QA+0pnrnCtGxl4Os1z3yy5YkC/BEYGY+TxWPTQaY1/OMV7GCX7ZNMlama2QN3n3w=="],
"@dnd-kit/react": ["@dnd-kit/react@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/dom": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-1Opg1xw6I75Z95c+rF2NJa0pdGb8rLAENtuopKtJ1J0PudWlz+P6yL137xy/6DV43uaRmNGtsdbMbR0yRYJ72g=="],
"@dnd-kit/state": ["@dnd-kit/state@0.3.2", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-dLUIkoYrIJhGXfF2wGLTfb46vUokEsO/OoE21TSfmahYrx7ysTmnwbePsznFaHlwgZhQEh6AlLvthLCeY21b1A=="],
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -263,8 +250,6 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@preact/signals-core": ["@preact/signals-core@1.14.0", "", {}, "sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],

View File

@@ -17,7 +17,6 @@
"ci:verify": "prettier --check . && eslint . && tsc --noEmit" "ci:verify": "prettier --check . && eslint . && tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/react": "^0.3.2",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",

View File

@@ -7,13 +7,6 @@ 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 { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { import {
Tooltip, Tooltip,
@@ -21,7 +14,6 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { usePlanAsignaturas } from '@/data'
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects' import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
export interface BibliografiaEntry { export interface BibliografiaEntry {
@@ -67,12 +59,8 @@ export default function AsignaturaDetailPage() {
const { asignaturaId } = useParams({ const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
}) })
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: asignaturaApi } = useSubject(asignaturaId) const { data: asignaturaApi } = useSubject(asignaturaId)
const { data: asignaturasApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId)
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null) const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
const updateAsignatura = useUpdateAsignatura() const updateAsignatura = useUpdateAsignatura()
@@ -93,54 +81,16 @@ export default function AsignaturaDetailPage() {
}, },
}) })
} }
const asignaturaSeriada = useMemo(() => {
if (!asignaturaApi?.prerrequisito_asignatura_id || !asignaturasApi)
return null
return asignaturasApi.find(
(asig) => asig.id === asignaturaApi.prerrequisito_asignatura_id,
)
}, [asignaturaApi, asignaturasApi])
const requisitosFormateados = useMemo(() => {
if (!asignaturaSeriada) return []
return [
{
type: 'Pre-requisito',
code: asignaturaSeriada.codigo,
name: asignaturaSeriada.nombre,
id: asignaturaSeriada.id, // Guardamos el ID para el select
},
]
}, [asignaturaSeriada])
const handleUpdatePrerrequisito = (newId: string | null) => {
updateAsignatura.mutate({
asignaturaId,
patch: {
prerrequisito_asignatura_id: newId,
},
})
}
/* ---------- sincronizar API ---------- */ /* ---------- sincronizar API ---------- */
useEffect(() => { useEffect(() => {
console.log(requisitosFormateados)
if (asignaturaApi) setAsignatura(asignaturaApi) if (asignaturaApi) setAsignatura(asignaturaApi)
}, [asignaturaApi, requisitosFormateados]) }, [asignaturaApi])
return ( return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
<DatosGenerales
pre={requisitosFormateados}
availableSubjects={asignaturasApi}
onPersistDato={handlePersistDatoGeneral}
/>
)
} }
function DatosGenerales({ function DatosGenerales({
onPersistDato, onPersistDato,
pre,
availableSubjects,
}: { }: {
onPersistDato: (clave: string, value: string) => void onPersistDato: (clave: string, value: string) => void
}) { }) {
@@ -315,19 +265,18 @@ function DatosGenerales({
<InfoCard <InfoCard
title="Requisitos y Seriación" title="Requisitos y Seriación"
type="requirements" type="requirements"
initialContent={pre} initialContent={[
// Pasamos las materias del plan para el Select (excluyendo la actual) {
availableSubjects={ type: 'Pre-requisito',
availableSubjects?.filter((a) => a.id !== asignaturaId) || [] code: 'PA-301',
} name: 'Programación Avanzada',
onPersist={({ value }) => { },
updateAsignatura.mutate({ {
asignaturaId, type: 'Co-requisito',
patch: { code: 'MAT-201',
prerrequisito_asignatura_id: value, // value ya viene como ID o null desde handleSave name: 'Matemáticas Discretas',
}, },
}) ]}
}}
/> />
{/* Tarjeta de Evaluación */} {/* Tarjeta de Evaluación */}
@@ -367,7 +316,6 @@ interface InfoCardProps {
containerRef?: React.RefObject<HTMLDivElement | null> containerRef?: React.RefObject<HTMLDivElement | null>
forceEditToken?: number forceEditToken?: number
highlightToken?: number highlightToken?: number
availableSubjects?: any
} }
function InfoCard({ function InfoCard({
@@ -384,7 +332,6 @@ function InfoCard({
containerRef, containerRef,
forceEditToken, forceEditToken,
highlightToken, highlightToken,
availableSubjects,
}: InfoCardProps) { }: InfoCardProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [isHighlighted, setIsHighlighted] = useState(false) const [isHighlighted, setIsHighlighted] = useState(false)
@@ -402,8 +349,7 @@ function InfoCard({
useEffect(() => { useEffect(() => {
setData(initialContent) setData(initialContent)
setTempText(initialContent) setTempText(initialContent)
console.log(data)
console.log(initialContent)
if (type === 'evaluation') { if (type === 'evaluation') {
const raw = Array.isArray(initialContent) ? initialContent : [] const raw = Array.isArray(initialContent) ? initialContent : []
const rows: Array<CriterioEvaluacionRowDraft> = raw const rows: Array<CriterioEvaluacionRowDraft> = raw
@@ -446,8 +392,6 @@ function InfoCard({
const handleSave = () => { const handleSave = () => {
console.log('clave, valor:', clave, String(tempText ?? '')) console.log('clave, valor:', clave, String(tempText ?? ''))
console.log(clave)
console.log(tempText)
if (type === 'evaluation') { if (type === 'evaluation') {
const cleaned: Array<CriterioEvaluacionRow> = [] const cleaned: Array<CriterioEvaluacionRow> = []
@@ -478,25 +422,6 @@ function InfoCard({
void onPersist?.({ type, clave, value: cleaned }) void onPersist?.({ type, clave, value: cleaned })
return return
} }
if (type === 'requirements') {
console.log('entre aqui ')
// Si tempText es un array y tiene elementos, tomamos el ID del primero
// Si es "none" o está vacío, mandamos null (para limpiar la seriación)
const prerequisiteId =
Array.isArray(tempText) && tempText.length > 0 ? tempText[0].id : null
setData(tempText) // Actualiza la vista local
setIsEditing(false)
// Mandamos el ID específico a la base de datos
void onPersist?.({
type,
clave: 'prerrequisito_asignatura_id', // Forzamos la columna correcta
value: prerequisiteId,
})
return
}
setData(tempText) setData(tempText)
setIsEditing(false) setIsEditing(false)
@@ -616,52 +541,7 @@ function InfoCard({
<CardContent className="pt-4"> <CardContent className="pt-4">
{isEditing ? ( {isEditing ? (
<div className="space-y-3"> <div className="space-y-3">
{/* Condicionales de edición según el tipo */} {type === 'evaluation' ? (
{type === 'requirements' ? (
<div className="space-y-3">
<label className="text-xs font-medium text-slate-500">
Materia de Seriación
</label>
<Select
value={tempText?.[0]?.id || 'none'}
onValueChange={(val) => {
const selected = availableSubjects?.find(
(s) => s.id === val,
)
if (val === 'none' || !selected) {
console.log('guardando')
setTempText([])
} else {
console.log('hola')
setTempText([
{
id: selected.id,
type: 'Pre-requisito',
code: selected.codigo,
name: selected.nombre,
},
])
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Selecciona una materia" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
Ninguna (Sin seriación)
</SelectItem>
{availableSubjects?.map((asig) => (
<SelectItem key={asig.id} value={asig.id}>
{asig.codigo} - {asig.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : type === 'evaluation' ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
{evalRows.map((row) => ( {evalRows.map((row) => (
@@ -683,36 +563,85 @@ function InfoCard({
) )
}} }}
/> />
<Input <Input
value={row.porcentaje} value={row.porcentaje}
placeholder="%" placeholder="%"
type="number" type="number"
min={1}
max={100}
step={1}
inputMode="numeric"
onChange={(e) => { onChange={(e) => {
const raw = e.target.value const raw = e.target.value
// Solo permitir '' o dígitos
if (raw !== '' && !/^\d+$/.test(raw)) return 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) => { setEvalRows((prev) => {
const next = prev.map((r) => const next = prev.map((r) =>
r.id === row.id ? { ...r, porcentaje: raw } : r, r.id === row.id
) ? {
const total = next.reduce( id: r.id,
(acc, r) => acc + (Number(r.porcentaje) || 0), criterio: r.criterio,
0, 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 return total > 100 ? prev : next
}) })
}} }}
/> />
<div className="text-sm text-slate-600">%</div>
<div
className="flex w-[1ch] items-center justify-center text-sm text-slate-600"
aria-hidden
>
%
</div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-red-600 hover:bg-red-50" className="h-8 w-8 text-red-600 hover:bg-red-50"
onClick={() => onClick={() => {
setEvalRows((prev) => setEvalRows((prev) =>
prev.filter((r) => r.id !== row.id), prev.filter((r) => r.id !== row.id),
) )
} }}
aria-label="Quitar renglón"
title="Quitar"
> >
<Minus className="h-4 w-4" /> <Minus className="h-4 w-4" />
</Button> </Button>
@@ -722,15 +651,22 @@ function InfoCard({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span <span
className={`text-sm ${evaluationTotal === 100 ? 'text-muted-foreground' : 'text-destructive font-semibold'}`} className={
'text-sm ' +
(evaluationTotal === 100
? 'text-muted-foreground'
: 'text-destructive font-semibold')
}
> >
Total: {evaluationTotal}/100 Total: {evaluationTotal}/100
</span> </span>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-emerald-700 hover:bg-emerald-50" className="text-emerald-700 hover:bg-emerald-50"
onClick={() => onClick={() => {
// Agregar una fila vacía (siempre permitido)
setEvalRows((prev) => [ setEvalRows((prev) => [
...prev, ...prev,
{ {
@@ -739,7 +675,7 @@ function InfoCard({
porcentaje: '', porcentaje: '',
}, },
]) ])
} }}
> >
<Plus className="mr-2 h-4 w-4" /> Agregar renglón <Plus className="mr-2 h-4 w-4" /> Agregar renglón
</Button> </Button>
@@ -753,15 +689,28 @@ function InfoCard({
className="min-h-30 text-sm leading-relaxed" className="min-h-30 text-sm leading-relaxed"
/> />
)} )}
{/* Botones de acción comunes */}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
setIsEditing(false) setIsEditing(false)
// Lógica de reset si es necesario... 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 Cancelar
@@ -777,7 +726,6 @@ function InfoCard({
</div> </div>
</div> </div>
) : ( ) : (
/* Modo Visualización */
<div className="text-sm leading-relaxed text-slate-600"> <div className="text-sm leading-relaxed text-slate-600">
{type === 'text' && {type === 'text' &&
(data ? ( (data ? (
@@ -786,7 +734,9 @@ function InfoCard({
<p className="text-slate-400 italic">Sin información.</p> <p className="text-slate-400 italic">Sin información.</p>
))} ))}
{type === 'requirements' && <RequirementsView items={data} />} {type === 'requirements' && <RequirementsView items={data} />}
{type === 'evaluation' && <EvaluationView items={data} />} {type === 'evaluation' && (
<EvaluationView items={data as Array<CriterioEvaluacionRow>} />
)}
</div> </div>
)} )}
</CardContent> </CardContent>

View File

@@ -1,5 +1,3 @@
import { DragDropProvider } from '@dnd-kit/react'
import { isSortable, useSortable } from '@dnd-kit/react/sortable'
import { useParams } from '@tanstack/react-router' import { useParams } from '@tanstack/react-router'
import { import {
Plus, Plus,
@@ -13,7 +11,7 @@ import {
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api' import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
import type { FocusEvent, KeyboardEvent, ReactNode } from 'react' import type { FocusEvent, KeyboardEvent } from 'react'
import { import {
AlertDialog, AlertDialog,
@@ -52,95 +50,6 @@ export interface UnidadTematica {
temas: Array<Tema> temas: Array<Tema>
} }
function createClientId(prefix: string) {
try {
const c = (globalThis as any).crypto
if (c && typeof c.randomUUID === 'function')
return `${prefix}-${c.randomUUID()}`
} catch {
// ignore
}
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function arrayMove<T>(array: Array<T>, fromIndex: number, toIndex: number) {
const next = array.slice()
const startIndex = fromIndex < 0 ? next.length + fromIndex : fromIndex
if (startIndex < 0 || startIndex >= next.length) return next
const endIndex = toIndex < 0 ? next.length + toIndex : toIndex
const [item] = next.splice(startIndex, 1)
next.splice(endIndex, 0, item)
return next
}
function renumberUnidades(unidades: Array<UnidadTematica>) {
return unidades.map((u, idx) => ({ ...u, numero: idx + 1 }))
}
function InsertUnidadOverlay({
onInsert,
position,
}: {
onInsert: () => void
position: 'top' | 'bottom'
}) {
return (
<div
className={cn(
'pointer-events-auto absolute right-0 left-0 z-30 flex justify-center',
// Match the `space-y-4` gap so the hover target is *between* units.
position === 'top' ? '-top-4 h-4' : '-bottom-4 h-4',
)}
>
<Button
type="button"
variant="outline"
size="sm"
className="bg-background/95 border-border/60 hover:bg-background cursor-pointer opacity-0 shadow-sm transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
onInsert()
}}
>
<Plus className="mr-2 h-3 w-3" /> Nueva unidad
</Button>
</div>
)
}
function SortableUnidad({
id,
index,
registerContainer,
children,
}: {
id: string
index: number
registerContainer: (el: HTMLDivElement | null) => void
children: (args: { handleRef: (el: HTMLElement | null) => void }) => ReactNode
}) {
const { ref, handleRef, isDragSource, isDropTarget } = useSortable({
id,
index,
})
return (
<div
ref={(el) => {
ref(el)
registerContainer(el)
}}
className={cn(
'group relative',
isDragSource && 'opacity-80',
isDropTarget && 'ring-primary/20 ring-2',
)}
>
{children({ handleRef })}
</div>
)
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value) return typeof value === 'object' && value !== null && !Array.isArray(value)
} }
@@ -191,18 +100,20 @@ function mapContenidoItem(value: unknown, index: number): ContenidoApi | null {
if (Array.isArray(value.temas)) { if (Array.isArray(value.temas)) {
temas = value.temas temas = value.temas
.map(mapTemaValue) .map(mapTemaValue)
.filter((x): x is ContenidoTemaApi => x !== null) .filter((t): t is ContenidoTemaApi => t !== null)
} else if (typeof value.temas === 'string' && value.temas.trim()) {
temas = value.temas
.split(/\r?\n|,/)
.map((t) => t.trim())
.filter(Boolean)
} }
return { return { unidad, titulo, temas }
...value,
unidad,
titulo,
temas,
}
} }
function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> { function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> {
if (value == null) return []
if (typeof value === 'string') { if (typeof value === 'string') {
try { try {
return mapContenidoTematicoFromDb(JSON.parse(value)) return mapContenidoTematicoFromDb(JSON.parse(value))
@@ -281,16 +192,7 @@ export function ContenidoTematico() {
const [temaDraftHoras, setTemaDraftHoras] = useState('') const [temaDraftHoras, setTemaDraftHoras] = useState('')
const [temaOriginalHoras, setTemaOriginalHoras] = useState(0) const [temaOriginalHoras, setTemaOriginalHoras] = useState(0)
const didInitExpandedUnitsRef = useRef(false)
const unidadesRef = useRef<Array<UnidadTematica>>([])
useEffect(() => {
unidadesRef.current = unidades
}, [unidades])
const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => { const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => {
// A partir del primer guardado, ya respetamos lo que el usuario deje expandido.
didInitExpandedUnitsRef.current = true
const payload = serializeUnidadesToApi(nextUnidades) const payload = serializeUnidadesToApi(nextUnidades)
await updateContenido.mutateAsync({ await updateContenido.mutateAsync({
subjectId: asignaturaId, subjectId: asignaturaId,
@@ -344,17 +246,10 @@ export function ContenidoTematico() {
}) })
} }
const parseHorasEstimadas = (raw: string): number => {
const normalized = raw.trim().replace(',', '.')
const parsed = Number.parseFloat(normalized)
if (!Number.isFinite(parsed)) return 0
return parsed
}
const commitEditTema = () => { const commitEditTema = () => {
if (!editingTema) return if (!editingTema) return
const horasEstimadas = parseHorasEstimadas(temaDraftHoras) const parsedHoras = Number.parseInt(temaDraftHoras, 10)
const horasEstimadas = Number.isFinite(parsedHoras) ? parsedHoras : 0
const next = unidades.map((u) => { const next = unidades.map((u) => {
if (u.id !== editingTema.unitId) return u if (u.id !== editingTema.unitId) return u
@@ -408,110 +303,28 @@ export function ContenidoTematico() {
data ? data.contenido_tematico : undefined, data ? data.contenido_tematico : undefined,
) )
// 1. EL ESCUDO: Comparamos si nuestro estado local ya tiene esta info exacta const transformed = contenido.map((u, idx) => ({
// (Esto ocurre justo después de arrastrar, ya que actualizamos la UI antes que la BD) id: `u-${u.unidad || idx + 1}`,
const currentPayload = JSON.stringify( numero: u.unidad || idx + 1,
serializeUnidadesToApi(unidadesRef.current), nombre: u.titulo || 'Sin título',
) temas: Array.isArray(u.temas)
? u.temas.map((t: any, tidx: number) => ({
// Normalizamos la data de la BD para que tenga exactamente la misma forma que el payload id: `t-${u.unidad || idx + 1}-${tidx + 1}`,
const incomingPayload = JSON.stringify( nombre: typeof t === 'string' ? t : t?.nombre || 'Tema',
contenido.map((u, idx) => ({ horasEstimadas: t?.horasEstimadas || 0,
unidad: u.unidad || idx + 1, }))
titulo: u.titulo || 'Sin título', : [],
temas: Array.isArray(u.temas) }))
? u.temas.map((t) => {
if (typeof t === 'string') {
return {
nombre: t,
horasEstimadas: 0,
descripcion: undefined,
}
}
return {
nombre: t.nombre || 'Tema',
horasEstimadas: t.horasEstimadas ?? 0,
descripcion: t.descripcion,
}
})
: [],
})),
)
// Si los datos son idénticos, abortamos el useEffect.
// ¡Nuestros IDs locales se salvan y no hay parpadeos!
if (currentPayload === incomingPayload && unidadesRef.current.length > 0) {
return
}
// 2. Si llegamos aquí, es la carga inicial o alguien más editó la BD desde otro lado.
// Reciclamos IDs buscando por CONTENIDO (nombre), NUNCA POR ÍNDICE.
const prevUnidades = [...unidadesRef.current]
const transformed = contenido.map((u, idx) => {
const dbTitulo = u.titulo || 'Sin título'
// Buscamos si ya existe una unidad con este mismo título
const existingUnitIndex = prevUnidades.findIndex(
(prev) => prev.nombre === dbTitulo,
)
let unidadId
let existingUnit = null
if (existingUnitIndex !== -1) {
existingUnit = prevUnidades[existingUnitIndex]
unidadId = existingUnit.id
prevUnidades.splice(existingUnitIndex, 1) // Lo sacamos de la lista para no repetirlo
} else {
unidadId = createClientId(`u-${u.unidad || idx + 1}`)
}
return {
id: unidadId,
numero: u.unidad || idx + 1,
nombre: dbTitulo,
temas: Array.isArray(u.temas)
? u.temas.map((t: any, tidx: number) => {
const dbTemaNombre =
typeof t === 'string' ? t : t?.nombre || 'Tema'
// Reciclamos subtemas por nombre también
const existingTema = existingUnit?.temas.find(
(prevT) => prevT.nombre === dbTemaNombre,
)
const temaId = existingTema
? existingTema.id
: createClientId(`t-${u.unidad || idx + 1}-${tidx + 1}`)
return {
id: temaId,
nombre: dbTemaNombre,
horasEstimadas:
coerceNumber(
typeof t === 'string' ? undefined : t?.horasEstimadas,
) ?? 0,
}
})
: [],
}
})
setUnidades(transformed) setUnidades(transformed)
// Mantener las unidades ya expandidas si existen; si no, expandir la primera.
setExpandedUnits((prev) => { setExpandedUnits((prev) => {
const validIds = new Set(transformed.map((u) => u.id)) const validIds = new Set(transformed.map((u) => u.id))
const filtered = new Set( const filtered = new Set(
Array.from(prev).filter((id) => validIds.has(id)), Array.from(prev).filter((id) => validIds.has(id)),
) )
if (filtered.size > 0) return filtered
// Expandir la primera unidad solo una vez al llegar a la ruta. return transformed.length > 0 ? new Set([transformed[0].id]) : new Set()
// Luego, no auto-expandimos de nuevo (aunque `data` cambie).
if (!didInitExpandedUnitsRef.current && transformed.length > 0) {
return filtered.size > 0 ? filtered : new Set([transformed[0].id])
}
return filtered
}) })
}, [data]) }, [data])
@@ -540,7 +353,7 @@ export function ContenidoTematico() {
// 3. Cálculo de horas (ahora dinámico basado en los nuevos datos) // 3. Cálculo de horas (ahora dinámico basado en los nuevos datos)
const totalHoras = unidades.reduce( const totalHoras = unidades.reduce(
(acc, u) => (acc, u) =>
acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas ?? 0), 0), acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0),
0, 0,
) )
@@ -551,22 +364,16 @@ export function ContenidoTematico() {
setExpandedUnits(newExpanded) setExpandedUnits(newExpanded)
} }
const insertUnidadAt = (insertIndex: number) => { const addUnidad = () => {
const newId = createClientId('u') const newNumero = unidades.length + 1
const newId = `u-${newNumero}`
const newUnidad: UnidadTematica = { const newUnidad: UnidadTematica = {
id: newId, id: newId,
nombre: 'Nueva Unidad', nombre: 'Nueva Unidad',
numero: 0, numero: newNumero,
temas: [], temas: [],
} }
const next = [...unidades, newUnidad]
const clampedIndex = Math.max(0, Math.min(insertIndex, unidades.length))
const next = renumberUnidades([
...unidades.slice(0, clampedIndex),
newUnidad,
...unidades.slice(clampedIndex),
])
setUnidades(next) setUnidades(next)
setExpandedUnits((prev) => { setExpandedUnits((prev) => {
const n = new Set(prev) const n = new Set(prev)
@@ -575,40 +382,10 @@ export function ContenidoTematico() {
}) })
setPendingScrollUnitId(newId) setPendingScrollUnitId(newId)
// Abrir edición del título inmediatamente
setEditingUnit(newId) setEditingUnit(newId)
setUnitDraftNombre(newUnidad.nombre) setUnitDraftNombre(newUnidad.nombre)
setUnitOriginalNombre(newUnidad.nombre) setUnitOriginalNombre(newUnidad.nombre)
void persistUnidades(next)
}
const handleReorderEnd = (event: any) => {
if (event?.canceled) return
const source = event?.operation?.source
if (!source) return
// Type-guard nativo de dnd-kit para asegurar que el elemento tiene metadata de orden
if (!isSortable(source)) return
// Extraemos las posiciones exactas calculadas por dnd-kit
const { initialIndex, index } = source.sortable
// Si lo soltó en la misma posición de la que salió, cancelamos
if (initialIndex === index) return
setUnidades((prev) => {
// Hacemos el movimiento usando los índices directos
const moved = arrayMove(prev, initialIndex, index)
const next = renumberUnidades(moved)
// Disparamos la persistencia hacia Supabase
void persistUnidades(next).catch((err) => {
console.error('No se pudo guardar el orden de unidades', err)
})
return next
})
} }
// --- Lógica de Temas --- // --- Lógica de Temas ---
@@ -674,182 +451,158 @@ export function ContenidoTematico() {
</div> </div>
</div> </div>
<DragDropProvider onDragEnd={handleReorderEnd}> <div className="space-y-4">
<div className="space-y-4"> {unidades.map((unidad) => (
{unidades.map((unidad, index) => ( <div
<SortableUnidad key={unidad.id}
key={unidad.id} ref={(el) => {
id={unidad.id} if (el) unitContainerRefs.current.set(unidad.id, el)
index={index} else unitContainerRefs.current.delete(unidad.id)
registerContainer={(el) => { }}
if (el) unitContainerRefs.current.set(unidad.id, el) >
else unitContainerRefs.current.delete(unidad.id) <Card className="overflow-hidden border-slate-200 shadow-sm">
}} <Collapsible
> open={expandedUnits.has(unidad.id)}
{({ handleRef }) => ( onOpenChange={() => toggleUnit(unidad.id)}
<> >
{index === 0 && ( <CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
<InsertUnidadOverlay <div className="flex items-center gap-3">
position="top" <GripVertical className="h-4 w-4 cursor-grab text-slate-300" />
onInsert={() => insertUnidadAt(index)} <CollapsibleTrigger asChild>
/> <Button variant="ghost" size="sm" className="h-auto p-0">
)} {expandedUnits.has(unidad.id) ? (
<InsertUnidadOverlay <ChevronDown className="h-4 w-4" />
position="bottom" ) : (
onInsert={() => insertUnidadAt(index + 1)} <ChevronRight className="h-4 w-4" />
/> )}
</Button>
</CollapsibleTrigger>
<Badge className="bg-blue-600 font-mono">
Unidad {unidad.numero}
</Badge>
<Card className="overflow-hidden border-slate-200 shadow-sm"> {editingUnit === unidad.id ? (
<Collapsible <Input
open={expandedUnits.has(unidad.id)} ref={unitTitleInputRef}
onOpenChange={() => toggleUnit(unidad.id)} value={unitDraftNombre}
> onChange={(e) => setUnitDraftNombre(e.target.value)}
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3"> onBlur={() => {
<div className="flex items-center gap-3"> if (cancelNextBlurRef.current) {
<span cancelNextBlurRef.current = false
ref={handleRef as any} return
className="inline-flex cursor-grab touch-none items-center text-slate-300" }
aria-label="Reordenar unidad" commitEditUnit()
> }}
<GripVertical className="h-4 w-4" /> onKeyDown={(e) => {
</span> if (e.key === 'Enter') {
<CollapsibleTrigger asChild> e.preventDefault()
<Button e.currentTarget.blur()
variant="ghost" return
size="sm" }
className="h-auto cursor-pointer p-0" if (e.key === 'Escape') {
> e.preventDefault()
{expandedUnits.has(unidad.id) ? ( cancelNextBlurRef.current = true
<ChevronDown className="h-4 w-4" /> cancelEditUnit()
) : ( e.currentTarget.blur()
<ChevronRight className="h-4 w-4" /> }
)} }}
</Button> className="h-8 max-w-md bg-white"
</CollapsibleTrigger> />
<Badge className="bg-blue-600 font-mono"> ) : (
Unidad {unidad.numero} <CardTitle
</Badge> className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
onClick={() => beginEditUnit(unidad.id)}
>
{unidad.nombre}
</CardTitle>
)}
{editingUnit === unidad.id ? ( <div className="ml-auto flex items-center gap-3">
<Input <span className="flex items-center gap-1 text-xs font-medium text-slate-400">
ref={unitTitleInputRef} <Clock className="h-3 w-3" />{' '}
value={unitDraftNombre} {unidad.temas.reduce(
onChange={(e) => (sum, t) => sum + (t.horasEstimadas || 0),
setUnitDraftNombre(e.target.value) 0,
} )}
onBlur={() => { h
if (cancelNextBlurRef.current) { </span>
cancelNextBlurRef.current = false <Button
return variant="ghost"
} size="icon"
commitEditUnit() className="h-8 w-8 text-slate-400 hover:text-red-500"
}} onClick={() =>
onKeyDown={(e) => { setDeleteDialog({ type: 'unidad', id: unidad.id })
if (e.key === 'Enter') { }
e.preventDefault() >
e.currentTarget.blur() <Trash2 className="h-4 w-4" />
return </Button>
} </div>
if (e.key === 'Escape') { </div>
e.preventDefault() </CardHeader>
cancelNextBlurRef.current = true <CollapsibleContent>
cancelEditUnit() <CardContent className="bg-white pt-4">
e.currentTarget.blur() <div className="ml-10 space-y-1 border-l-2 border-slate-50 pl-4">
} {unidad.temas.map((tema, idx) => (
}} <TemaRow
className="h-8 max-w-md bg-white" key={tema.id}
/> tema={tema}
) : ( index={idx + 1}
<CardTitle isEditing={
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600" !!editingTema &&
onClick={() => beginEditUnit(unidad.id)} editingTema.unitId === unidad.id &&
> editingTema.temaId === tema.id
{unidad.nombre} }
</CardTitle> draftNombre={temaDraftNombre}
)} draftHoras={temaDraftHoras}
onBeginEdit={() => beginEditTema(unidad.id, tema.id)}
onDraftNombreChange={setTemaDraftNombre}
onDraftHorasChange={setTemaDraftHoras}
onEditorBlurCapture={handleTemaEditorBlurCapture}
onEditorKeyDownCapture={
handleTemaEditorKeyDownCapture
}
onNombreInputRef={(el) => {
temaNombreInputElRef.current = el
}}
onDelete={() =>
setDeleteDialog({
type: 'tema',
id: tema.id,
parentId: unidad.id,
})
}
/>
))}
<Button
variant="ghost"
size="sm"
className="mt-2 w-full justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
onClick={() => addTema(unidad.id)}
>
<Plus className="mr-2 h-3 w-3" /> Añadir subtema
</Button>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
</div>
))}
</div>
<div className="ml-auto flex items-center gap-3"> <div className="flex justify-center pt-2">
<span className="flex cursor-default items-center gap-1 text-xs font-medium text-slate-400"> <Button
<Clock className="h-3 w-3" />{' '} variant="outline"
{unidad.temas.reduce( className="gap-2"
(sum, t) => sum + (t.horasEstimadas || 0), onClick={(e) => {
0, // Evita que Enter vuelva a disparar el click sobre el botón.
)} e.currentTarget.blur()
h addUnidad()
</span> }}
<Button >
variant="ghost" <Plus className="h-4 w-4" /> Nueva unidad
size="icon" </Button>
className="h-8 w-8 cursor-pointer text-slate-400 hover:text-red-500" </div>
onClick={() =>
setDeleteDialog({
type: 'unidad',
id: unidad.id,
})
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="bg-white pt-4">
<div className="ml-10 space-y-1 border-l-2 border-slate-50 pl-4">
{unidad.temas.map((tema, idx) => (
<TemaRow
key={tema.id}
tema={tema}
index={idx + 1}
isEditing={
!!editingTema &&
editingTema.unitId === unidad.id &&
editingTema.temaId === tema.id
}
draftNombre={temaDraftNombre}
draftHoras={temaDraftHoras}
onBeginEdit={() =>
beginEditTema(unidad.id, tema.id)
}
onDraftNombreChange={setTemaDraftNombre}
onDraftHorasChange={setTemaDraftHoras}
onEditorBlurCapture={
handleTemaEditorBlurCapture
}
onEditorKeyDownCapture={
handleTemaEditorKeyDownCapture
}
onNombreInputRef={(el) => {
temaNombreInputElRef.current = el
}}
onDelete={() =>
setDeleteDialog({
type: 'tema',
id: tema.id,
parentId: unidad.id,
})
}
/>
))}
<Button
variant="ghost"
size="sm"
className="mt-2 w-full cursor-pointer justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
onClick={() => addTema(unidad.id)}
>
<Plus className="mr-2 h-3 w-3" /> Añadir subtema
</Button>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
</>
)}
</SortableUnidad>
))}
</div>
</DragDropProvider>
<DeleteConfirmDialog <DeleteConfirmDialog
dialog={deleteDialog} dialog={deleteDialog}
@@ -914,9 +667,6 @@ function TemaRow({
<Input <Input
type="number" type="number"
value={draftHoras} value={draftHoras}
min={0}
max={200}
step={0.5}
onChange={(e) => onDraftHorasChange(e.target.value)} onChange={(e) => onDraftHorasChange(e.target.value)}
className="h-8 w-16 bg-white" className="h-8 w-16 bg-white"
/> />
@@ -925,7 +675,7 @@ function TemaRow({
<> <>
<button <button
type="button" type="button"
className="flex flex-1 cursor-pointer items-center gap-3 text-left" className="flex flex-1 items-center gap-3 text-left"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onBeginEdit() onBeginEdit()
@@ -940,7 +690,7 @@ function TemaRow({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 cursor-pointer text-slate-400 hover:text-blue-600" className="h-7 w-7 text-slate-400 hover:text-blue-600"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onBeginEdit() onBeginEdit()
@@ -951,7 +701,7 @@ function TemaRow({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 cursor-pointer text-slate-400 hover:text-red-500" className="h-7 w-7 text-slate-400 hover:text-red-500"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onDelete() onDelete()

View File

@@ -13,33 +13,21 @@ import {
X, X,
MessageSquarePlus, MessageSquarePlus,
Archive, Archive,
History, History, // Agregado
Edit2, // Agregado
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { ImprovementCard } from './SaveAsignatura/ImprovementCardProps'
import type { IASugerencia } from '@/types/asignatura' import type { IASugerencia } from '@/types/asignatura'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { import {
useAISubjectChat, useAISubjectChat,
useConversationBySubject, useConversationBySubject,
useMessagesBySubjectChat, useMessagesBySubjectChat,
useSubject, useSubject,
useUpdateSubjectConversationName,
useUpdateSubjectConversationStatus, useUpdateSubjectConversationStatus,
} from '@/data' } from '@/data'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -87,24 +75,6 @@ export function IAAsignaturaTab({
const { mutate: updateStatus } = useUpdateSubjectConversationStatus() const { mutate: updateStatus } = useUpdateSubjectConversationStatus()
const [isCreatingNewChat, setIsCreatingNewChat] = useState(false) const [isCreatingNewChat, setIsCreatingNewChat] = useState(false)
const hasInitialSelected = useRef(false) const hasInitialSelected = useRef(false)
const { mutate: updateName } = useUpdateSubjectConversationName()
const [editingId, setEditingId] = useState<string | null>(null)
const [tempName, setTempName] = useState('')
const [openIA, setOpenIA] = useState(false)
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
[],
)
const [selectedRepositorioIds, setSelectedRepositorioIds] = useState<
Array<string>
>([])
const [uploadedFiles, setUploadedFiles] = useState<Array<File>>([])
// Cálculo del total para el Badge del botón
const totalReferencias =
selectedArchivoIds.length +
selectedRepositorioIds.length +
uploadedFiles.length
const isAiThinking = useMemo(() => { const isAiThinking = useMemo(() => {
if (isSending) return true if (isSending) return true
@@ -136,92 +106,38 @@ export function IAAsignaturaTab({
} }
}, [todasConversaciones]) }, [todasConversaciones])
const availableFields = useMemo(() => {
// 1. Obtenemos los campos dinámicos de la DB
const dynamicFields = datosGenerales?.datos
? Object.keys(datosGenerales.datos).map((key) => {
const estructuraProps =
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
return {
key,
label:
estructuraProps[key]?.title ||
key.replace(/_/g, ' ').toUpperCase(),
value: String(datosGenerales.datos[key] || ''),
}
})
: []
// 2. Definimos tus campos manuales (hardcoded)
const hardcodedFields = [
{
key: 'contenido_tematico',
label: 'Contenido temático',
value: '', // Puedes dejarlo vacío o buscarlo en datosGenerales si existiera
},
{
key: 'criterios_de_evaluacion',
label: 'Criterios de evaluación',
value: '',
},
]
// 3. Unimos ambos, filtrando duplicados por si acaso el backend ya los envía
const combined = [...dynamicFields]
hardcodedFields.forEach((hf) => {
if (!combined.some((f) => f.key === hf.key)) {
combined.push(hf)
}
})
return combined
}, [datosGenerales])
// --- PROCESAMIENTO DE MENSAJES ---
// --- PROCESAMIENTO DE MENSAJES --- // --- PROCESAMIENTO DE MENSAJES ---
const messages = useMemo(() => { const messages = useMemo(() => {
const msgs: Array<any> = [] if (!rawMessages) return []
return rawMessages.flatMap((m) => {
const msgs = []
// 1. Mensajes existentes de la DB // 1. Mensaje del usuario
if (rawMessages) { msgs.push({ id: `${m.id}-user`, role: 'user', content: m.mensaje })
rawMessages.forEach((m) => {
// Mensaje del usuario
msgs.push({ id: `${m.id}-user`, role: 'user', content: m.mensaje })
// Respuesta de la IA (si existe) // 2. Respuesta de la IA
if (m.respuesta) { if (m.respuesta) {
const sugerencias = // Mapeamos TODAS las recomendaciones del array
m.propuesta?.recommendations?.map((rec: any, index: number) => ({ const sugerencias =
id: `${m.id}-sug-${index}`, m.propuesta?.recommendations?.map((rec: any, index: number) => ({
messageId: m.id, id: `${m.id}-sug-${index}`, // ID único por sugerencia
campoKey: rec.campo_afectado, messageId: m.id,
campoNombre: rec.campo_afectado.replace(/_/g, ' '), campoKey: rec.campo_afectado,
valorSugerido: rec.texto_mejora, campoNombre: rec.campo_afectado.replace(/_/g, ' '),
aceptada: rec.aplicada, valorSugerido: rec.texto_mejora,
})) || [] aceptada: rec.aplicada,
})) || []
msgs.push({ msgs.push({
id: `${m.id}-ai`, id: `${m.id}-ai`,
role: 'assistant', role: 'assistant',
content: m.respuesta, content: m.respuesta,
sugerencias: sugerencias, sugerencias: sugerencias, // Ahora es un plural (array)
}) })
} }
}) return msgs
} })
}, [rawMessages])
// 2. INYECCIÓN OPTIMISTA: Si estamos enviando, mostramos el texto actual del input como mensaje de usuario
if (isSending && input.trim()) {
msgs.push({
id: 'optimistic-user-msg',
role: 'user',
content: input,
})
}
return msgs
}, [rawMessages, isSending, input])
// Auto-selección inicial // Auto-selección inicial
useEffect(() => { useEffect(() => {
@@ -234,58 +150,6 @@ export function IAAsignaturaTab({
} }
}, [activeChats, loadingConv]) }, [activeChats, loadingConv])
const filteredFields = useMemo(() => {
if (!showSuggestions) return availableFields
// Extraemos lo que hay después del último ':' para filtrar
const lastColonIndex = input.lastIndexOf(':')
const query = input.slice(lastColonIndex + 1).toLowerCase()
return availableFields.filter(
(f) =>
f.label.toLowerCase().includes(query) ||
f.key.toLowerCase().includes(query),
)
}, [availableFields, input, showSuggestions])
// 2. Efecto para cerrar con ESC
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShowSuggestions(false)
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
// 3. Función para insertar el campo y limpiar el prompt
const handleSelectField = (field: SelectedField) => {
// 1. Agregamos al array de objetos (para tu lógica de API)
if (!selectedFields.find((f) => f.key === field.key)) {
setSelectedFields((prev) => [...prev, field])
}
// 2. Lógica de autocompletado en el texto
const lastColonIndex = input.lastIndexOf(':')
if (lastColonIndex !== -1) {
// Tomamos lo que había antes del ":" + el Nombre del Campo + un espacio
const nuevoTexto = input.slice(0, lastColonIndex) + `${field.label} `
setInput(nuevoTexto)
}
// 3. Cerramos el buscador y devolvemos el foco al textarea
setShowSuggestions(false)
// Opcional: Si tienes una ref del textarea, puedes hacer:
// textareaRef.current?.focus()
}
const handleSaveName = (id: string) => {
if (tempName.trim()) {
updateName({ id, nombre: tempName })
}
setEditingId(null)
}
const handleSend = async (promptOverride?: string) => { const handleSend = async (promptOverride?: string) => {
const text = promptOverride || input const text = promptOverride || input
if (!text.trim() && selectedFields.length === 0) return if (!text.trim() && selectedFields.length === 0) return
@@ -305,7 +169,7 @@ export function IAAsignaturaTab({
} }
setInput('') setInput('')
// setSelectedFields([]) setSelectedFields([])
// Invalidamos la lista de conversaciones para que el nuevo chat aparezca en el historial (panel izquierdo) // Invalidamos la lista de conversaciones para que el nuevo chat aparezca en el historial (panel izquierdo)
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -326,6 +190,18 @@ export function IAAsignaturaTab({
) )
} }
const availableFields = useMemo(() => {
if (!datosGenerales?.datos) return []
const estructuraProps =
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
return Object.keys(datosGenerales.datos).map((key) => ({
key,
label:
estructuraProps[key]?.title || key.replace(/_/g, ' ').toUpperCase(),
value: String(datosGenerales.datos[key] || ''),
}))
}, [datosGenerales])
const createNewChat = () => { const createNewChat = () => {
setActiveChatId(undefined) // Al ser undefined, el próximo mensaje creará la charla en el backend setActiveChatId(undefined) // Al ser undefined, el próximo mensaje creará la charla en el backend
setInput('') setInput('')
@@ -377,8 +253,11 @@ export function IAAsignaturaTab({
<Button <Button
onClick={() => { onClick={() => {
// 1. Limpiamos el ID
setActiveChatId(undefined) setActiveChatId(undefined)
// 2. Marcamos que ya hubo una "interacción inicial" para que el useEffect no actúe
hasInitialSelected.current = true hasInitialSelected.current = true
// 3. Limpiamos estados visuales
setIsCreatingNewChat(true) setIsCreatingNewChat(true)
setInput('') setInput('')
setSelectedFields([]) setSelectedFields([])
@@ -392,117 +271,47 @@ export function IAAsignaturaTab({
<MessageSquarePlus size={18} /> Nuevo Chat <MessageSquarePlus size={18} /> Nuevo Chat
</Button> </Button>
{/* PANEL IZQUIERDO - Cambios en ScrollArea y contenedor */}
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="flex flex-col gap-1 pr-3"> <div className="space-y-1 pr-3">
{' '}
{/* Eliminado space-y-1 para mejor control con gap */}
{(showArchived ? archivedChats : activeChats).map((chat: any) => ( {(showArchived ? archivedChats : activeChats).map((chat: any) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div <div
key={chat.id} key={chat.id}
onClick={() => {
setActiveChatId(chat.id)
setIsCreatingNewChat(false) // <--- Volvemos al modo normal
}}
className={cn( className={cn(
// Agregamos 'overflow-hidden' para que nada salga de este cuadro 'group relative flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all',
'group relative flex w-full min-w-0 items-center justify-between gap-2 overflow-hidden rounded-lg px-3 py-2 text-sm transition-all',
activeChatId === chat.id activeChatId === chat.id
? 'bg-teal-50 text-teal-900' ? 'bg-teal-50 font-medium text-teal-900'
: 'text-slate-600 hover:bg-slate-100', : 'text-slate-600 hover:bg-slate-100',
)} )}
onDoubleClick={() => {
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || 'Conversacion')
}}
> >
{editingId === chat.id ? ( <FileText size={14} className="shrink-0 opacity-50" />
<div className="flex min-w-0 flex-1 items-center"> <span className="flex-1 truncate">
<input {chat.titulo || 'Conversación'}
autoFocus </span>
className="w-full rounded border-none bg-white px-1 text-xs ring-1 ring-teal-400 outline-none" <button
value={tempName} onClick={(e) => {
onChange={(e) => setTempName(e.target.value)} e.stopPropagation()
onBlur={() => handleSaveName(chat.id)} updateStatus(
onKeyDown={(e) => { {
if (e.key === 'Enter') handleSaveName(chat.id) id: chat.id,
if (e.key === 'Escape') setEditingId(null) estado: showArchived ? 'ACTIVA' : 'ARCHIVADA',
}} },
/> {
</div> onSuccess: () =>
) : ( queryClient.invalidateQueries({
<> queryKey: ['conversation-by-subject'],
{/* CLAVE 2: 'truncate' y 'min-w-0' en el span para que ceda ante los botones */} }),
<span },
onClick={() => setActiveChatId(chat.id)} )
className="block max-w-[140px] min-w-0 flex-1 cursor-pointer truncate pr-1" }}
title={chat.nombre || chat.titulo} className="rounded p-1 opacity-0 group-hover:opacity-100 hover:bg-slate-200"
> >
{chat.nombre || chat.titulo || 'Conversación'} <Archive size={12} />
</span> </button>
{/* CLAVE 3: 'shrink-0' asegura que los botones NUNCA desaparezcan */}
<div
className={cn(
'z-10 flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100',
activeChatId === chat.id
? 'bg-teal-50'
: 'bg-slate-100',
)}
>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation()
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || '')
}}
className="rounded-md p-1 transition-colors hover:bg-slate-200 hover:text-teal-600"
>
<Edit2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-[10px]">
Editar nombre
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation()
const nuevoEstado =
chat.estado === 'ACTIVA'
? 'ARCHIVADA'
: 'ACTIVA'
updateStatus({
id: chat.id,
estado: nuevoEstado,
})
}}
className={cn(
'rounded-md p-1 transition-colors hover:bg-slate-200',
chat.estado === 'ACTIVA'
? 'hover:text-red-500'
: 'hover:text-teal-600',
)}
>
{chat.estado === 'ACTIVA' ? (
<Archive size={14} />
) : (
<History size={14} className="scale-x-[-1]" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-[10px]">
{chat.estado === 'ACTIVA'
? 'Archivar'
: 'Desarchivar'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</>
)}
</div> </div>
))} ))}
</div> </div>
@@ -511,22 +320,10 @@ export function IAAsignaturaTab({
{/* PANEL CENTRAL */} {/* PANEL CENTRAL */}
<div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm"> <div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm">
<div className="flex shrink-0 items-center justify-between border-b bg-white p-3"> <div className="shrink-0 border-b bg-white p-3">
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase"> <span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
Asistente IA Asistente IA
</span> </span>
<button
onClick={() => setOpenIA(true)}
className="flex items-center gap-2 rounded-md bg-slate-100 px-3 py-1.5 text-xs font-medium transition hover:bg-slate-200"
>
<FileText size={14} className="text-slate-500" />
Referencias
{totalReferencias > 0 && (
<span className="animate-in zoom-in flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
{totalReferencias}
</span>
)}
</button>
</div> </div>
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
@@ -573,7 +370,6 @@ export function IAAsignaturaTab({
> >
{/* Texto del mensaje principal */} {/* Texto del mensaje principal */}
<div <div
style={{ whiteSpace: 'pre-line' }}
className={cn( className={cn(
'text-sm leading-relaxed', 'text-sm leading-relaxed',
msg.role === 'assistant' && 'p-4', msg.role === 'assistant' && 'p-4',
@@ -590,52 +386,62 @@ export function IAAsignaturaTab({
<p className="mb-1 text-[10px] font-bold text-slate-400 uppercase"> <p className="mb-1 text-[10px] font-bold text-slate-400 uppercase">
Mejoras disponibles: Mejoras disponibles:
</p> </p>
{msg.sugerencias.map((sug: any) => ( {msg.sugerencias.map((sug: any) =>
<ImprovementCard sug.aceptada ? (
key={sug.id} /* --- ESTADO: YA APLICADO (Basado en tu última imagen) --- */
sug={sug} <div
asignaturaId={asignaturaId} key={sug.id}
onApplied={(campoFinalizado) => { className="group flex flex-col rounded-xl border border-slate-100 bg-white p-3 shadow-sm transition-all"
// Filtramos el array para conservar todos MENOS el que se aplicó >
console.log(campoFinalizado) <div className="mb-3 flex items-center justify-between gap-4">
console.log('campos:', selectedFields) <span className="text-sm font-bold text-slate-800">
{sug.campoNombre}
</span>
setSelectedFields((prev) => {/* Badge de Aplicado */}
prev.filter((fieldObj) => { <div className="flex items-center gap-1.5 rounded-full border border-slate-100 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-400">
// Accedemos a .key porque fieldObj es { key: "...", label: "..." } <Check size={14} />
return fieldObj.key !== campoFinalizado Aplicado
}), </div>
) </div>
}}
/> <div className="rounded-lg border border-teal-100 bg-teal-50/30 p-3 text-xs leading-relaxed text-slate-500">
))} "{sug.valorSugerido}"
</div>
</div>
) : (
/* --- ESTADO: PENDIENTE POR APLICAR --- */
<div
key={sug.id}
className="group flex flex-col rounded-xl border border-teal-100 bg-white p-3 shadow-sm transition-all hover:border-teal-200"
>
<div className="mb-3 flex items-center justify-between gap-4">
<span className="max-w-[150px] truncate rounded-lg border border-teal-100 bg-teal-50/50 px-2.5 py-1 text-[10px] font-bold tracking-wider text-teal-700 uppercase">
{sug.campoNombre}
</span>
<Button
size="sm"
className="h-8 w-auto bg-teal-600 px-4 text-xs font-semibold shadow-sm transition-colors hover:bg-teal-700"
onClick={() => onAcceptSuggestion(sug)}
>
<Check size={14} className="mr-1.5" />
Aplicar mejora
</Button>
</div>
<div className="line-clamp-4 rounded-lg border border-dashed border-slate-200 bg-slate-50/50 p-3 text-xs leading-relaxed text-slate-600 italic">
"{sug.valorSugerido}"
</div>
</div>
),
)}
</div> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
))} ))}
{isAiThinking && (
<div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
<Avatar className="h-9 w-9 shrink-0 border bg-teal-600 text-white shadow-sm">
<AvatarFallback>
<Sparkles size={16} className="animate-pulse" />
</AvatarFallback>
</Avatar>
<div className="flex flex-col items-start gap-2">
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]"></span>
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]"></span>
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"></span>
</div>
</div>
<span className="text-[10px] font-medium text-slate-400 italic">
La IA está analizando tu solicitud...
</span>
</div>
</div>
)}
{/* Espacio extra al final para que el scroll no tape el último mensaje */} {/* Espacio extra al final para que el scroll no tape el último mensaje */}
<div className="h-4" /> <div className="h-4" />
</div> </div>
@@ -646,53 +452,39 @@ export function IAAsignaturaTab({
<div className="shrink-0 border-t bg-white p-4"> <div className="shrink-0 border-t bg-white p-4">
<div className="relative mx-auto max-w-4xl"> <div className="relative mx-auto max-w-4xl">
{showSuggestions && ( {showSuggestions && (
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full left-0 z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl"> <div className="animate-in slide-in-from-bottom-2 absolute bottom-full z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl">
<div className="flex justify-between border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase"> <div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<span>Filtrando campos...</span> Campos de Asignatura
<span className="rounded bg-slate-200 px-1 text-[9px] text-slate-400">
ESC para cerrar
</span>
</div> </div>
<div className="max-h-60 overflow-y-auto p-1"> <div className="max-h-64 overflow-y-auto p-1">
{filteredFields.length > 0 ? ( {availableFields.map((field) => (
filteredFields.map((field) => ( <button
<button key={field.key}
key={field.key} onClick={() => toggleField(field)}
onClick={() => handleSelectField(field)} className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors hover:bg-teal-50"
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50" >
> <span className="text-slate-700">{field.label}</span>
<div className="flex flex-col"> {selectedFields.find((f) => f.key === field.key) && (
<span className="font-medium text-slate-700"> <Check size={14} className="text-teal-600" />
{field.label} )}
</span> </button>
</div> ))}
{selectedFields.find((f) => f.key === field.key) && (
<Check size={14} className="text-teal-600" />
)}
</button>
))
) : (
<div className="p-4 text-center text-xs text-slate-400 italic">
No se encontraron coincidencias
</div>
)}
</div> </div>
</div> </div>
)} )}
<div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500"> <div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500">
{selectedFields.length > 0 && ( {selectedFields.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-2 pt-1"> <div className="flex flex-wrap gap-2 px-2 pt-1">
{selectedFields.map((field) => ( {selectedFields.map((field) => (
<div <div
key={field.key} key={field.key}
className="animate-in zoom-in-95 flex items-center gap-1 rounded-md border border-teal-200 bg-teal-50 px-2 py-0.5 text-[11px] font-bold text-teal-700 shadow-sm" className="animate-in zoom-in-95 flex items-center gap-1 rounded-md border border-teal-200 bg-teal-100 px-2 py-0.5 text-[11px] font-semibold text-teal-800"
> >
<Target size={10} />
{field.label} {field.label}
<button <button
onClick={() => toggleField(field)} onClick={() => toggleField(field)}
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-teal-200/50" className="ml-1 rounded-full p-0.5 hover:bg-teal-200"
> >
<X size={10} /> <X size={10} />
</button> </button>
@@ -755,41 +547,6 @@ export function IAAsignaturaTab({
))} ))}
</div> </div>
</div> </div>
{/* --- DRAWER DE REFERENCIAS --- */}
<Drawer open={openIA} onOpenChange={setOpenIA}>
<DrawerContent className="fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border bg-white shadow-2xl">
<div className="flex items-center justify-between border-b bg-slate-50/50 px-4 py-3">
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
Referencias para la IA
</h2>
<button
onClick={() => setOpenIA(false)}
className="text-slate-400 hover:text-slate-600"
>
<X size={18} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<ReferenciasParaIA
selectedArchivoIds={selectedArchivoIds}
selectedRepositorioIds={selectedRepositorioIds}
uploadedFiles={uploadedFiles}
onToggleArchivo={(id, checked) => {
setSelectedArchivoIds((prev) =>
checked ? [...prev, id] : prev.filter((a) => a !== id),
)
}}
onToggleRepositorio={(id, checked) => {
setSelectedRepositorioIds((prev) =>
checked ? [...prev, id] : prev.filter((r) => r !== id),
)
}}
onFilesChange={(files) => setUploadedFiles(files)}
/>
</div>
</DrawerContent>
</Drawer>
</div> </div>
) )
} }

View File

@@ -1,208 +0,0 @@
import { Check, Loader2, BookOpen, Clock, ListChecks } from 'lucide-react'
import { useState } from 'react'
import type { IASugerencia } from '@/types/asignatura'
import { Button } from '@/components/ui/button'
import {
useUpdateAsignatura,
useSubject,
useUpdateSubjectRecommendation,
} from '@/data'
import { cn } from '@/lib/utils'
interface ImprovementCardProps {
sug: IASugerencia
asignaturaId: string
onApplied: (campoKey: string) => void
}
export function ImprovementCard({
sug,
asignaturaId,
onApplied,
}: ImprovementCardProps) {
const { data: asignatura } = useSubject(asignaturaId)
const updateAsignatura = useUpdateAsignatura()
const updateRecommendation = useUpdateSubjectRecommendation()
const [isApplying, setIsApplying] = useState(false)
const handleApply = async () => {
if (!asignatura) return
setIsApplying(true)
try {
// 1. Identificar a qué columna debe ir el guardado
let patchData = {}
if (sug.campoKey === 'contenido_tematico') {
// Se guarda directamente en la columna contenido_tematico
patchData = { contenido_tematico: sug.valorSugerido }
} else if (sug.campoKey === 'criterios_de_evaluacion') {
// Se guarda directamente en la columna criterios_de_evaluacion
patchData = { criterios_de_evaluacion: sug.valorSugerido }
} else {
// Otros campos (ciclo, fines, etc.) se siguen guardando en el JSON de la columna 'datos'
patchData = {
datos: {
...asignatura.datos,
[sug.campoKey]: sug.valorSugerido,
},
}
}
// 2. Ejecutar la actualización con la estructura correcta
await updateAsignatura.mutateAsync({
asignaturaId: asignaturaId as any,
patch: patchData as any,
})
// 3. Marcar la recomendación como aplicada
await updateRecommendation.mutateAsync({
mensajeId: sug.messageId,
campoAfectado: sug.campoKey,
})
console.log(sug.campoKey)
onApplied(sug.campoKey)
} catch (error) {
console.error('Error al aplicar mejora:', error)
} finally {
setIsApplying(false)
}
}
// --- FUNCIÓN PARA RENDERIZAR EL CONTENIDO DE FORMA SEGURA ---
const renderContenido = (valor: any) => {
// Si no es un array, es texto simple
if (!Array.isArray(valor)) {
return <p className="italic">"{String(valor)}"</p>
}
// --- CASO 1: CONTENIDO TEMÁTICO (Detectamos si el primer objeto tiene 'unidad') ---
if (valor[0]?.hasOwnProperty('unidad')) {
return (
<div className="space-y-3">
{valor.map((u: any, idx: number) => (
<div
key={idx}
className="rounded-md border border-teal-100 bg-white p-2 shadow-sm"
>
<div className="mb-1 flex items-center gap-2 border-b border-slate-50 pb-1 text-[11px] font-bold text-teal-800">
<BookOpen size={12} /> Unidad {u.unidad}: {u.titulo}
</div>
<ul className="space-y-1">
{u.temas?.map((t: any, tidx: number) => (
<li
key={tidx}
className="flex items-start justify-between gap-2 text-[10px] text-slate-600"
>
<span className="leading-tight"> {t.nombre}</span>
<span className="flex shrink-0 items-center gap-0.5 font-mono text-slate-400">
<Clock size={10} /> {t.horasEstimadas}h
</span>
</li>
))}
</ul>
</div>
))}
</div>
)
}
// --- CASO 2: CRITERIOS DE EVALUACIÓN (Detectamos si tiene 'criterio') ---
if (valor[0]?.hasOwnProperty('criterio')) {
return (
<div className="space-y-2">
<div className="mb-1 flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase">
<ListChecks size={12} /> Desglose de evaluación
</div>
{valor.map((c: any, idx: number) => (
<div
key={idx}
className="flex items-center justify-between gap-3 rounded-md border border-slate-100 bg-white p-2 shadow-sm"
>
<span className="text-[11px] leading-tight text-slate-700">
{c.criterio}
</span>
<div className="flex shrink-0 items-center gap-1 rounded-full border border-orange-100 bg-orange-50 px-2 py-0.5 text-[10px] font-bold text-orange-600">
{c.porcentaje}%
</div>
</div>
))}
{/* Opcional: Suma total para verificar que de 100% */}
<div className="pt-1 text-right text-[9px] font-medium text-slate-400">
Total:{' '}
{valor.reduce(
(acc: number, curr: any) => acc + (curr.porcentaje || 0),
0,
)}
%
</div>
</div>
)
}
// Caso por defecto (Array genérico)
return (
<pre className="text-[10px]">
{/* JSON.stringify(valor, null, 2)*/ 'hola'}
</pre>
)
}
// --- ESTADO APLICADO ---
if (sug.aceptada) {
return (
<div className="flex flex-col rounded-xl border border-slate-100 bg-white p-3 opacity-80 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-4">
<span className="text-sm font-bold text-slate-800">
{sug.campoNombre}
</span>
<div className="flex items-center gap-1.5 rounded-full border border-slate-100 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-400">
<Check size={14} />
Aplicado
</div>
</div>
<div className="rounded-lg border border-teal-100 bg-teal-50/30 p-3 text-xs leading-relaxed text-slate-500">
{renderContenido(sug.valorSugerido)}
</div>
</div>
)
}
// --- ESTADO PENDIENTE ---
return (
<div className="group flex flex-col rounded-xl border border-teal-100 bg-white p-3 shadow-sm transition-all hover:border-teal-200">
<div className="mb-3 flex items-center justify-between gap-4">
<span className="max-w-[150px] truncate rounded-lg border border-teal-100 bg-teal-50/50 px-2.5 py-1 text-[10px] font-bold tracking-wider text-teal-700 uppercase">
{sug.campoNombre}
</span>
<Button
size="sm"
disabled={isApplying || !asignatura}
className="h-8 w-auto bg-teal-600 px-4 text-xs font-semibold shadow-sm hover:bg-teal-700"
onClick={handleApply}
>
{isApplying ? (
<Loader2 size={14} className="mr-1.5 animate-spin" />
) : (
<Check size={14} className="mr-1.5" />
)}
{isApplying ? 'Aplicando...' : 'Aplicar mejora'}
</Button>
</div>
<div
className={cn(
'rounded-lg border border-dashed border-slate-200 bg-slate-50/50 p-3 text-xs leading-relaxed text-slate-600',
!Array.isArray(sug.valorSugerido) && 'line-clamp-4 italic',
)}
>
{renderContenido(sug.valorSugerido)}
</div>
</div>
)
}

View File

@@ -1,43 +0,0 @@
import * as React from "react"
import { CircleIcon } from "lucide-react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -5,24 +5,16 @@ export function WizardResponsiveHeader({
wizard, wizard,
methods, methods,
titleOverrides, titleOverrides,
hiddenStepIds,
}: { }: {
wizard: any wizard: any
methods: any methods: any
titleOverrides?: Record<string, string> titleOverrides?: Record<string, string>
hiddenStepIds?: Array<string>
}) { }) {
const hidden = new Set(hiddenStepIds ?? []) const idx = wizard.utils.getIndex(methods.current.id)
const visibleSteps = (wizard.steps as Array<any>).filter( const totalSteps = wizard.steps.length
(s) => s && !hidden.has(s.id), const currentIndex = idx + 1
) const hasNextStep = idx < totalSteps - 1
const nextStep = wizard.steps[currentIndex]
const idx = visibleSteps.findIndex((s) => s.id === methods.current.id)
const safeIdx = idx >= 0 ? idx : 0
const totalSteps = visibleSteps.length
const currentIndex = Math.min(safeIdx + 1, totalSteps)
const hasNextStep = safeIdx < totalSteps - 1
const nextStep = visibleSteps[safeIdx + 1]
const resolveTitle = (step: any) => titleOverrides?.[step?.id] ?? step?.title const resolveTitle = (step: any) => titleOverrides?.[step?.id] ?? step?.title
@@ -53,11 +45,10 @@ export function WizardResponsiveHeader({
<div className="hidden sm:block"> <div className="hidden sm:block">
<wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2"> <wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
{visibleSteps.map((step: any, visibleIdx: number) => ( {wizard.steps.map((step: any) => (
<wizard.Stepper.Step <wizard.Stepper.Step
key={step.id} key={step.id}
of={step.id} of={step.id}
icon={visibleIdx + 1}
className="whitespace-nowrap" className="whitespace-nowrap"
> >
<wizard.Stepper.Title> <wizard.Stepper.Title>

View File

@@ -359,19 +359,3 @@ export async function update_subject_conversation_status(
if (error) throw error if (error) throw error
return data return data
} }
export async function update_subject_conversation_name(
conversacionId: string,
nuevoNombre: string,
) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('conversaciones_asignatura')
.update({ nombre: nuevoNombre }) // Asumiendo que la columna es 'titulo' según tu código previo, o cambia a 'nombre'
.eq('id', conversacionId)
.select()
.single()
if (error) throw error
return data
}

View File

@@ -207,7 +207,7 @@ export async function plan_asignaturas_list(
const { data, error } = await supabase const { data, error } = await supabase
.from('asignaturas') .from('asignaturas')
.select( .select(
'id,plan_estudio_id,horas_academicas,horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,prerrequisito_asignatura_id', 'id,plan_estudio_id,horas_academicas,horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en',
) )
.eq('plan_estudio_id', planId) .eq('plan_estudio_id', planId)
.order('numero_ciclo', { ascending: true, nullsFirst: false }) .order('numero_ciclo', { ascending: true, nullsFirst: false })

View File

@@ -191,7 +191,7 @@ 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,criterios_de_evaluacion,prerrequisito_asignatura_id, 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))
@@ -232,7 +232,7 @@ export async function subjects_bibliografia_list(
const { data, error } = await supabase const { data, error } = await supabase
.from('bibliografia_asignatura') .from('bibliografia_asignatura')
.select( .select(
'id,asignatura_id,tipo,cita,referencia_biblioteca,referencia_en_linea,creado_por,creado_en,actualizado_en', 'id,asignatura_id,tipo,cita,tipo_fuente,biblioteca_item_id,creado_por,creado_en,actualizado_en',
) )
.eq('asignatura_id', subjectId) .eq('asignatura_id', subjectId)
.order('tipo', { ascending: true }) .order('tipo', { ascending: true })

View File

@@ -19,7 +19,6 @@ import {
getConversationBySubject, getConversationBySubject,
ai_subject_chat_v2, ai_subject_chat_v2,
create_subject_conversation, create_subject_conversation,
update_subject_conversation_name,
} from '../api/ai.api' } from '../api/ai.api'
import { supabaseBrowser } from '../supabase/client' import { supabaseBrowser } from '../supabase/client'
@@ -321,17 +320,3 @@ export function useUpdateSubjectConversationStatus() {
}, },
}) })
} }
export function useUpdateSubjectConversationName() {
const qc = useQueryClient()
return useMutation({
mutationFn: (payload: { id: string; nombre: string }) =>
update_subject_conversation_name(payload.id, payload.nombre),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['conversation-by-subject'] })
// También invalidamos los mensajes si el título se muestra en la cabecera
qc.invalidateQueries({ queryKey: ['subject-messages'] })
},
})
}

View File

@@ -26,12 +26,6 @@ import type {
import type { TablesInsert } from '@/types/supabase' import type { TablesInsert } from '@/types/supabase'
import { defineStepper } from '@/components/stepper' import { defineStepper } from '@/components/stepper'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@@ -44,7 +38,6 @@ import {
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -52,7 +45,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { import {
Tooltip, Tooltip,
@@ -65,221 +57,6 @@ import { buscar_bibliografia } from '@/data'
import { useCreateBibliografia } from '@/data/hooks/useSubjects' import { useCreateBibliografia } from '@/data/hooks/useSubjects'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type BibliotecaOption = {
id: string
title: string
authors: Array<string>
publisher?: string
year?: number
isbn?: string
shelf?: string
badgeText?: string
}
type BibliotecaOptionTemplate = Omit<BibliotecaOption, 'id'>
// Hardcodeado: 3 conjuntos de coincidencias (0, 2 y 5).
const BIBLIOTECA_MATCH_SETS: Array<Array<BibliotecaOptionTemplate>> = [
[],
[
{
title: 'Coincidencia en biblioteca (Ejemplar 1)',
authors: ['Autor A', 'Autor B'],
publisher: 'Editorial X',
year: 2020,
isbn: '9780000000001',
shelf: 'QA76.9 .A1 2020',
badgeText: 'Coincidencia ISBN',
},
{
title: 'Coincidencia en biblioteca (Ejemplar 2)',
authors: ['Autor C'],
publisher: 'Editorial Y',
year: 2016,
shelf: 'QA76.9 .A2 2016',
},
],
[
{
title: 'Coincidencia en biblioteca (Ejemplar 1)',
authors: ['Autor A', 'Autor B'],
publisher: 'Editorial X',
year: 2020,
isbn: '9780000000001',
shelf: 'QA76.9 .A1 2020',
badgeText: 'Coincidencia ISBN',
},
{
title: 'Coincidencia en biblioteca (Ejemplar 2)',
authors: ['Autor C'],
publisher: 'Editorial Y',
year: 2016,
shelf: 'QA76.9 .A2 2016',
},
{
title: 'Coincidencia en biblioteca (Ejemplar 3)',
authors: ['Autor D', 'Autor E'],
publisher: 'Editorial Z',
year: 2014,
shelf: 'QA76.9 .A3 2014',
},
{
title: 'Coincidencia en biblioteca (Ejemplar 4)',
authors: ['Autor F'],
publisher: 'Editorial W',
year: 2011,
shelf: 'QA76.9 .A4 2011',
},
{
title: 'Coincidencia en biblioteca (Ejemplar 5)',
authors: ['Autor G'],
publisher: 'Editorial V',
year: 2009,
shelf: 'QA76.9 .A5 2009',
},
],
]
export function BookSelectionAccordion({
onlineSourceLabel,
online,
options,
value,
onValueChange,
}: {
onlineSourceLabel: string
online: {
id: string
title: string
authorsLine: string
year?: number
isbn?: string
}
options: Array<BibliotecaOption>
value: string | undefined
onValueChange: (value: string) => void
}) {
// Estado inicial indefinido para que nada esté seleccionado por defecto
const [selectedBook, setSelectedBook] = useState<string | undefined>(value)
useEffect(() => {
setSelectedBook(value)
}, [value])
const onlineValue = `online:${online.id}`
const optionBaseClass =
'relative flex items-start space-x-3 rounded-lg border p-4 transition-colors'
const optionClass = (isSelected: boolean) =>
cn(
optionBaseClass,
isSelected
? 'border-primary bg-primary/5'
: 'hover:border-primary/30 hover:bg-accent/50',
)
return (
<>
{/* Un solo RadioGroup controla ambos lados */}
<RadioGroup
value={selectedBook}
onValueChange={(v) => {
setSelectedBook(v)
onValueChange(v)
}}
className="flex flex-col gap-6 md:flex-row"
>
{/* --- LADO IZQUIERDO: Sugerencia Online --- */}
<div className="flex-1 space-y-4">
<h4 className="text-muted-foreground text-sm font-medium">
Sugerencia Original ({onlineSourceLabel})
</h4>
<div className={optionClass(selectedBook === onlineValue)}>
<RadioGroupItem
value={onlineValue}
id={onlineValue}
className="mt-1"
/>
<Label
htmlFor={onlineValue}
className="flex flex-1 cursor-pointer flex-col"
>
<span className="font-semibold">{online.title}</span>
<span className="text-muted-foreground text-sm">
{online.authorsLine}
{online.year ? ` (${online.year})` : ''}
</span>
{online.isbn ? (
<span className="text-muted-foreground mt-1 text-xs">
ISBN: {online.isbn}
</span>
) : null}
</Label>
</div>
</div>
{/* Separador vertical para escritorio, horizontal en móviles */}
<Separator orientation="vertical" className="hidden h-auto md:block" />
<Separator orientation="horizontal" className="md:hidden" />
{/* --- LADO DERECHO: Alternativas de Biblioteca --- */}
<div className="flex-1 space-y-4">
<h4 className="text-muted-foreground text-sm font-medium">
Disponibles en Biblioteca
</h4>
<div className="max-h-75 space-y-3 overflow-y-auto pr-2">
{options.length === 0 ? (
<div className="text-muted-foreground text-sm">
No se encontraron alternativas.
</div>
) : (
options.map((opt) => {
const optValue = `biblio:${opt.id}`
const authorsLine = opt.authors.join('; ')
const isSelected = selectedBook === optValue
return (
<div key={opt.id} className={optionClass(isSelected)}>
<RadioGroupItem
value={optValue}
id={optValue}
className="mt-1 cursor-pointer"
/>
<Label
htmlFor={optValue}
className="flex flex-1 cursor-pointer flex-col"
>
<div className="flex items-center gap-2">
<span className="font-semibold">{opt.title}</span>
{opt.badgeText ? (
<Badge className="bg-green-600 hover:bg-green-700">
{opt.badgeText}
</Badge>
) : null}
</div>
<span className="text-muted-foreground text-sm">
{authorsLine}
{opt.year ? ` (${opt.year})` : ''}
</span>
{opt.shelf ? (
<span className="bg-muted mt-2 w-fit rounded px-1 font-mono text-xs">
Estante: {opt.shelf}
</span>
) : null}
</Label>
</div>
)
})
)}
</div>
</div>
</RadioGroup>
</>
)
}
type MetodoBibliografia = 'MANUAL' | 'EN_LINEA' | null type MetodoBibliografia = 'MANUAL' | 'EN_LINEA' | null
export type FormatoCita = 'apa' | 'ieee' | 'vancouver' | 'chicago' export type FormatoCita = 'apa' | 'ieee' | 'vancouver' | 'chicago'
@@ -352,9 +129,13 @@ type CSLItem = {
type BibliografiaAsignaturaInsert = TablesInsert<'bibliografia_asignatura'> type BibliografiaAsignaturaInsert = TablesInsert<'bibliografia_asignatura'>
type BibliografiaTipo = BibliografiaAsignaturaInsert['tipo'] type BibliografiaTipo = BibliografiaAsignaturaInsert['tipo']
type BibliografiaTipoFuente = NonNullable<
BibliografiaAsignaturaInsert['tipo_fuente']
>
type BibliografiaRef = { type BibliografiaRef = {
id: string id: string
source: BibliografiaTipoFuente
raw?: GoogleBooksVolume | OpenLibraryDoc raw?: GoogleBooksVolume | OpenLibraryDoc
title: string title: string
subtitle?: string subtitle?: string
@@ -379,10 +160,6 @@ type WizardState = {
selected: boolean selected: boolean
endpoint: EndpointResult['endpoint'] endpoint: EndpointResult['endpoint']
item: GoogleBooksVolume | OpenLibraryDoc item: GoogleBooksVolume | OpenLibraryDoc
biblioteca?: {
options?: Array<BibliotecaOption>
choiceId?: string
}
}> }>
isLoading: boolean isLoading: boolean
errorMessage: string | null errorMessage: string | null
@@ -419,96 +196,10 @@ const Wizard = defineStepper(
title: 'Datos básicos', title: 'Datos básicos',
description: 'Seleccionar o capturar', description: 'Seleccionar o capturar',
}, },
{
id: 'biblioteca',
title: 'Biblioteca',
description: 'Comparar con alternativas de la biblioteca',
},
{ id: 'paso3', title: 'Detalles', description: 'Formato y citas' }, { id: 'paso3', title: 'Detalles', description: 'Formato y citas' },
{ id: 'resumen', title: 'Resumen', description: 'Confirmar' }, { id: 'resumen', title: 'Resumen', description: 'Confirmar' },
) )
type BibliotecaStepHandle = {
validateBeforeNext: () => boolean
}
function bibliotecaOptionToRef(opt: BibliotecaOption): BibliografiaRef {
return {
id: opt.id,
raw: undefined,
title: opt.title,
subtitle: undefined,
authors: opt.authors,
publisher: opt.publisher,
year: opt.year,
isbn: opt.isbn,
tipo: 'BASICA',
}
}
function getOnlineSuggestionTitle(s: IASugerencia): string {
if (s.endpoint === 'google') {
const info = (s.item as GoogleBooksVolume).volumeInfo ?? {}
return (info.title ?? '').trim() || 'Sin título'
}
const doc = s.item as OpenLibraryDoc
return (
(typeof doc['title'] === 'string' ? doc['title'] : '').trim() ||
'Sin título'
)
}
function getOnlineSuggestionAuthors(s: IASugerencia): Array<string> {
if (s.endpoint === 'google') {
const info = (s.item as GoogleBooksVolume).volumeInfo ?? {}
return Array.isArray(info.authors) ? info.authors : []
}
const doc = s.item as OpenLibraryDoc
return Array.isArray(doc['author_name'])
? (doc['author_name'] as Array<unknown>).filter(
(a): a is string => typeof a === 'string',
)
: []
}
function getOnlineSuggestionIsbn(s: IASugerencia): string | undefined {
if (s.endpoint === 'google') {
const info = (s.item as GoogleBooksVolume).volumeInfo
const isbn = info?.industryIdentifiers?.find(
(x) => x.identifier,
)?.identifier
return typeof isbn === 'string' && isbn.trim() ? isbn.trim() : undefined
}
const doc = s.item as OpenLibraryDoc
const isbn = Array.isArray(doc['isbn'])
? (doc['isbn'] as Array<unknown>).find(
(x): x is string => typeof x === 'string',
)
: undefined
return typeof isbn === 'string' && isbn.trim() ? isbn.trim() : undefined
}
function getOnlineSuggestionYear(s: IASugerencia): number | undefined {
return s.endpoint === 'google'
? tryParseYear((s.item as GoogleBooksVolume).volumeInfo?.publishedDate)
: tryParseYearFromOpenLibrary(s.item as OpenLibraryDoc)
}
function iaSugerenciaToChosenRef(s: IASugerencia): BibliografiaRef {
const choiceId = s.biblioteca?.choiceId
const options = s.biblioteca?.options
if (choiceId && choiceId !== 'online' && Array.isArray(options)) {
const chosen = options.find((o) => o.id === choiceId)
if (chosen) return bibliotecaOptionToRef(chosen)
}
return endpointResultToRef(iaSugerenciaToEndpointResult(s))
}
function parsearAutor(nombreCompleto: string): CSLAuthor { function parsearAutor(nombreCompleto: string): CSLAuthor {
if (nombreCompleto.includes(',')) { if (nombreCompleto.includes(',')) {
return { return {
@@ -608,6 +299,7 @@ function endpointResultToRef(result: EndpointResult): BibliografiaRef {
return { return {
id: getEndpointResultId(result), id: getEndpointResultId(result),
source: 'BIBLIOTECA',
raw: volume, raw: volume,
title, title,
subtitle, subtitle,
@@ -646,6 +338,7 @@ function endpointResultToRef(result: EndpointResult): BibliografiaRef {
return { return {
id: getEndpointResultId(result), id: getEndpointResultId(result),
source: 'BIBLIOTECA',
raw: doc, raw: doc,
title, title,
subtitle, subtitle,
@@ -759,7 +452,6 @@ export function NuevaBibliografiaModalContainer({
const createBibliografia = useCreateBibliografia() const createBibliografia = useCreateBibliografia()
const formatoStepRef = useRef<FormatoYCitasStepHandle | null>(null) const formatoStepRef = useRef<FormatoYCitasStepHandle | null>(null)
const bibliotecaStepRef = useRef<BibliotecaStepHandle | null>(null)
const [wizard, setWizard] = useState<WizardState>({ const [wizard, setWizard] = useState<WizardState>({
metodo: null, metodo: null,
@@ -797,9 +489,9 @@ export function NuevaBibliografiaModalContainer({
const styleCacheRef = useRef(new Map<string, string>()) const styleCacheRef = useRef(new Map<string, string>())
const localeCacheRef = useRef(new Map<string, string>()) const localeCacheRef = useRef(new Map<string, string>())
const titleOverrides: Record<string, string> = const titleOverrides =
wizard.metodo === 'EN_LINEA' wizard.metodo === 'EN_LINEA'
? { paso2: 'Sugerencias', biblioteca: 'Biblioteca', paso3: 'Estructura' } ? { paso2: 'Sugerencias', paso3: 'Estructura' }
: { paso2: 'Datos básicos', paso3: 'Detalles' } : { paso2: 'Datos básicos', paso3: 'Detalles' }
const handleClose = () => { const handleClose = () => {
@@ -813,7 +505,7 @@ export function NuevaBibliografiaModalContainer({
wizard.metodo === 'EN_LINEA' wizard.metodo === 'EN_LINEA'
? wizard.ia.sugerencias ? wizard.ia.sugerencias
.filter((s) => s.selected) .filter((s) => s.selected)
.map((s) => iaSugerenciaToChosenRef(s)) .map((s) => endpointResultToRef(iaSugerenciaToEndpointResult(s)))
: wizard.manual.refs : wizard.manual.refs
// Mantener `wizard.refs` como snapshot para pasos 3/4. // Mantener `wizard.refs` como snapshot para pasos 3/4.
@@ -1083,8 +775,8 @@ export function NuevaBibliografiaModalContainer({
asignatura_id: asignaturaId, asignatura_id: asignaturaId,
tipo: r.tipo, tipo: r.tipo,
cita: map[r.id] ?? '', cita: map[r.id] ?? '',
// tipo_fuente: r.source, tipo_fuente: r.source,
// biblioteca_item_id: null, biblioteca_item_id: null,
}), }),
), ),
) )
@@ -1103,17 +795,14 @@ export function NuevaBibliografiaModalContainer({
} }
} }
const WizardDef = Wizard as any
return ( return (
<WizardDef.Stepper.Provider <Wizard.Stepper.Provider
initialStep={WizardDef.utils.getFirst().id} initialStep={Wizard.utils.getFirst().id}
className="flex h-full flex-col" className="flex h-full flex-col"
> >
{({ methods }: any) => { {({ methods }) => {
const idx = WizardDef.utils.getIndex(methods.current.id) const idx = Wizard.utils.getIndex(methods.current.id)
const isLast = idx >= WizardDef.steps.length - 1 const isLast = idx >= Wizard.steps.length - 1
const currentId = methods.current.id as string
return ( return (
<WizardLayout <WizardLayout
@@ -1121,59 +810,17 @@ export function NuevaBibliografiaModalContainer({
onClose={handleClose} onClose={handleClose}
headerSlot={ headerSlot={
<WizardResponsiveHeader <WizardResponsiveHeader
wizard={WizardDef} wizard={Wizard}
methods={methods} methods={methods}
titleOverrides={titleOverrides} titleOverrides={titleOverrides}
hiddenStepIds={
wizard.metodo === 'MANUAL' ? ['biblioteca'] : undefined
}
/> />
} }
footerSlot={ footerSlot={
<WizardDef.Stepper.Controls> <Wizard.Stepper.Controls>
<div className="flex grow items-center justify-between"> <div className="flex grow items-center justify-between">
<Button <Button
variant="secondary" variant="secondary"
onClick={() => { onClick={() => methods.prev()}
const goToStep = (targetId: string) => {
if (typeof methods?.goTo === 'function') {
methods.goTo(targetId)
return
}
if (typeof methods?.setStep === 'function') {
methods.setStep(targetId)
return
}
if (typeof methods?.navigation?.goTo === 'function') {
methods.navigation.goTo(targetId)
return
}
const targetIdx = WizardDef.utils.getIndex(targetId)
const stepOnce = () => {
const currentIdx = WizardDef.utils.getIndex(
methods.current.id,
)
if (currentIdx === targetIdx) return
if (currentIdx < targetIdx) methods.next()
else methods.prev()
queueMicrotask(stepOnce)
}
stepOnce()
}
if (
wizard.metodo === 'MANUAL' &&
methods.current.id === 'paso3'
) {
goToStep('paso2')
return
}
methods.prev()
}}
disabled={ disabled={
idx === 0 || wizard.ia.isLoading || wizard.isSaving idx === 0 || wizard.ia.isLoading || wizard.isSaving
} }
@@ -1189,79 +836,26 @@ export function NuevaBibliografiaModalContainer({
) : ( ) : (
<Button <Button
onClick={() => { onClick={() => {
const goToStep = (targetId: string) => { if (idx === 2) {
if (typeof methods?.goTo === 'function') {
methods.goTo(targetId)
return
}
if (typeof methods?.setStep === 'function') {
methods.setStep(targetId)
return
}
if (typeof methods?.navigation?.goTo === 'function') {
methods.navigation.goTo(targetId)
return
}
const targetIdx = WizardDef.utils.getIndex(targetId)
const stepOnce = () => {
const currentIdx = WizardDef.utils.getIndex(
methods.current.id,
)
if (currentIdx === targetIdx) return
if (currentIdx < targetIdx) methods.next()
else methods.prev()
queueMicrotask(stepOnce)
}
stepOnce()
}
if (
wizard.metodo === 'MANUAL' &&
currentId === 'paso2'
) {
goToStep('paso3')
return
}
if (currentId === 'biblioteca') {
const ok =
bibliotecaStepRef.current?.validateBeforeNext() ??
true
if (!ok) return
}
if (currentId === 'paso3') {
const ok = const ok =
formatoStepRef.current?.validateBeforeNext() ?? true formatoStepRef.current?.validateBeforeNext() ?? true
if (!ok) return if (!ok) return
if (wizard.metodo === 'EN_LINEA' && wizard.formato) {
void generateCitasForFormato(
wizard.formato,
wizard.refs,
{
force: true,
},
)
}
} }
methods.next() methods.next()
}} }}
disabled={ disabled={
wizard.ia.isLoading || wizard.ia.isLoading ||
wizard.isSaving || wizard.isSaving ||
(currentId === 'metodo' && !canContinueDesdeMetodo) || (idx === 0 && !canContinueDesdeMetodo) ||
(currentId === 'paso2' && !canContinueDesdePaso2) || (idx === 1 && !canContinueDesdePaso2) ||
(currentId === 'paso3' && !canContinueDesdePaso3) (idx === 2 && !canContinueDesdePaso3)
} }
> >
Siguiente Siguiente
</Button> </Button>
)} )}
</div> </div>
</WizardDef.Stepper.Controls> </Wizard.Stepper.Controls>
} }
> >
<div className="mx-auto max-w-3xl"> <div className="mx-auto max-w-3xl">
@@ -1275,8 +869,8 @@ export function NuevaBibliografiaModalContainer({
</Card> </Card>
) : null} ) : null}
{currentId === 'metodo' && ( {idx === 0 && (
<WizardDef.Stepper.Panel> <Wizard.Stepper.Panel>
<MetodoStep <MetodoStep
metodo={wizard.metodo} metodo={wizard.metodo}
onChange={(metodo) => onChange={(metodo) =>
@@ -1288,11 +882,11 @@ export function NuevaBibliografiaModalContainer({
})) }))
} }
/> />
</WizardDef.Stepper.Panel> </Wizard.Stepper.Panel>
)} )}
{currentId === 'paso2' && ( {idx === 1 && (
<WizardDef.Stepper.Panel> <Wizard.Stepper.Panel>
{wizard.metodo === 'EN_LINEA' ? ( {wizard.metodo === 'EN_LINEA' ? (
<SugerenciasStep <SugerenciasStep
q={wizard.ia.q} q={wizard.ia.q}
@@ -1353,33 +947,11 @@ export function NuevaBibliografiaModalContainer({
} }
/> />
)} )}
</WizardDef.Stepper.Panel> </Wizard.Stepper.Panel>
)} )}
{currentId === 'biblioteca' && wizard.metodo === 'EN_LINEA' && ( {idx === 2 && (
<WizardDef.Stepper.Panel> <Wizard.Stepper.Panel>
<BibliotecaStep
ref={bibliotecaStepRef}
sugerencias={wizard.ia.sugerencias.filter(
(s) => s.selected,
)}
onPatchSugerencia={(id, patch) =>
setWizard((w) => ({
...w,
ia: {
...w.ia,
sugerencias: w.ia.sugerencias.map((s) =>
s.id === id ? { ...s, ...patch } : s,
),
},
}))
}
/>
</WizardDef.Stepper.Panel>
)}
{currentId === 'paso3' && (
<WizardDef.Stepper.Panel>
<FormatoYCitasStep <FormatoYCitasStep
ref={formatoStepRef} ref={formatoStepRef}
refs={wizard.refs} refs={wizard.refs}
@@ -1419,11 +991,11 @@ export function NuevaBibliografiaModalContainer({
})) }))
} }
/> />
</WizardDef.Stepper.Panel> </Wizard.Stepper.Panel>
)} )}
{currentId === 'resumen' && ( {idx === 3 && (
<WizardDef.Stepper.Panel> <Wizard.Stepper.Panel>
<ResumenStep <ResumenStep
metodo={wizard.metodo} metodo={wizard.metodo}
formato={wizard.formato} formato={wizard.formato}
@@ -1432,13 +1004,13 @@ export function NuevaBibliografiaModalContainer({
wizard.formato ? wizard.citaEdits[wizard.formato] : {} wizard.formato ? wizard.citaEdits[wizard.formato] : {}
} }
/> />
</WizardDef.Stepper.Panel> </Wizard.Stepper.Panel>
)} )}
</div> </div>
</WizardLayout> </WizardLayout>
) )
}} }}
</WizardDef.Stepper.Provider> </Wizard.Stepper.Provider>
) )
} }
@@ -1782,189 +1354,6 @@ function SugerenciasStep({
) )
} }
type BibliotecaStepProps = {
sugerencias: Array<IASugerencia>
onPatchSugerencia: (id: string, patch: Partial<IASugerencia>) => void
}
const BibliotecaStep = forwardRef<BibliotecaStepHandle, BibliotecaStepProps>(
function BibliotecaStep({ sugerencias, onPatchSugerencia }, ref) {
const [openIds, setOpenIds] = useState<Array<string>>([])
const anchorRefs = useRef<Record<string, HTMLDivElement | null>>({})
const initializedRef = useRef(new Set<string>())
const scrollToAccordion = (id: string) => {
const el = anchorRefs.current[id]
if (!el) return
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
useEffect(() => {
for (const s of sugerencias) {
const b = s.biblioteca
const hasOptions = Array.isArray(b?.options)
if (hasOptions) continue
if (initializedRef.current.has(s.id)) continue
initializedRef.current.add(s.id)
const setIdx = Math.floor(Math.random() * 3)
const templates = BIBLIOTECA_MATCH_SETS[setIdx] ?? []
const options: Array<BibliotecaOption> = templates.map((t, i) => ({
id: `biblio:${s.id}:${i + 1}`,
...t,
}))
onPatchSugerencia(s.id, {
biblioteca: {
options,
choiceId: options.length === 0 ? 'online' : undefined,
},
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sugerencias])
const validateBeforeNext = () => {
const unresolved = sugerencias.find((s) => {
const b = s.biblioteca
if (!b || !Array.isArray(b.options)) return true
if (b.options.length === 0) return false
return !b.choiceId
})
if (!unresolved) return true
setOpenIds((prev) =>
prev.includes(unresolved.id) ? prev : [...prev, unresolved.id],
)
requestAnimationFrame(() => scrollToAccordion(unresolved.id))
return false
}
useImperativeHandle(ref, () => ({ validateBeforeNext }))
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Comparar con alternativas de la biblioteca</CardTitle>
<CardDescription>
Conserva la sugerencia original o sustitúyela por una
coincidencia.
</CardDescription>
</CardHeader>
</Card>
<Accordion
type="multiple"
value={openIds}
onValueChange={setOpenIds}
className="w-full space-y-2"
>
{sugerencias.map((s) => {
const title = getOnlineSuggestionTitle(s)
const authors = getOnlineSuggestionAuthors(s)
const authorsLine = authors.join('; ') || '—'
const year = getOnlineSuggestionYear(s)
const isbn = getOnlineSuggestionIsbn(s)
const sourceLabel =
s.endpoint === 'google' ? 'Google Books' : 'Open Library'
const b = s.biblioteca
const options = b?.options ?? []
const badgeState: 'por_revisar' | 'sustituido' | 'mantenido' =
!b || !Array.isArray(b.options)
? 'por_revisar'
: options.length === 0
? 'mantenido'
: !b.choiceId
? 'por_revisar'
: b.choiceId === 'online'
? 'mantenido'
: 'sustituido'
const badge =
badgeState === 'por_revisar' ? (
<Badge className="bg-yellow-500 text-black hover:bg-yellow-500">
Por revisar
</Badge>
) : badgeState === 'sustituido' ? (
<Badge className="bg-green-600 text-white hover:bg-green-700">
Sustituido
</Badge>
) : (
<Badge className="bg-blue-600 text-white hover:bg-blue-700">
Mantenido
</Badge>
)
const radioValue =
b?.choiceId === 'online' || (options.length === 0 && !b?.choiceId)
? `online:${s.id}`
: typeof b?.choiceId === 'string'
? `biblio:${b.choiceId}`
: undefined
return (
<AccordionItem
key={s.id}
value={s.id}
className="border-border/60 bg-background/40 rounded-lg border border-b-0 px-3"
>
<div
ref={(el) => {
anchorRefs.current[s.id] = el
}}
/>
<AccordionTrigger className="hover:bg-accent/30 data-[state=open]:bg-accent/20 data-[state=open]:text-accent-foreground -mx-3 px-3">
<div className="flex w-full items-center justify-between gap-3">
<span className="min-w-0 text-wrap">{title}</span>
{badge}
</div>
</AccordionTrigger>
<AccordionContent className="text-muted-foreground mt-4">
<div className="mx-1 grid gap-3 pb-2">
<BookSelectionAccordion
onlineSourceLabel={sourceLabel}
online={{
id: s.id,
title,
authorsLine,
year,
isbn,
}}
options={options}
value={radioValue}
onValueChange={(v) => {
const nextChoiceId = v.startsWith('online:')
? 'online'
: v.startsWith('biblio:')
? v.slice('biblio:'.length)
: undefined
if (!nextChoiceId) return
onPatchSugerencia(s.id, {
biblioteca: {
options,
choiceId: nextChoiceId,
},
})
}}
/>
</div>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
</div>
)
},
)
function DatosBasicosManualStep({ function DatosBasicosManualStep({
draft, draft,
refs, refs,
@@ -2036,12 +1425,6 @@ function DatosBasicosManualStep({
publisher: e.target.value.slice(0, 300), publisher: e.target.value.slice(0, 300),
}) })
} }
onBlur={() => {
const trimmed = draft.publisher.trim()
if (trimmed !== draft.publisher) {
onChangeDraft({ ...draft, publisher: trimmed })
}
}}
maxLength={300} maxLength={300}
/> />
</div> </div>
@@ -2095,6 +1478,7 @@ function DatosBasicosManualStep({
const ref: BibliografiaRef = { const ref: BibliografiaRef = {
id: `manual-${randomUUID()}`, id: `manual-${randomUUID()}`,
source: 'MANUAL',
title, title,
authors: draft.authorsText authors: draft.authorsText
.split(/\r?\n/) .split(/\r?\n/)
@@ -2440,17 +1824,9 @@ const FormatoYCitasStep = forwardRef<
onChange={(e) => { onChange={(e) => {
const raw = e.currentTarget.value.slice(0, 300) const raw = e.currentTarget.value.slice(0, 300)
onChangeRef(r.id, { onChangeRef(r.id, {
publisher: raw.length > 0 ? raw : undefined, publisher: raw.trim() || undefined,
}) })
}} }}
onBlur={() => {
const trimmed = publisherText.trim()
if (trimmed !== publisherText) {
onChangeRef(r.id, {
publisher: trimmed || undefined,
})
}
}}
/> />
</div> </div>
</div> </div>

View File

@@ -13,9 +13,8 @@ import {
X, X,
MessageSquarePlus, MessageSquarePlus,
Archive, Archive,
Loader2,
Sparkles,
RotateCcw, RotateCcw,
Loader2,
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
@@ -23,17 +22,10 @@ import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/
import { ImprovementCard } from '@/components/planes/detalle/Ia/ImprovementCard' import { ImprovementCard } from '@/components/planes/detalle/Ia/ImprovementCard'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA' import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer' import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { import {
useAIPlanChat, useAIPlanChat,
useConversationByPlan, useConversationByPlan,
@@ -129,7 +121,6 @@ function RouteComponent() {
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null) const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const isInitialLoad = useRef(true)
const [showArchived, setShowArchived] = useState(false) const [showArchived, setShowArchived] = useState(false)
const [editingChatId, setEditingChatId] = useState<string | null>(null) const [editingChatId, setEditingChatId] = useState<string | null>(null)
const editableRef = useRef<HTMLSpanElement>(null) const editableRef = useRef<HTMLSpanElement>(null)
@@ -206,20 +197,20 @@ function RouteComponent() {
return messages return messages
}) })
}, [mensajesDelChat, activeChatId, availableFields]) }, [mensajesDelChat, activeChatId, availableFields])
const scrollToBottom = (behavior = 'smooth') => { const scrollToBottom = () => {
if (scrollRef.current) { if (scrollRef.current) {
// Buscamos el viewport interno del ScrollArea de Radix
const scrollContainer = scrollRef.current.querySelector( const scrollContainer = scrollRef.current.querySelector(
'[data-radix-scroll-area-viewport]', '[data-radix-scroll-area-viewport]',
) )
if (scrollContainer) { if (scrollContainer) {
scrollContainer.scrollTo({ scrollContainer.scrollTo({
top: scrollContainer.scrollHeight, top: scrollContainer.scrollHeight,
behavior: behavior, // 'instant' para carga inicial, 'smooth' para mensajes nuevos behavior: 'smooth',
}) })
} }
} }
} }
const { activeChats, archivedChats } = useMemo(() => { const { activeChats, archivedChats } = useMemo(() => {
const allChats = lastConversation || [] const allChats = lastConversation || []
return { return {
@@ -231,22 +222,22 @@ function RouteComponent() {
}, [lastConversation]) }, [lastConversation])
useEffect(() => { useEffect(() => {
if (chatMessages.length > 0) { console.log(mensajesDelChat)
if (isInitialLoad.current) {
// Si es el primer render con mensajes, vamos al final al instante scrollToBottom()
scrollToBottom('instant') }, [chatMessages, isLoading])
isInitialLoad.current = false
} else {
// Si ya estaba cargado y llegan nuevos, hacemos el smooth
scrollToBottom('smooth')
}
}
}, [chatMessages])
// 2. Resetear el flag cuando cambies de chat activo
useEffect(() => { useEffect(() => {
isInitialLoad.current = true // Verificamos cuáles campos de la lista "selectedFields" ya no están presentes en el texto del input
}, [activeChatId]) const camposActualizados = selectedFields.filter((field) =>
input.includes(field.label),
)
// Solo actualizamos el estado si hubo un cambio real (para evitar bucles infinitos)
if (camposActualizados.length !== selectedFields.length) {
setSelectedFields(camposActualizados)
}
}, [input, selectedFields])
useEffect(() => { useEffect(() => {
if (isLoadingConv || isSending) return if (isLoadingConv || isSending) return
@@ -306,7 +297,7 @@ function RouteComponent() {
}, },
]) ])
setInput('') setInput('')
// setSelectedFields([]) setSelectedFields([])
} }
const archiveChat = (e: React.MouseEvent, id: string) => { const archiveChat = (e: React.MouseEvent, id: string) => {
@@ -414,7 +405,7 @@ function RouteComponent() {
setIsSending(true) setIsSending(true)
setOptimisticMessage(finalContent) setOptimisticMessage(finalContent)
setInput('') setInput('')
// setSelectedFields([]) setSelectedFields([])
try { try {
const payload = { const payload = {
@@ -510,114 +501,82 @@ function RouteComponent() {
</div> </div>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="space-y-1 pr-2"> <div className="space-y-1">
{' '}
{/* Agregamos un pr-2 para que el scrollbar no tape botones */}
{!showArchived ? ( {!showArchived ? (
activeChats.map((chat) => ( activeChats.map((chat) => (
<div <div
key={chat.id} key={chat.id}
onClick={() => setActiveChatId(chat.id)} onClick={() => setActiveChatId(chat.id)}
className={`group relative flex w-full items-center overflow-hidden rounded-lg px-3 py-3 text-sm transition-colors ${ className={`group relative flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-sm transition-colors ${
activeChatId === chat.id activeChatId === chat.id
? 'bg-slate-100 font-medium text-slate-900' ? 'bg-slate-100 font-medium text-slate-900'
: 'text-slate-600 hover:bg-slate-50' : 'text-slate-600 hover:bg-slate-50'
}`} }`}
> >
{/* LADO IZQUIERDO: Icono + Texto */} <FileText size={16} className="shrink-0 opacity-40" />
<div
className="flex min-w-0 flex-1 items-center gap-3 transition-all duration-200" <span
style={{ ref={editingChatId === chat.id ? editableRef : null}
// Aplicamos la máscara solo cuando el mouse está encima para que se note el desvanecimiento contentEditable={editingChatId === chat.id}
// donde aparecen los botones suppressContentEditableWarning={true}
maskImage: className={`truncate pr-14 transition-all outline-none ${
'linear-gradient(to right, black 70%, transparent 95%)', editingChatId === chat.id
WebkitMaskImage: ? 'min-w-[50px] cursor-text rounded bg-white px-1 ring-1 ring-teal-500'
'linear-gradient(to right, black 70%, transparent 95%)', : 'cursor-pointer'
}`}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingChatId(chat.id)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
const newTitle = e.currentTarget.textContent || ''
updateTitleMutation(
{ id: chat.id, nombre: newTitle },
{
onSuccess: () => setEditingChatId(null),
},
)
}
if (e.key === 'Escape') {
setEditingChatId(null)
e.currentTarget.textContent = chat.nombre || ''
}
}}
onBlur={(e) => {
if (editingChatId === chat.id) {
const newTitle = e.currentTarget.textContent || ''
if (newTitle !== chat.nombre) {
updateTitleMutation({ id: chat.id, nombre: newTitle })
}
setEditingChatId(null)
}
}}
onClick={(e) => {
if (editingChatId === chat.id) e.stopPropagation()
}} }}
> >
{/* pr-12 reserva espacio para los botones absolutos */} {chat.nombre || `Chat ${chat.creado_en.split('T')[0]}`}
<FileText size={16} className="shrink-0 opacity-40" /> </span>
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild className="min-w-0 flex-1">
<div className="min-w-0 flex-1">
<span
ref={
editingChatId === chat.id ? editableRef : null
}
contentEditable={editingChatId === chat.id}
suppressContentEditableWarning={true}
className={`block truncate outline-none ${
editingChatId === chat.id
? 'max-h-20 min-w-[100px] cursor-text overflow-y-auto rounded bg-white px-1 break-all shadow-sm ring-1 ring-teal-500'
: 'cursor-pointer'
}`}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingChatId(chat.id)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
}
if (e.key === 'Escape') {
setEditingChatId(null)
e.currentTarget.textContent =
chat.nombre || ''
}
}}
onBlur={(e) => {
if (editingChatId === chat.id) {
const newTitle =
e.currentTarget.textContent?.trim() || ''
if (newTitle && newTitle !== chat.nombre) {
updateTitleMutation({
id: chat.id,
nombre: newTitle,
})
}
setEditingChatId(null)
}
}}
>
{chat.nombre ||
`Chat ${chat.creado_en.split('T')[0]}`}
</span>
</div>
</TooltipTrigger>
{editingChatId !== chat.id && (
<TooltipContent
side="right"
className="max-w-[280px] break-all"
>
{chat.nombre || 'Conversación'}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
{/* LADO DERECHO: Acciones ABSOLUTAS */} {/* ACCIONES */}
<div <div className="absolute right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100">
className={`absolute top-1/2 right-2 z-20 flex -translate-y-1/2 items-center gap-1 rounded-md px-1 opacity-0 transition-opacity group-hover:opacity-100 ${
activeChatId === chat.id ? 'bg-slate-100' : 'bg-slate-50'
}`}
>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setEditingChatId(chat.id) setEditingChatId(chat.id)
// Pequeño timeout para asegurar que el DOM se actualice antes de enfocar
setTimeout(() => editableRef.current?.focus(), 50) setTimeout(() => editableRef.current?.focus(), 50)
}} }}
className="rounded-md p-1 text-slate-400 transition-colors hover:text-teal-600" className="p-1 text-slate-400 hover:text-teal-600"
> >
<Send size={12} className="rotate-45" /> <Send size={12} className="rotate-45" />
</button> </button>
<button <button
onClick={(e) => archiveChat(e, chat.id)} onClick={(e) => archiveChat(e, chat.id)}
className="rounded-md p-1 text-slate-400 transition-colors hover:text-amber-600" className="p-1 text-slate-400 hover:text-amber-600"
> >
<Archive size={14} /> <Archive size={14} />
</button> </button>
@@ -625,26 +584,24 @@ function RouteComponent() {
</div> </div>
)) ))
) : ( ) : (
/* Sección de archivados (Simplificada para mantener consistencia) */ /* ... Resto del código de archivados (sin cambios) ... */
<div className="animate-in fade-in slide-in-from-left-2 px-1"> <div className="animate-in fade-in slide-in-from-left-2">
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase"> <p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
Archivados Archivados
</p> </p>
{archivedChats.map((chat) => ( {archivedChats.map((chat) => (
<div <div
key={chat.id} key={chat.id}
className="group relative mb-1 flex w-full items-center overflow-hidden rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400" className="group relative mb-1 flex w-full items-center gap-3 rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400"
> >
<div className="flex min-w-0 flex-1 items-center gap-3 pr-10"> <Archive size={14} className="shrink-0 opacity-30" />
<Archive size={14} className="shrink-0 opacity-30" /> <span className="truncate pr-8">
<span className="block truncate"> {chat.nombre ||
{chat.nombre || `Archivado ${chat.creado_en.split('T')[0]}`}
`Archivado ${chat.creado_en.split('T')[0]}`} </span>
</span>
</div>
<button <button
onClick={(e) => unarchiveChat(e, chat.id)} onClick={(e) => unarchiveChat(e, chat.id)}
className="absolute top-1/2 right-2 shrink-0 -translate-y-1/2 rounded bg-slate-100 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-teal-600" className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600"
> >
<RotateCcw size={14} /> <RotateCcw size={14} />
</button> </button>
@@ -764,24 +721,33 @@ function RouteComponent() {
) )
})} })}
{(isSending || isSyncing) && ( {(isSending || isSyncing) &&
<div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4"> optimisticMessage &&
<Avatar className="h-9 w-9 shrink-0 border bg-teal-600 text-white shadow-sm"> !chatMessages.some(
<AvatarFallback> (m) => m.content === optimisticMessage,
<Sparkles size={16} className="animate-pulse" /> ) && (
</AvatarFallback> <div className="animate-in fade-in slide-in-from-right-2 ml-auto flex max-w-[85%] flex-col items-end">
</Avatar> <div className="rounded-2xl rounded-tr-none bg-teal-600/70 p-3 text-sm whitespace-pre-wrap text-white shadow-sm">
<div className="flex flex-col items-start gap-2"> {optimisticMessage}
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm"> </div>
<div className="flex gap-1"> </div>
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]"></span> )}
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]"></span>
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"></span> {(isSending || isSyncing) && (
</div> <div className="animate-in fade-in slide-in-from-left-2 flex flex-col items-start duration-300">
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center gap-2">
<div className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500" />
</div>
<span className="text-[10px] font-medium tracking-tight text-slate-400 uppercase">
{isSyncing
? 'Actualizando historial...'
: 'Esperando respuesta...'}
</span>
</div> </div>
<span className="text-[10px] font-medium text-slate-400 italic">
La IA está analizando tu solicitud...
</span>
</div> </div>
</div> </div>
)} )}

View File

@@ -76,7 +76,7 @@ const mapAsignaturasToAsignaturas = (
// Mapeo directo de los nuevos campos de la API // Mapeo directo de los nuevos campos de la API
hd: asig.horas_academicas ?? 0, hd: asig.horas_academicas ?? 0,
hi: asig.horas_independientes ?? 0, hi: asig.horas_independientes ?? 0,
prerrequisito_asignatura_id: asig.prerrequisito_asignatura_id ?? null, prerrequisitos: [],
} }
}) })
} }
@@ -336,7 +336,6 @@ function MapaCurricularPage() {
horas_independientes?: TablesUpdate<'asignaturas'>['horas_independientes'] horas_independientes?: TablesUpdate<'asignaturas'>['horas_independientes']
numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo'] numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo']
linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id'] linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id']
prerrequisito_asignatura_id?: string | null
} }
const patch: Partial<AsignaturaPatch> = { const patch: Partial<AsignaturaPatch> = {
nombre: editingData.nombre, nombre: editingData.nombre,
@@ -346,7 +345,6 @@ function MapaCurricularPage() {
horas_independientes: editingData.hi, horas_independientes: editingData.hi,
numero_ciclo: editingData.ciclo, numero_ciclo: editingData.ciclo,
linea_plan_id: editingData.lineaCurricularId, linea_plan_id: editingData.lineaCurricularId,
prerrequisito_asignatura_id: editingData.prerrequisito_asignatura_id,
tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA) tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA)
} }
@@ -492,7 +490,7 @@ function MapaCurricularPage() {
e: React.FocusEvent<HTMLSpanElement>, e: React.FocusEvent<HTMLSpanElement>,
id: string, id: string,
) => { ) => {
const nuevoNombre = e.currentTarget.textContent.trim() || '' const nuevoNombre = e.currentTarget.textContent?.trim() || ''
// Buscamos la línea original para comparar // Buscamos la línea original para comparar
const lineaOriginal = lineas.find((l) => l.id === id) const lineaOriginal = lineas.find((l) => l.id === id)
@@ -937,55 +935,65 @@ function MapaCurricularPage() {
{/* Fila 4: Seriación (Prerrequisitos) */} {/* Fila 4: Seriación (Prerrequisitos) */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase"> <label className="text-xs font-bold text-slate-500 uppercase">
Seriación (Prerrequisito) Seriación (Prerrequisitos)
</label> </label>
<Select <Select
// Cambiamos a manejo de valor único basado en el ID de la columna value={seriacionValue}
value={editingData.prerrequisito_asignatura_id || undefined}
onValueChange={(val) => { onValueChange={(val) => {
console.log(editingData) if (val === 'none') {
setSeriacionValue('')
setEditingData({ return
...editingData, }
prerrequisito_asignatura_id: val === 'none' ? null : val, if (!editingData.prerrequisitos.includes(val)) {
}) setEditingData({
...editingData,
prerrequisitos: [...editingData.prerrequisitos, val],
})
}
setSeriacionValue('')
}} }}
> >
<SelectTrigger className="w-full bg-white"> <SelectTrigger>
<SelectValue placeholder="Seleccionar asignatura..." /> <SelectValue placeholder="Seleccionar asignatura..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">-- Sin Seriación --</SelectItem> <SelectItem value="none">-- Sin Seriación --</SelectItem>
{asignaturas {asignaturas
.filter((asig) => { .filter((m) => m.id !== editingData.id)
// 1. No es la misma materia .map((m) => (
const noEsMisma = asig.id !== editingData.id <SelectItem key={m.id} value={m.id}>
// 2. El ciclo debe ser estrictamente MENOR {m.nombre} ({m.clave})
const esCicloMenor =
asig.ciclo !== null &&
editingData.ciclo !== null &&
asig.ciclo < editingData.ciclo
return noEsMisma && esCicloMenor
})
.sort(
(a, b) =>
(a.ciclo || 0) - (b.ciclo || 0) ||
a.nombre.localeCompare(b.nombre),
)
.map((asig) => (
<SelectItem key={asig.id} value={asig.id}>
<span className="font-bold text-teal-600">
[C{asig.ciclo}]
</span>{' '}
{asig.nombre}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{/* Visualización del Prerrequisito con el Nombre */} {/* Visualización de los prerrequisitos seleccionados */}
<div className="mt-2 flex flex-wrap gap-2">
{editingData.prerrequisitos.map((pre) => (
<Badge
key={pre}
variant="secondary"
className="bg-slate-100 text-slate-600"
>
{pre}
<button
className="ml-1 hover:text-red-500"
onClick={() => {
setEditingData({
...editingData,
prerrequisitos: editingData.prerrequisitos.filter(
(p) => p !== pre,
),
})
}}
>
×
</button>
</Badge>
))}
</div>
</div> </div>
{/* Fila 5: Tipo */} {/* Fila 5: Tipo */}

View File

@@ -166,20 +166,30 @@ function AsignaturaLayout() {
onSave={(val) => handleUpdateHeader('nombre', val)} onSave={(val) => handleUpdateHeader('nombre', val)}
/> />
</h1> </h1>
{
// console.log(headerData),
console.log(asignaturaApi.planes_estudio?.nombre)
}
<div className="flex flex-wrap gap-4 text-sm text-blue-200"> <div className="flex flex-wrap gap-4 text-sm text-blue-200">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<GraduationCap className="h-4 w-4 shrink-0" /> <GraduationCap className="h-4 w-4 shrink-0" />
Pertenece al plan:{' '}
<span className="text-blue-100"> <span className="text-blue-100">
{(asignaturaApi.planes_estudio as DatosPlan).nombre || ''} {(asignaturaApi.planes_estudio?.datos as DatosPlan)
.nombre || ''}
</span>
</span>
<span className="flex items-center gap-1">
<span className="text-blue-100">
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
.nombre ?? ''}
</span> </span>
</span> </span>
</div> </div>
<p className="text-sm text-blue-300">
Pertenece al plan:{' '}
<span className="cursor-pointer underline">
{asignaturaApi.planes_estudio?.nombre}
</span>
</p>
</div> </div>
<div className="flex flex-col items-end gap-2 text-right"> <div className="flex flex-col items-end gap-2 text-right">

View File

@@ -50,7 +50,7 @@ export interface Asignatura {
orden?: number orden?: number
hd: number // <--- Añadir hd: number // <--- Añadir
hi: number // <--- Añadir hi: number // <--- Añadir
prerrequisito_asignatura_id: string | null prerrequisitos: Array<string>
} }
export interface Plan { export interface Plan {

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +0,0 @@
{
"navigationFallback": {
"rewrite": "/index.html",
"exclude": [
"/assets/*",
"/*.css",
"/*.js",
"/*.ico",
"/*.png",
"/*.jpg",
"/*.svg"
]
}
}