Author SHA1 Message Date
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
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
29 changed files with 1473 additions and 569 deletions
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.
@@ -15,6 +15,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects' import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
import { columnParsers } from '@/lib/asignaturaColumnParsers'
export interface BibliografiaEntry { export interface BibliografiaEntry {
id: string id: string
@@ -38,6 +39,10 @@ export interface AsignaturaResponse {
datos: AsignaturaDatos datos: AsignaturaDatos
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
type CriterioEvaluacionRow = { type CriterioEvaluacionRow = {
criterio: string criterio: string
porcentaje: number porcentaje: number
@@ -791,80 +796,3 @@ function EvaluationView({ items }: { items: Array<CriterioEvaluacionRow> }) {
</div> </div>
) )
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function parseContenidoTematicoToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
const blocks: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const unidad =
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
? item.unidad
: undefined
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
if (!header) continue
const lines: Array<string> = [header]
const temas = Array.isArray(item.temas) ? item.temas : []
temas.forEach((tema, idx) => {
const temaNombre =
typeof tema === 'string'
? tema
: isRecord(tema) && typeof tema.nombre === 'string'
? tema.nombre
: ''
if (!temaNombre) return
if (unidad != null) {
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
} else {
lines.push(`${idx + 1}. ${temaNombre}`)
}
})
blocks.push(lines.join('\n'))
}
return blocks.join('\n\n').trimEnd()
}
function parseCriteriosEvaluacionToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
const lines: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const label = typeof item.criterio === 'string' ? item.criterio.trim() : ''
const valueNum =
typeof item.porcentaje === 'number'
? item.porcentaje
: typeof item.porcentaje === 'string'
? Number(item.porcentaje)
: NaN
if (!label) continue
if (!Number.isFinite(valueNum)) continue
const v = Math.trunc(valueNum)
if (v < 1 || v > 100) continue
lines.push(`${label}: ${v}%`)
}
return lines.join('\n')
}
const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
contenido_tematico: parseContenidoTematicoToPlainText,
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
}
@@ -18,7 +18,8 @@ import { Card } from '@/components/ui/card'
interface DocumentoSEPTabProps { interface DocumentoSEPTabProps {
pdfUrl: string | null pdfUrl: string | null
isLoading: boolean isLoading: boolean
onDownload: () => void onDownloadPdf: () => void
onDownloadWord: () => void
onRegenerate: () => void onRegenerate: () => void
isRegenerating: boolean isRegenerating: boolean
} }
@@ -26,7 +27,8 @@ interface DocumentoSEPTabProps {
export function DocumentoSEPTab({ export function DocumentoSEPTab({
pdfUrl, pdfUrl,
isLoading, isLoading,
onDownload, onDownloadPdf,
onDownloadWord,
onRegenerate, onRegenerate,
isRegenerating, isRegenerating,
}: DocumentoSEPTabProps) { }: DocumentoSEPTabProps) {
@@ -52,25 +54,23 @@ export function DocumentoSEPTab({
</div> </div>
<div className="flex items-center gap-2"> <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 <AlertDialog
open={showConfirmDialog} open={showConfirmDialog}
onOpenChange={setShowConfirmDialog} onOpenChange={setShowConfirmDialog}
> >
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button disabled={isRegenerating}> <Button
variant="outline"
size="sm"
className="gap-2"
disabled={isRegenerating}
>
{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> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
@@ -91,11 +91,31 @@ export function DocumentoSEPTab({
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </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>
</div> </div>
{/* PDF Preview */} {/* PDF Preview */}
<Card className="h-[800px] overflow-hidden"> <Card className="h-200 overflow-hidden">
{isLoading ? ( {isLoading ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<Loader2 className="h-10 w-10 animate-spin" /> <Loader2 className="h-10 w-10 animate-spin" />
@@ -20,12 +20,15 @@ import { useState, useEffect, useRef, useMemo } from 'react'
import { ImprovementCard } from './SaveAsignatura/ImprovementCardProps' import { ImprovementCard } from './SaveAsignatura/ImprovementCardProps'
import type { IASugerencia } from '@/types/asignatura'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA' import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer' import {
Drawer,
DrawerContent,
DrawerOverlay,
DrawerPortal,
} from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { import {
@@ -50,16 +53,7 @@ interface SelectedField {
value: string value: string
} }
interface IAAsignaturaTabProps { export function IAAsignaturaTab() {
asignatura?: Record<string, any>
onAcceptSuggestion: (sugerencia: IASugerencia) => void
onRejectSuggestion: (messageId: string) => void
}
export function IAAsignaturaTab({
onAcceptSuggestion,
onRejectSuggestion,
}: IAAsignaturaTabProps) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { asignaturaId } = useParams({ const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
@@ -75,6 +69,7 @@ export function IAAsignaturaTab({
const [showSuggestions, setShowSuggestions] = useState(false) const [showSuggestions, setShowSuggestions] = useState(false)
const [isSending, setIsSending] = useState(false) const [isSending, setIsSending] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
// --- DATA QUERIES --- // --- DATA QUERIES ---
const { data: datosGenerales } = useSubject(asignaturaId) const { data: datosGenerales } = useSubject(asignaturaId)
@@ -141,7 +136,7 @@ export function IAAsignaturaTab({
const dynamicFields = datosGenerales?.datos const dynamicFields = datosGenerales?.datos
? Object.keys(datosGenerales.datos).map((key) => { ? Object.keys(datosGenerales.datos).map((key) => {
const estructuraProps = const estructuraProps =
datosGenerales?.estructuras_asignatura?.definicion?.properties || {} datosGenerales.estructuras_asignatura?.definicion?.properties || {}
return { return {
key, key,
label: label:
@@ -234,12 +229,38 @@ export function IAAsignaturaTab({
} }
}, [activeChats, loadingConv]) }, [activeChats, loadingConv])
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(() => { const filteredFields = useMemo(() => {
if (!showSuggestions) return availableFields 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 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( return availableFields.filter(
(f) => (f) =>
@@ -259,24 +280,28 @@ export function IAAsignaturaTab({
// 3. Función para insertar el campo y limpiar el prompt // 3. Función para insertar el campo y limpiar el prompt
const handleSelectField = (field: SelectedField) => { const handleSelectField = (field: SelectedField) => {
// 1. Agregamos al array de objetos (para tu lógica de API)
if (!selectedFields.find((f) => f.key === field.key)) { if (!selectedFields.find((f) => f.key === field.key)) {
setSelectedFields((prev) => [...prev, field]) setSelectedFields((prev) => [...prev, field])
} }
// 2. Lógica de autocompletado en el texto
const lastColonIndex = input.lastIndexOf(':') const lastColonIndex = input.lastIndexOf(':')
if (lastColonIndex !== -1) { if (lastColonIndex !== -1) {
// Tomamos lo que había antes del ":" + el Nombre del Campo + un espacio const parteAntesDelColon = input.slice(0, lastColonIndex)
const nuevoTexto = input.slice(0, lastColonIndex) + `${field.label} `
// 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) setInput(nuevoTexto)
} }
// 3. Cerramos el buscador y devolvemos el foco al textarea
setShowSuggestions(false) setShowSuggestions(false)
// Opcional: Si tienes una ref del textarea, puedes hacer:
// textareaRef.current?.focus()
} }
const handleSaveName = (id: string) => { const handleSaveName = (id: string) => {
@@ -355,10 +380,35 @@ export function IAAsignaturaTab({
] ]
return ( return (
<div className="flex h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4"> <div className="flex h-full w-full overflow-hidden bg-white">
{/* PANEL IZQUIERDO */} <div className="fixed top-0 z-40 flex w-full items-center justify-between border-b bg-white/80 p-2 backdrop-blur-md">
<div className="flex w-64 flex-col border-r pr-4"> <Button
<div className="mb-4 flex items-center justify-between px-2"> 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"> <h2 className="flex items-center gap-2 text-xs font-bold text-slate-500 uppercase">
<History size={14} /> Historial <History size={14} /> Historial
</h2> </h2>
@@ -378,25 +428,19 @@ export function IAAsignaturaTab({
<Button <Button
onClick={() => { onClick={() => {
setActiveChatId(undefined) setActiveChatId(undefined)
hasInitialSelected.current = true
setIsCreatingNewChat(true) setIsCreatingNewChat(true)
setInput('') setInput('')
setSelectedFields([]) setSelectedFields([])
// 4. Opcional: Limpiar el caché de mensajes actual para que la pantalla se vea vacía al instante
queryClient.setQueryData(['subject-messages', undefined], []) queryClient.setQueryData(['subject-messages', undefined], [])
}} }}
variant="outline" 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 <MessageSquarePlus size={18} /> Nuevo Chat
</Button> </Button>
{/* PANEL IZQUIERDO - Cambios en ScrollArea y contenedor */}
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="flex flex-col gap-1 pr-3"> <div className="flex flex-col gap-1 pr-3">
{' '}
{/* Eliminado space-y-1 para mejor control con gap */}
{(showArchived ? archivedChats : activeChats).map((chat: any) => ( {(showArchived ? archivedChats : activeChats).map((chat: any) => (
<div <div
key={chat.id} key={chat.id}
@@ -507,31 +551,51 @@ export function IAAsignaturaTab({
))} ))}
</div> </div>
</ScrollArea> </ScrollArea>
</aside>
{/* 2. PANEL CENTRAL (CHAT) - EL RECUADRO ESTILIZADO */}
<main 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:m-2">
{/* 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> </div>
{/* PANEL CENTRAL */} <div className="flex gap-2">
<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>
<button <button
onClick={() => setOpenIA(true)} 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" /> <FileText size={14} />
Referencias <span className="xs:inline hidden">Referencias</span>
{totalReferencias > 0 && ( {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} {totalReferencias}
</span> </span>
)} )}
</button> </button>
</div> </div>
</div>
{/* Área de Mensajes */}
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
<ScrollArea ref={scrollRef} className="h-full w-full"> <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) => ( {messages.map((msg) => (
<div <div
key={msg.id} key={msg.id}
@@ -642,8 +706,9 @@ export function IAAsignaturaTab({
</ScrollArea> </ScrollArea>
</div> </div>
{/* INPUT */} {/* Input de Chat */}
<div className="shrink-0 border-t bg-white p-4"> <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"> <div className="relative mx-auto max-w-4xl">
{showSuggestions && ( {showSuggestions && (
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full left-0 z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl"> <div className="animate-in 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 +770,28 @@ export function IAAsignaturaTab({
<Textarea <Textarea
value={input} value={input}
onChange={(e) => { onChange={(e) => {
setInput(e.target.value) const val = e.target.value
if (e.target.value.endsWith(':')) setShowSuggestions(true) const cursor = e.target.selectionStart
else if (showSuggestions && !e.target.value.includes(':')) 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) setShowSuggestions(false)
}
} else {
setShowSuggestions(false)
}
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
@@ -722,7 +805,8 @@ export function IAAsignaturaTab({
<Button <Button
onClick={() => handleSend()} onClick={() => handleSend()}
disabled={ disabled={
(!input.trim() && selectedFields.length === 0) || isSending (!input.trim() && selectedFields.length === 0) ||
isSending
} }
size="icon" size="icon"
className="h-9 w-9 bg-teal-600 hover:bg-teal-700" className="h-9 w-9 bg-teal-600 hover:bg-teal-700"
@@ -733,29 +817,33 @@ export function IAAsignaturaTab({
</div> </div>
</div> </div>
</div> </div>
</div> </footer>
</main>
{/* PANEL DERECHO ACCIONES */} {/* 3. PANEL DERECHO (ATAJOS) */}
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2"> <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"> <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> </h4>
<div className="space-y-2"> <div className="space-y-2">
{PRESETS.map((preset) => ( {PRESETS.map((preset) => (
<button <button
key={preset.id} key={preset.id}
onClick={() => handleSend(preset.prompt)} 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} /> <preset.icon size={16} />
</div> </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> </button>
))} ))}
</div> </div>
</div> </aside>
{/* --- DRAWER DE REFERENCIAS --- */}
{/* DRAWERS (Referencias e Historial Móvil) */}
<Drawer open={openIA} onOpenChange={setOpenIA}> <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"> <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"> <div className="flex items-center justify-between border-b bg-slate-50/50 px-4 py-3">
@@ -790,6 +878,140 @@ export function IAAsignaturaTab({
</div> </div>
</DrawerContent> </DrawerContent>
</Drawer> </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">
{/* 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> </div>
) )
} }
+60 -26
View File
@@ -1,52 +1,86 @@
// document.api.ts // document.api.ts
const DOCUMENT_PDF_URL = import { supabaseBrowser } from '../supabase/client'
'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434' import { invokeEdge } from '../supabase/invokeEdge'
const DOCUMENT_PDF_ASIGNATURA_URL = import { requireData, throwIfError } from './_helpers'
'https://n8n.app.lci.ulsa.mx/webhook/041a68be-7568-46d0-bc08-09ded12d017d'
import type { Tables } from '@/types/supabase'
const EDGE = {
carbone_io_wrapper: 'carbone-io-wrapper',
} as const
interface GeneratePdfParams { interface GeneratePdfParams {
plan_estudio_id: string plan_estudio_id: string
convertTo?: 'pdf'
} }
interface GeneratePdfParamsAsignatura { interface GeneratePdfParamsAsignatura {
asignatura_id: string asignatura_id: string
convertTo?: 'pdf'
} }
export async function fetchPlanPdf({ export async function fetchPlanPdf({
plan_estudio_id, plan_estudio_id,
convertTo,
}: GeneratePdfParams): Promise<Blob> { }: GeneratePdfParams): Promise<Blob> {
const response = await fetch(DOCUMENT_PDF_URL, { return await invokeEdge<Blob>(
method: 'POST', EDGE.carbone_io_wrapper,
{
action: 'downloadReport',
plan_estudio_id,
body: convertTo ? { convertTo } : {},
},
{
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ plan_estudio_id }), responseType: 'blob',
}) },
)
if (!response.ok) {
throw new Error('Error al generar el PDF')
}
// n8n devuelve el archivo → lo tratamos como blob
return await response.blob()
} }
export async function fetchAsignaturaPdf({ export async function fetchAsignaturaPdf({
asignatura_id, asignatura_id,
convertTo,
}: GeneratePdfParamsAsignatura): Promise<Blob> { }: GeneratePdfParamsAsignatura): Promise<Blob> {
const response = await fetch(DOCUMENT_PDF_ASIGNATURA_URL, { const supabase = supabaseBrowser()
method: 'POST',
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: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ asignatura_id }), responseType: 'blob',
}) },
)
if (!response.ok) {
throw new Error('Error al generar el PDF')
}
// n8n devuelve el archivo → lo tratamos como blob
return await response.blob()
} }
+72 -1
View File
@@ -12,6 +12,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
export type EdgeInvokeOptions = { export type EdgeInvokeOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
headers?: Record<string, string> headers?: Record<string, string>
responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer'
} }
export class EdgeFunctionError extends Error { 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>( export async function invokeEdge<TOut>(
functionName: string, functionName: string,
body?: body?:
@@ -42,10 +92,16 @@ export async function invokeEdge<TOut>(
): Promise<TOut> { ): Promise<TOut> {
const supabase = client ?? supabaseBrowser() 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, body,
method: opts.method ?? 'POST', method: opts.method ?? 'POST',
headers: opts.headers, headers: opts.headers,
responseType: opts.responseType,
}) })
if (error) { if (error) {
@@ -104,5 +160,20 @@ export async function invokeEdge<TOut>(
throw new EdgeFunctionError(message, functionName, status, details) 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 return data as TOut
} }
+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,
}
@@ -218,27 +218,31 @@ function AsignaturasPage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-slate-50/50"> <TableRow className="bg-slate-50/50">
<TableHead className="w-30">Clave</TableHead> <TableHead className="w-30 px-6 py-4">Clave</TableHead>
<TableHead>Nombre</TableHead> <TableHead className="px-6 py-4">Nombre</TableHead>
<TableHead className="text-center">Créditos</TableHead> <TableHead className="px-6 py-4 text-center">Créditos</TableHead>
<TableHead className="text-center">Ciclo</TableHead> <TableHead className="px-6 py-4 text-center">Ciclo</TableHead>
<TableHead>Línea Curricular</TableHead> <TableHead className="px-6 py-4">Línea Curricular</TableHead>
<TableHead>Tipo</TableHead> <TableHead className="px-6 py-4">Tipo</TableHead>
<TableHead>Estado</TableHead> <TableHead className="px-6 py-4">Estado</TableHead>
<TableHead className="w-12.5"></TableHead> <TableHead className="w-12.5 px-6 py-4"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredAsignaturas.length === 0 ? ( {filteredAsignaturas.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={8} className="h-40 text-center"> <TableCell colSpan={8} className="h-40 px-6 py-8 text-center">
<div className="text-muted-foreground flex flex-col items-center justify-center"> <div className="text-muted-foreground flex flex-col items-center justify-center gap-3">
<BookOpen className="mb-2 h-10 w-10 opacity-20" /> <BookOpen className="h-10 w-10 opacity-20" />
<p className="font-medium">No se encontraron asignaturas</p> <div>
<p className="text-xs"> <p className="font-medium">
No se encontraron asignaturas
</p>
<p className="mt-1 text-xs">
Intenta cambiar los filtros de búsqueda Intenta cambiar los filtros de búsqueda
</p> </p>
</div> </div>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
@@ -251,25 +255,25 @@ function AsignaturasPage() {
to: '/planes/$planId/asignaturas/$asignaturaId', to: '/planes/$planId/asignaturas/$asignaturaId',
params: { params: {
planId, planId,
asignaturaId: asignatura.id, // 👈 puede ser índice, consecutivo o slug asignaturaId: asignatura.id,
}, },
state: { state: {
realId: asignatura.id, // 👈 ID largo oculto realId: asignatura.id,
asignaturaId: asignatura.id, asignaturaId: asignatura.id,
} as any, } 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} {asignatura.clave}
</TableCell> </TableCell>
<TableCell className="font-semibold text-slate-700"> <TableCell className="px-6 py-4 font-semibold text-slate-700">
{asignatura.nombre} {asignatura.nombre}
</TableCell> </TableCell>
<TableCell className="text-center font-medium"> <TableCell className="px-6 py-4 text-center font-medium">
{asignatura.creditos} {asignatura.creditos}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="px-6 py-4 text-center">
{asignatura.ciclo ? ( {asignatura.ciclo ? (
<Badge variant="outline" className="font-normal"> <Badge variant="outline" className="font-normal">
Ciclo {asignatura.ciclo} Ciclo {asignatura.ciclo}
@@ -278,10 +282,10 @@ function AsignaturasPage() {
<span className="text-slate-300"></span> <span className="text-slate-300"></span>
)} )}
</TableCell> </TableCell>
<TableCell className="text-sm text-slate-600"> <TableCell className="px-6 py-4 text-sm text-slate-600">
{getLineaNombre(asignatura.lineaCurricularId)} {getLineaNombre(asignatura.lineaCurricularId)}
</TableCell> </TableCell>
<TableCell> <TableCell className="px-6 py-4">
<Badge <Badge
variant="outline" variant="outline"
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`} className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`}
@@ -289,7 +293,7 @@ function AsignaturasPage() {
{tipoConfig[asignatura.tipo].label} {tipoConfig[asignatura.tipo].label}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell className="px-6 py-4">
<Badge <Badge
variant="outline" variant="outline"
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`} className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`}
@@ -297,7 +301,7 @@ function AsignaturasPage() {
{statusConfig[asignatura.estado].label} {statusConfig[asignatura.estado].label}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell className="px-6 py-4">
<div className="opacity-0 transition-opacity group-hover:opacity-100"> <div className="opacity-0 transition-opacity group-hover:opacity-100">
<ChevronRight className="h-5 w-5 text-slate-400" /> <ChevronRight className="h-5 w-5 text-slate-400" />
</div> </div>
@@ -8,10 +8,11 @@ import {
Clock, Clock,
FileJson, FileJson,
} from 'lucide-react' } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { usePlan } from '@/data'
import { fetchPlanPdf } from '@/data/api/document.api' import { fetchPlanPdf } from '@/data/api/document.api'
export const Route = createFileRoute('/planes/$planId/_detalle/documento')({ export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
@@ -20,30 +21,41 @@ export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
function RouteComponent() { function RouteComponent() {
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' }) const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
const { data: plan } = usePlan(planId)
const [pdfUrl, setPdfUrl] = useState<string | null>(null) const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const pdfUrlRef = useRef<string | null>(null)
const isMountedRef = useRef<boolean>(false)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const planFileBaseName = sanitizeFileBaseName(plan?.nombre ?? 'plan_estudios')
const loadPdfPreview = useCallback(async () => { const loadPdfPreview = useCallback(async () => {
try { try {
setIsLoading(true) if (isMountedRef.current) setIsLoading(true)
const pdfBlob = await fetchPlanPdf({ plan_estudio_id: planId }) const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
convertTo: 'pdf',
})
if (!isMountedRef.current) return
const url = window.URL.createObjectURL(pdfBlob) const url = window.URL.createObjectURL(pdfBlob)
// Limpiar URL anterior si existe para evitar fugas de memoria if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl) pdfUrlRef.current = url
setPdfUrl(url) setPdfUrl(url)
} catch (error) { } catch (error) {
console.error('Error cargando preview:', error) console.error('Error cargando preview:', error)
} finally { } finally {
setIsLoading(false) if (isMountedRef.current) setIsLoading(false)
} }
}, [planId]) }, [planId])
useEffect(() => { useEffect(() => {
isMountedRef.current = true
loadPdfPreview() loadPdfPreview()
return () => { return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl) isMountedRef.current = false
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
} }
}, [loadPdfPreview]) }, [loadPdfPreview])
@@ -51,12 +63,13 @@ function RouteComponent() {
try { try {
const pdfBlob = await fetchPlanPdf({ const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId, plan_estudio_id: planId,
convertTo: 'pdf',
}) })
const url = window.URL.createObjectURL(pdfBlob) const url = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
link.download = 'plan_estudios.pdf' link.download = `${planFileBaseName}.pdf`
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
@@ -67,6 +80,27 @@ function RouteComponent() {
alert('No se pudo generar el PDF') 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 ( return (
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6"> <div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
{/* HEADER DE ACCIONES */} {/* HEADER DE ACCIONES */}
@@ -88,12 +122,17 @@ function RouteComponent() {
> >
<RefreshCcw size={16} /> Regenerar <RefreshCcw size={16} /> Regenerar
</Button> </Button>
<Button variant="outline" size="sm" className="gap-2">
<Download size={16} /> Descargar Word
</Button>
<Button <Button
size="sm" size="sm"
className="gap-2 bg-teal-700 hover:bg-teal-800" 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} onClick={handleDownloadPdf}
> >
<Download size={16} /> Descargar PDF <Download size={16} /> Descargar PDF
@@ -139,7 +178,7 @@ function RouteComponent() {
)} )}
</div> </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 ? ( {isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 text-white"> <div className="flex flex-col items-center justify-center gap-4 text-white">
<RefreshCcw size={40} className="animate-spin opacity-50" /> <RefreshCcw size={40} className="animate-spin opacity-50" />
@@ -149,7 +188,7 @@ function RouteComponent() {
/* 3. VISOR DE PDF REAL */ /* 3. VISOR DE PDF REAL */
<iframe <iframe
src={`${pdfUrl}#toolbar=0&navpanes=0`} 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" 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 // Componente pequeño para las tarjetas de estado superior
function StatusCard({ function StatusCard({
icon, icon,
+99 -33
View File
@@ -139,6 +139,10 @@ function RouteComponent() {
null, null,
) )
const [filterQuery, setFilterQuery] = useState('') const [filterQuery, setFilterQuery] = useState('')
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
const [isActionsOpen, setIsActionsOpen] = useState(false)
const availableFields = useMemo(() => { const availableFields = useMemo(() => {
const definicion = data?.estructuras_plan const definicion = data?.estructuras_plan
?.definicion as EstructuraDefinicion ?.definicion as EstructuraDefinicion
@@ -206,7 +210,7 @@ function RouteComponent() {
return messages return messages
}) })
}, [mensajesDelChat, activeChatId, availableFields]) }, [mensajesDelChat, activeChatId, availableFields])
const scrollToBottom = (behavior = 'smooth') => { const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
if (scrollRef.current) { if (scrollRef.current) {
const scrollContainer = scrollRef.current.querySelector( const scrollContainer = scrollRef.current.querySelector(
'[data-radix-scroll-area-viewport]', '[data-radix-scroll-area-viewport]',
@@ -214,7 +218,7 @@ function RouteComponent() {
if (scrollContainer) { if (scrollContainer) {
scrollContainer.scrollTo({ scrollContainer.scrollTo({
top: scrollContainer.scrollHeight, top: scrollContainer.scrollHeight,
behavior: behavior, // 'instant' para carga inicial, 'smooth' para mensajes nuevos behavior,
}) })
} }
} }
@@ -478,37 +482,38 @@ function RouteComponent() {
} }
return ( return (
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4"> <div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full flex-col gap-6 overflow-hidden p-4 md:flex-row">
{/* --- PANEL IZQUIERDO: HISTORIAL --- */} {/* --- HEADER MÓVIL (Solo visible en < md) --- */}
<div className="flex w-64 flex-col border-r pr-4"> <div className="flex items-center justify-between rounded-lg border bg-white p-2 md:hidden">
<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>
<Button <Button
onClick={createNewChat} variant="ghost"
variant="outline" size="sm"
className="mb-4 w-full justify-start gap-2 border-slate-200 hover:bg-teal-50 hover:text-teal-700" 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> </Button>
</div> </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"> <ScrollArea className="flex-1">
<div className="space-y-1 pr-2"> <div className="space-y-1 pr-2">
{' '} {' '}
@@ -571,7 +576,7 @@ function RouteComponent() {
onBlur={(e) => { onBlur={(e) => {
if (editingChatId === chat.id) { if (editingChatId === chat.id) {
const newTitle = const newTitle =
e.currentTarget.textContent?.trim() || '' e.currentTarget.textContent.trim() || ''
if (newTitle && newTitle !== chat.nombre) { if (newTitle && newTitle !== chat.nombre) {
updateTitleMutation({ updateTitleMutation({
id: chat.id, id: chat.id,
@@ -655,8 +660,9 @@ function RouteComponent() {
</div> </div>
</ScrollArea> </ScrollArea>
</div> </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:flex-[3]">
{/* NUEVO: Barra superior de campos seleccionados */} {/* NUEVO: Barra superior de campos seleccionados */}
<div className="shrink-0 border-b bg-white p-3"> <div className="shrink-0 border-b bg-white p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -931,8 +937,9 @@ function RouteComponent() {
</div> </div>
</div> </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"> <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 <Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
</h4> </h4>
@@ -953,6 +960,65 @@ function RouteComponent() {
))} ))}
</div> </div>
</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}> <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"> <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 */} {/* Cabecera más compacta */}
+306 -73
View File
@@ -1,14 +1,8 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/label-has-associated-control */
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { import { Plus, ChevronDown, AlertTriangle, Trash2, Pencil } from 'lucide-react'
Plus, import * as Icons from 'lucide-react'
ChevronDown,
AlertTriangle,
GripVertical,
Trash2,
Pencil,
} from 'lucide-react'
import { useMemo, useState, useEffect, Fragment } from 'react' import { useMemo, useState, useEffect, Fragment } from 'react'
import type { TipoAsignatura } from '@/data' import type { TipoAsignatura } from '@/data'
@@ -37,6 +31,12 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { import {
useCreateLinea, useCreateLinea,
useDeleteLinea, useDeleteLinea,
@@ -48,14 +48,25 @@ import {
} from '@/data' } from '@/data'
// --- Mapeadores (Fuera del componente para mayor limpieza) --- // --- Mapeadores (Fuera del componente para mayor limpieza) ---
const palette = [
'#4F46E5', // índigo
'#7C3AED', // violeta
'#EA580C', // naranja
'#059669', // esmeralda
'#DC2626', // rojo
'#0891B2', // cyan
'#CA8A04', // ámbar
'#C026D3', // fucsia
]
const mapLineasToLineaCurricular = ( const mapLineasToLineaCurricular = (
lineasApi: Array<any> = [], lineasApi: Array<any> = [],
): Array<LineaCurricular> => { ): Array<LineaCurricular> => {
return lineasApi.map((linea) => ({ return lineasApi.map((linea, index) => ({
id: linea.id, id: linea.id,
nombre: linea.nombre, nombre: linea.nombre,
orden: linea.orden ?? 0, orden: linea.orden ?? 0,
color: '#1976d2', color: palette[index % palette.length],
})) }))
} }
@@ -121,52 +132,212 @@ function StatItem({
) )
} }
const estadoConfig: Record<
Asignatura['estado'],
{
label: string
dot: string
soft: string
icon: React.ComponentType<{ className?: string }>
}
> = {
borrador: {
label: 'Borrador',
dot: 'bg-slate-500',
soft: 'bg-slate-100 text-slate-700',
icon: Icons.FileText,
},
revisada: {
label: 'Revisada',
dot: 'bg-amber-500',
soft: 'bg-amber-100 text-amber-700',
icon: Icons.ScanSearch,
},
aprobada: {
label: 'Aprobada',
dot: 'bg-emerald-500',
soft: 'bg-emerald-100 text-emerald-700',
icon: Icons.BadgeCheck,
},
generando: {
label: 'Generando',
dot: 'bg-sky-500',
soft: 'bg-sky-100 text-sky-700',
icon: Icons.LoaderCircle,
},
}
function hexToRgba(hex: string, alpha: number) {
const clean = hex.replace('#', '')
const bigint = parseInt(clean, 16)
const r = (bigint >> 16) & 255
const g = (bigint >> 8) & 255
const b = bigint & 255
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
function AsignaturaCardItem({ function AsignaturaCardItem({
asignatura, asignatura,
lineaColor,
lineaNombre,
onDragStart, onDragStart,
isDragging, isDragging,
onClick, onClick,
}: { }: {
asignatura: Asignatura asignatura: Asignatura
lineaColor: string
lineaNombre?: string
onDragStart: (e: React.DragEvent, id: string) => void onDragStart: (e: React.DragEvent, id: string) => void
isDragging: boolean isDragging: boolean
onClick: () => void onClick: () => void
}) { }) {
const estado = estadoConfig[asignatura.estado]
const EstadoIcon = estado.icon
return ( return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button <button
draggable draggable
onDragStart={(e) => onDragStart(e, asignatura.id)} onDragStart={(e) => onDragStart(e, asignatura.id)}
onClick={onClick} onClick={onClick}
className={`group cursor-grab rounded-lg border bg-white p-3 shadow-sm transition-all active:cursor-grabbing ${ className={[
'group relative h-50 w-40 shrink-0 overflow-hidden rounded-[22px] border text-left',
'transition-all duration-300 ease-out',
'focus-visible:ring-ring/30 focus-visible:ring-2 focus-visible:outline-none',
'cursor-grab active:cursor-grabbing',
isDragging isDragging
? 'scale-95 opacity-40' ? 'scale-[0.985] opacity-45 shadow-none'
: 'hover:border-teal-400 hover:shadow-md' : 'hover:-translate-y-1 hover:shadow-lg',
}`} ].join(' ')}
style={{
borderColor: hexToRgba(lineaColor, 0.18),
background: `
radial-gradient(circle at top right, ${hexToRgba(lineaColor, 0.22)} 0%, transparent 34%),
linear-gradient(180deg, ${hexToRgba(lineaColor, 0.12)} 0%, ${hexToRgba(lineaColor, 0.04)} 42%, var(--card) 100%)
`,
}}
title={asignatura.nombre}
> >
<div className="mb-1 flex items-start justify-between"> {/* franja */}
<span className="font-mono text-[10px] font-bold text-slate-400"> <div
{asignatura.clave} className="absolute inset-x-0 top-0 h-2"
</span> style={{ backgroundColor: lineaColor }}
<Badge
variant="outline"
className={`px-1 py-0 text-[9px] uppercase ${statusBadge[asignatura.estado] || ''}`}
>
{asignatura.estado}
</Badge>
</div>
<p className="mb-1 text-xs leading-tight font-bold text-slate-700">
{asignatura.nombre}
</p>
<div className="mt-2 flex items-center justify-between">
<span className="text-[10px] text-slate-500">
{asignatura.creditos} CR HD:{asignatura.hd} HI:{asignatura.hi}
</span>
<GripVertical
size={12}
className="text-slate-300 opacity-0 transition-opacity group-hover:opacity-100"
/> />
{/* glow decorativo */}
<div
className="absolute -top-10 -right-10 h-28 w-28 rounded-full blur-2xl"
style={{ backgroundColor: hexToRgba(lineaColor, 0.22) }}
/>
<div className="relative flex h-full flex-col p-4">
{/* top */}
<div className="flex items-start justify-between gap-2">
<div
className="inline-flex h-8 max-w-32 items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-semibold"
style={{
borderColor: hexToRgba(lineaColor, 0.2),
backgroundColor: hexToRgba(lineaColor, 0.1),
color: lineaColor,
}}
>
<Icons.KeyRound className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
{asignatura.clave || 'Sin clave'}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="bg-background/70 flex h-8 items-center rounded-full px-2 backdrop-blur-sm">
<EstadoIcon className="text-foreground/65 h-3.5 w-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="left">
<span className="text-xs font-semibold">
{estado.label}
</span>
</TooltipContent>
</Tooltip>
</div>
{/* titulo */}
<div className="mt-4 min-h-18">
<h3
className="text-foreground text-md overflow-hidden leading-[1.08] font-bold"
style={{
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
}}
>
{asignatura.nombre}
</h3>
</div>
{/* bottom */}
<div className="mt-auto grid grid-cols-3 gap-2">
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
<div className="text-muted-foreground mb-1 flex items-center gap-1.5">
<Icons.Award className="h-3.5 w-3.5" />
<span className="text-[10px] font-medium tracking-wide uppercase">
CR
</span>
</div>
<div className="text-foreground text-sm font-bold">
{asignatura.creditos}
</div>
</div>
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
<div className="text-muted-foreground mb-1 flex items-center gap-1.5">
<Icons.Clock3 className="h-3.5 w-3.5" />
<span className="text-[10px] font-medium tracking-wide uppercase">
HD
</span>
</div>
<div className="text-foreground text-sm font-bold">
{asignatura.hd}
</div>
</div>
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
<div className="text-muted-foreground mb-1 flex items-center gap-1.5">
<Icons.BookOpenText className="h-3.5 w-3.5" />
<span className="text-[10px] font-medium tracking-wide uppercase">
HI
</span>
</div>
<div className="text-foreground text-sm font-bold">
{asignatura.hi}
</div>
</div>
</div>
{/* drag affordance */}
<div className="bg-background/70 pointer-events-none absolute right-3 bottom-3 rounded-full p-1.5 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:opacity-100">
<Icons.GripVertical className="text-muted-foreground/55 h-4 w-4" />
</div>
</div> </div>
</button> </button>
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="text-lg">
{/* ciclo */}
{asignatura.ciclo ? (
<span className="font-bold">C{asignatura.ciclo} · </span>
) : null}
{lineaNombre ? (
<span className="font-medium">{lineaNombre} · </span>
) : null}
{asignatura.nombre}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) )
} }
@@ -509,7 +680,7 @@ function MapaCurricularPage() {
return <div className="p-10 text-center">Cargando mapa curricular...</div> return <div className="p-10 text-center">Cargando mapa curricular...</div>
return ( return (
<div className="container mx-auto px-2 py-6"> <div className="container">
{/* Header */} {/* Header */}
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<div> <div>
@@ -588,11 +759,10 @@ function MapaCurricularPage() {
</div> </div>
<div className="overflow-x-auto pb-6"> <div className="overflow-x-auto pb-6">
<div className="min-w-[1500px]">
<div <div
className="grid gap-3" className="grid gap-2"
style={{ style={{
gridTemplateColumns: `220px repeat(${ciclosTotales}, minmax(auto, 1fr)) 120px`, gridTemplateColumns: `140px repeat(${ciclosTotales}, minmax(auto, 1fr)) 120px`,
}} }}
> >
<div className="self-end px-2 text-xs font-bold text-slate-400"> <div className="self-end px-2 text-xs font-bold text-slate-400">
@@ -618,11 +788,11 @@ function MapaCurricularPage() {
return ( return (
<Fragment key={linea.id}> <Fragment key={linea.id}>
<div <div
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${ className={`group relative flex items-center justify-between rounded-xl border-l-4 p-3 transition-all ${
lineColors[idx % lineColors.length] lineColors[idx % lineColors.length]
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`} } ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`}
> >
<div className="flex-1 overflow-hidden"> <div className="min-w-0 flex-1 overflow-hidden">
<span <span
contentEditable={editingLineaId === linea.id} contentEditable={editingLineaId === linea.id}
suppressContentEditableWarning suppressContentEditableWarning
@@ -635,7 +805,7 @@ function MapaCurricularPage() {
setTempNombreLinea(linea.nombre) setTempNombreLinea(linea.nombre)
} }
}} }}
className={`block w-full text-xs font-bold break-words outline-none ${ className={`block w-full truncate text-xs font-bold break-words outline-none ${
editingLineaId === linea.id editingLineaId === linea.id
? 'cursor-text border-b border-teal-500/50 pb-1' ? 'cursor-text border-b border-teal-500/50 pb-1'
: 'cursor-pointer' : 'cursor-pointer'
@@ -644,7 +814,7 @@ function MapaCurricularPage() {
{linea.nombre} {linea.nombre}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="ml-2 flex flex-shrink-0 items-center gap-1">
<button <button
onClick={() => setEditingLineaId(linea.id)} onClick={() => setEditingLineaId(linea.id)}
className="..." className="..."
@@ -665,18 +835,19 @@ function MapaCurricularPage() {
key={`${linea.id}-${ciclo}`} key={`${linea.id}-${ciclo}`}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, ciclo, linea.id)} onDrop={(e) => handleDrop(e, ciclo, linea.id)}
className="min-h-[140px] space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 p-2" className="min-h-35 space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20"
> >
{asignaturas {asignaturas
.filter( .filter(
(m) => (m) =>
m.ciclo === ciclo && m.ciclo === ciclo && m.lineaCurricularId === linea.id,
m.lineaCurricularId === linea.id,
) )
.map((m) => ( .map((m) => (
<AsignaturaCardItem <AsignaturaCardItem
key={m.id} key={m.id}
asignatura={m} asignatura={m}
lineaColor={linea.color || '#1976d2'}
lineaNombre={linea.nombre}
isDragging={draggedAsignatura === m.id} isDragging={draggedAsignatura === m.id}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onClick={() => { onClick={() => {
@@ -688,10 +859,27 @@ function MapaCurricularPage() {
</div> </div>
))} ))}
<div className="flex flex-col justify-center rounded-xl border border-slate-100 bg-slate-50 p-4 text-[10px] font-medium text-slate-500"> <div
<div>Cr: {sub.cr}</div> className={`flex flex-col justify-center rounded-xl border p-4 text-[10px] font-medium ${
<div>HD: {sub.hd}</div> sub.cr === 0 && sub.hd === 0 && sub.hi === 0
<div>HI: {sub.hi}</div> ? 'border-slate-100 bg-slate-50/50 text-slate-300'
: 'border-slate-200 bg-slate-50 text-slate-600'
}`}
>
{sub.cr === 0 && sub.hd === 0 && sub.hi === 0 ? (
<div className="text-slate-400"></div>
) : (
<>
<div className="space-y-1">
<div className="font-bold text-slate-700">
Cr: {sub.cr}
</div>
<div className="text-slate-600">
HD: {sub.hd} HI: {sub.hi}
</div>
</div>
</>
)}
</div> </div>
</Fragment> </Fragment>
) )
@@ -705,15 +893,25 @@ function MapaCurricularPage() {
{ciclosArray.map((ciclo) => { {ciclosArray.map((ciclo) => {
const t = getTotalesCiclo(ciclo) const t = getTotalesCiclo(ciclo)
const isEmpty = t.cr === 0 && t.hd === 0 && t.hi === 0
return ( return (
<div <div
key={`footer-${ciclo}`} key={`footer-${ciclo}`}
className="rounded-lg bg-slate-50 p-2 text-center text-[10px]" className={`rounded-lg p-2 text-center text-[10px] ${
isEmpty ? 'bg-slate-100/50 text-slate-400' : 'bg-slate-50'
}`}
> >
{isEmpty ? (
<div className="py-1 text-xs text-slate-400"></div>
) : (
<>
<div className="font-bold text-slate-700">Cr: {t.cr}</div> <div className="font-bold text-slate-700">Cr: {t.cr}</div>
<div> <div>
HD: {t.hd} HI: {t.hi} HD: {t.hd} HI: {t.hi}
</div> </div>
</>
)}
</div> </div>
) )
})} })}
@@ -724,48 +922,83 @@ function MapaCurricularPage() {
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Asignaturas Sin Asignar */} {/* Asignaturas Sin Asignar */}
<div className="mt-10 rounded-2xl border border-slate-200 bg-slate-50 p-6"> <div className="border-border bg-card/80 mt-12 rounded-[28px] border p-5 shadow-sm backdrop-blur-sm">
<div className="mb-4 flex items-center justify-between"> <div className="mb-5 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2 text-slate-600"> <div className="min-w-0">
<h3 className="text-sm font-bold tracking-wider uppercase"> <div className="flex items-center gap-2">
Bandeja de Entrada / Asignaturas sin asignar <div className="bg-muted text-muted-foreground flex h-9 w-9 items-center justify-center rounded-2xl">
</h3> <Icons.Inbox className="h-4.5 w-4.5" />
<Badge variant="secondary">{unassignedAsignaturas.length}</Badge>
</div> </div>
<p className="text-xs text-slate-400">
Arrastra una asignatura aquí para quitarla del mapa <div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-foreground text-sm font-bold tracking-wide uppercase">
Bandeja de entrada
</h3>
<div className="bg-muted text-muted-foreground inline-flex h-6 min-w-6 items-center justify-center rounded-full px-2 text-[11px] font-semibold">
{unassignedAsignaturas.length}
</div>
</div>
<p className="text-muted-foreground mt-0.5 text-sm">
Asignaturas sin ciclo o línea curricular
</p> </p>
</div> </div>
</div>
</div>
<div className="border-border bg-background/80 text-muted-foreground flex items-center gap-2 rounded-full border border-dashed px-3 py-1.5 text-xs">
<Icons.MoveDown className="h-3.5 w-3.5" />
<span>Arrastra aquí para desasignar</span>
</div>
</div>
<div <div
className={`flex min-h-[120px] flex-wrap gap-4 rounded-xl border-2 border-dashed p-4 transition-colors ${
draggedAsignatura
? 'border-teal-300 bg-teal-50/50'
: 'border-slate-200 bg-white/50'
}`}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, null, null)} // Limpia ciclo y línea onDrop={(e) => handleDrop(e, null, null)}
className={[
'rounded-[24px] border-2 border-dashed p-4 transition-all duration-300',
'min-h-[220px]',
draggedAsignatura
? 'border-primary/35 bg-primary/6 shadow-inner'
: 'border-border bg-muted/20',
].join(' ')}
> >
{unassignedAsignaturas.length > 0 ? (
<div className="flex flex-wrap gap-4">
{unassignedAsignaturas.map((m) => ( {unassignedAsignaturas.map((m) => (
<div key={m.id} className="w-[200px]"> <div key={m.id} className="w-[272px] shrink-0">
<AsignaturaCardItem <AsignaturaCardItem
asignatura={m} asignatura={m}
lineaColor="#94A3B8"
lineaNombre="Sin asignar"
isDragging={draggedAsignatura === m.id} isDragging={draggedAsignatura === m.id}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onClick={() => { onClick={() => {
setEditingData(m) // Cargamos los datos en el estado de edición setEditingData(m)
setIsEditModalOpen(true) setIsEditModalOpen(true)
}} }}
/> />
</div> </div>
))} ))}
{unassignedAsignaturas.length === 0 && ( </div>
<div className="flex w-full items-center justify-center text-sm text-slate-400"> ) : (
No hay asignaturas pendientes. Arrastra una asignatura aquí para <div className="border-border/70 bg-background/70 flex min-h-[188px] flex-col items-center justify-center rounded-[20px] border px-6 text-center">
desasignarla. <div className="bg-muted text-muted-foreground mb-3 flex h-12 w-12 items-center justify-center rounded-2xl">
<Icons.CheckCheck className="h-5 w-5" />
</div>
<p className="text-foreground text-sm font-semibold">
No hay asignaturas pendientes
</p>
<p className="text-muted-foreground mt-1 max-w-md text-sm">
Todo está colocado en el mapa. Arrastra una asignatura aquí para
quitarle ciclo y línea curricular.
</p>
</div> </div>
)} )}
</div> </div>
@@ -1,7 +1,8 @@
import { createFileRoute, useParams } from '@tanstack/react-router' 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 { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
import { useSubject } from '@/data'
import { fetchAsignaturaPdf } from '@/data/api/document.api' import { fetchAsignaturaPdf } from '@/data/api/document.api'
export const Route = createFileRoute( export const Route = createFileRoute(
@@ -15,48 +16,75 @@ function RouteComponent() {
from: '/planes/$planId/asignaturas/$asignaturaId/documento', 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 [pdfUrl, setPdfUrl] = useState<string | null>(null)
const pdfUrlRef = useRef<string | null>(null)
const isMountedRef = useRef<boolean>(false)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isRegenerating, setIsRegenerating] = useState(false) const [isRegenerating, setIsRegenerating] = useState(false)
const loadPdfPreview = useCallback(async () => { const loadPdfPreview = useCallback(async () => {
try { try {
setIsLoading(true) if (isMountedRef.current) setIsLoading(true)
const pdfBlob = await fetchAsignaturaPdf({ const pdfBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId, asignatura_id: asignaturaId,
convertTo: 'pdf',
}) })
if (!isMountedRef.current) return
const url = window.URL.createObjectURL(pdfBlob) const url = window.URL.createObjectURL(pdfBlob)
setPdfUrl((prev) => { if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
if (prev) window.URL.revokeObjectURL(prev) pdfUrlRef.current = url
return url setPdfUrl(url)
})
} catch (error) { } catch (error) {
console.error('Error cargando PDF:', error) console.error('Error cargando PDF:', error)
} finally { } finally {
setIsLoading(false) if (isMountedRef.current) setIsLoading(false)
} }
}, [asignaturaId]) }, [asignaturaId])
useEffect(() => { useEffect(() => {
isMountedRef.current = true
loadPdfPreview() loadPdfPreview()
return () => { return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl) isMountedRef.current = false
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
} }
}, [loadPdfPreview]) }, [loadPdfPreview])
const handleDownload = async () => { const handleDownloadPdf = async () => {
const pdfBlob = await fetchAsignaturaPdf({ const pdfBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId, asignatura_id: asignaturaId,
convertTo: 'pdf',
}) })
const url = window.URL.createObjectURL(pdfBlob) const url = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = url 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) document.body.appendChild(link)
link.click() link.click()
link.remove() link.remove()
@@ -77,9 +105,28 @@ function RouteComponent() {
<DocumentoSEPTab <DocumentoSEPTab
pdfUrl={pdfUrl} pdfUrl={pdfUrl}
isLoading={isLoading} isLoading={isLoading}
onDownload={handleDownload} onDownloadPdf={handleDownloadPdf}
onDownloadWord={handleDownloadWord}
onRegenerate={handleRegenerate} onRegenerate={handleRegenerate}
isRegenerating={isRegenerating} 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)
}
+139 -12
View File
@@ -4,18 +4,145 @@
@custom-variant dark (&:is(.dark *)); @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 { body {
@apply m-0; @apply m-0;
font-family: font-family: var(--font-sans);
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
code { code {
font-family: font-family: var(--font-mono);
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; }
strong,
b,
.font-bold {
font-family: 'Indivisa Sans', serif;
font-weight: 900;
/* Inter letter space */
letter-spacing: -0.025em;
} }
:root { :root {
@@ -51,9 +178,9 @@ code {
--sidebar-accent-foreground: oklch(0.6304 0.2472 28.2698); --sidebar-accent-foreground: oklch(0.6304 0.2472 28.2698);
--sidebar-border: oklch(0.9401 0 0); --sidebar-border: oklch(0.9401 0 0);
--sidebar-ring: oklch(0 0 0); --sidebar-ring: oklch(0 0 0);
--font-sans: Plus Jakarta Sans, sans-serif; --font-sans: 'Indivisa Sans', sans-serif;
--font-serif: Lora, serif; --font-serif: 'Indivisa Serif', serif;
--font-mono: IBM Plex Mono, monospace; --font-mono: 'Indivisa Sans', monospace;
--radius: 1.4rem; --radius: 1.4rem;
--shadow-x: 0px; --shadow-x: 0px;
--shadow-y: 2px; --shadow-y: 2px;
@@ -101,7 +228,7 @@ code {
--chart-1: oklch(0.6686 0.1794 251.7436); --chart-1: oklch(0.6686 0.1794 251.7436);
--chart-2: oklch(0.6342 0.2516 22.4415); --chart-2: oklch(0.6342 0.2516 22.4415);
--chart-3: oklch(0.8718 0.1716 90.9505); --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); --chart-5: oklch(0.8322 0.146 185.9404);
--sidebar: oklch(0.1564 0.0688 261.2771); --sidebar: oklch(0.1564 0.0688 261.2771);
--sidebar-foreground: oklch(0.9551 0 0); --sidebar-foreground: oklch(0.9551 0 0);
@@ -111,9 +238,9 @@ code {
--sidebar-accent-foreground: oklch(0.6786 0.2095 24.6583); --sidebar-accent-foreground: oklch(0.6786 0.2095 24.6583);
--sidebar-border: oklch(0.3289 0.0092 268.3843); --sidebar-border: oklch(0.3289 0.0092 268.3843);
--sidebar-ring: oklch(0.6048 0.2166 257.2136); --sidebar-ring: oklch(0.6048 0.2166 257.2136);
--font-sans: Plus Jakarta Sans, sans-serif; --font-sans: 'Indivisa Sans', sans-serif;
--font-serif: Lora, serif; --font-serif: 'Indivisa Serif', serif;
--font-mono: IBM Plex Mono, monospace; --font-mono: 'Indivisa Sans', monospace;
--radius: 1.4rem; --radius: 1.4rem;
--shadow-x: 0px; --shadow-x: 0px;
--shadow-y: 2px; --shadow-y: 2px;
+18 -1
View File
@@ -154,9 +154,9 @@ export type Database = {
numero_ciclo: number | null numero_ciclo: number | null
orden_celda: number | null orden_celda: number | null
plan_estudio_id: string plan_estudio_id: string
prerrequisito_asignatura_id: string | null
tipo: Database['public']['Enums']['tipo_asignatura'] tipo: Database['public']['Enums']['tipo_asignatura']
tipo_origen: Database['public']['Enums']['tipo_origen'] | null tipo_origen: Database['public']['Enums']['tipo_origen'] | null
prerrequisito_asignatura_id?: string
} }
Insert: { Insert: {
actualizado_en?: string actualizado_en?: string
@@ -180,6 +180,7 @@ export type Database = {
numero_ciclo?: number | null numero_ciclo?: number | null
orden_celda?: number | null orden_celda?: number | null
plan_estudio_id: string plan_estudio_id: string
prerrequisito_asignatura_id?: string | null
tipo?: Database['public']['Enums']['tipo_asignatura'] tipo?: Database['public']['Enums']['tipo_asignatura']
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
} }
@@ -205,6 +206,7 @@ export type Database = {
numero_ciclo?: number | null numero_ciclo?: number | null
orden_celda?: number | null orden_celda?: number | null
plan_estudio_id?: string plan_estudio_id?: string
prerrequisito_asignatura_id?: string | null
tipo?: Database['public']['Enums']['tipo_asignatura'] tipo?: Database['public']['Enums']['tipo_asignatura']
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
} }
@@ -258,6 +260,20 @@ export type Database = {
referencedRelation: 'plantilla_plan' referencedRelation: 'plantilla_plan'
referencedColumns: ['plan_estudio_id'] 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: { bibliografia_asignatura: {
@@ -1377,6 +1393,7 @@ export type Database = {
Args: { p_append: Json; p_id: string } Args: { p_append: Json; p_id: string }
Returns: undefined Returns: undefined
} }
suma_porcentajes: { Args: { '': Json }; Returns: number }
unaccent: { Args: { '': string }; Returns: string } unaccent: { Args: { '': string }; Returns: string }
unaccent_immutable: { Args: { '': string }; Returns: string } unaccent_immutable: { Args: { '': string }; Returns: string }
} }