56 Commits

Author SHA1 Message Date
roberto.silva 350d7c0f69 Merge pull request 'Que se puedan aplicar todos los campos y todas las mejoras fix #165' (#226) from issue/165-que-se-puedan-aplicar-todos-los-campos-y-todas-las into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m21s
Reviewed-on: #226
2026-03-25 21:14:55 +00:00
roberto.silva f2900f3d22 Que se puedan aplicar todos los campos y todas las mejoras
fix #165
2026-03-25 15:14:29 -06:00
roberto.silva 1f27ea6252 Merge pull request 'Que sirvan la redirección de la IA fix #192' (#224) from issue/192-que-sirvan-la-redireccin-de-la-ia into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m17s
Reviewed-on: #224
2026-03-25 15:38:54 +00:00
roberto.silva 0e710f9637 Que sirvan la redirección de la IA
fix #192
2026-03-25 09:38:09 -06:00
roberto.silva 75784658bf Merge pull request 'Se corrige desbordamiento en historial y en bibliografia fix #219' (#223) from issue/219-mejorar-responsividad into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m11s
Reviewed-on: #223
2026-03-24 20:58:16 +00:00
roberto.silva d2f12d96de Merge branch 'main' into issue/219-mejorar-responsividad 2026-03-24 20:58:11 +00:00
roberto.silva 7f9c3162a0 Se corrige desbordamiento en historial y en bibliografia
fix #219
2026-03-24 14:57:49 -06:00
roberto.silva 03ef51d8a6 Merge pull request 'Se corrige desborde de chat de ia en mobile' (#222) from issue/219-mejorar-responsividad into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m17s
Reviewed-on: #222
2026-03-24 19:04:12 +00:00
roberto.silva f81b404223 Merge branch 'main' into issue/219-mejorar-responsividad 2026-03-24 19:04:04 +00:00
roberto.silva 614150f8d1 Se corrige desborde de chat de ia en mobile 2026-03-24 13:03:33 -06:00
roberto.silva 54a3b74836 Merge pull request 'Mejorar responsividad fix#219' (#221) from issue/219-mejorar-responsividad into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m18s
Reviewed-on: #221
2026-03-24 16:55:32 +00:00
roberto.silva 0820bf19a5 Merge branch 'main' into issue/219-mejorar-responsividad 2026-03-24 16:55:20 +00:00
roberto.silva 7ddc5c7f1f Mejorar responsividad
fix #219
2026-03-24 10:54:40 -06:00
alexrg ad7a741d1e Merge pull request 'Refactor MapaCurricularPage: update state variables and enhance UI components for better clarity and usability' (#220) from features/mejora-de-estilos into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m34s
Reviewed-on: #220
2026-03-24 15:12:44 +00:00
alexrg 60bfa31186 Refactor MapaCurricularPage: improve line editing functionality and enhance tooltip interactions 2026-03-24 09:11:40 -06:00
alexrg 1bd37c7e27 Refactor MapaCurricularPage: update state variables and enhance UI components for better clarity and usability 2026-03-24 08:45:59 -06:00
roberto.silva 7eb9ca0bec Merge pull request 'Que el contenido temático se muestre en el historial , Actualizar esta sección de seriación fix#197 fix #195' (#218) from issue/197-que-el-contenido-temtico-se-muestre-en-el-historia into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m15s
Reviewed-on: #218
2026-03-23 22:34:21 +00:00
roberto.silva 10dc299311 Merge branch 'main' into issue/197-que-el-contenido-temtico-se-muestre-en-el-historia 2026-03-23 22:34:11 +00:00
roberto.silva b3954ab16c Merge branch 'issue/195-actualizar-esta-seccin-de-seriacin' into issue/197-que-el-contenido-temtico-se-muestre-en-el-historia 2026-03-23 16:29:27 -06:00
alexrg 88a2a28a8d Enhance table styling and improve empty state message in AsignaturasPage for better user experience
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m25s
2026-03-23 16:16:42 -06:00
alexrg 36a11e3793 Improve conditional rendering and styling for empty state in MapaCurricularPage 2026-03-23 16:15:43 -06:00
alexrg 33efaed03f Adjust text sizes and spacing in AsignaturaCardItem and MapaCurricularPage for improved readability
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m22s
2026-03-23 16:05:20 -06:00
alexrg d481e9706c Fix estado assignment in AsignaturaCardItem and adjust width for improved layout
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m19s
2026-03-23 15:54:12 -06:00
alexrg ee3b7a56ec Merge pull request 'Mejorar el espaciado y la organización de la interfaz en el componente MapaCurricular' (#217) from issue/214-mejorar-el-espaciado-de-la-interfaz into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m24s
Reviewed-on: #217
2026-03-23 20:31:46 +00:00
alexrg 2359e38f85 Mejorar el espaciado y la organización de la interfaz en el componente MapaCurricular 2026-03-23 14:30:16 -06:00
roberto.silva c262cd16be Merge pull request 'Que aparezcan los campos a mitad de texto fix #213' (#216) from issue/213-que-aparezcan-los-campos-a-mitad-de-texto into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m24s
Reviewed-on: #216
2026-03-23 19:02:56 +00:00
roberto.silva a08b2abf87 Que aparezcan los campos a mitad de texto
fix #213
2026-03-23 13:02:31 -06:00
roberto.silva a07213d959 Merge pull request 'Mejorar la responsividad de los chats fix #202 fix #203' (#215) from issue/202-mejorar-la-responsividad-de-los-chats into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 2m36s
Reviewed-on: #215
2026-03-23 18:34:30 +00:00
roberto.silva a2234e5022 Mejorar la responsividad de los chats fix #202 2026-03-23 12:33:12 -06:00
roberto.silva 658c392f96 Mejorar la responsividad de los chats fix #202 2026-03-23 11:59:58 -06:00
Guillermo.Arrieta 4c730fa0ab Merge pull request 'Se renderizan las previsualizaciones del plan y de la asignatura y también se pueden descargar como word o pdf' (#211) from issue/200-renderizado-de-plantillas-con-edge-function-de-car into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m21s
Reviewed-on: #211
2026-03-20 23:47:37 +00:00
Guillermo.Arrieta 2abe296b9e close #200: Se guardan los docx y pdf con el nombre del plan/asignatura 2026-03-20 17:44:36 -06:00
Guillermo.Arrieta 1bce226d15 Se descargan correctamente los docx del plan y de la asignatura 2026-03-20 17:31:59 -06:00
Guillermo.Arrieta b986ec343e Se visualiza y descarga el pdf de la asignatura 2026-03-20 17:31:07 -06:00
roberto.silva 9fd816bfa1 Actualizar esta sección de seriación fix #195 2026-03-20 16:09:39 -06:00
alexrg 379e2d3826 Actualizar src/routes/planes/$planId/_detalle/mapa.tsx
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m20s
2026-03-20 21:30:16 +00:00
alexrg cb5422f57c Merge pull request 'Refactor AsignaturaCardItem to use Tooltip and improve styling; update color mapping for lineas' (#208) from mejorar-diseño-de-tarjetas into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m27s
Reviewed-on: #208
2026-03-20 21:17:37 +00:00
Your Name 67724181fd Refactor AsignaturaCardItem to use Tooltip and improve styling; update color mapping for lineas 2026-03-20 15:17:17 -06:00
Guillermo.Arrieta d9a5cec3c5 En el body se manda el parámetro para convertir el documento a pdf 2026-03-20 13:22:23 -06:00
Guillermo.Arrieta 96848e1793 Se utiliza la edge function de carbone para obtener el pdf del anexo del plan de estudios a partir del id del plan 2026-03-20 12:24:17 -06:00
alexrg cbaf96c6b5 Merge pull request 'Add letter-spacing to font-bold class in styles.css' (#206) from agregar-tipografía into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m13s
Reviewed-on: #206
2026-03-20 17:37:05 +00:00
alexrg 0fb831fb58 Merge branch 'main' into agregar-tipografía 2026-03-20 17:36:58 +00:00
alexrg 0d1aa61022 Add letter-spacing to font-bold class in styles.css 2026-03-20 11:35:51 -06:00
alexrg 84281a88f2 Merge pull request 'Add Indivisa font family and update styles.css' (#205) from agregar-tipografía into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m30s
Reviewed-on: #205
2026-03-20 17:33:03 +00:00
alexrg d91018c612 Add Indivisa font family and update styles.css 2026-03-20 11:30:39 -06:00
roberto.silva 658b2e245c Merge pull request 'Que no haga scroll fix #193' (#199) from issue/193-que-no-haga-scroll into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m20s
Reviewed-on: #199
2026-03-19 20:20:45 +00:00
roberto.silva 30562fead0 Merge branch 'main' into issue/193-que-no-haga-scroll 2026-03-19 20:20:30 +00:00
roberto.silva 2b91004129 Que no haga scroll #193 2026-03-19 14:18:21 -06:00
alexrg 96a045dc67 Añadir staticwebapp.config.json
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m7s
2026-03-19 13:58:43 +00:00
alexrg a8229f12d5 Actualizar .gitea/workflows/deploy.yaml
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m30s
2026-03-19 13:56:12 +00:00
alexrg dd4ac5374a Añadir .gitea/workflows/deploy.yaml
Deploy to Azure Static Web Apps / build-and-deploy (push) Failing after 48s
2026-03-18 22:39:50 +00:00
roberto.silva 670e0b1d14 Merge pull request 'Que se guarden las seriaciones fix #175 fix #151 fix #180' (#191) from issue/175-que-se-guarden-las-seriaciones into main
Reviewed-on: #191
2026-03-18 22:10:20 +00:00
roberto.silva 93fe247a19 Merge branch 'main' into issue/175-que-se-guarden-las-seriaciones 2026-03-18 22:10:09 +00:00
roberto.silva 32ebfde9ed Que se guarden las seriaciones
fix #175
fix #151
fix #180
2026-03-18 15:48:49 -06:00
Guillermo.Arrieta 32f0c4c4d4 fix #189: Se arregló un bug en el que no se podía poner espacios al editar la editorial de una referencia 2026-03-18 14:48:55 -06:00
Guillermo.Arrieta 6a520ef6b1 close #186: se agregó botón de Nueva Unidad al inicio del contenido temático 2026-03-17 15:45:36 -06:00
40 changed files with 2370 additions and 975 deletions
+37
View File
@@ -0,0 +1,37 @@
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"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,6 +7,13 @@ import type { AsignaturaDetail } from '@/data'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
@@ -14,7 +21,9 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { usePlanAsignaturas } from '@/data'
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
import { columnParsers } from '@/lib/asignaturaColumnParsers'
export interface BibliografiaEntry {
id: string
@@ -38,6 +47,10 @@ export interface AsignaturaResponse {
datos: AsignaturaDatos
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
type CriterioEvaluacionRow = {
criterio: string
porcentaje: number
@@ -59,8 +72,12 @@ export default function AsignaturaDetailPage() {
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: asignaturaApi } = useSubject(asignaturaId)
const { data: asignaturasApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId)
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
const updateAsignatura = useUpdateAsignatura()
@@ -81,16 +98,54 @@ 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 ---------- */
useEffect(() => {
if (asignaturaApi) setAsignatura(asignaturaApi)
}, [asignaturaApi])
console.log(requisitosFormateados)
return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
if (asignaturaApi) setAsignatura(asignaturaApi)
}, [asignaturaApi, requisitosFormateados])
return (
<DatosGenerales
pre={requisitosFormateados}
availableSubjects={asignaturasApi}
onPersistDato={handlePersistDatoGeneral}
/>
)
}
function DatosGenerales({
onPersistDato,
pre,
availableSubjects,
}: {
onPersistDato: (clave: string, value: string) => void
}) {
@@ -263,24 +318,32 @@ function DatosGenerales({
<div className="space-y-6">
{/* Tarjeta de Requisitos */}
<InfoCard
asignaturaId={asignaturaId}
title="Requisitos y Seriación"
type="requirements"
initialContent={[
{
type: 'Pre-requisito',
code: 'PA-301',
name: 'Programación Avanzada',
initialContent={pre}
// Pasamos las materias del plan para el Select (excluyendo la actual)
availableSubjects={
availableSubjects?.filter(
(a) =>
a.id !== asignaturaId &&
a.numero_ciclo < data?.numero_ciclo &&
a.numero_ciclo,
) || []
}
onPersist={({ value }) => {
updateAsignatura.mutate({
asignaturaId,
patch: {
prerrequisito_asignatura_id: value, // value ya viene como ID o null desde handleSave
},
{
type: 'Co-requisito',
code: 'MAT-201',
name: 'Matemáticas Discretas',
},
]}
})
}}
/>
{/* Tarjeta de Evaluación */}
<InfoCard
asignaturaId={asignaturaId}
title="Sistema de Evaluación"
type="evaluation"
initialContent={criteriosEvaluacion}
@@ -316,6 +379,7 @@ interface InfoCardProps {
containerRef?: React.RefObject<HTMLDivElement | null>
forceEditToken?: number
highlightToken?: number
availableSubjects?: any
}
function InfoCard({
@@ -332,6 +396,7 @@ function InfoCard({
containerRef,
forceEditToken,
highlightToken,
availableSubjects,
}: InfoCardProps) {
const [isEditing, setIsEditing] = useState(false)
const [isHighlighted, setIsHighlighted] = useState(false)
@@ -349,7 +414,8 @@ function InfoCard({
useEffect(() => {
setData(initialContent)
setTempText(initialContent)
console.log(data)
console.log(initialContent)
if (type === 'evaluation') {
const raw = Array.isArray(initialContent) ? initialContent : []
const rows: Array<CriterioEvaluacionRowDraft> = raw
@@ -392,6 +458,8 @@ function InfoCard({
const handleSave = () => {
console.log('clave, valor:', clave, String(tempText ?? ''))
console.log(clave)
console.log(tempText)
if (type === 'evaluation') {
const cleaned: Array<CriterioEvaluacionRow> = []
@@ -422,6 +490,25 @@ function InfoCard({
void onPersist?.({ type, clave, value: cleaned })
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)
setIsEditing(false)
@@ -432,16 +519,25 @@ function InfoCard({
}
const handleIARequest = (campoClave: string) => {
console.log(placeholder)
console.log(campoClave)
let targetClave = campoClave
if (type === 'evaluation' && !targetClave) {
targetClave = 'criterios_de_evaluacion'
}
if (targetClave === 'contenido') {
targetClave = 'contenido_tematico'
}
console.log(targetClave)
console.log(asignaturaId)
// Añadimos un timestamp a la state para forzar que la navegación
// genere una nueva ubicación incluso si la ruta y los params son iguales.
navigate({
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
params: { planId, asignaturaId: asignaturaId! },
state: {
activeTab: 'ia',
prefillCampo: campoClave,
prefillCampo: targetClave,
prefillContenido: data,
_ts: Date.now(),
} as any,
@@ -496,19 +592,21 @@ function InfoCard({
{!isEditing && (
<div className="flex gap-1">
{type !== 'requirements' && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
onClick={() => clave && handleIARequest(clave)}
onClick={() => handleIARequest(clave)}
>
<Sparkles className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Mejorar con IA</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
@@ -541,7 +639,66 @@ function InfoCard({
<CardContent className="pt-4">
{isEditing ? (
<div className="space-y-3">
{type === 'evaluation' ? (
{/* Condicionales de edición según el tipo */}
{type === 'requirements' ? (
<div className="space-y-3">
<label className="text-xs font-medium text-slate-500">
Materia de Seriación
</label>
<Select
value={
Array.isArray(tempText) && tempText.length > 0
? tempText[0].id
: 'none'
}
onValueChange={(val) => {
console.log(availableSubjects)
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 className="w-full">
<div className="flex-1 truncate text-left">
<SelectValue placeholder="Selecciona una materia" />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
Ninguna (Sin seriación)
</SelectItem>
{availableSubjects?.map((asig) => (
<SelectItem
key={asig.id}
value={asig.id}
className="max-w-[300px] sm:max-w-[500px]"
>
<span className="block truncate">
{asig.codigo} - {asig.nombre}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : type === 'evaluation' ? (
<div className="space-y-3">
<div className="space-y-2">
{evalRows.map((row) => (
@@ -563,85 +720,36 @@ function InfoCard({
)
}}
/>
<Input
value={row.porcentaje}
placeholder="%"
type="number"
min={1}
max={100}
step={1}
inputMode="numeric"
onChange={(e) => {
const raw = e.target.value
// Solo permitir '' o dígitos
if (raw !== '' && !/^\d+$/.test(raw)) return
if (raw === '') {
setEvalRows((prev) =>
prev.map((r) =>
r.id === row.id
? {
id: r.id,
criterio: r.criterio,
porcentaje: '',
}
: r,
),
)
return
}
const n = Number(raw)
if (!Number.isFinite(n)) return
const porcentaje = Math.trunc(n)
if (porcentaje < 1 || porcentaje > 100) return
// No permitir suma > 100
setEvalRows((prev) => {
const next = prev.map((r) =>
r.id === row.id
? {
id: r.id,
criterio: r.criterio,
porcentaje: raw,
}
: r,
r.id === row.id ? { ...r, porcentaje: raw } : r,
)
const total = next.reduce(
(acc, r) => acc + (Number(r.porcentaje) || 0),
0,
)
const total = next.reduce((acc, r) => {
const v = String(r.porcentaje).trim()
if (!v) return acc
const nn = Number(v)
if (!Number.isFinite(nn)) return acc
const vv = Math.trunc(nn)
if (vv < 1 || vv > 100) return acc
return acc + vv
}, 0)
return total > 100 ? prev : next
})
}}
/>
<div
className="flex w-[1ch] items-center justify-center text-sm text-slate-600"
aria-hidden
>
%
</div>
<div className="text-sm text-slate-600">%</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600 hover:bg-red-50"
onClick={() => {
onClick={() =>
setEvalRows((prev) =>
prev.filter((r) => r.id !== row.id),
)
}}
aria-label="Quitar renglón"
title="Quitar"
}
>
<Minus className="h-4 w-4" />
</Button>
@@ -651,22 +759,15 @@ function InfoCard({
<div className="flex items-center justify-between">
<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
</span>
<Button
variant="ghost"
size="sm"
className="text-emerald-700 hover:bg-emerald-50"
onClick={() => {
// Agregar una fila vacía (siempre permitido)
onClick={() =>
setEvalRows((prev) => [
...prev,
{
@@ -675,7 +776,7 @@ function InfoCard({
porcentaje: '',
},
])
}}
}
>
<Plus className="mr-2 h-4 w-4" /> Agregar renglón
</Button>
@@ -689,28 +790,15 @@ function InfoCard({
className="min-h-30 text-sm leading-relaxed"
/>
)}
{/* Botones de acción comunes */}
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => {
setIsEditing(false)
if (type === 'evaluation') {
const raw = Array.isArray(data) ? data : []
setEvalRows(
raw.map((r: CriterioEvaluacionRow) => ({
id: crypto.randomUUID(),
criterio:
typeof r.criterio === 'string' ? r.criterio : '',
porcentaje:
typeof r.porcentaje === 'number'
? String(Math.trunc(r.porcentaje))
: typeof r.porcentaje === 'string'
? String(Math.trunc(Number(r.porcentaje)))
: '',
})),
)
}
// Lógica de reset si es necesario...
}}
>
Cancelar
@@ -726,6 +814,7 @@ function InfoCard({
</div>
</div>
) : (
/* Modo Visualización */
<div className="text-sm leading-relaxed text-slate-600">
{type === 'text' &&
(data ? (
@@ -734,9 +823,7 @@ function InfoCard({
<p className="text-slate-400 italic">Sin información.</p>
))}
{type === 'requirements' && <RequirementsView items={data} />}
{type === 'evaluation' && (
<EvaluationView items={data as Array<CriterioEvaluacionRow>} />
)}
{type === 'evaluation' && <EvaluationView items={data} />}
</div>
)}
</CardContent>
@@ -791,80 +878,3 @@ function EvaluationView({ items }: { items: Array<CriterioEvaluacionRow> }) {
</div>
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function parseContenidoTematicoToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
const blocks: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const unidad =
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
? item.unidad
: undefined
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
if (!header) continue
const lines: Array<string> = [header]
const temas = Array.isArray(item.temas) ? item.temas : []
temas.forEach((tema, idx) => {
const temaNombre =
typeof tema === 'string'
? tema
: isRecord(tema) && typeof tema.nombre === 'string'
? tema.nombre
: ''
if (!temaNombre) return
if (unidad != null) {
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
} else {
lines.push(`${idx + 1}. ${temaNombre}`)
}
})
blocks.push(lines.join('\n'))
}
return blocks.join('\n\n').trimEnd()
}
function parseCriteriosEvaluacionToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
const lines: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const label = typeof item.criterio === 'string' ? item.criterio.trim() : ''
const valueNum =
typeof item.porcentaje === 'number'
? item.porcentaje
: typeof item.porcentaje === 'string'
? Number(item.porcentaje)
: NaN
if (!label) continue
if (!Number.isFinite(valueNum)) continue
const v = Math.trunc(valueNum)
if (v < 1 || v > 100) continue
lines.push(`${label}: ${v}%`)
}
return lines.join('\n')
}
const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
contenido_tematico: parseContenidoTematicoToPlainText,
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
}
@@ -126,7 +126,7 @@ export function BibliographyItem() {
return (
<div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500">
<div className="flex items-center justify-between border-b pb-4">
<div className="flex flex-col gap-4 border-b pb-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
Bibliografía
@@ -688,6 +688,12 @@ export function ContenidoTematico() {
>
{({ handleRef }) => (
<>
{index === 0 && (
<InsertUnidadOverlay
position="top"
onInsert={() => insertUnidadAt(index)}
/>
)}
<InsertUnidadOverlay
position="bottom"
onInsert={() => insertUnidadAt(index + 1)}
@@ -18,7 +18,8 @@ import { Card } from '@/components/ui/card'
interface DocumentoSEPTabProps {
pdfUrl: string | null
isLoading: boolean
onDownload: () => void
onDownloadPdf: () => void
onDownloadWord: () => void
onRegenerate: () => void
isRegenerating: boolean
}
@@ -26,7 +27,8 @@ interface DocumentoSEPTabProps {
export function DocumentoSEPTab({
pdfUrl,
isLoading,
onDownload,
onDownloadPdf,
onDownloadWord,
onRegenerate,
isRegenerating,
}: DocumentoSEPTabProps) {
@@ -52,25 +54,23 @@ export function DocumentoSEPTab({
</div>
<div className="flex items-center gap-2">
{pdfUrl && !isLoading && (
<Button variant="outline" onClick={onDownload}>
<Download className="mr-2 h-4 w-4" />
Descargar
</Button>
)}
<AlertDialog
open={showConfirmDialog}
onOpenChange={setShowConfirmDialog}
>
<AlertDialogTrigger asChild>
<Button disabled={isRegenerating}>
<Button
variant="outline"
size="sm"
className="gap-2"
disabled={isRegenerating}
>
{isRegenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
<RefreshCw className="h-4 w-4" />
)}
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
{isRegenerating ? 'Generando...' : 'Regenerar'}
</Button>
</AlertDialogTrigger>
@@ -91,11 +91,31 @@ export function DocumentoSEPTab({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{pdfUrl && !isLoading && (
<>
<Button
size="sm"
className="gap-2 bg-teal-700 hover:bg-teal-800"
onClick={onDownloadWord}
>
<Download className="h-4 w-4" /> Descargar Word
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={onDownloadPdf}
>
<Download className="h-4 w-4" /> Descargar PDF
</Button>
</>
)}
</div>
</div>
{/* PDF Preview */}
<Card className="h-[800px] overflow-hidden">
<Card className="h-200 overflow-hidden">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-10 w-10 animate-spin" />
@@ -1,5 +1,5 @@
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from '@tanstack/react-router'
import { useLocation, useParams } from '@tanstack/react-router'
import {
Sparkles,
Send,
@@ -14,18 +14,22 @@ import {
MessageSquarePlus,
Archive,
History,
Edit2, // Agregado
Edit2,
Loader2, // Agregado
} from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react'
import { ImprovementCard } from './SaveAsignatura/ImprovementCardProps'
import type { IASugerencia } from '@/types/asignatura'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer'
import {
Drawer,
DrawerContent,
DrawerOverlay,
DrawerPortal,
} from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import {
@@ -39,8 +43,10 @@ import {
useConversationBySubject,
useMessagesBySubjectChat,
useSubject,
useUpdateAsignatura,
useUpdateSubjectConversationName,
useUpdateSubjectConversationStatus,
useUpdateSubjectRecommendation,
} from '@/data'
import { cn } from '@/lib/utils'
@@ -50,21 +56,12 @@ interface SelectedField {
value: string
}
interface IAAsignaturaTabProps {
asignatura?: Record<string, any>
onAcceptSuggestion: (sugerencia: IASugerencia) => void
onRejectSuggestion: (messageId: string) => void
}
export function IAAsignaturaTab({
onAcceptSuggestion,
onRejectSuggestion,
}: IAAsignaturaTabProps) {
export function IAAsignaturaTab() {
const queryClient = useQueryClient()
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const location = useLocation()
// --- ESTADOS ---
const [activeChatId, setActiveChatId] = useState<string | undefined>(
undefined,
@@ -75,6 +72,10 @@ export function IAAsignaturaTab({
const [showSuggestions, setShowSuggestions] = useState(false)
const [isSending, setIsSending] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const updateAsignatura = useUpdateAsignatura()
const updateRecommendation = useUpdateSubjectRecommendation()
// --- DATA QUERIES ---
const { data: datosGenerales } = useSubject(asignaturaId)
@@ -99,6 +100,81 @@ export function IAAsignaturaTab({
Array<string>
>([])
const [uploadedFiles, setUploadedFiles] = useState<Array<File>>([])
const [selectedImprovements, setSelectedImprovements] = useState<
Array<string>
>([])
const handleApplyMultiple = async (sugerencias: Array<any>) => {
if (!asignaturaId || !datosGenerales || sugerencias.length === 0) return
setIsSending(true)
try {
// 1. Consolidar el Patch
const patchData: any = {
datos: { ...datosGenerales.datos },
}
for (const sug of sugerencias) {
if (sug.campoKey === 'contenido_tematico') {
patchData.contenido_tematico = sug.valorSugerido
} else if (sug.campoKey === 'criterios_de_evaluacion') {
patchData.criterios_de_evaluacion = sug.valorSugerido
} else {
patchData.datos[sug.campoKey] = sug.valorSugerido
}
}
// 2. Actualización única de la asignatura
await updateAsignatura.mutateAsync({
asignaturaId: asignaturaId as any,
patch: patchData,
})
// 3. Marcar recomendaciones una por una (Secuencialmente para evitar colisiones)
for (const sug of sugerencias) {
await updateRecommendation.mutateAsync({
mensajeId: sug.messageId,
campoAfectado: sug.campoKey,
})
}
// 4. Limpieza de estados
const appliedKeys = sugerencias.map((s) => s.campoKey)
setSelectedFields((prev) =>
prev.filter((f) => !appliedKeys.includes(f.key)),
)
setSelectedImprovements([])
// Forzar actualización visual
queryClient.invalidateQueries({ queryKey: ['subject', asignaturaId] })
} catch (error) {
console.error('Error en aplicación masiva:', error)
} finally {
setIsSending(false)
}
}
const toggleImprovementSelection = (sugId: string) => {
setSelectedImprovements((prev) =>
prev.includes(sugId)
? prev.filter((id) => id !== sugId)
: [...prev, sugId],
)
}
const toggleAllFromMessage = (sugerencias: Array<any>) => {
const allIds = sugerencias.map((s) => s.id)
const allSelected = allIds.every((id) => selectedImprovements.includes(id))
if (allSelected) {
setSelectedImprovements((prev) =>
prev.filter((id) => !allIds.includes(id)),
)
} else {
setSelectedImprovements((prev) =>
Array.from(new Set([...prev, ...allIds])),
)
}
}
// Cálculo del total para el Badge del botón
const totalReferencias =
@@ -141,7 +217,7 @@ export function IAAsignaturaTab({
const dynamicFields = datosGenerales?.datos
? Object.keys(datosGenerales.datos).map((key) => {
const estructuraProps =
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
datosGenerales.estructuras_asignatura?.definicion?.properties || {}
return {
key,
label:
@@ -234,12 +310,38 @@ export function IAAsignaturaTab({
}
}, [activeChats, loadingConv])
const filteredFields = useMemo(() => {
if (!showSuggestions) return availableFields
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
const selectionStart = e.target.selectionStart // Posición del cursor
setInput(value)
// Buscamos si el carácter anterior al cursor es ':'
const lastChar = value.slice(selectionStart - 1, selectionStart)
if (lastChar === ':') {
setShowSuggestions(true)
} else if (!value.includes(':')) {
// Si borran todos los dos puntos, cerramos
setShowSuggestions(false)
}
}
const filteredFields = useMemo(() => {
if (!showSuggestions || !input) return availableFields
// 1. Encontrar el ":" más cercano a la IZQUIERDA del cursor
// Usamos una posición de referencia (si no tienes ref, usaremos el final del string,
// pero para mayor precisión lo ideal es usar e.target.selectionStart en el onChange)
// Extraemos lo que hay después del último ':' para filtrar
const lastColonIndex = input.lastIndexOf(':')
const query = input.slice(lastColonIndex + 1).toLowerCase()
if (lastColonIndex === -1) return availableFields
// 2. Extraer solo el fragmento de "búsqueda"
// Cortamos desde el ":" hasta el final, y luego tomamos solo la primera palabra
const textAfterColon = input.slice(lastColonIndex + 1)
const query = textAfterColon.split(/\s/)[0].toLowerCase() // Se detiene al encontrar un espacio
if (!query) return availableFields
return availableFields.filter(
(f) =>
@@ -248,6 +350,30 @@ export function IAAsignaturaTab({
)
}, [availableFields, input, showSuggestions])
useEffect(() => {
const state = location.state as any
// Verificamos si existe el timestamp (_ts) para saber que es una acción nueva
if (state?.activeTab === 'ia' && state?._ts) {
// Si el campo no está ya seleccionado, lo agregamos
if (state.prefillCampo) {
const fieldToSelect = availableFields.find(
(f) => f.key === state.prefillCampo,
)
if (fieldToSelect) {
setSelectedFields([fieldToSelect]) // Reemplaza o añade según prefieras
// Generamos un prompt inicial automático
const autoPrompt = `Mejora el contenido de: ${fieldToSelect.label}`
setInput(autoPrompt)
// Opcional: Si quieres que dispare la IA inmediatamente al llegar:
// handleSend(autoPrompt)
}
}
}
}, [location.state, availableFields])
// 2. Efecto para cerrar con ESC
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -259,24 +385,28 @@ export function IAAsignaturaTab({
// 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} `
const parteAntesDelColon = input.slice(0, lastColonIndex)
// Buscamos si hay texto después de la palabra que estamos escribiendo
const textoDespuesDelColon = input.slice(lastColonIndex + 1)
const espacioIndex = textoDespuesDelColon.indexOf(' ')
// Si hay un espacio, guardamos lo que sigue. Si no, es el final del texto.
const parteRestante =
espacioIndex !== -1 ? textoDespuesDelColon.slice(espacioIndex) : ''
// Reconstruimos: [Antes] + [Label] + [Lo que ya estaba después]
const nuevoTexto = `${parteAntesDelColon}${field.label}${parteRestante}`
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) => {
@@ -355,10 +485,35 @@ export function IAAsignaturaTab({
]
return (
<div className="flex h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
{/* PANEL IZQUIERDO */}
<div className="flex w-64 flex-col border-r pr-4">
<div className="mb-4 flex items-center justify-between px-2">
<div className="flex h-full w-full overflow-hidden bg-white">
<div className="fixed top-0 z-40 flex w-full items-center justify-between border-b bg-white/80 p-2 backdrop-blur-md">
<Button
variant="ghost"
size="sm"
className="text-slate-600"
onClick={() => setIsSidebarOpen(true)}
>
<History size={18} className="mr-2" /> Historial
</Button>
<div className="flex flex-col items-center">
<span className="text-[9px] font-bold tracking-wider text-teal-600 uppercase">
Asistente
</span>
</div>
<Button
variant="ghost"
size="sm"
className="text-slate-600"
onClick={() => setOpenIA(true)} // O el drawer de acciones/referencias
>
<FileText size={18} className="mr-2 text-teal-600" /> Referencias
</Button>
</div>
{/* 1. PANEL IZQUIERDO (HISTORIAL) - Desktop */}
<aside className="hidden h-full w-64 shrink-0 flex-col border-r bg-white pr-4 md:flex">
<div className="mb-4 flex items-center justify-between px-2 pt-4">
<h2 className="flex items-center gap-2 text-xs font-bold text-slate-500 uppercase">
<History size={14} /> Historial
</h2>
@@ -378,25 +533,19 @@ export function IAAsignaturaTab({
<Button
onClick={() => {
setActiveChatId(undefined)
hasInitialSelected.current = true
setIsCreatingNewChat(true)
setInput('')
setSelectedFields([])
// 4. Opcional: Limpiar el caché de mensajes actual para que la pantalla se vea vacía al instante
queryClient.setQueryData(['subject-messages', undefined], [])
}}
variant="outline"
className="mb-4 w-full justify-start gap-2 border-dashed border-slate-300 hover:border-teal-500"
className="mb-4 w-full justify-start gap-2 border-dashed border-slate-300 text-slate-600 hover:border-teal-500 hover:bg-teal-50/50"
>
<MessageSquarePlus size={18} /> Nuevo Chat
</Button>
{/* PANEL IZQUIERDO - Cambios en ScrollArea y contenedor */}
<ScrollArea className="flex-1">
<div className="flex flex-col gap-1 pr-3">
{' '}
{/* Eliminado space-y-1 para mejor control con gap */}
{(showArchived ? archivedChats : activeChats).map((chat: any) => (
<div
key={chat.id}
@@ -507,31 +656,57 @@ export function IAAsignaturaTab({
))}
</div>
</ScrollArea>
</aside>
{/* 2. PANEL CENTRAL (CHAT) - EL RECUADRO ESTILIZADO */}
<main
className={cn(
'relative flex min-w-0 flex-1 flex-col overflow-hidden bg-slate-50/50 shadow-sm',
'mt-[50px] h-[calc(100svh-50px)]', // 50px es la altura aproximada de tu header fixed
'md:m-2 md:mt-0 md:h-[calc(100vh-160px)] md:rounded-xl md:border md:border-slate-200',
)}
>
{/* Header Interno del Recuadro */}
<div className="flex shrink-0 items-center justify-between border-b bg-white p-3">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setIsSidebarOpen(true)}
>
<History size={20} className="text-slate-500" />
</Button>
<div className="flex flex-col">
<span className="text-[10px] font-bold tracking-wider text-teal-600 uppercase">
Asistente Académico
</span>
<span className="text-[11px] text-slate-400">
Personalizado para tu asignatura
</span>
</div>
</div>
{/* 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="flex shrink-0 items-center justify-between border-b bg-white p-3">
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
Asistente IA
</span>
<div className="flex gap-2">
<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"
className="flex items-center gap-2 rounded-lg bg-slate-100 px-3 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-200"
>
<FileText size={14} className="text-slate-500" />
Referencias
<FileText size={14} />
<span className="xs:inline hidden">Referencias</span>
{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">
<span className="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">
{/* Área de Mensajes */}
<div className="relative min-h-0 w-full flex-1 overflow-hidden">
<ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto max-w-3xl space-y-8 p-6">
<div className="mx-auto max-w-3xl space-y-6 p-3 md:p-6">
{messages.map((msg) => (
<div
key={msg.id}
@@ -584,31 +759,90 @@ export function IAAsignaturaTab({
{/* CONTENEDOR DE SUGERENCIAS INTEGRADO */}
{msg.role === 'assistant' &&
msg.sugerencias &&
msg.sugerencias.length > 0 && (
msg.sugerencias?.length > 0 && (
<div className="space-y-3 border-t bg-slate-50/50 p-3">
<p className="mb-1 text-[10px] font-bold text-slate-400 uppercase">
Mejoras disponibles:
<div className="flex items-center justify-between">
<p className="text-[10px] font-bold text-slate-400 uppercase">
Mejoras disponibles
</p>
{/* Solo mostramos "Seleccionar todo" si hay sugerencias pendientes en ESTE mensaje */}
{msg.sugerencias.some((s) => !s.aceptada) && (
<Button
variant="ghost"
className="h-6 text-[10px] text-teal-600 hover:bg-teal-100"
onClick={() =>
toggleAllFromMessage(msg.sugerencias)
}
>
{msg.sugerencias
.filter((s) => !s.aceptada)
.every((s) =>
selectedImprovements.includes(s.id),
)
? 'Desmarcar todas'
: 'Seleccionar todas'}
</Button>
)}
</div>
{msg.sugerencias.map((sug: any) => (
<div key={sug.id} className="flex gap-2">
{!sug.aceptada && (
<input
type="checkbox"
className="mt-4 h-4 w-4 rounded border-slate-300 accent-teal-600"
checked={selectedImprovements.includes(
sug.id,
)}
onChange={() =>
toggleImprovementSelection(sug.id)
}
/>
)}
<div className="flex-1">
<ImprovementCard
key={sug.id}
sug={sug}
asignaturaId={asignaturaId}
onApplied={(campoFinalizado) => {
// Filtramos el array para conservar todos MENOS el que se aplicó
console.log(campoFinalizado)
console.log('campos:', selectedFields)
onApplied={(key) =>
setSelectedFields((prev) =>
prev.filter((fieldObj) => {
// Accedemos a .key porque fieldObj es { key: "...", label: "..." }
return fieldObj.key !== campoFinalizado
}),
prev.filter((f) => f.key !== key),
)
}
/>
</div>
</div>
))}
{/* EL BOTÓN DE APLICAR: Lo movemos para que solo aparezca si hay algo seleccionado de ESTE mensaje */}
{msg.sugerencias.some((s) =>
selectedImprovements.includes(s.id),
) && (
<Button
size="sm"
disabled={isSending}
className="w-full bg-teal-600 text-xs font-bold shadow-md hover:bg-teal-700"
onClick={() => {
const seleccionadasDeEsteMensaje =
msg.sugerencias.filter((s) =>
selectedImprovements.includes(s.id),
)
handleApplyMultiple(
seleccionadasDeEsteMensaje,
)
}}
>
{isSending ? (
<Loader2
className="mr-2 animate-spin"
size={14}
/>
))}
) : (
<Check className="mr-2" size={14} />
)}
Aplicar mejoras seleccionadas
</Button>
)}
</div>
)}
</div>
@@ -642,8 +876,9 @@ export function IAAsignaturaTab({
</ScrollArea>
</div>
{/* INPUT */}
<div className="shrink-0 border-t bg-white p-4">
{/* Input de Chat */}
<footer className="shrink-0 border-t bg-white p-3 md:p-4">
<div className="shrink-0 border-t bg-white p-2 md:p-4">
<div className="relative mx-auto max-w-4xl">
{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">
@@ -705,10 +940,28 @@ export function IAAsignaturaTab({
<Textarea
value={input}
onChange={(e) => {
setInput(e.target.value)
if (e.target.value.endsWith(':')) setShowSuggestions(true)
else if (showSuggestions && !e.target.value.includes(':'))
const val = e.target.value
const cursor = e.target.selectionStart
setInput(val)
const textBeforeCursor = val.slice(0, cursor)
const lastColonIndex = textBeforeCursor.lastIndexOf(':')
if (lastColonIndex !== -1) {
const textSinceColon = textBeforeCursor.slice(
lastColonIndex + 1,
)
// Si hay un espacio después del ":", cerramos sugerencias (ya no es un comando)
// Si no hay espacio, activamos
if (!textSinceColon.includes(' ')) {
setShowSuggestions(true)
} else {
setShowSuggestions(false)
}
} else {
setShowSuggestions(false)
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -722,7 +975,8 @@ export function IAAsignaturaTab({
<Button
onClick={() => handleSend()}
disabled={
(!input.trim() && selectedFields.length === 0) || isSending
(!input.trim() && selectedFields.length === 0) ||
isSending
}
size="icon"
className="h-9 w-9 bg-teal-600 hover:bg-teal-700"
@@ -733,29 +987,33 @@ export function IAAsignaturaTab({
</div>
</div>
</div>
</div>
</footer>
</main>
{/* PANEL DERECHO ACCIONES */}
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
{/* 3. PANEL DERECHO (ATAJOS) */}
<aside className="hidden w-64 shrink-0 flex-col gap-4 overflow-y-auto p-4 lg:flex">
<h4 className="flex items-center gap-2 text-sm font-bold text-slate-800">
<Lightbulb size={18} className="text-orange-500" /> Atajos
<Lightbulb size={18} className="text-orange-500" /> Atajos Rápidos
</h4>
<div className="space-y-2">
{PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => handleSend(preset.prompt)}
className="group flex w-full items-center gap-3 rounded-xl border bg-white p-3 text-left text-sm transition-all hover:border-teal-500 hover:bg-teal-50"
className="group flex w-full items-center gap-3 rounded-xl border border-slate-100 bg-white p-3 text-left text-sm transition-all hover:border-teal-500 hover:shadow-sm"
>
<div className="rounded-lg bg-slate-100 p-2 group-hover:bg-teal-100 group-hover:text-teal-600">
<div className="rounded-lg bg-slate-50 p-2 group-hover:bg-teal-50 group-hover:text-teal-600">
<preset.icon size={16} />
</div>
<span className="font-medium text-slate-700">{preset.label}</span>
<span className="font-medium text-slate-600 group-hover:text-slate-900">
{preset.label}
</span>
</button>
))}
</div>
</div>
{/* --- DRAWER DE REFERENCIAS --- */}
</aside>
{/* DRAWERS (Referencias e Historial Móvil) */}
<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">
@@ -790,6 +1048,140 @@ export function IAAsignaturaTab({
</div>
</DrawerContent>
</Drawer>
<Drawer open={isSidebarOpen} onOpenChange={setIsSidebarOpen}>
<DrawerPortal>
<DrawerOverlay className="fixed inset-0 bg-black/40" />
<DrawerContent className="fixed right-0 bottom-0 left-0 h-[70vh] p-4 outline-none">
<div className="flex h-full flex-col overflow-hidden pt-8">
{/* Reutiliza aquí el componente de la lista de chats */}
<Button
onClick={() => {
setActiveChatId(undefined)
setIsCreatingNewChat(true)
setInput('')
setSelectedFields([])
queryClient.setQueryData(['subject-messages', undefined], [])
setIsSidebarOpen(false) // Cierra el drawer al crear nuevo
}}
variant="outline"
className="mb-6 w-full justify-center gap-2 border-dashed border-teal-500 bg-teal-50/50 text-teal-700 hover:bg-teal-100"
>
<MessageSquarePlus size={18} /> Nuevo Chat
</Button>
<h2 className="mb-4 font-bold">Historial de Chats</h2>
{(showArchived ? archivedChats : activeChats).map((chat: any) => (
<div
key={chat.id}
className={cn(
// Agregamos 'overflow-hidden' para que nada salga de este cuadro
'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
? 'bg-teal-50 text-teal-900'
: 'text-slate-600 hover:bg-slate-100',
)}
onDoubleClick={() => {
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || 'Conversacion')
}}
>
{editingId === chat.id ? (
<div className="flex min-w-0 flex-1 items-center">
<input
autoFocus
className="w-full rounded border-none bg-white px-1 text-xs ring-1 ring-teal-400 outline-none"
value={tempName}
onChange={(e) => setTempName(e.target.value)}
onBlur={() => handleSaveName(chat.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveName(chat.id)
if (e.key === 'Escape') setEditingId(null)
}}
/>
</div>
) : (
<>
{/* 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}
>
{chat.nombre || chat.titulo || 'Conversación'}
</span>
{/* 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>
</DrawerContent>
</DrawerPortal>
</Drawer>
</div>
)
}
@@ -15,6 +15,7 @@ interface ImprovementCardProps {
sug: IASugerencia
asignaturaId: string
onApplied: (campoKey: string) => void
isSelected?: boolean
}
export function ImprovementCard({
+60 -26
View File
@@ -1,52 +1,86 @@
// document.api.ts
const DOCUMENT_PDF_URL =
'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434'
import { supabaseBrowser } from '../supabase/client'
import { invokeEdge } from '../supabase/invokeEdge'
const DOCUMENT_PDF_ASIGNATURA_URL =
'https://n8n.app.lci.ulsa.mx/webhook/041a68be-7568-46d0-bc08-09ded12d017d'
import { requireData, throwIfError } from './_helpers'
import type { Tables } from '@/types/supabase'
const EDGE = {
carbone_io_wrapper: 'carbone-io-wrapper',
} as const
interface GeneratePdfParams {
plan_estudio_id: string
convertTo?: 'pdf'
}
interface GeneratePdfParamsAsignatura {
asignatura_id: string
convertTo?: 'pdf'
}
export async function fetchPlanPdf({
plan_estudio_id,
convertTo,
}: GeneratePdfParams): Promise<Blob> {
const response = await fetch(DOCUMENT_PDF_URL, {
method: 'POST',
return await invokeEdge<Blob>(
EDGE.carbone_io_wrapper,
{
action: 'downloadReport',
plan_estudio_id,
body: convertTo ? { convertTo } : {},
},
{
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ plan_estudio_id }),
})
if (!response.ok) {
throw new Error('Error al generar el PDF')
}
// n8n devuelve el archivo → lo tratamos como blob
return await response.blob()
responseType: 'blob',
},
)
}
export async function fetchAsignaturaPdf({
asignatura_id,
convertTo,
}: GeneratePdfParamsAsignatura): Promise<Blob> {
const response = await fetch(DOCUMENT_PDF_ASIGNATURA_URL, {
method: 'POST',
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('asignaturas')
.select('*')
.eq('id', asignatura_id)
.single()
throwIfError(error)
const row = requireData(
data as Pick<
Tables<'asignaturas'>,
'datos' | 'contenido_tematico' | 'criterios_de_evaluacion'
>,
'Asignatura no encontrada',
)
const body: Record<string, unknown> = {
data: row,
}
if (convertTo) body.convertTo = convertTo
return await invokeEdge<Blob>(
EDGE.carbone_io_wrapper,
{
action: 'downloadReport',
asignatura_id,
body: {
...body,
},
},
{
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ asignatura_id }),
})
if (!response.ok) {
throw new Error('Error al generar el PDF')
}
// n8n devuelve el archivo → lo tratamos como blob
return await response.blob()
responseType: 'blob',
},
)
}
+1 -1
View File
@@ -207,7 +207,7 @@ export async function plan_asignaturas_list(
const { data, error } = await supabase
.from('asignaturas')
.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',
'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',
)
.eq('plan_estudio_id', planId)
.order('numero_ciclo', { ascending: true, nullsFirst: false })
+1 -1
View File
@@ -191,7 +191,7 @@ export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
.from('asignaturas')
.select(
`
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,
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,
planes_estudio(
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
+72 -1
View File
@@ -12,6 +12,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
export type EdgeInvokeOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
headers?: Record<string, string>
responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer'
}
export class EdgeFunctionError extends Error {
@@ -26,6 +27,55 @@ export class EdgeFunctionError extends Error {
}
}
// Soporta base64 puro o data:...;base64,...
function decodeBase64ToUint8Array(input: string): Uint8Array {
const trimmed = input.trim()
const base64 = trimmed.startsWith('data:')
? trimmed.slice(trimmed.indexOf(',') + 1)
: trimmed
const bin = atob(base64)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
return bytes
}
function stripDataUrlPrefix(input: string): string {
const trimmed = input.trim()
if (!trimmed.startsWith('data:')) return trimmed
const commaIdx = trimmed.indexOf(',')
return commaIdx >= 0 ? trimmed.slice(commaIdx + 1) : trimmed
}
function looksLikeBase64(s: string): boolean {
const t = stripDataUrlPrefix(s).replace(/\s+/g, '').replace(/=+$/g, '')
// base64 típico: solo chars permitidos y longitud razonable
if (t.length < 64) return false
return /^[A-Za-z0-9+/]+$/.test(t)
}
function startsWithZip(bytes: Uint8Array): boolean {
return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b // "PK"
}
function startsWithPdf(bytes: Uint8Array): boolean {
return (
bytes.length >= 5 &&
bytes[0] === 0x25 &&
bytes[1] === 0x50 &&
bytes[2] === 0x44 &&
bytes[3] === 0x46 &&
bytes[4] === 0x2d
) // "%PDF-"
}
function binaryStringToUint8Array(input: string): Uint8Array {
const bytes = new Uint8Array(input.length)
for (let i = 0; i < input.length; i++) bytes[i] = input.charCodeAt(i) & 0xff
return bytes
}
export async function invokeEdge<TOut>(
functionName: string,
body?:
@@ -42,10 +92,16 @@ export async function invokeEdge<TOut>(
): Promise<TOut> {
const supabase = client ?? supabaseBrowser()
const { data, error } = await supabase.functions.invoke(functionName, {
// Nota: algunas versiones/defs de @supabase/supabase-js no tipan `responseType`
// aunque el runtime lo soporte. Usamos `any` para no bloquear el uso de Blob.
const invoke: any = (supabase.functions as any).invoke.bind(
supabase.functions,
)
const { data, error } = await invoke(functionName, {
body,
method: opts.method ?? 'POST',
headers: opts.headers,
responseType: opts.responseType,
})
if (error) {
@@ -104,5 +160,20 @@ export async function invokeEdge<TOut>(
throw new EdgeFunctionError(message, functionName, status, details)
}
if (opts.responseType === 'blob') {
const anyData: unknown = data
if (anyData instanceof Blob) {
return anyData as TOut
}
throw new EdgeFunctionError(
'La Edge Function no devolvió un binario (Blob) válido.',
functionName,
undefined,
{ receivedType: typeof anyData, received: anyData },
)
}
return data as TOut
}
@@ -2036,6 +2036,12 @@ function DatosBasicosManualStep({
publisher: e.target.value.slice(0, 300),
})
}
onBlur={() => {
const trimmed = draft.publisher.trim()
if (trimmed !== draft.publisher) {
onChangeDraft({ ...draft, publisher: trimmed })
}
}}
maxLength={300}
/>
</div>
@@ -2434,9 +2440,17 @@ const FormatoYCitasStep = forwardRef<
onChange={(e) => {
const raw = e.currentTarget.value.slice(0, 300)
onChangeRef(r.id, {
publisher: raw.trim() || undefined,
publisher: raw.length > 0 ? raw : undefined,
})
}}
onBlur={() => {
const trimmed = publisherText.trim()
if (trimmed !== publisherText) {
onChangeRef(r.id, {
publisher: trimmed || undefined,
})
}
}}
/>
</div>
</div>
+78
View File
@@ -0,0 +1,78 @@
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function parseContenidoTematicoToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
const blocks: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const unidad =
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
? item.unidad
: undefined
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
if (!header) continue
const lines: Array<string> = [header]
const temas = Array.isArray(item.temas) ? item.temas : []
temas.forEach((tema, idx) => {
const temaNombre =
typeof tema === 'string'
? tema
: isRecord(tema) && typeof tema.nombre === 'string'
? tema.nombre
: ''
if (!temaNombre) return
if (unidad != null) {
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
} else {
lines.push(`${idx + 1}. ${temaNombre}`)
}
})
blocks.push(lines.join('\n'))
}
return blocks.join('\n\n').trimEnd()
}
export function parseCriteriosEvaluacionToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
const lines: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const label = typeof item.criterio === 'string' ? item.criterio.trim() : ''
const valueNum =
typeof item.porcentaje === 'number'
? item.porcentaje
: typeof item.porcentaje === 'string'
? Number(item.porcentaje)
: NaN
if (!label) continue
if (!Number.isFinite(valueNum)) continue
const v = Math.trunc(valueNum)
if (v < 1 || v > 100) continue
lines.push(`${label}: ${v}%`)
}
return lines.join('\n')
}
export const columnParsers: Partial<
Record<string, (value: unknown) => string>
> = {
contenido_tematico: parseContenidoTematicoToPlainText,
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
}
@@ -73,7 +73,7 @@ const mapAsignaturas = (
estado: asig.estado,
hd: asig.horas_academicas ?? 0,
hi: asig.horas_independientes ?? 0,
prerrequisitos: [],
prerrequisito_asignatura_id: asig.prerrequisito_asignatura_id ?? null,
}))
}
@@ -218,27 +218,31 @@ function AsignaturasPage() {
<Table>
<TableHeader>
<TableRow className="bg-slate-50/50">
<TableHead className="w-30">Clave</TableHead>
<TableHead>Nombre</TableHead>
<TableHead className="text-center">Créditos</TableHead>
<TableHead className="text-center">Ciclo</TableHead>
<TableHead>Línea Curricular</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Estado</TableHead>
<TableHead className="w-12.5"></TableHead>
<TableHead className="w-30 px-6 py-4">Clave</TableHead>
<TableHead className="px-6 py-4">Nombre</TableHead>
<TableHead className="px-6 py-4 text-center">Créditos</TableHead>
<TableHead className="px-6 py-4 text-center">Ciclo</TableHead>
<TableHead className="px-6 py-4">Línea Curricular</TableHead>
<TableHead className="px-6 py-4">Tipo</TableHead>
<TableHead className="px-6 py-4">Estado</TableHead>
<TableHead className="w-12.5 px-6 py-4"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAsignaturas.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="h-40 text-center">
<div className="text-muted-foreground flex flex-col items-center justify-center">
<BookOpen className="mb-2 h-10 w-10 opacity-20" />
<p className="font-medium">No se encontraron asignaturas</p>
<p className="text-xs">
<TableCell colSpan={8} className="h-40 px-6 py-8 text-center">
<div className="text-muted-foreground flex flex-col items-center justify-center gap-3">
<BookOpen className="h-10 w-10 opacity-20" />
<div>
<p className="font-medium">
No se encontraron asignaturas
</p>
<p className="mt-1 text-xs">
Intenta cambiar los filtros de búsqueda
</p>
</div>
</div>
</TableCell>
</TableRow>
) : (
@@ -251,25 +255,25 @@ function AsignaturasPage() {
to: '/planes/$planId/asignaturas/$asignaturaId',
params: {
planId,
asignaturaId: asignatura.id, // 👈 puede ser índice, consecutivo o slug
asignaturaId: asignatura.id,
},
state: {
realId: asignatura.id, // 👈 ID largo oculto
realId: asignatura.id,
asignaturaId: asignatura.id,
} as any,
})
}
>
<TableCell className="font-mono text-xs font-bold text-slate-400">
<TableCell className="px-6 py-4 font-mono text-xs font-bold text-slate-400">
{asignatura.clave}
</TableCell>
<TableCell className="font-semibold text-slate-700">
<TableCell className="px-6 py-4 font-semibold text-slate-700">
{asignatura.nombre}
</TableCell>
<TableCell className="text-center font-medium">
<TableCell className="px-6 py-4 text-center font-medium">
{asignatura.creditos}
</TableCell>
<TableCell className="text-center">
<TableCell className="px-6 py-4 text-center">
{asignatura.ciclo ? (
<Badge variant="outline" className="font-normal">
Ciclo {asignatura.ciclo}
@@ -278,10 +282,10 @@ function AsignaturasPage() {
<span className="text-slate-300"></span>
)}
</TableCell>
<TableCell className="text-sm text-slate-600">
<TableCell className="px-6 py-4 text-sm text-slate-600">
{getLineaNombre(asignatura.lineaCurricularId)}
</TableCell>
<TableCell>
<TableCell className="px-6 py-4">
<Badge
variant="outline"
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`}
@@ -289,7 +293,7 @@ function AsignaturasPage() {
{tipoConfig[asignatura.tipo].label}
</Badge>
</TableCell>
<TableCell>
<TableCell className="px-6 py-4">
<Badge
variant="outline"
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`}
@@ -297,7 +301,7 @@ function AsignaturasPage() {
{statusConfig[asignatura.estado].label}
</Badge>
</TableCell>
<TableCell>
<TableCell className="px-6 py-4">
<div className="opacity-0 transition-opacity group-hover:opacity-100">
<ChevronRight className="h-5 w-5 text-slate-400" />
</div>
@@ -8,10 +8,11 @@ import {
Clock,
FileJson,
} from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { usePlan } from '@/data'
import { fetchPlanPdf } from '@/data/api/document.api'
export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
@@ -20,30 +21,41 @@ export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
function RouteComponent() {
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
const { data: plan } = usePlan(planId)
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const pdfUrlRef = useRef<string | null>(null)
const isMountedRef = useRef<boolean>(false)
const [isLoading, setIsLoading] = useState(true)
const planFileBaseName = sanitizeFileBaseName(plan?.nombre ?? 'plan_estudios')
const loadPdfPreview = useCallback(async () => {
try {
setIsLoading(true)
const pdfBlob = await fetchPlanPdf({ plan_estudio_id: planId })
if (isMountedRef.current) setIsLoading(true)
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
convertTo: 'pdf',
})
if (!isMountedRef.current) return
const url = window.URL.createObjectURL(pdfBlob)
// Limpiar URL anterior si existe para evitar fugas de memoria
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
pdfUrlRef.current = url
setPdfUrl(url)
} catch (error) {
console.error('Error cargando preview:', error)
} finally {
setIsLoading(false)
if (isMountedRef.current) setIsLoading(false)
}
}, [planId])
useEffect(() => {
isMountedRef.current = true
loadPdfPreview()
return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
isMountedRef.current = false
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
}
}, [loadPdfPreview])
@@ -51,12 +63,13 @@ function RouteComponent() {
try {
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
convertTo: 'pdf',
})
const url = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a')
link.href = url
link.download = 'plan_estudios.pdf'
link.download = `${planFileBaseName}.pdf`
document.body.appendChild(link)
link.click()
@@ -67,6 +80,27 @@ function RouteComponent() {
alert('No se pudo generar el PDF')
}
}
const handleDownloadWord = async () => {
try {
const docBlob = await fetchPlanPdf({
plan_estudio_id: planId,
})
const url = window.URL.createObjectURL(docBlob)
const link = document.createElement('a')
link.href = url
link.download = `${planFileBaseName}.docx`
document.body.appendChild(link)
link.click()
link.remove()
setTimeout(() => window.URL.revokeObjectURL(url), 1000)
} catch (error) {
console.error(error)
alert('No se pudo generar el Word')
}
}
return (
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
{/* HEADER DE ACCIONES */}
@@ -88,12 +122,17 @@ function RouteComponent() {
>
<RefreshCcw size={16} /> Regenerar
</Button>
<Button variant="outline" size="sm" className="gap-2">
<Download size={16} /> Descargar Word
</Button>
<Button
size="sm"
className="gap-2 bg-teal-700 hover:bg-teal-800"
onClick={handleDownloadWord}
>
<Download size={16} /> Descargar Word
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={handleDownloadPdf}
>
<Download size={16} /> Descargar PDF
@@ -139,7 +178,7 @@ function RouteComponent() {
)}
</div>
<CardContent className="flex min-h-[800px] justify-center bg-slate-500 p-0">
<CardContent className="flex min-h-200 justify-center bg-slate-500 p-0">
{isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 text-white">
<RefreshCcw size={40} className="animate-spin opacity-50" />
@@ -149,7 +188,7 @@ function RouteComponent() {
/* 3. VISOR DE PDF REAL */
<iframe
src={`${pdfUrl}#toolbar=0&navpanes=0`}
className="h-[1000px] w-full max-w-[1000px] border-none shadow-2xl"
className="h-250 w-full max-w-250 border-none shadow-2xl"
title="PDF Preview"
/>
) : (
@@ -163,6 +202,24 @@ function RouteComponent() {
)
}
function sanitizeFileBaseName(input: string): string {
const text = String(input)
const withoutControlChars = Array.from(text)
.filter((ch) => {
const code = ch.charCodeAt(0)
return code >= 32 && code !== 127
})
.join('')
const cleaned = withoutControlChars
.replace(/[<>:"/\\|?*]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/g, '')
return (cleaned || 'documento').slice(0, 150)
}
// Componente pequeño para las tarjetas de estado superior
function StatusCard({
icon,
@@ -139,13 +139,16 @@ function RouteComponent() {
</div>
<div className="relative space-y-0">
<div className="absolute top-0 bottom-0 left-9 w-px bg-slate-200" />
<div className="absolute top-0 bottom-0 left-6 w-px bg-slate-200 md:left-9" />
{historyEvents.length === 0 ? (
<div className="ml-20 py-10 text-slate-500">No hay registros.</div>
) : (
historyEvents.map((event) => (
<div key={event.id} className="group relative flex gap-6 pb-8">
<div className="relative z-10 flex h-18 flex-col items-center">
<div
key={event.id}
className="group relative flex gap-3 pb-8 md:gap-6"
>
<div className="relative z-10 flex flex-col items-center">
<div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm transition-colors group-hover:bg-teal-50 group-hover:text-teal-600">
{event.icon}
</div>
@@ -153,10 +156,10 @@ function RouteComponent() {
<Card className="flex-1 border-slate-200 shadow-none transition-colors hover:border-teal-200">
<CardContent className="p-4">
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-2">
{/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex flex-col justify-between gap-2 md:flex-row md:items-center">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-bold text-slate-800">
{event.type}
</span>
@@ -172,9 +175,9 @@ function RouteComponent() {
</div>
{/* Grupo de elementos alineados a la derecha */}
<div className="flex items-center gap-4 text-slate-500">
<div className="flex flex-wrap items-center gap-3 text-slate-500 md:gap-4">
{/* Usuario e Icono */}
<div className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-1.5 text-xs">
<User className="h-3.5 w-3.5" />
<span className="text-muted-foreground">
{event.user}
@@ -184,14 +187,14 @@ function RouteComponent() {
{/* Botón Ver Cambios */}
<button
onClick={() => openCompareModal(event)}
className="group/btn flex items-center gap-1.5 text-xs transition-colors hover:text-teal-600"
className="group/btn flex items-center gap-1.5 text-xs font-medium text-teal-600 md:text-slate-500 md:hover:text-teal-600"
>
<Eye className="h-4 w-4 text-slate-400 group-hover/btn:text-teal-600" />
<span>Ver cambios</span>
</button>
{/* Fecha exacta (Solo visible en desktop para no amontonar) */}
<span className="hidden text-[11px] text-slate-400 md:block">
<span className="hidden text-[11px] text-slate-400 lg:block">
{format(event.date, 'yyyy-MM-dd HH:mm')}
</span>
</div>
@@ -233,7 +236,7 @@ function RouteComponent() {
))
)}
{historyEvents.length > 0 && (
<div className="mt-10 ml-20 flex items-center justify-between border-t pt-4">
<div className="mt-10 ml-12 flex flex-col gap-3 border-t pt-4 md:ml-20 md:flex-row md:items-center md:justify-between">
<p className="text-xs text-slate-500">
Mostrando {rawData.length} de {totalRecords} cambios
</p>
+328 -71
View File
@@ -15,6 +15,7 @@ import {
Archive,
Loader2,
Sparkles,
RotateCcw,
} from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react'
@@ -39,6 +40,8 @@ import {
useMessagesByChat,
useUpdateConversationStatus,
useUpdateConversationTitle,
useUpdatePlanFields,
useUpdateRecommendationApplied,
} from '@/data'
import { usePlan } from '@/data/hooks/usePlans'
@@ -128,6 +131,7 @@ function RouteComponent() {
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
const queryClient = useQueryClient()
const scrollRef = useRef<HTMLDivElement>(null)
const isInitialLoad = useRef(true)
const [showArchived, setShowArchived] = useState(false)
const [editingChatId, setEditingChatId] = useState<string | null>(null)
const editableRef = useRef<HTMLSpanElement>(null)
@@ -137,6 +141,16 @@ function RouteComponent() {
null,
)
const [filterQuery, setFilterQuery] = useState('')
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
const [isActionsOpen, setIsActionsOpen] = useState(false)
const [selectedImprovements, setSelectedImprovements] = useState<
Array<string>
>([])
const updatePlan = useUpdatePlanFields()
const updateAppliedStatus = useUpdateRecommendationApplied()
const availableFields = useMemo(() => {
const definicion = data?.estructuras_plan
?.definicion as EstructuraDefinicion
@@ -204,20 +218,110 @@ function RouteComponent() {
return messages
})
}, [mensajesDelChat, activeChatId, availableFields])
const scrollToBottom = () => {
// En tu componente principal (el padre)
const handleApplyMultiple = async (
sugerencias: Array<any>,
dbMessageId: string,
) => {
if (!planId || !data?.datos || sugerencias.length === 0) return
setIsSending(true)
try {
const datosActualizados = { ...data.datos }
for (const sug of sugerencias) {
const key = sug.key
const newValue = sug.newValue
const currentValue = datosActualizados[key]
if (
typeof currentValue === 'object' &&
currentValue !== null &&
'description' in currentValue
) {
datosActualizados[key] = { ...currentValue, description: newValue }
} else {
datosActualizados[key] = newValue
}
}
await updatePlan.mutateAsync({
planId: planId as any,
patch: { datos: datosActualizados },
})
for (const sug of sugerencias) {
try {
await updateAppliedStatus.mutateAsync({
conversacionId: dbMessageId,
campoAfectado: sug.key,
})
console.log(sug.key)
removeSelectedField(sug.key)
} catch (err) {
console.error(
`Error al marcar aplicada la sugerencia: ${sug.key}`,
err,
)
// Si una falla, el bucle sigue con las demás
}
}
setSelectedImprovements([])
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['plan', planId] }),
queryClient.invalidateQueries({ queryKey: ['conversation-messages'] }),
])
} catch (error) {
console.error('Error crítico en aplicación masiva:', error)
} finally {
setIsSending(false)
}
}
const toggleImprovementSelection = (sugKey: string) => {
setSelectedImprovements((prev) =>
prev.includes(sugKey)
? prev.filter((key) => key !== sugKey)
: [...prev, sugKey],
)
}
const toggleAllFromMessage = (suggestions: Array<any>) => {
const pending = suggestions.filter((s) => !s.applied)
const allKeys = pending.map((s) => s.key)
const allSelected = allKeys.every((key) =>
selectedImprovements.includes(key),
)
if (allSelected) {
setSelectedImprovements((prev) =>
prev.filter((key) => !allKeys.includes(key)),
)
} else {
setSelectedImprovements((prev) =>
Array.from(new Set([...prev, ...allKeys])),
)
}
}
const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
if (scrollRef.current) {
// Buscamos el viewport interno del ScrollArea de Radix
const scrollContainer = scrollRef.current.querySelector(
'[data-radix-scroll-area-viewport]',
)
if (scrollContainer) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: 'smooth',
behavior,
})
}
}
}
const { activeChats, archivedChats } = useMemo(() => {
const allChats = lastConversation || []
return {
@@ -229,22 +333,22 @@ function RouteComponent() {
}, [lastConversation])
useEffect(() => {
console.log(mensajesDelChat)
scrollToBottom()
}, [chatMessages, isLoading])
/* useEffect(() => {
// Verificamos cuáles campos de la lista "selectedFields" ya no están presentes en el texto del input
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)
if (chatMessages.length > 0) {
if (isInitialLoad.current) {
// Si es el primer render con mensajes, vamos al final al instante
scrollToBottom('instant')
isInitialLoad.current = false
} else {
// Si ya estaba cargado y llegan nuevos, hacemos el smooth
scrollToBottom('smooth')
}
}, [input, selectedFields]) */
}
}, [chatMessages])
// 2. Resetear el flag cuando cambies de chat activo
useEffect(() => {
isInitialLoad.current = true
}, [activeChatId])
useEffect(() => {
if (isLoadingConv || isSending) return
@@ -472,63 +576,76 @@ function RouteComponent() {
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
const removeSelectedField = (fieldKey: string) => {
console.log(fieldKey)
setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey))
}
return (
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
{/* --- PANEL IZQUIERDO: HISTORIAL --- */}
<div className="flex w-64 flex-col border-r pr-4">
<div className="mb-4">
<div className="mb-4 flex items-center justify-between px-2">
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
Chats
</h2>
{/* Botón de toggle archivados movido aquí arriba */}
<button
onClick={() => setShowArchived(!showArchived)}
className={`rounded-md p-1.5 transition-colors ${
showArchived
? 'bg-teal-50 text-teal-600'
: 'text-slate-400 hover:bg-slate-100'
}`}
title={showArchived ? 'Ver chats activos' : 'Ver archivados'}
>
<Archive size={16} />
</button>
</div>
<div className="flex h-[calc(100vh-80px)] w-full flex-col gap-4 p-4 md:h-[calc(100vh-160px)] md:max-h-[calc(100vh-160px)] md:flex-row md:overflow-hidden">
{/* --- HEADER MÓVIL (Solo visible en < md) --- */}
<div className="flex shrink-0 items-center justify-between rounded-lg border bg-white p-2 shadow-sm md:hidden">
<Button
onClick={createNewChat}
variant="outline"
className="mb-4 w-full justify-start gap-2 border-slate-200 hover:bg-teal-50 hover:text-teal-700"
variant="ghost"
size="sm"
onClick={() => setIsHistoryOpen(true)}
>
<MessageSquarePlus size={18} /> Nuevo chat
<Archive size={18} className="mr-2" /> Historial
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setIsActionsOpen(true)}
>
<Lightbulb size={18} className="mr-2 text-orange-500" /> Acciones
</Button>
</div>
{/* --- PANEL IZQUIERDO: HISTORIAL (Escritorio) --- */}
<div className="hidden w-64 flex-col border-r pr-4 md:flex">
{/* ... (Tu código actual del historial de chats) ... */}
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
Chats
</h2>
<Button
onClick={createNewChat}
variant="outline"
className="mt-2 mb-4 w-full justify-start gap-2"
>
<MessageSquarePlus size={18} /> Nuevo chat
</Button>
<ScrollArea className="flex-1">
<div className="space-y-1">
<div className="space-y-1 pr-2">
{' '}
{/* Agregamos un pr-2 para que el scrollbar no tape botones */}
{!showArchived ? (
activeChats.map((chat) => (
<div
key={chat.id}
onClick={() => setActiveChatId(chat.id)}
className={`group relative flex w-full items-center justify-between overflow-hidden rounded-lg px-3 py-3 text-sm transition-colors ${
className={`group relative flex w-full items-center overflow-hidden rounded-lg px-3 py-3 text-sm transition-colors ${
activeChatId === chat.id
? 'bg-slate-100 font-medium text-slate-900'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
{/* LADO IZQUIERDO: Icono + Texto con Tooltip */}
<div className="flex min-w-0 flex-1 items-center gap-3">
{/* LADO IZQUIERDO: Icono + Texto */}
<div
className="flex min-w-0 flex-1 items-center gap-3 transition-all duration-200"
style={{
// Aplicamos la máscara solo cuando el mouse está encima para que se note el desvanecimiento
// donde aparecen los botones
maskImage:
'linear-gradient(to right, black 70%, transparent 95%)',
WebkitMaskImage:
'linear-gradient(to right, black 70%, transparent 95%)',
}}
>
{/* pr-12 reserva espacio para los botones absolutos */}
<FileText size={16} className="shrink-0 opacity-40" />
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild>
{/* Este contenedor es el que obliga al span a truncarse */}
<div className="max-w-[calc(100%-48px)] min-w-0 flex-1">
<TooltipTrigger asChild className="min-w-0 flex-1">
<div className="min-w-0 flex-1">
<span
ref={
editingChatId === chat.id ? editableRef : null
@@ -558,7 +675,7 @@ function RouteComponent() {
onBlur={(e) => {
if (editingChatId === chat.id) {
const newTitle =
e.currentTarget.textContent?.trim() || ''
e.currentTarget.textContent.trim() || ''
if (newTitle && newTitle !== chat.nombre) {
updateTitleMutation({
id: chat.id,
@@ -574,8 +691,6 @@ function RouteComponent() {
</span>
</div>
</TooltipTrigger>
{/* Tooltip: Solo aparece si no estás editando y el texto es largo */}
{editingChatId !== chat.id && (
<TooltipContent
side="right"
@@ -588,9 +703,9 @@ function RouteComponent() {
</TooltipProvider>
</div>
{/* LADO DERECHO: Acciones con shrink-0 para que no se muevan */}
{/* LADO DERECHO: Acciones ABSOLUTAS */}
<div
className={`flex shrink-0 items-center gap-1 pl-2 opacity-0 transition-opacity 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'
}`}
>
@@ -614,7 +729,7 @@ function RouteComponent() {
</div>
))
) : (
/* Sección de archivados */
/* Sección de archivados (Simplificada para mantener consistencia) */
<div className="animate-in fade-in slide-in-from-left-2 px-1">
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
Archivados
@@ -622,18 +737,18 @@ function RouteComponent() {
{archivedChats.map((chat) => (
<div
key={chat.id}
className="group relative mb-1 flex w-full items-center justify-between 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 overflow-hidden 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">
<div className="flex min-w-0 flex-1 items-center gap-3 pr-10">
<Archive size={14} className="shrink-0 opacity-30" />
<span className="block min-w-0 flex-1 truncate">
<span className="block truncate">
{chat.nombre ||
`Archivado ${chat.creado_en.split('T')[0]}`}
</span>
</div>
<button
onClick={(e) => unarchiveChat(e, chat.id)}
className="ml-2 shrink-0 rounded bg-slate-50/80 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-teal-600"
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"
>
<RotateCcw size={14} />
</button>
@@ -644,10 +759,11 @@ function RouteComponent() {
</div>
</ScrollArea>
</div>
{/* PANEL DE CHAT PRINCIPAL */}
<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">
{/* --- PANEL DE CHAT PRINCIPAL (Centro) --- */}
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm md:h-full md:flex-[3]">
{/* NUEVO: Barra superior de campos seleccionados */}
<div className="shrink-0 border-b bg-white p-3">
<div className="z-10 shrink-0 border-b bg-white p-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase">
Mejorar con IA
@@ -668,9 +784,9 @@ function RouteComponent() {
</div>
{/* CONTENIDO DEL CHAT */}
<div className="relative min-h-0 flex-1">
<div className="relative flex min-h-0 flex-1 flex-col">
<ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6">
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
{!activeChatId &&
chatMessages.length === 0 &&
!optimisticMessage ? (
@@ -735,9 +851,50 @@ function RouteComponent() {
{/* Recomendaciones */}
{isAI && msg.suggestions?.length > 0 && (
<div className="mt-4">
<div className="mt-4 w-full space-y-3 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
<div className="flex items-center justify-between px-1">
<span className="text-[10px] font-bold text-slate-400 uppercase">
Sugerencias de mejora
</span>
{/* Botón Seleccionar Todo */}
{msg.suggestions.some(
(s: any) => !s.applied,
) && (
<Button
variant="ghost"
className="h-6 px-2 text-[10px] text-teal-600 hover:bg-teal-100"
onClick={() =>
toggleAllFromMessage(msg.suggestions)
}
>
{msg.suggestions
.filter((s: any) => !s.applied)
.every((s: any) =>
selectedImprovements.includes(s.key),
)
? 'Desmarcar todo'
: 'Seleccionar todo'}
</Button>
)}
</div>
{msg.suggestions.map((sug: any) => (
<div key={sug.key} className="flex gap-2">
{!sug.applied && (
<input
type="checkbox"
className="mt-4 h-4 w-4 shrink-0 rounded border-slate-300 accent-teal-600"
checked={selectedImprovements.includes(
sug.key,
)}
onChange={() =>
toggleImprovementSelection(sug.key)
}
/>
)}
<div className="flex-1">
<ImprovementCard
suggestions={msg.suggestions} // Usamos el nombre normalizado en el flatMap
suggestions={[sug]} // Pasamos solo una para que la card actúe individualmente
dbMessageId={msg.dbMessageId}
planId={planId}
currentDatos={data?.datos}
@@ -747,6 +904,46 @@ function RouteComponent() {
}
/>
</div>
</div>
))}
{/* Botón de Acción Masiva específico para este mensaje */}
{msg.suggestions.some((s: any) =>
selectedImprovements.includes(s.key),
) && (
<Button
size="sm"
disabled={isSending}
className="w-full bg-teal-600 py-1 text-xs font-bold text-white hover:bg-teal-700"
onClick={() => {
const seleccionadas =
msg.suggestions.filter((s: any) =>
selectedImprovements.includes(s.key),
)
handleApplyMultiple(
seleccionadas,
msg.dbMessageId,
)
}}
>
{isSending ? (
<Loader2
className="mr-2 animate-spin"
size={12}
/>
) : (
<Check className="mr-2" size={12} />
)}
Aplicar seleccionadas (
{
msg.suggestions.filter((s: any) =>
selectedImprovements.includes(s.key),
).length
}
)
</Button>
)}
</div>
)}
</div>
</div>
@@ -920,8 +1117,9 @@ function RouteComponent() {
</div>
</div>
</div>
{/* PANEL LATERAL */}
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
{/* --- PANEL LATERAL: ACCIONES RÁPIDAS (Escritorio) --- */}
<div className="hidden flex-[1] flex-col gap-4 overflow-y-auto pr-2 md:flex">
<h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800">
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
</h4>
@@ -942,6 +1140,65 @@ function RouteComponent() {
))}
</div>
</div>
{/* --- DRAWER: HISTORIAL (Móvil) --- */}
<Drawer open={isHistoryOpen} onOpenChange={setIsHistoryOpen}>
<DrawerContent className="h-[80vh] p-4">
<Button
onClick={() => {
createNewChat()
setIsHistoryOpen(false)
}}
className="mb-4 w-full bg-teal-600 text-white"
>
<MessageSquarePlus size={18} className="mr-2" /> Nuevo Chat
</Button>
<ScrollArea className="flex-1">
{/* Reutiliza aquí el mapeo de chats que tienes en el panel izquierdo */}
<p className="mb-4 text-xs font-bold text-slate-400 uppercase">
Historial Reciente
</p>
{activeChats.map((chat) => (
<div
key={chat.id}
onClick={() => {
setActiveChatId(chat.id)
setIsHistoryOpen(false)
}}
className="border-b p-3 text-sm"
>
{chat.nombre || 'Chat sin nombre'}
</div>
))}
</ScrollArea>
</DrawerContent>
</Drawer>
{/* --- DRAWER: ACCIONES RÁPIDAS (Móvil) --- */}
<Drawer open={isActionsOpen} onOpenChange={setIsActionsOpen}>
<DrawerContent className="h-[60vh] p-4">
<h4 className="mb-4 flex items-center gap-2 font-bold">
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
</h4>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => {
handleSend(preset.prompt)
setIsActionsOpen(false)
}}
className="flex items-center gap-3 rounded-xl border p-4 text-left text-sm"
>
<preset.icon size={16} />
<span>{preset.label}</span>
</button>
))}
</div>
</DrawerContent>
</Drawer>
{/* Tu Drawer de Referencias IA se queda igual */}
<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">
{/* Cabecera más compacta */}
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,8 @@
import { createFileRoute, useParams } from '@tanstack/react-router'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
import { useSubject } from '@/data'
import { fetchAsignaturaPdf } from '@/data/api/document.api'
export const Route = createFileRoute(
@@ -15,48 +16,75 @@ function RouteComponent() {
from: '/planes/$planId/asignaturas/$asignaturaId/documento',
})
const { data: asignatura } = useSubject(asignaturaId)
const asignaturaFileBaseName = sanitizeFileBaseName(
asignatura?.nombre ?? 'documento_sep',
)
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const pdfUrlRef = useRef<string | null>(null)
const isMountedRef = useRef<boolean>(false)
const [isLoading, setIsLoading] = useState(true)
const [isRegenerating, setIsRegenerating] = useState(false)
const loadPdfPreview = useCallback(async () => {
try {
setIsLoading(true)
if (isMountedRef.current) setIsLoading(true)
const pdfBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId,
convertTo: 'pdf',
})
if (!isMountedRef.current) return
const url = window.URL.createObjectURL(pdfBlob)
setPdfUrl((prev) => {
if (prev) window.URL.revokeObjectURL(prev)
return url
})
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
pdfUrlRef.current = url
setPdfUrl(url)
} catch (error) {
console.error('Error cargando PDF:', error)
} finally {
setIsLoading(false)
if (isMountedRef.current) setIsLoading(false)
}
}, [asignaturaId])
useEffect(() => {
isMountedRef.current = true
loadPdfPreview()
return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
isMountedRef.current = false
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
}
}, [loadPdfPreview])
const handleDownload = async () => {
const handleDownloadPdf = async () => {
const pdfBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId,
convertTo: 'pdf',
})
const url = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a')
link.href = url
link.download = 'documento_sep.pdf'
link.download = `${asignaturaFileBaseName}.pdf`
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
}
const handleDownloadWord = async () => {
const docBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId,
})
const url = window.URL.createObjectURL(docBlob)
const link = document.createElement('a')
link.href = url
link.download = `${asignaturaFileBaseName}.docx`
document.body.appendChild(link)
link.click()
link.remove()
@@ -77,9 +105,28 @@ function RouteComponent() {
<DocumentoSEPTab
pdfUrl={pdfUrl}
isLoading={isLoading}
onDownload={handleDownload}
onDownloadPdf={handleDownloadPdf}
onDownloadWord={handleDownloadWord}
onRegenerate={handleRegenerate}
isRegenerating={isRegenerating}
/>
)
}
function sanitizeFileBaseName(input: string): string {
const text = String(input)
const withoutControlChars = Array.from(text)
.filter((ch) => {
const code = ch.charCodeAt(0)
return code >= 32 && code !== 127
})
.join('')
const cleaned = withoutControlChars
.replace(/[<>:"/\\|?*]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/g, '')
return (cleaned || 'documento').slice(0, 150)
}
@@ -166,30 +166,20 @@ function AsignaturaLayout() {
onSave={(val) => handleUpdateHeader('nombre', val)}
/>
</h1>
{
// console.log(headerData),
console.log(asignaturaApi.planes_estudio?.nombre)
}
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
<span className="flex items-center gap-1">
<GraduationCap className="h-4 w-4 shrink-0" />
Pertenece al plan:{' '}
<span className="text-blue-100">
{(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 ?? ''}
{(asignaturaApi.planes_estudio as DatosPlan).nombre || ''}
</span>
</span>
</div>
<p className="text-sm text-blue-300">
Pertenece al plan:{' '}
<span className="cursor-pointer underline">
{asignaturaApi.planes_estudio?.nombre}
</span>
</p>
</div>
<div className="flex flex-col items-end gap-2 text-right">
@@ -225,9 +215,14 @@ function AsignaturaLayout() {
{/* TABS */}
<nav className="border-b bg-white">
<nav className="sticky top-0 z-20 border-b bg-white">
<div className="mx-auto max-w-7xl px-6">
<div className="flex justify-center gap-8">
{/* CAMBIOS CLAVE:
1. overflow-x-auto: Permite scroll horizontal.
2. scrollbar-hide: (Opcional) para que no se vea la barra fea.
3. justify-start md:justify-center: Alineado a la izquierda en móvil para que el scroll funcione, centrado en desktop.
*/}
<div className="no-scrollbar flex items-center justify-start gap-8 overflow-x-auto whitespace-nowrap md:justify-center">
{[
{ label: 'Datos', to: '' },
{ label: 'Contenido', to: 'contenido' },
@@ -251,7 +246,7 @@ function AsignaturaLayout() {
}
from="/planes/$planId/asignaturas/$asignaturaId"
params={{ planId, asignaturaId }}
className={`border-b-2 py-3 text-sm font-medium ${
className={`shrink-0 border-b-2 py-4 text-sm font-medium transition-colors ${
isActive
? 'border-blue-600 text-blue-600'
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700'
+139 -12
View File
@@ -4,18 +4,145 @@
@custom-variant dark (&:is(.dark *));
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-Light.otf') format('opentype');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-LightItalic.otf')
format('opentype');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-Regular.otf') format('opentype');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-RegularItalic.otf')
format('opentype');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-Bold.otf') format('opentype');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-BoldItalic.otf') format('opentype');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-Black.otf') format('opentype');
font-weight: 900;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-BlackItalic.otf')
format('opentype');
font-weight: 900;
font-style: italic;
}
/* Serif */
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-Light.otf') format('opentype');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-LightItalic.otf')
format('opentype');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-Regular.otf') format('opentype');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-RegularItalic.otf')
format('opentype');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-Bold.otf') format('opentype');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-BoldItalic.otf')
format('opentype');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-Black.otf') format('opentype');
font-weight: 900;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-BlackItalic.otf')
format('opentype');
font-weight: 900;
font-style: italic;
}
body {
@apply m-0;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family:
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
font-family: var(--font-mono);
}
strong,
b,
.font-bold {
font-family: 'Indivisa Sans', serif;
font-weight: 900;
/* Inter letter space */
letter-spacing: -0.025em;
}
:root {
@@ -51,9 +178,9 @@ code {
--sidebar-accent-foreground: oklch(0.6304 0.2472 28.2698);
--sidebar-border: oklch(0.9401 0 0);
--sidebar-ring: oklch(0 0 0);
--font-sans: Plus Jakarta Sans, sans-serif;
--font-serif: Lora, serif;
--font-mono: IBM Plex Mono, monospace;
--font-sans: 'Indivisa Sans', sans-serif;
--font-serif: 'Indivisa Serif', serif;
--font-mono: 'Indivisa Sans', monospace;
--radius: 1.4rem;
--shadow-x: 0px;
--shadow-y: 2px;
@@ -101,7 +228,7 @@ code {
--chart-1: oklch(0.6686 0.1794 251.7436);
--chart-2: oklch(0.6342 0.2516 22.4415);
--chart-3: oklch(0.8718 0.1716 90.9505);
--chart-4: oklch(0.4503 0.229 263.0881);
--chart-4: oklch(11.492% 0.00001 271.152);
--chart-5: oklch(0.8322 0.146 185.9404);
--sidebar: oklch(0.1564 0.0688 261.2771);
--sidebar-foreground: oklch(0.9551 0 0);
@@ -111,9 +238,9 @@ code {
--sidebar-accent-foreground: oklch(0.6786 0.2095 24.6583);
--sidebar-border: oklch(0.3289 0.0092 268.3843);
--sidebar-ring: oklch(0.6048 0.2166 257.2136);
--font-sans: Plus Jakarta Sans, sans-serif;
--font-serif: Lora, serif;
--font-mono: IBM Plex Mono, monospace;
--font-sans: 'Indivisa Sans', sans-serif;
--font-serif: 'Indivisa Serif', serif;
--font-mono: 'Indivisa Sans', monospace;
--radius: 1.4rem;
--shadow-x: 0px;
--shadow-y: 2px;
+1 -1
View File
@@ -50,7 +50,7 @@ export interface Asignatura {
orden?: number
hd: number // <--- Añadir
hi: number // <--- Añadir
prerrequisitos: Array<string>
prerrequisito_asignatura_id: string | null
}
export interface Plan {
+18
View File
@@ -154,6 +154,7 @@ export type Database = {
numero_ciclo: number | null
orden_celda: number | null
plan_estudio_id: string
prerrequisito_asignatura_id: string | null
tipo: Database['public']['Enums']['tipo_asignatura']
tipo_origen: Database['public']['Enums']['tipo_origen'] | null
}
@@ -179,6 +180,7 @@ export type Database = {
numero_ciclo?: number | null
orden_celda?: number | null
plan_estudio_id: string
prerrequisito_asignatura_id?: string | null
tipo?: Database['public']['Enums']['tipo_asignatura']
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
}
@@ -204,6 +206,7 @@ export type Database = {
numero_ciclo?: number | null
orden_celda?: number | null
plan_estudio_id?: string
prerrequisito_asignatura_id?: string | null
tipo?: Database['public']['Enums']['tipo_asignatura']
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
}
@@ -257,6 +260,20 @@ export type Database = {
referencedRelation: 'plantilla_plan'
referencedColumns: ['plan_estudio_id']
},
{
foreignKeyName: 'asignaturas_prerrequisito_asignatura_id_fkey'
columns: ['prerrequisito_asignatura_id']
isOneToOne: false
referencedRelation: 'asignaturas'
referencedColumns: ['id']
},
{
foreignKeyName: 'asignaturas_prerrequisito_asignatura_id_fkey'
columns: ['prerrequisito_asignatura_id']
isOneToOne: false
referencedRelation: 'plantilla_asignatura'
referencedColumns: ['asignatura_id']
},
]
}
bibliografia_asignatura: {
@@ -1376,6 +1393,7 @@ export type Database = {
Args: { p_append: Json; p_id: string }
Returns: undefined
}
suma_porcentajes: { Args: { '': Json }; Returns: number }
unaccent: { Args: { '': string }; Returns: string }
unaccent_immutable: { Args: { '': string }; Returns: string }
}
+14
View File
@@ -0,0 +1,14 @@
{
"navigationFallback": {
"rewrite": "/index.html",
"exclude": [
"/assets/*",
"/*.css",
"/*.js",
"/*.ico",
"/*.png",
"/*.jpg",
"/*.svg"
]
}
}