Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bce226d15 | |||
| b986ec343e | |||
| d9a5cec3c5 | |||
| 96848e1793 | |||
| 658b2e245c | |||
| 30562fead0 | |||
| 2b91004129 | |||
| 96a045dc67 | |||
| a8229f12d5 | |||
| dd4ac5374a | |||
| 670e0b1d14 | |||
| 93fe247a19 | |||
| 32ebfde9ed | |||
| 32f0c4c4d4 | |||
| 6a520ef6b1 |
@@ -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" />
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"navigationFallback": {
|
||||
"rewrite": "/index.html",
|
||||
"exclude": [
|
||||
"/assets/*",
|
||||
"/*.css",
|
||||
"/*.js",
|
||||
"/*.ico",
|
||||
"/*.png",
|
||||
"/*.jpg",
|
||||
"/*.svg"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user