Author SHA1 Message Date
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
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
35 changed files with 1413 additions and 513 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.
@@ -15,6 +15,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
import { columnParsers } from '@/lib/asignaturaColumnParsers'
export interface BibliografiaEntry {
id: string
@@ -38,6 +39,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
@@ -791,80 +796,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,
}
@@ -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" />
@@ -20,12 +20,15 @@ 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 {
@@ -50,16 +53,7 @@ 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',
@@ -75,6 +69,7 @@ export function IAAsignaturaTab({
const [showSuggestions, setShowSuggestions] = useState(false)
const [isSending, setIsSending] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
// --- DATA QUERIES ---
const { data: datosGenerales } = useSubject(asignaturaId)
@@ -141,7 +136,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:
@@ -355,10 +350,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 +398,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 +521,51 @@ export function IAAsignaturaTab({
))}
</div>
</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>
{/* 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>
{/* Área de Mensajes */}
<div className="relative min-h-0 flex-1">
<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}
@@ -642,8 +676,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">
@@ -722,7 +757,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 +769,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 +830,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">
{/* 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>
)
}
+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 })
+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,
}
@@ -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,
+143 -66
View File
@@ -15,6 +15,7 @@ import {
Archive,
Loader2,
Sparkles,
RotateCcw,
} from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react'
@@ -128,6 +129,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 +139,10 @@ function RouteComponent() {
null,
)
const [filterQuery, setFilterQuery] = useState('')
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
const [isActionsOpen, setIsActionsOpen] = useState(false)
const availableFields = useMemo(() => {
const definicion = data?.estructuras_plan
?.definicion as EstructuraDefinicion
@@ -204,20 +210,20 @@ function RouteComponent() {
return messages
})
}, [mensajesDelChat, activeChatId, availableFields])
const scrollToBottom = () => {
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 +235,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
@@ -476,59 +482,71 @@ function RouteComponent() {
}
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-160px)] max-h-[calc(100vh-160px)] w-full flex-col gap-6 overflow-hidden p-4 md:flex-row">
{/* --- HEADER MÓVIL (Solo visible en < md) --- */}
<div className="flex items-center justify-between rounded-lg border bg-white p-2 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 +576,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 +592,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 +604,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 +630,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 +638,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,8 +660,9 @@ 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:flex-[3]">
{/* NUEVO: Barra superior de campos seleccionados */}
<div className="shrink-0 border-b bg-white p-3">
<div className="flex items-center justify-between">
@@ -920,8 +937,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 +960,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 */}
+301 -93
View File
@@ -5,7 +5,6 @@ import {
Plus,
ChevronDown,
AlertTriangle,
GripVertical,
Trash2,
Pencil,
} from 'lucide-react'
@@ -46,16 +45,33 @@ import {
useUpdateAsignatura,
useUpdateLinea,
} from '@/data'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
// --- 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 = (
lineasApi: Array<any> = [],
): Array<LineaCurricular> => {
return lineasApi.map((linea) => ({
return lineasApi.map((linea, index) => ({
id: linea.id,
nombre: linea.nombre,
orden: linea.orden ?? 0,
color: '#1976d2',
color: palette[index % palette.length],
}))
}
@@ -76,7 +92,7 @@ const mapAsignaturasToAsignaturas = (
// Mapeo directo de los nuevos campos de la API
hd: asig.horas_academicas ?? 0,
hi: asig.horas_independientes ?? 0,
prerrequisitos: [],
prerrequisito_asignatura_id: asig.prerrequisito_asignatura_id ?? null,
}
})
}
@@ -121,52 +137,216 @@ function StatItem({
)
}
import * as Icons from 'lucide-react'
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({
asignatura,
lineaColor,
lineaNombre,
onDragStart,
isDragging,
onClick,
}: {
asignatura: Asignatura
lineaColor: string
lineaNombre?: string
onDragStart: (e: React.DragEvent, id: string) => void
isDragging: boolean
onClick: () => void
}) {
const estado = estadoConfig[asignatura.estado] ?? estadoConfig.borrador
const EstadoIcon = estado.icon
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
draggable
onDragStart={(e) => onDragStart(e, asignatura.id)}
onClick={onClick}
className={`group cursor-grab rounded-lg border bg-white p-3 shadow-sm transition-all active:cursor-grabbing ${
className={[
'group relative h-[200px] w-[272px] shrink-0 overflow-hidden rounded-[22px] border text-left',
'transition-all duration-300 ease-out',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
'active:cursor-grabbing cursor-grab',
isDragging
? 'scale-95 opacity-40'
: 'hover:border-teal-400 hover:shadow-md'
}`}
? 'scale-[0.985] opacity-45 shadow-none'
: '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">
<span className="font-mono text-[10px] font-bold text-slate-400">
{asignatura.clave}
</span>
<Badge
variant="outline"
className={`px-1 py-0 text-[9px] uppercase ${statusBadge[asignatura.estado] || ''}`}
{/* franja */}
<div
className="absolute inset-x-0 top-0 h-2"
style={{ backgroundColor: lineaColor }}
/>
{/* glow decorativo */}
<div
className="absolute -top-10 -right-10 h-28 w-28 rounded-full blur-2xl transition-transform duration-500 group-hover:scale-110"
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-[200px] 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,
}}
>
{asignatura.estado}
</Badge>
<Icons.KeyRound className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{asignatura.clave || 'Sin clave'}</span>
</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"
<div className="relative flex h-8 items-center overflow-hidden rounded-full bg-background/70 px-2 backdrop-blur-sm">
<div className="flex gap-4 items-center gap-1.5 transition-transform duration-300 group-hover:-translate-x-[72px]">
<span className={`h-2.5 w-2.5 rounded-full ${estado.dot}`} />
<EstadoIcon
className={[
'h-3.5 w-3.5 text-foreground/65',
asignatura.estado === 'generando' ? 'animate-spin' : '',
].join(' ')}
/>
</div>
<div
className={[
'absolute right-2 flex translate-x-6 items-center gap-1.5 opacity-0 transition-all duration-300',
'group-hover:translate-x-0 group-hover:opacity-100'
].join(' ')}
>
<span className="text-[11px] font-semibold whitespace-nowrap">
{estado.label}
</span>
</div>
</div>
</div>
{/* titulo */}
<div className="mt-4 min-h-[72px]">
<h3
className="overflow-hidden text-[18px] leading-[1.08] font-bold text-foreground"
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="mb-1 flex items-center gap-1.5 text-muted-foreground">
<Icons.Award className="h-3.5 w-3.5" />
<span className="text-[10px] font-medium uppercase tracking-wide">
CR
</span>
</div>
<div className="text-sm font-bold text-foreground">
{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="mb-1 flex items-center gap-1.5 text-muted-foreground">
<Icons.Clock3 className="h-3.5 w-3.5" />
<span className="text-[10px] font-medium uppercase tracking-wide">
HD
</span>
</div>
<div className="text-sm font-bold text-foreground">
{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="mb-1 flex items-center gap-1.5 text-muted-foreground">
<Icons.BookOpenText className="h-3.5 w-3.5" />
<span className="text-[10px] font-medium uppercase tracking-wide">
HI
</span>
</div>
<div className="text-sm font-bold text-foreground">
{asignatura.hi}
</div>
</div>
</div>
{/* drag affordance */}
<div className="pointer-events-none absolute right-3 bottom-3 rounded-full bg-background/70 p-1.5 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:opacity-100">
<Icons.GripVertical className="h-4 w-4 text-muted-foreground/55" />
</div>
</div>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="text-xs">
{lineaNombre ? `${lineaNombre} · ` : ''}
{asignatura.nombre}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
@@ -336,6 +516,7 @@ function MapaCurricularPage() {
horas_independientes?: TablesUpdate<'asignaturas'>['horas_independientes']
numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo']
linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id']
prerrequisito_asignatura_id?: string | null
}
const patch: Partial<AsignaturaPatch> = {
nombre: editingData.nombre,
@@ -345,6 +526,7 @@ function MapaCurricularPage() {
horas_independientes: editingData.hi,
numero_ciclo: editingData.ciclo,
linea_plan_id: editingData.lineaCurricularId,
prerrequisito_asignatura_id: editingData.prerrequisito_asignatura_id,
tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA)
}
@@ -490,7 +672,7 @@ function MapaCurricularPage() {
e: React.FocusEvent<HTMLSpanElement>,
id: string,
) => {
const nuevoNombre = e.currentTarget.textContent?.trim() || ''
const nuevoNombre = e.currentTarget.textContent.trim() || ''
// Buscamos la línea original para comparar
const lineaOriginal = lineas.find((l) => l.id === id)
@@ -616,8 +798,7 @@ function MapaCurricularPage() {
return (
<Fragment key={linea.id}>
<div
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${
lineColors[idx % lineColors.length]
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${lineColors[idx % lineColors.length]
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`}
>
<div className="flex-1 overflow-hidden">
@@ -633,8 +814,7 @@ function MapaCurricularPage() {
setTempNombreLinea(linea.nombre)
}
}}
className={`block w-full text-xs font-bold break-words outline-none ${
editingLineaId === linea.id
className={`block w-full text-xs font-bold break-words outline-none ${editingLineaId === linea.id
? 'cursor-text border-b border-teal-500/50 pb-1'
: 'cursor-pointer'
}`}
@@ -675,6 +855,8 @@ function MapaCurricularPage() {
<AsignaturaCardItem
key={m.id}
asignatura={m}
lineaColor={linea.color || '#1976d2'}
lineaNombre={linea.nombre}
isDragging={draggedAsignatura === m.id}
onDragStart={handleDragStart}
onClick={() => {
@@ -725,45 +907,81 @@ function MapaCurricularPage() {
</div>
{/* Asignaturas Sin Asignar */}
<div className="mt-10 rounded-2xl border border-slate-200 bg-slate-50 p-6">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 text-slate-600">
<h3 className="text-sm font-bold tracking-wider uppercase">
Bandeja de Entrada / Asignaturas sin asignar
</h3>
<Badge variant="secondary">{unassignedAsignaturas.length}</Badge>
<div className="mt-12 rounded-[28px] border border-border bg-card/80 p-5 shadow-sm backdrop-blur-sm">
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Icons.Inbox className="h-4.5 w-4.5" />
</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-sm font-bold tracking-wide text-foreground uppercase">
Bandeja de entrada
</h3>
<div className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-muted px-2 text-[11px] font-semibold text-muted-foreground">
{unassignedAsignaturas.length}
</div>
</div>
<p className="mt-0.5 text-sm text-muted-foreground">
Asignaturas sin ciclo o línea curricular
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 rounded-full border border-dashed border-border bg-background/80 px-3 py-1.5 text-xs text-muted-foreground">
<Icons.MoveDown className="h-3.5 w-3.5" />
<span>Arrastra aquí para desasignar</span>
</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}
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) => (
<div key={m.id} className="w-[200px]">
<div key={m.id} className="w-[272px] shrink-0">
<AsignaturaCardItem
asignatura={m}
lineaColor="#94A3B8"
lineaNombre="Sin asignar"
isDragging={draggedAsignatura === m.id}
onDragStart={handleDragStart}
onClick={() => {
setEditingData(m) // Cargamos los datos en el estado de edición
setEditingData(m)
setIsEditModalOpen(true)
}}
/>
</div>
))}
{unassignedAsignaturas.length === 0 && (
<div className="flex w-full items-center justify-center text-sm text-slate-400">
No hay asignaturas pendientes. Arrastra una asignatura aquí para
desasignarla.
</div>
) : (
<div className="flex min-h-[188px] flex-col items-center justify-center rounded-[20px] border border-border/70 bg-background/70 px-6 text-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Icons.CheckCheck className="h-5 w-5" />
</div>
<p className="text-sm font-semibold text-foreground">
No hay asignaturas pendientes
</p>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
Todo está colocado en el mapa. Arrastra una asignatura aquí para quitarle
ciclo y línea curricular.
</p>
</div>
)}
</div>
@@ -935,65 +1153,55 @@ function MapaCurricularPage() {
{/* Fila 4: Seriación (Prerrequisitos) */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
Seriación (Prerrequisitos)
Seriación (Prerrequisito)
</label>
<Select
value={seriacionValue}
// Cambiamos a manejo de valor único basado en el ID de la columna
value={editingData.prerrequisito_asignatura_id || undefined}
onValueChange={(val) => {
if (val === 'none') {
setSeriacionValue('')
return
}
if (!editingData.prerrequisitos.includes(val)) {
console.log(editingData)
setEditingData({
...editingData,
prerrequisitos: [...editingData.prerrequisitos, val],
prerrequisito_asignatura_id: val === 'none' ? null : val,
})
}
setSeriacionValue('')
}}
>
<SelectTrigger>
<SelectTrigger className="w-full bg-white">
<SelectValue placeholder="Seleccionar asignatura..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">-- Sin Seriación --</SelectItem>
{asignaturas
.filter((m) => m.id !== editingData.id)
.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.nombre} ({m.clave})
.filter((asig) => {
// 1. No es la misma materia
const noEsMisma = asig.id !== editingData.id
// 2. El ciclo debe ser estrictamente MENOR
const esCicloMenor =
asig.ciclo !== null &&
editingData.ciclo !== null &&
asig.ciclo < editingData.ciclo
return noEsMisma && esCicloMenor
})
.sort(
(a, b) =>
(a.ciclo || 0) - (b.ciclo || 0) ||
a.nombre.localeCompare(b.nombre),
)
.map((asig) => (
<SelectItem key={asig.id} value={asig.id}>
<span className="font-bold text-teal-600">
[C{asig.ciclo}]
</span>{' '}
{asig.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Visualización de los prerrequisitos seleccionados */}
<div className="mt-2 flex flex-wrap gap-2">
{editingData.prerrequisitos.map((pre) => (
<Badge
key={pre}
variant="secondary"
className="bg-slate-100 text-slate-600"
>
{pre}
<button
className="ml-1 hover:text-red-500"
onClick={() => {
setEditingData({
...editingData,
prerrequisitos: editingData.prerrequisitos.filter(
(p) => p !== pre,
),
})
}}
>
×
</button>
</Badge>
))}
</div>
{/* Visualización del Prerrequisito con el Nombre */}
</div>
{/* Fila 5: Tipo */}
@@ -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">
+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"
]
}
}