Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c730fa0ab | |||
| 2abe296b9e | |||
| 1bce226d15 | |||
| b986ec343e | |||
| 379e2d3826 | |||
| cb5422f57c | |||
| 67724181fd | |||
| d9a5cec3c5 | |||
| 96848e1793 | |||
| cbaf96c6b5 | |||
| 0fb831fb58 | |||
| 0d1aa61022 | |||
| 84281a88f2 | |||
| d91018c612 | |||
| 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"
|
||||||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -15,6 +15,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
|
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
|
||||||
|
import { columnParsers } from '@/lib/asignaturaColumnParsers'
|
||||||
|
|
||||||
export interface BibliografiaEntry {
|
export interface BibliografiaEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -38,6 +39,10 @@ export interface AsignaturaResponse {
|
|||||||
datos: AsignaturaDatos
|
datos: AsignaturaDatos
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
type CriterioEvaluacionRow = {
|
type CriterioEvaluacionRow = {
|
||||||
criterio: string
|
criterio: string
|
||||||
porcentaje: number
|
porcentaje: number
|
||||||
@@ -791,80 +796,3 @@ function EvaluationView({ items }: { items: Array<CriterioEvaluacionRow> }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseContenidoTematicoToPlainText(value: unknown): string {
|
|
||||||
if (!Array.isArray(value)) return ''
|
|
||||||
|
|
||||||
const blocks: Array<string> = []
|
|
||||||
|
|
||||||
for (const item of value) {
|
|
||||||
if (!isRecord(item)) continue
|
|
||||||
|
|
||||||
const unidad =
|
|
||||||
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
|
|
||||||
? item.unidad
|
|
||||||
: undefined
|
|
||||||
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
|
|
||||||
|
|
||||||
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
|
|
||||||
if (!header) continue
|
|
||||||
|
|
||||||
const lines: Array<string> = [header]
|
|
||||||
|
|
||||||
const temas = Array.isArray(item.temas) ? item.temas : []
|
|
||||||
temas.forEach((tema, idx) => {
|
|
||||||
const temaNombre =
|
|
||||||
typeof tema === 'string'
|
|
||||||
? tema
|
|
||||||
: isRecord(tema) && typeof tema.nombre === 'string'
|
|
||||||
? tema.nombre
|
|
||||||
: ''
|
|
||||||
if (!temaNombre) return
|
|
||||||
|
|
||||||
if (unidad != null) {
|
|
||||||
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
|
|
||||||
} else {
|
|
||||||
lines.push(`${idx + 1}. ${temaNombre}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
blocks.push(lines.join('\n'))
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks.join('\n\n').trimEnd()
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCriteriosEvaluacionToPlainText(value: unknown): string {
|
|
||||||
if (!Array.isArray(value)) return ''
|
|
||||||
|
|
||||||
const lines: Array<string> = []
|
|
||||||
for (const item of value) {
|
|
||||||
if (!isRecord(item)) continue
|
|
||||||
const label = typeof item.criterio === 'string' ? item.criterio.trim() : ''
|
|
||||||
const valueNum =
|
|
||||||
typeof item.porcentaje === 'number'
|
|
||||||
? item.porcentaje
|
|
||||||
: typeof item.porcentaje === 'string'
|
|
||||||
? Number(item.porcentaje)
|
|
||||||
: NaN
|
|
||||||
|
|
||||||
if (!label) continue
|
|
||||||
if (!Number.isFinite(valueNum)) continue
|
|
||||||
|
|
||||||
const v = Math.trunc(valueNum)
|
|
||||||
if (v < 1 || v > 100) continue
|
|
||||||
|
|
||||||
lines.push(`${label}: ${v}%`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
|
|
||||||
contenido_tematico: parseContenidoTematicoToPlainText,
|
|
||||||
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -688,6 +688,12 @@ export function ContenidoTematico() {
|
|||||||
>
|
>
|
||||||
{({ handleRef }) => (
|
{({ handleRef }) => (
|
||||||
<>
|
<>
|
||||||
|
{index === 0 && (
|
||||||
|
<InsertUnidadOverlay
|
||||||
|
position="top"
|
||||||
|
onInsert={() => insertUnidadAt(index)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<InsertUnidadOverlay
|
<InsertUnidadOverlay
|
||||||
position="bottom"
|
position="bottom"
|
||||||
onInsert={() => insertUnidadAt(index + 1)}
|
onInsert={() => insertUnidadAt(index + 1)}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import { Card } from '@/components/ui/card'
|
|||||||
interface DocumentoSEPTabProps {
|
interface DocumentoSEPTabProps {
|
||||||
pdfUrl: string | null
|
pdfUrl: string | null
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
onDownload: () => void
|
onDownloadPdf: () => void
|
||||||
|
onDownloadWord: () => void
|
||||||
onRegenerate: () => void
|
onRegenerate: () => void
|
||||||
isRegenerating: boolean
|
isRegenerating: boolean
|
||||||
}
|
}
|
||||||
@@ -26,7 +27,8 @@ interface DocumentoSEPTabProps {
|
|||||||
export function DocumentoSEPTab({
|
export function DocumentoSEPTab({
|
||||||
pdfUrl,
|
pdfUrl,
|
||||||
isLoading,
|
isLoading,
|
||||||
onDownload,
|
onDownloadPdf,
|
||||||
|
onDownloadWord,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
isRegenerating,
|
isRegenerating,
|
||||||
}: DocumentoSEPTabProps) {
|
}: DocumentoSEPTabProps) {
|
||||||
@@ -52,25 +54,23 @@ export function DocumentoSEPTab({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{pdfUrl && !isLoading && (
|
|
||||||
<Button variant="outline" onClick={onDownload}>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Descargar
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={showConfirmDialog}
|
open={showConfirmDialog}
|
||||||
onOpenChange={setShowConfirmDialog}
|
onOpenChange={setShowConfirmDialog}
|
||||||
>
|
>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button disabled={isRegenerating}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isRegenerating}
|
||||||
|
>
|
||||||
{isRegenerating ? (
|
{isRegenerating ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
|
{isRegenerating ? 'Generando...' : 'Regenerar'}
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
|
|
||||||
@@ -91,11 +91,31 @@ export function DocumentoSEPTab({
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{pdfUrl && !isLoading && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="gap-2 bg-teal-700 hover:bg-teal-800"
|
||||||
|
onClick={onDownloadWord}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" /> Descargar Word
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={onDownloadPdf}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" /> Descargar PDF
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PDF Preview */}
|
{/* PDF Preview */}
|
||||||
<Card className="h-[800px] overflow-hidden">
|
<Card className="h-200 overflow-hidden">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<Loader2 className="h-10 w-10 animate-spin" />
|
<Loader2 className="h-10 w-10 animate-spin" />
|
||||||
|
|||||||
@@ -1,52 +1,86 @@
|
|||||||
// document.api.ts
|
// document.api.ts
|
||||||
|
|
||||||
const DOCUMENT_PDF_URL =
|
import { supabaseBrowser } from '../supabase/client'
|
||||||
'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434'
|
import { invokeEdge } from '../supabase/invokeEdge'
|
||||||
|
|
||||||
const DOCUMENT_PDF_ASIGNATURA_URL =
|
import { requireData, throwIfError } from './_helpers'
|
||||||
'https://n8n.app.lci.ulsa.mx/webhook/041a68be-7568-46d0-bc08-09ded12d017d'
|
|
||||||
|
import type { Tables } from '@/types/supabase'
|
||||||
|
|
||||||
|
const EDGE = {
|
||||||
|
carbone_io_wrapper: 'carbone-io-wrapper',
|
||||||
|
} as const
|
||||||
|
|
||||||
interface GeneratePdfParams {
|
interface GeneratePdfParams {
|
||||||
plan_estudio_id: string
|
plan_estudio_id: string
|
||||||
|
convertTo?: 'pdf'
|
||||||
}
|
}
|
||||||
interface GeneratePdfParamsAsignatura {
|
interface GeneratePdfParamsAsignatura {
|
||||||
asignatura_id: string
|
asignatura_id: string
|
||||||
|
convertTo?: 'pdf'
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPlanPdf({
|
export async function fetchPlanPdf({
|
||||||
plan_estudio_id,
|
plan_estudio_id,
|
||||||
|
convertTo,
|
||||||
}: GeneratePdfParams): Promise<Blob> {
|
}: GeneratePdfParams): Promise<Blob> {
|
||||||
const response = await fetch(DOCUMENT_PDF_URL, {
|
return await invokeEdge<Blob>(
|
||||||
method: 'POST',
|
EDGE.carbone_io_wrapper,
|
||||||
|
{
|
||||||
|
action: 'downloadReport',
|
||||||
|
plan_estudio_id,
|
||||||
|
body: convertTo ? { convertTo } : {},
|
||||||
|
},
|
||||||
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ plan_estudio_id }),
|
responseType: 'blob',
|
||||||
})
|
},
|
||||||
|
)
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Error al generar el PDF')
|
|
||||||
}
|
|
||||||
|
|
||||||
// n8n devuelve el archivo → lo tratamos como blob
|
|
||||||
return await response.blob()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAsignaturaPdf({
|
export async function fetchAsignaturaPdf({
|
||||||
asignatura_id,
|
asignatura_id,
|
||||||
|
convertTo,
|
||||||
}: GeneratePdfParamsAsignatura): Promise<Blob> {
|
}: GeneratePdfParamsAsignatura): Promise<Blob> {
|
||||||
const response = await fetch(DOCUMENT_PDF_ASIGNATURA_URL, {
|
const supabase = supabaseBrowser()
|
||||||
method: 'POST',
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('asignaturas')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', asignatura_id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
throwIfError(error)
|
||||||
|
|
||||||
|
const row = requireData(
|
||||||
|
data as Pick<
|
||||||
|
Tables<'asignaturas'>,
|
||||||
|
'datos' | 'contenido_tematico' | 'criterios_de_evaluacion'
|
||||||
|
>,
|
||||||
|
'Asignatura no encontrada',
|
||||||
|
)
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
data: row,
|
||||||
|
}
|
||||||
|
if (convertTo) body.convertTo = convertTo
|
||||||
|
|
||||||
|
return await invokeEdge<Blob>(
|
||||||
|
EDGE.carbone_io_wrapper,
|
||||||
|
{
|
||||||
|
action: 'downloadReport',
|
||||||
|
asignatura_id,
|
||||||
|
body: {
|
||||||
|
...body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ asignatura_id }),
|
responseType: 'blob',
|
||||||
})
|
},
|
||||||
|
)
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Error al generar el PDF')
|
|
||||||
}
|
|
||||||
|
|
||||||
// n8n devuelve el archivo → lo tratamos como blob
|
|
||||||
return await response.blob()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export async function plan_asignaturas_list(
|
|||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('asignaturas')
|
.from('asignaturas')
|
||||||
.select(
|
.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)
|
.eq('plan_estudio_id', planId)
|
||||||
.order('numero_ciclo', { ascending: true, nullsFirst: false })
|
.order('numero_ciclo', { ascending: true, nullsFirst: false })
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
|||||||
export type EdgeInvokeOptions = {
|
export type EdgeInvokeOptions = {
|
||||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
|
responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer'
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EdgeFunctionError extends Error {
|
export class EdgeFunctionError extends Error {
|
||||||
@@ -26,6 +27,55 @@ export class EdgeFunctionError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Soporta base64 puro o data:...;base64,...
|
||||||
|
function decodeBase64ToUint8Array(input: string): Uint8Array {
|
||||||
|
const trimmed = input.trim()
|
||||||
|
const base64 = trimmed.startsWith('data:')
|
||||||
|
? trimmed.slice(trimmed.indexOf(',') + 1)
|
||||||
|
: trimmed
|
||||||
|
|
||||||
|
const bin = atob(base64)
|
||||||
|
const bytes = new Uint8Array(bin.length)
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripDataUrlPrefix(input: string): string {
|
||||||
|
const trimmed = input.trim()
|
||||||
|
if (!trimmed.startsWith('data:')) return trimmed
|
||||||
|
const commaIdx = trimmed.indexOf(',')
|
||||||
|
return commaIdx >= 0 ? trimmed.slice(commaIdx + 1) : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeBase64(s: string): boolean {
|
||||||
|
const t = stripDataUrlPrefix(s).replace(/\s+/g, '').replace(/=+$/g, '')
|
||||||
|
|
||||||
|
// base64 típico: solo chars permitidos y longitud razonable
|
||||||
|
if (t.length < 64) return false
|
||||||
|
return /^[A-Za-z0-9+/]+$/.test(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startsWithZip(bytes: Uint8Array): boolean {
|
||||||
|
return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b // "PK"
|
||||||
|
}
|
||||||
|
|
||||||
|
function startsWithPdf(bytes: Uint8Array): boolean {
|
||||||
|
return (
|
||||||
|
bytes.length >= 5 &&
|
||||||
|
bytes[0] === 0x25 &&
|
||||||
|
bytes[1] === 0x50 &&
|
||||||
|
bytes[2] === 0x44 &&
|
||||||
|
bytes[3] === 0x46 &&
|
||||||
|
bytes[4] === 0x2d
|
||||||
|
) // "%PDF-"
|
||||||
|
}
|
||||||
|
|
||||||
|
function binaryStringToUint8Array(input: string): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(input.length)
|
||||||
|
for (let i = 0; i < input.length; i++) bytes[i] = input.charCodeAt(i) & 0xff
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
export async function invokeEdge<TOut>(
|
export async function invokeEdge<TOut>(
|
||||||
functionName: string,
|
functionName: string,
|
||||||
body?:
|
body?:
|
||||||
@@ -42,10 +92,16 @@ export async function invokeEdge<TOut>(
|
|||||||
): Promise<TOut> {
|
): Promise<TOut> {
|
||||||
const supabase = client ?? supabaseBrowser()
|
const supabase = client ?? supabaseBrowser()
|
||||||
|
|
||||||
const { data, error } = await supabase.functions.invoke(functionName, {
|
// Nota: algunas versiones/defs de @supabase/supabase-js no tipan `responseType`
|
||||||
|
// aunque el runtime lo soporte. Usamos `any` para no bloquear el uso de Blob.
|
||||||
|
const invoke: any = (supabase.functions as any).invoke.bind(
|
||||||
|
supabase.functions,
|
||||||
|
)
|
||||||
|
const { data, error } = await invoke(functionName, {
|
||||||
body,
|
body,
|
||||||
method: opts.method ?? 'POST',
|
method: opts.method ?? 'POST',
|
||||||
headers: opts.headers,
|
headers: opts.headers,
|
||||||
|
responseType: opts.responseType,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -104,5 +160,20 @@ export async function invokeEdge<TOut>(
|
|||||||
throw new EdgeFunctionError(message, functionName, status, details)
|
throw new EdgeFunctionError(message, functionName, status, details)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts.responseType === 'blob') {
|
||||||
|
const anyData: unknown = data
|
||||||
|
|
||||||
|
if (anyData instanceof Blob) {
|
||||||
|
return anyData as TOut
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new EdgeFunctionError(
|
||||||
|
'La Edge Function no devolvió un binario (Blob) válido.',
|
||||||
|
functionName,
|
||||||
|
undefined,
|
||||||
|
{ receivedType: typeof anyData, received: anyData },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return data as TOut
|
return data as TOut
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2036,6 +2036,12 @@ function DatosBasicosManualStep({
|
|||||||
publisher: e.target.value.slice(0, 300),
|
publisher: e.target.value.slice(0, 300),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
onBlur={() => {
|
||||||
|
const trimmed = draft.publisher.trim()
|
||||||
|
if (trimmed !== draft.publisher) {
|
||||||
|
onChangeDraft({ ...draft, publisher: trimmed })
|
||||||
|
}
|
||||||
|
}}
|
||||||
maxLength={300}
|
maxLength={300}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -2434,9 +2440,17 @@ const FormatoYCitasStep = forwardRef<
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const raw = e.currentTarget.value.slice(0, 300)
|
const raw = e.currentTarget.value.slice(0, 300)
|
||||||
onChangeRef(r.id, {
|
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>
|
||||||
</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,10 +8,11 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
FileJson,
|
FileJson,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { usePlan } from '@/data'
|
||||||
import { fetchPlanPdf } from '@/data/api/document.api'
|
import { fetchPlanPdf } from '@/data/api/document.api'
|
||||||
|
|
||||||
export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
|
export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
|
||||||
@@ -20,30 +21,41 @@ export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
|
|||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
|
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
|
||||||
|
const { data: plan } = usePlan(planId)
|
||||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
||||||
|
const pdfUrlRef = useRef<string | null>(null)
|
||||||
|
const isMountedRef = useRef<boolean>(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
const planFileBaseName = sanitizeFileBaseName(plan?.nombre ?? 'plan_estudios')
|
||||||
|
|
||||||
const loadPdfPreview = useCallback(async () => {
|
const loadPdfPreview = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true)
|
if (isMountedRef.current) setIsLoading(true)
|
||||||
const pdfBlob = await fetchPlanPdf({ plan_estudio_id: planId })
|
const pdfBlob = await fetchPlanPdf({
|
||||||
|
plan_estudio_id: planId,
|
||||||
|
convertTo: 'pdf',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isMountedRef.current) return
|
||||||
const url = window.URL.createObjectURL(pdfBlob)
|
const url = window.URL.createObjectURL(pdfBlob)
|
||||||
|
|
||||||
// Limpiar URL anterior si existe para evitar fugas de memoria
|
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
||||||
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
|
pdfUrlRef.current = url
|
||||||
|
|
||||||
setPdfUrl(url)
|
setPdfUrl(url)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando preview:', error)
|
console.error('Error cargando preview:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
if (isMountedRef.current) setIsLoading(false)
|
||||||
}
|
}
|
||||||
}, [planId])
|
}, [planId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true
|
||||||
loadPdfPreview()
|
loadPdfPreview()
|
||||||
return () => {
|
return () => {
|
||||||
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
|
isMountedRef.current = false
|
||||||
|
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
||||||
}
|
}
|
||||||
}, [loadPdfPreview])
|
}, [loadPdfPreview])
|
||||||
|
|
||||||
@@ -51,12 +63,13 @@ function RouteComponent() {
|
|||||||
try {
|
try {
|
||||||
const pdfBlob = await fetchPlanPdf({
|
const pdfBlob = await fetchPlanPdf({
|
||||||
plan_estudio_id: planId,
|
plan_estudio_id: planId,
|
||||||
|
convertTo: 'pdf',
|
||||||
})
|
})
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(pdfBlob)
|
const url = window.URL.createObjectURL(pdfBlob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = 'plan_estudios.pdf'
|
link.download = `${planFileBaseName}.pdf`
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
|
|
||||||
@@ -67,6 +80,27 @@ function RouteComponent() {
|
|||||||
alert('No se pudo generar el PDF')
|
alert('No se pudo generar el PDF')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDownloadWord = async () => {
|
||||||
|
try {
|
||||||
|
const docBlob = await fetchPlanPdf({
|
||||||
|
plan_estudio_id: planId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(docBlob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `${planFileBaseName}.docx`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
|
||||||
|
link.remove()
|
||||||
|
setTimeout(() => window.URL.revokeObjectURL(url), 1000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
alert('No se pudo generar el Word')
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
|
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
|
||||||
{/* HEADER DE ACCIONES */}
|
{/* HEADER DE ACCIONES */}
|
||||||
@@ -88,12 +122,17 @@ function RouteComponent() {
|
|||||||
>
|
>
|
||||||
<RefreshCcw size={16} /> Regenerar
|
<RefreshCcw size={16} /> Regenerar
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
|
||||||
<Download size={16} /> Descargar Word
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-2 bg-teal-700 hover:bg-teal-800"
|
className="gap-2 bg-teal-700 hover:bg-teal-800"
|
||||||
|
onClick={handleDownloadWord}
|
||||||
|
>
|
||||||
|
<Download size={16} /> Descargar Word
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
onClick={handleDownloadPdf}
|
onClick={handleDownloadPdf}
|
||||||
>
|
>
|
||||||
<Download size={16} /> Descargar PDF
|
<Download size={16} /> Descargar PDF
|
||||||
@@ -139,7 +178,7 @@ function RouteComponent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent className="flex min-h-[800px] justify-center bg-slate-500 p-0">
|
<CardContent className="flex min-h-200 justify-center bg-slate-500 p-0">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 text-white">
|
<div className="flex flex-col items-center justify-center gap-4 text-white">
|
||||||
<RefreshCcw size={40} className="animate-spin opacity-50" />
|
<RefreshCcw size={40} className="animate-spin opacity-50" />
|
||||||
@@ -149,7 +188,7 @@ function RouteComponent() {
|
|||||||
/* 3. VISOR DE PDF REAL */
|
/* 3. VISOR DE PDF REAL */
|
||||||
<iframe
|
<iframe
|
||||||
src={`${pdfUrl}#toolbar=0&navpanes=0`}
|
src={`${pdfUrl}#toolbar=0&navpanes=0`}
|
||||||
className="h-[1000px] w-full max-w-[1000px] border-none shadow-2xl"
|
className="h-250 w-full max-w-250 border-none shadow-2xl"
|
||||||
title="PDF Preview"
|
title="PDF Preview"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -163,6 +202,24 @@ function RouteComponent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeFileBaseName(input: string): string {
|
||||||
|
const text = String(input)
|
||||||
|
const withoutControlChars = Array.from(text)
|
||||||
|
.filter((ch) => {
|
||||||
|
const code = ch.charCodeAt(0)
|
||||||
|
return code >= 32 && code !== 127
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const cleaned = withoutControlChars
|
||||||
|
.replace(/[<>:"/\\|?*]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/[. ]+$/g, '')
|
||||||
|
|
||||||
|
return (cleaned || 'documento').slice(0, 150)
|
||||||
|
}
|
||||||
|
|
||||||
// Componente pequeño para las tarjetas de estado superior
|
// Componente pequeño para las tarjetas de estado superior
|
||||||
function StatusCard({
|
function StatusCard({
|
||||||
icon,
|
icon,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Archive,
|
Archive,
|
||||||
Loader2,
|
Loader2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
RotateCcw,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
|
|
||||||
@@ -128,6 +129,7 @@ function RouteComponent() {
|
|||||||
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
|
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const isInitialLoad = useRef(true)
|
||||||
const [showArchived, setShowArchived] = useState(false)
|
const [showArchived, setShowArchived] = useState(false)
|
||||||
const [editingChatId, setEditingChatId] = useState<string | null>(null)
|
const [editingChatId, setEditingChatId] = useState<string | null>(null)
|
||||||
const editableRef = useRef<HTMLSpanElement>(null)
|
const editableRef = useRef<HTMLSpanElement>(null)
|
||||||
@@ -204,20 +206,20 @@ function RouteComponent() {
|
|||||||
return messages
|
return messages
|
||||||
})
|
})
|
||||||
}, [mensajesDelChat, activeChatId, availableFields])
|
}, [mensajesDelChat, activeChatId, availableFields])
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = (behavior = 'smooth') => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
// Buscamos el viewport interno del ScrollArea de Radix
|
|
||||||
const scrollContainer = scrollRef.current.querySelector(
|
const scrollContainer = scrollRef.current.querySelector(
|
||||||
'[data-radix-scroll-area-viewport]',
|
'[data-radix-scroll-area-viewport]',
|
||||||
)
|
)
|
||||||
if (scrollContainer) {
|
if (scrollContainer) {
|
||||||
scrollContainer.scrollTo({
|
scrollContainer.scrollTo({
|
||||||
top: scrollContainer.scrollHeight,
|
top: scrollContainer.scrollHeight,
|
||||||
behavior: 'smooth',
|
behavior: behavior, // 'instant' para carga inicial, 'smooth' para mensajes nuevos
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { activeChats, archivedChats } = useMemo(() => {
|
const { activeChats, archivedChats } = useMemo(() => {
|
||||||
const allChats = lastConversation || []
|
const allChats = lastConversation || []
|
||||||
return {
|
return {
|
||||||
@@ -229,22 +231,22 @@ function RouteComponent() {
|
|||||||
}, [lastConversation])
|
}, [lastConversation])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(mensajesDelChat)
|
if (chatMessages.length > 0) {
|
||||||
|
if (isInitialLoad.current) {
|
||||||
scrollToBottom()
|
// Si es el primer render con mensajes, vamos al final al instante
|
||||||
}, [chatMessages, isLoading])
|
scrollToBottom('instant')
|
||||||
|
isInitialLoad.current = false
|
||||||
/* useEffect(() => {
|
} else {
|
||||||
// Verificamos cuáles campos de la lista "selectedFields" ya no están presentes en el texto del input
|
// Si ya estaba cargado y llegan nuevos, hacemos el smooth
|
||||||
const camposActualizados = selectedFields.filter((field) =>
|
scrollToBottom('smooth')
|
||||||
input.includes(field.label),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Solo actualizamos el estado si hubo un cambio real (para evitar bucles infinitos)
|
|
||||||
if (camposActualizados.length !== selectedFields.length) {
|
|
||||||
setSelectedFields(camposActualizados)
|
|
||||||
}
|
}
|
||||||
}, [input, selectedFields]) */
|
}
|
||||||
|
}, [chatMessages])
|
||||||
|
|
||||||
|
// 2. Resetear el flag cuando cambies de chat activo
|
||||||
|
useEffect(() => {
|
||||||
|
isInitialLoad.current = true
|
||||||
|
}, [activeChatId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoadingConv || isSending) return
|
if (isLoadingConv || isSending) return
|
||||||
@@ -508,27 +510,38 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<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 ? (
|
{!showArchived ? (
|
||||||
activeChats.map((chat) => (
|
activeChats.map((chat) => (
|
||||||
<div
|
<div
|
||||||
key={chat.id}
|
key={chat.id}
|
||||||
onClick={() => setActiveChatId(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
|
activeChatId === chat.id
|
||||||
? 'bg-slate-100 font-medium text-slate-900'
|
? 'bg-slate-100 font-medium text-slate-900'
|
||||||
: 'text-slate-600 hover:bg-slate-50'
|
: 'text-slate-600 hover:bg-slate-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* LADO IZQUIERDO: Icono + Texto con Tooltip */}
|
{/* LADO IZQUIERDO: Icono + Texto */}
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
<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" />
|
<FileText size={16} className="shrink-0 opacity-40" />
|
||||||
|
|
||||||
<TooltipProvider delayDuration={400}>
|
<TooltipProvider delayDuration={400}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild className="min-w-0 flex-1">
|
||||||
{/* Este contenedor es el que obliga al span a truncarse */}
|
<div className="min-w-0 flex-1">
|
||||||
<div className="max-w-[calc(100%-48px)] min-w-0 flex-1">
|
|
||||||
<span
|
<span
|
||||||
ref={
|
ref={
|
||||||
editingChatId === chat.id ? editableRef : null
|
editingChatId === chat.id ? editableRef : null
|
||||||
@@ -574,8 +587,6 @@ function RouteComponent() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
||||||
{/* Tooltip: Solo aparece si no estás editando y el texto es largo */}
|
|
||||||
{editingChatId !== chat.id && (
|
{editingChatId !== chat.id && (
|
||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="right"
|
side="right"
|
||||||
@@ -588,9 +599,9 @@ function RouteComponent() {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* LADO DERECHO: Acciones con shrink-0 para que no se muevan */}
|
{/* LADO DERECHO: Acciones ABSOLUTAS */}
|
||||||
<div
|
<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'
|
activeChatId === chat.id ? 'bg-slate-100' : 'bg-slate-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -614,7 +625,7 @@ function RouteComponent() {
|
|||||||
</div>
|
</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">
|
<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">
|
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
|
||||||
Archivados
|
Archivados
|
||||||
@@ -622,18 +633,18 @@ function RouteComponent() {
|
|||||||
{archivedChats.map((chat) => (
|
{archivedChats.map((chat) => (
|
||||||
<div
|
<div
|
||||||
key={chat.id}
|
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" />
|
<Archive size={14} className="shrink-0 opacity-30" />
|
||||||
<span className="block min-w-0 flex-1 truncate">
|
<span className="block truncate">
|
||||||
{chat.nombre ||
|
{chat.nombre ||
|
||||||
`Archivado ${chat.creado_en.split('T')[0]}`}
|
`Archivado ${chat.creado_en.split('T')[0]}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => unarchiveChat(e, chat.id)}
|
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} />
|
<RotateCcw size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
GripVertical,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
Pencil,
|
Pencil,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -46,16 +45,33 @@ import {
|
|||||||
useUpdateAsignatura,
|
useUpdateAsignatura,
|
||||||
useUpdateLinea,
|
useUpdateLinea,
|
||||||
} from '@/data'
|
} from '@/data'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
|
||||||
// --- Mapeadores (Fuera del componente para mayor limpieza) ---
|
// --- Mapeadores (Fuera del componente para mayor limpieza) ---
|
||||||
|
const palette = [
|
||||||
|
'#4F46E5', // índigo
|
||||||
|
'#7C3AED', // violeta
|
||||||
|
'#EA580C', // naranja
|
||||||
|
'#059669', // esmeralda
|
||||||
|
'#DC2626', // rojo
|
||||||
|
'#0891B2', // cyan
|
||||||
|
'#CA8A04', // ámbar
|
||||||
|
'#C026D3', // fucsia
|
||||||
|
]
|
||||||
|
|
||||||
const mapLineasToLineaCurricular = (
|
const mapLineasToLineaCurricular = (
|
||||||
lineasApi: Array<any> = [],
|
lineasApi: Array<any> = [],
|
||||||
): Array<LineaCurricular> => {
|
): Array<LineaCurricular> => {
|
||||||
return lineasApi.map((linea) => ({
|
return lineasApi.map((linea, index) => ({
|
||||||
id: linea.id,
|
id: linea.id,
|
||||||
nombre: linea.nombre,
|
nombre: linea.nombre,
|
||||||
orden: linea.orden ?? 0,
|
orden: linea.orden ?? 0,
|
||||||
color: '#1976d2',
|
color: palette[index % palette.length],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +92,7 @@ const mapAsignaturasToAsignaturas = (
|
|||||||
// Mapeo directo de los nuevos campos de la API
|
// Mapeo directo de los nuevos campos de la API
|
||||||
hd: asig.horas_academicas ?? 0,
|
hd: asig.horas_academicas ?? 0,
|
||||||
hi: asig.horas_independientes ?? 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({
|
function AsignaturaCardItem({
|
||||||
asignatura,
|
asignatura,
|
||||||
|
lineaColor,
|
||||||
|
lineaNombre,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
isDragging,
|
isDragging,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
asignatura: Asignatura
|
asignatura: Asignatura
|
||||||
|
lineaColor: string
|
||||||
|
lineaNombre?: string
|
||||||
onDragStart: (e: React.DragEvent, id: string) => void
|
onDragStart: (e: React.DragEvent, id: string) => void
|
||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const estado = estadoConfig[asignatura.estado] ?? estadoConfig.borrador
|
||||||
|
const EstadoIcon = estado.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<TooltipProvider delayDuration={150}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => onDragStart(e, asignatura.id)}
|
onDragStart={(e) => onDragStart(e, asignatura.id)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`group cursor-grab rounded-lg border bg-white p-3 shadow-sm transition-all active:cursor-grabbing ${
|
className={[
|
||||||
|
'group relative h-[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
|
isDragging
|
||||||
? 'scale-95 opacity-40'
|
? 'scale-[0.985] opacity-45 shadow-none'
|
||||||
: 'hover:border-teal-400 hover:shadow-md'
|
: 'hover:-translate-y-1 hover:shadow-lg',
|
||||||
}`}
|
].join(' ')}
|
||||||
|
style={{
|
||||||
|
borderColor: hexToRgba(lineaColor, 0.18),
|
||||||
|
background: `
|
||||||
|
radial-gradient(circle at top right, ${hexToRgba(lineaColor, 0.22)} 0%, transparent 34%),
|
||||||
|
linear-gradient(180deg, ${hexToRgba(lineaColor, 0.12)} 0%, ${hexToRgba(lineaColor, 0.04)} 42%, var(--card) 100%)
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
title={asignatura.nombre}
|
||||||
>
|
>
|
||||||
<div className="mb-1 flex items-start justify-between">
|
{/* franja */}
|
||||||
<span className="font-mono text-[10px] font-bold text-slate-400">
|
<div
|
||||||
{asignatura.clave}
|
className="absolute inset-x-0 top-0 h-2"
|
||||||
</span>
|
style={{ backgroundColor: lineaColor }}
|
||||||
<Badge
|
/>
|
||||||
variant="outline"
|
|
||||||
className={`px-1 py-0 text-[9px] uppercase ${statusBadge[asignatura.estado] || ''}`}
|
{/* 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}
|
<Icons.KeyRound className="h-3.5 w-3.5 shrink-0" />
|
||||||
</Badge>
|
<span className="truncate">{asignatura.clave || 'Sin clave'}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-1 text-xs leading-tight font-bold text-slate-700">
|
|
||||||
{asignatura.nombre}
|
<div className="relative flex h-8 items-center overflow-hidden rounded-full bg-background/70 px-2 backdrop-blur-sm">
|
||||||
</p>
|
<div className="flex gap-4 items-center gap-1.5 transition-transform duration-300 group-hover:-translate-x-[72px]">
|
||||||
<div className="mt-2 flex items-center justify-between">
|
<span className={`h-2.5 w-2.5 rounded-full ${estado.dot}`} />
|
||||||
<span className="text-[10px] text-slate-500">
|
<EstadoIcon
|
||||||
{asignatura.creditos} CR • HD:{asignatura.hd} • HI:{asignatura.hi}
|
className={[
|
||||||
</span>
|
'h-3.5 w-3.5 text-foreground/65',
|
||||||
<GripVertical
|
asignatura.estado === 'generando' ? 'animate-spin' : '',
|
||||||
size={12}
|
].join(' ')}
|
||||||
className="text-slate-300 opacity-0 transition-opacity group-hover:opacity-100"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</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']
|
horas_independientes?: TablesUpdate<'asignaturas'>['horas_independientes']
|
||||||
numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo']
|
numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo']
|
||||||
linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id']
|
linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id']
|
||||||
|
prerrequisito_asignatura_id?: string | null
|
||||||
}
|
}
|
||||||
const patch: Partial<AsignaturaPatch> = {
|
const patch: Partial<AsignaturaPatch> = {
|
||||||
nombre: editingData.nombre,
|
nombre: editingData.nombre,
|
||||||
@@ -345,6 +526,7 @@ function MapaCurricularPage() {
|
|||||||
horas_independientes: editingData.hi,
|
horas_independientes: editingData.hi,
|
||||||
numero_ciclo: editingData.ciclo,
|
numero_ciclo: editingData.ciclo,
|
||||||
linea_plan_id: editingData.lineaCurricularId,
|
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)
|
tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,7 +672,7 @@ function MapaCurricularPage() {
|
|||||||
e: React.FocusEvent<HTMLSpanElement>,
|
e: React.FocusEvent<HTMLSpanElement>,
|
||||||
id: string,
|
id: string,
|
||||||
) => {
|
) => {
|
||||||
const nuevoNombre = e.currentTarget.textContent?.trim() || ''
|
const nuevoNombre = e.currentTarget.textContent.trim() || ''
|
||||||
|
|
||||||
// Buscamos la línea original para comparar
|
// Buscamos la línea original para comparar
|
||||||
const lineaOriginal = lineas.find((l) => l.id === id)
|
const lineaOriginal = lineas.find((l) => l.id === id)
|
||||||
@@ -616,8 +798,7 @@ function MapaCurricularPage() {
|
|||||||
return (
|
return (
|
||||||
<Fragment key={linea.id}>
|
<Fragment key={linea.id}>
|
||||||
<div
|
<div
|
||||||
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${
|
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${lineColors[idx % lineColors.length]
|
||||||
lineColors[idx % lineColors.length]
|
|
||||||
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`}
|
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
@@ -633,8 +814,7 @@ function MapaCurricularPage() {
|
|||||||
setTempNombreLinea(linea.nombre)
|
setTempNombreLinea(linea.nombre)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full text-xs font-bold break-words outline-none ${
|
className={`block w-full text-xs font-bold break-words outline-none ${editingLineaId === linea.id
|
||||||
editingLineaId === linea.id
|
|
||||||
? 'cursor-text border-b border-teal-500/50 pb-1'
|
? 'cursor-text border-b border-teal-500/50 pb-1'
|
||||||
: 'cursor-pointer'
|
: 'cursor-pointer'
|
||||||
}`}
|
}`}
|
||||||
@@ -675,6 +855,8 @@ function MapaCurricularPage() {
|
|||||||
<AsignaturaCardItem
|
<AsignaturaCardItem
|
||||||
key={m.id}
|
key={m.id}
|
||||||
asignatura={m}
|
asignatura={m}
|
||||||
|
lineaColor={linea.color || '#1976d2'}
|
||||||
|
lineaNombre={linea.nombre}
|
||||||
isDragging={draggedAsignatura === m.id}
|
isDragging={draggedAsignatura === m.id}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -725,45 +907,81 @@ function MapaCurricularPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Asignaturas Sin Asignar */}
|
{/* Asignaturas Sin Asignar */}
|
||||||
<div className="mt-10 rounded-2xl border border-slate-200 bg-slate-50 p-6">
|
<div className="mt-12 rounded-[28px] border border-border bg-card/80 p-5 shadow-sm backdrop-blur-sm">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex items-center gap-2 text-slate-600">
|
<div className="min-w-0">
|
||||||
<h3 className="text-sm font-bold tracking-wider uppercase">
|
<div className="flex items-center gap-2">
|
||||||
Bandeja de Entrada / Asignaturas sin asignar
|
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||||
</h3>
|
<Icons.Inbox className="h-4.5 w-4.5" />
|
||||||
<Badge variant="secondary">{unassignedAsignaturas.length}</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400">
|
|
||||||
Arrastra una asignatura aquí para quitarla del mapa
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-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>
|
</p>
|
||||||
</div>
|
</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
|
<div
|
||||||
className={`flex min-h-[120px] flex-wrap gap-4 rounded-xl border-2 border-dashed p-4 transition-colors ${
|
|
||||||
draggedAsignatura
|
|
||||||
? 'border-teal-300 bg-teal-50/50'
|
|
||||||
: 'border-slate-200 bg-white/50'
|
|
||||||
}`}
|
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={(e) => handleDrop(e, null, null)} // Limpia ciclo y línea
|
onDrop={(e) => handleDrop(e, null, null)}
|
||||||
|
className={[
|
||||||
|
'rounded-[24px] border-2 border-dashed p-4 transition-all duration-300',
|
||||||
|
'min-h-[220px]',
|
||||||
|
draggedAsignatura
|
||||||
|
? 'border-primary/35 bg-primary/6 shadow-inner'
|
||||||
|
: 'border-border bg-muted/20',
|
||||||
|
].join(' ')}
|
||||||
>
|
>
|
||||||
|
{unassignedAsignaturas.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
{unassignedAsignaturas.map((m) => (
|
{unassignedAsignaturas.map((m) => (
|
||||||
<div key={m.id} className="w-[200px]">
|
<div key={m.id} className="w-[272px] shrink-0">
|
||||||
<AsignaturaCardItem
|
<AsignaturaCardItem
|
||||||
asignatura={m}
|
asignatura={m}
|
||||||
|
lineaColor="#94A3B8"
|
||||||
|
lineaNombre="Sin asignar"
|
||||||
isDragging={draggedAsignatura === m.id}
|
isDragging={draggedAsignatura === m.id}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingData(m) // Cargamos los datos en el estado de edición
|
setEditingData(m)
|
||||||
setIsEditModalOpen(true)
|
setIsEditModalOpen(true)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{unassignedAsignaturas.length === 0 && (
|
</div>
|
||||||
<div className="flex w-full items-center justify-center text-sm text-slate-400">
|
) : (
|
||||||
No hay asignaturas pendientes. Arrastra una asignatura aquí para
|
<div className="flex min-h-[188px] flex-col items-center justify-center rounded-[20px] border border-border/70 bg-background/70 px-6 text-center">
|
||||||
desasignarla.
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -935,65 +1153,55 @@ function MapaCurricularPage() {
|
|||||||
{/* Fila 4: Seriación (Prerrequisitos) */}
|
{/* Fila 4: Seriación (Prerrequisitos) */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-slate-500 uppercase">
|
<label className="text-xs font-bold text-slate-500 uppercase">
|
||||||
Seriación (Prerrequisitos)
|
Seriación (Prerrequisito)
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={seriacionValue}
|
// Cambiamos a manejo de valor único basado en el ID de la columna
|
||||||
|
value={editingData.prerrequisito_asignatura_id || undefined}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
if (val === 'none') {
|
console.log(editingData)
|
||||||
setSeriacionValue('')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!editingData.prerrequisitos.includes(val)) {
|
|
||||||
setEditingData({
|
setEditingData({
|
||||||
...editingData,
|
...editingData,
|
||||||
prerrequisitos: [...editingData.prerrequisitos, val],
|
prerrequisito_asignatura_id: val === 'none' ? null : val,
|
||||||
})
|
})
|
||||||
}
|
|
||||||
setSeriacionValue('')
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full bg-white">
|
||||||
<SelectValue placeholder="Seleccionar asignatura..." />
|
<SelectValue placeholder="Seleccionar asignatura..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">-- Sin Seriación --</SelectItem>
|
<SelectItem value="none">-- Sin Seriación --</SelectItem>
|
||||||
|
|
||||||
{asignaturas
|
{asignaturas
|
||||||
.filter((m) => m.id !== editingData.id)
|
.filter((asig) => {
|
||||||
.map((m) => (
|
// 1. No es la misma materia
|
||||||
<SelectItem key={m.id} value={m.id}>
|
const noEsMisma = asig.id !== editingData.id
|
||||||
{m.nombre} ({m.clave})
|
// 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>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* Visualización de los prerrequisitos seleccionados */}
|
{/* Visualización del Prerrequisito con el Nombre */}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fila 5: Tipo */}
|
{/* Fila 5: Tipo */}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
|
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
|
||||||
|
import { useSubject } from '@/data'
|
||||||
import { fetchAsignaturaPdf } from '@/data/api/document.api'
|
import { fetchAsignaturaPdf } from '@/data/api/document.api'
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
@@ -15,48 +16,75 @@ function RouteComponent() {
|
|||||||
from: '/planes/$planId/asignaturas/$asignaturaId/documento',
|
from: '/planes/$planId/asignaturas/$asignaturaId/documento',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: asignatura } = useSubject(asignaturaId)
|
||||||
|
const asignaturaFileBaseName = sanitizeFileBaseName(
|
||||||
|
asignatura?.nombre ?? 'documento_sep',
|
||||||
|
)
|
||||||
|
|
||||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
||||||
|
const pdfUrlRef = useRef<string | null>(null)
|
||||||
|
const isMountedRef = useRef<boolean>(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isRegenerating, setIsRegenerating] = useState(false)
|
const [isRegenerating, setIsRegenerating] = useState(false)
|
||||||
|
|
||||||
const loadPdfPreview = useCallback(async () => {
|
const loadPdfPreview = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true)
|
if (isMountedRef.current) setIsLoading(true)
|
||||||
|
|
||||||
const pdfBlob = await fetchAsignaturaPdf({
|
const pdfBlob = await fetchAsignaturaPdf({
|
||||||
asignatura_id: asignaturaId,
|
asignatura_id: asignaturaId,
|
||||||
|
convertTo: 'pdf',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!isMountedRef.current) return
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(pdfBlob)
|
const url = window.URL.createObjectURL(pdfBlob)
|
||||||
|
|
||||||
setPdfUrl((prev) => {
|
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
||||||
if (prev) window.URL.revokeObjectURL(prev)
|
pdfUrlRef.current = url
|
||||||
return url
|
setPdfUrl(url)
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando PDF:', error)
|
console.error('Error cargando PDF:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
if (isMountedRef.current) setIsLoading(false)
|
||||||
}
|
}
|
||||||
}, [asignaturaId])
|
}, [asignaturaId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true
|
||||||
loadPdfPreview()
|
loadPdfPreview()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
|
isMountedRef.current = false
|
||||||
|
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
||||||
}
|
}
|
||||||
}, [loadPdfPreview])
|
}, [loadPdfPreview])
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownloadPdf = async () => {
|
||||||
const pdfBlob = await fetchAsignaturaPdf({
|
const pdfBlob = await fetchAsignaturaPdf({
|
||||||
asignatura_id: asignaturaId,
|
asignatura_id: asignaturaId,
|
||||||
|
convertTo: 'pdf',
|
||||||
})
|
})
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(pdfBlob)
|
const url = window.URL.createObjectURL(pdfBlob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = 'documento_sep.pdf'
|
link.download = `${asignaturaFileBaseName}.pdf`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownloadWord = async () => {
|
||||||
|
const docBlob = await fetchAsignaturaPdf({
|
||||||
|
asignatura_id: asignaturaId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(docBlob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `${asignaturaFileBaseName}.docx`
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
link.remove()
|
link.remove()
|
||||||
@@ -77,9 +105,28 @@ function RouteComponent() {
|
|||||||
<DocumentoSEPTab
|
<DocumentoSEPTab
|
||||||
pdfUrl={pdfUrl}
|
pdfUrl={pdfUrl}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onDownload={handleDownload}
|
onDownloadPdf={handleDownloadPdf}
|
||||||
|
onDownloadWord={handleDownloadWord}
|
||||||
onRegenerate={handleRegenerate}
|
onRegenerate={handleRegenerate}
|
||||||
isRegenerating={isRegenerating}
|
isRegenerating={isRegenerating}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeFileBaseName(input: string): string {
|
||||||
|
const text = String(input)
|
||||||
|
const withoutControlChars = Array.from(text)
|
||||||
|
.filter((ch) => {
|
||||||
|
const code = ch.charCodeAt(0)
|
||||||
|
return code >= 32 && code !== 127
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const cleaned = withoutControlChars
|
||||||
|
.replace(/[<>:"/\\|?*]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/[. ]+$/g, '')
|
||||||
|
|
||||||
|
return (cleaned || 'documento').slice(0, 150)
|
||||||
|
}
|
||||||
|
|||||||
@@ -166,30 +166,20 @@ function AsignaturaLayout() {
|
|||||||
onSave={(val) => handleUpdateHeader('nombre', val)}
|
onSave={(val) => handleUpdateHeader('nombre', val)}
|
||||||
/>
|
/>
|
||||||
</h1>
|
</h1>
|
||||||
|
{
|
||||||
|
// console.log(headerData),
|
||||||
|
|
||||||
|
console.log(asignaturaApi.planes_estudio?.nombre)
|
||||||
|
}
|
||||||
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
|
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<GraduationCap className="h-4 w-4 shrink-0" />
|
<GraduationCap className="h-4 w-4 shrink-0" />
|
||||||
|
Pertenece al plan:{' '}
|
||||||
<span className="text-blue-100">
|
<span className="text-blue-100">
|
||||||
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
|
{(asignaturaApi.planes_estudio as DatosPlan).nombre || ''}
|
||||||
.nombre || ''}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span className="text-blue-100">
|
|
||||||
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
|
|
||||||
.nombre ?? ''}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-blue-300">
|
|
||||||
Pertenece al plan:{' '}
|
|
||||||
<span className="cursor-pointer underline">
|
|
||||||
{asignaturaApi.planes_estudio?.nombre}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-end gap-2 text-right">
|
<div className="flex flex-col items-end gap-2 text-right">
|
||||||
|
|||||||
+139
-12
@@ -4,18 +4,145 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Indivisa Sans';
|
||||||
|
src: url('/fonts/indivisa/IndivisaTextSans-Light.otf') format('opentype');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Indivisa Sans';
|
||||||
|
src: url('/fonts/indivisa/IndivisaTextSans-LightItalic.otf')
|
||||||
|
format('opentype');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Indivisa Sans';
|
||||||
|
src: url('/fonts/indivisa/IndivisaTextSans-Regular.otf') format('opentype');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Indivisa Sans';
|
||||||
|
src: url('/fonts/indivisa/IndivisaTextSans-RegularItalic.otf')
|
||||||
|
format('opentype');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Indivisa Sans';
|
||||||
|
src: url('/fonts/indivisa/IndivisaTextSans-Bold.otf') format('opentype');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Indivisa Sans';
|
||||||
|
src: url('/fonts/indivisa/IndivisaTextSans-BoldItalic.otf') format('opentype');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Indivisa Sans';
|
||||||
|
src: url('/fonts/indivisa/IndivisaTextSans-Black.otf') format('opentype');
|
||||||
|
font-weight: 900;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Indivisa Sans';
|
||||||
|
src: url('/fonts/indivisa/IndivisaTextSans-BlackItalic.otf')
|
||||||
|
format('opentype');
|
||||||
|
font-weight: 900;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Serif */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Indivisa Serif';
|
||||||
|
src: url('/fonts/indivisa/IndivisaTextSerif-Light.otf') format('opentype');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Indivisa Serif';
|
||||||
|
src: url('/fonts/indivisa/IndivisaTextSerif-LightItalic.otf')
|
||||||
|
format('opentype');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Indivisa Serif';
|
||||||
|
src: url('/fonts/indivisa/IndivisaTextSerif-Regular.otf') format('opentype');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Indivisa Serif';
|
||||||
|
src: url('/fonts/indivisa/IndivisaTextSerif-RegularItalic.otf')
|
||||||
|
format('opentype');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Indivisa Serif';
|
||||||
|
src: url('/fonts/indivisa/IndivisaTextSerif-Bold.otf') format('opentype');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Indivisa Serif';
|
||||||
|
src: url('/fonts/indivisa/IndivisaTextSerif-BoldItalic.otf')
|
||||||
|
format('opentype');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Indivisa Serif';
|
||||||
|
src: url('/fonts/indivisa/IndivisaTextSerif-Black.otf') format('opentype');
|
||||||
|
font-weight: 900;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Indivisa Serif';
|
||||||
|
src: url('/fonts/indivisa/IndivisaTextSerif-BlackItalic.otf')
|
||||||
|
format('opentype');
|
||||||
|
font-weight: 900;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply m-0;
|
@apply m-0;
|
||||||
font-family:
|
font-family: var(--font-sans);
|
||||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
|
|
||||||
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family:
|
font-family: var(--font-mono);
|
||||||
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
}
|
||||||
|
|
||||||
|
strong,
|
||||||
|
b,
|
||||||
|
.font-bold {
|
||||||
|
font-family: 'Indivisa Sans', serif;
|
||||||
|
font-weight: 900;
|
||||||
|
/* Inter letter space */
|
||||||
|
letter-spacing: -0.025em;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -51,9 +178,9 @@ code {
|
|||||||
--sidebar-accent-foreground: oklch(0.6304 0.2472 28.2698);
|
--sidebar-accent-foreground: oklch(0.6304 0.2472 28.2698);
|
||||||
--sidebar-border: oklch(0.9401 0 0);
|
--sidebar-border: oklch(0.9401 0 0);
|
||||||
--sidebar-ring: oklch(0 0 0);
|
--sidebar-ring: oklch(0 0 0);
|
||||||
--font-sans: Plus Jakarta Sans, sans-serif;
|
--font-sans: 'Indivisa Sans', sans-serif;
|
||||||
--font-serif: Lora, serif;
|
--font-serif: 'Indivisa Serif', serif;
|
||||||
--font-mono: IBM Plex Mono, monospace;
|
--font-mono: 'Indivisa Sans', monospace;
|
||||||
--radius: 1.4rem;
|
--radius: 1.4rem;
|
||||||
--shadow-x: 0px;
|
--shadow-x: 0px;
|
||||||
--shadow-y: 2px;
|
--shadow-y: 2px;
|
||||||
@@ -101,7 +228,7 @@ code {
|
|||||||
--chart-1: oklch(0.6686 0.1794 251.7436);
|
--chart-1: oklch(0.6686 0.1794 251.7436);
|
||||||
--chart-2: oklch(0.6342 0.2516 22.4415);
|
--chart-2: oklch(0.6342 0.2516 22.4415);
|
||||||
--chart-3: oklch(0.8718 0.1716 90.9505);
|
--chart-3: oklch(0.8718 0.1716 90.9505);
|
||||||
--chart-4: oklch(0.4503 0.229 263.0881);
|
--chart-4: oklch(11.492% 0.00001 271.152);
|
||||||
--chart-5: oklch(0.8322 0.146 185.9404);
|
--chart-5: oklch(0.8322 0.146 185.9404);
|
||||||
--sidebar: oklch(0.1564 0.0688 261.2771);
|
--sidebar: oklch(0.1564 0.0688 261.2771);
|
||||||
--sidebar-foreground: oklch(0.9551 0 0);
|
--sidebar-foreground: oklch(0.9551 0 0);
|
||||||
@@ -111,9 +238,9 @@ code {
|
|||||||
--sidebar-accent-foreground: oklch(0.6786 0.2095 24.6583);
|
--sidebar-accent-foreground: oklch(0.6786 0.2095 24.6583);
|
||||||
--sidebar-border: oklch(0.3289 0.0092 268.3843);
|
--sidebar-border: oklch(0.3289 0.0092 268.3843);
|
||||||
--sidebar-ring: oklch(0.6048 0.2166 257.2136);
|
--sidebar-ring: oklch(0.6048 0.2166 257.2136);
|
||||||
--font-sans: Plus Jakarta Sans, sans-serif;
|
--font-sans: 'Indivisa Sans', sans-serif;
|
||||||
--font-serif: Lora, serif;
|
--font-serif: 'Indivisa Serif', serif;
|
||||||
--font-mono: IBM Plex Mono, monospace;
|
--font-mono: 'Indivisa Sans', monospace;
|
||||||
--radius: 1.4rem;
|
--radius: 1.4rem;
|
||||||
--shadow-x: 0px;
|
--shadow-x: 0px;
|
||||||
--shadow-y: 2px;
|
--shadow-y: 2px;
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ export interface Asignatura {
|
|||||||
orden?: number
|
orden?: number
|
||||||
hd: number // <--- Añadir
|
hd: number // <--- Añadir
|
||||||
hi: number // <--- Añadir
|
hi: number // <--- Añadir
|
||||||
prerrequisitos: Array<string>
|
prerrequisito_asignatura_id: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Plan {
|
export interface Plan {
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ export type Database = {
|
|||||||
numero_ciclo: number | null
|
numero_ciclo: number | null
|
||||||
orden_celda: number | null
|
orden_celda: number | null
|
||||||
plan_estudio_id: string
|
plan_estudio_id: string
|
||||||
|
prerrequisito_asignatura_id: string | null
|
||||||
tipo: Database['public']['Enums']['tipo_asignatura']
|
tipo: Database['public']['Enums']['tipo_asignatura']
|
||||||
tipo_origen: Database['public']['Enums']['tipo_origen'] | null
|
tipo_origen: Database['public']['Enums']['tipo_origen'] | null
|
||||||
}
|
}
|
||||||
@@ -179,6 +180,7 @@ export type Database = {
|
|||||||
numero_ciclo?: number | null
|
numero_ciclo?: number | null
|
||||||
orden_celda?: number | null
|
orden_celda?: number | null
|
||||||
plan_estudio_id: string
|
plan_estudio_id: string
|
||||||
|
prerrequisito_asignatura_id?: string | null
|
||||||
tipo?: Database['public']['Enums']['tipo_asignatura']
|
tipo?: Database['public']['Enums']['tipo_asignatura']
|
||||||
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
|
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
|
||||||
}
|
}
|
||||||
@@ -204,6 +206,7 @@ export type Database = {
|
|||||||
numero_ciclo?: number | null
|
numero_ciclo?: number | null
|
||||||
orden_celda?: number | null
|
orden_celda?: number | null
|
||||||
plan_estudio_id?: string
|
plan_estudio_id?: string
|
||||||
|
prerrequisito_asignatura_id?: string | null
|
||||||
tipo?: Database['public']['Enums']['tipo_asignatura']
|
tipo?: Database['public']['Enums']['tipo_asignatura']
|
||||||
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
|
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
|
||||||
}
|
}
|
||||||
@@ -257,6 +260,20 @@ export type Database = {
|
|||||||
referencedRelation: 'plantilla_plan'
|
referencedRelation: 'plantilla_plan'
|
||||||
referencedColumns: ['plan_estudio_id']
|
referencedColumns: ['plan_estudio_id']
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: 'asignaturas_prerrequisito_asignatura_id_fkey'
|
||||||
|
columns: ['prerrequisito_asignatura_id']
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: 'asignaturas'
|
||||||
|
referencedColumns: ['id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: 'asignaturas_prerrequisito_asignatura_id_fkey'
|
||||||
|
columns: ['prerrequisito_asignatura_id']
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: 'plantilla_asignatura'
|
||||||
|
referencedColumns: ['asignatura_id']
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
bibliografia_asignatura: {
|
bibliografia_asignatura: {
|
||||||
@@ -1376,6 +1393,7 @@ export type Database = {
|
|||||||
Args: { p_append: Json; p_id: string }
|
Args: { p_append: Json; p_id: string }
|
||||||
Returns: undefined
|
Returns: undefined
|
||||||
}
|
}
|
||||||
|
suma_porcentajes: { Args: { '': Json }; Returns: number }
|
||||||
unaccent: { Args: { '': string }; Returns: string }
|
unaccent: { Args: { '': string }; Returns: string }
|
||||||
unaccent_immutable: { Args: { '': string }; Returns: string }
|
unaccent_immutable: { Args: { '': string }; Returns: string }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"navigationFallback": {
|
||||||
|
"rewrite": "/index.html",
|
||||||
|
"exclude": [
|
||||||
|
"/assets/*",
|
||||||
|
"/*.css",
|
||||||
|
"/*.js",
|
||||||
|
"/*.ico",
|
||||||
|
"/*.png",
|
||||||
|
"/*.jpg",
|
||||||
|
"/*.svg"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user