15 Commits

Author SHA1 Message Date
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
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
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
17 changed files with 500 additions and 235 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"
@@ -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" />
+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,7 +8,7 @@ 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'
@@ -21,18 +21,23 @@ export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
function RouteComponent() {
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const pdfUrlRef = useRef<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const loadPdfPreview = useCallback(async () => {
try {
setIsLoading(true)
const pdfBlob = await fetchPlanPdf({ plan_estudio_id: planId })
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
convertTo: 'pdf',
})
const url = window.URL.createObjectURL(pdfBlob)
// Limpiar URL anterior si existe para evitar fugas de memoria
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
setPdfUrl(url)
setPdfUrl((prev) => {
if (prev) window.URL.revokeObjectURL(prev)
pdfUrlRef.current = url
return url
})
} catch (error) {
console.error('Error cargando preview:', error)
} finally {
@@ -43,7 +48,7 @@ function RouteComponent() {
useEffect(() => {
loadPdfPreview()
return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
}
}, [loadPdfPreview])
@@ -51,6 +56,7 @@ function RouteComponent() {
try {
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
convertTo: 'pdf',
})
const url = window.URL.createObjectURL(pdfBlob)
@@ -67,6 +73,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 = 'plan_estudios.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 +115,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 +171,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 +181,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"
/>
) : (
+46 -35
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)
@@ -204,20 +206,20 @@ function RouteComponent() {
return messages
})
}, [mensajesDelChat, activeChatId, availableFields])
const scrollToBottom = () => {
const scrollToBottom = (behavior = '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: behavior, // 'instant' para carga inicial, 'smooth' para mensajes nuevos
})
}
}
}
const { activeChats, archivedChats } = useMemo(() => {
const allChats = lastConversation || []
return {
@@ -229,22 +231,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
@@ -508,27 +510,38 @@ function RouteComponent() {
</div>
<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
@@ -574,8 +587,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 +599,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 +625,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 +633,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>
+34 -42
View File
@@ -76,7 +76,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,
}
})
}
@@ -336,6 +336,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 +346,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 +492,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)
@@ -935,65 +937,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,5 +1,5 @@
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 { fetchAsignaturaPdf } from '@/data/api/document.api'
@@ -16,6 +16,7 @@ function RouteComponent() {
})
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const pdfUrlRef = useRef<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isRegenerating, setIsRegenerating] = useState(false)
@@ -25,12 +26,14 @@ function RouteComponent() {
const pdfBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId,
convertTo: 'pdf',
})
const url = window.URL.createObjectURL(pdfBlob)
setPdfUrl((prev) => {
if (prev) window.URL.revokeObjectURL(prev)
pdfUrlRef.current = url
return url
})
} catch (error) {
@@ -44,13 +47,14 @@ function RouteComponent() {
loadPdfPreview()
return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
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)
@@ -63,6 +67,21 @@ function RouteComponent() {
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 = 'documento_sep.docx'
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
}
const handleRegenerate = async () => {
try {
setIsRegenerating(true)
@@ -77,7 +96,8 @@ function RouteComponent() {
<DocumentoSEPTab
pdfUrl={pdfUrl}
isLoading={isLoading}
onDownload={handleDownload}
onDownloadPdf={handleDownloadPdf}
onDownloadWord={handleDownloadWord}
onRegenerate={handleRegenerate}
isRegenerating={isRegenerating}
/>
@@ -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">
+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"
]
}
}