Author SHA1 Message Date
alexrg 88a2a28a8d Enhance table styling and improve empty state message in AsignaturasPage for better user experience
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m25s
2026-03-23 16:16:42 -06:00
alexrg 36a11e3793 Improve conditional rendering and styling for empty state in MapaCurricularPage 2026-03-23 16:15:43 -06:00
alexrg 33efaed03f Adjust text sizes and spacing in AsignaturaCardItem and MapaCurricularPage for improved readability
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m22s
2026-03-23 16:05:20 -06:00
alexrg d481e9706c Fix estado assignment in AsignaturaCardItem and adjust width for improved layout
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m19s
2026-03-23 15:54:12 -06:00
alexrg ee3b7a56ec Merge pull request 'Mejorar el espaciado y la organización de la interfaz en el componente MapaCurricular' (#217) from issue/214-mejorar-el-espaciado-de-la-interfaz into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m24s
Reviewed-on: #217
2026-03-23 20:31:46 +00:00
alexrg 2359e38f85 Mejorar el espaciado y la organización de la interfaz en el componente MapaCurricular 2026-03-23 14:30:16 -06:00
roberto.silva c262cd16be Merge pull request 'Que aparezcan los campos a mitad de texto fix #213' (#216) from issue/213-que-aparezcan-los-campos-a-mitad-de-texto into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m24s
Reviewed-on: #216
2026-03-23 19:02:56 +00:00
roberto.silva a08b2abf87 Que aparezcan los campos a mitad de texto
fix #213
2026-03-23 13:02:31 -06:00
roberto.silva a07213d959 Merge pull request 'Mejorar la responsividad de los chats fix #202 fix #203' (#215) from issue/202-mejorar-la-responsividad-de-los-chats into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 2m36s
Reviewed-on: #215
2026-03-23 18:34:30 +00:00
roberto.silva a2234e5022 Mejorar la responsividad de los chats fix #202 2026-03-23 12:33:12 -06:00
roberto.silva 658c392f96 Mejorar la responsividad de los chats fix #202 2026-03-23 11:59:58 -06:00
Guillermo.Arrieta 4c730fa0ab Merge pull request 'Se renderizan las previsualizaciones del plan y de la asignatura y también se pueden descargar como word o pdf' (#211) from issue/200-renderizado-de-plantillas-con-edge-function-de-car into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m21s
Reviewed-on: #211
2026-03-20 23:47:37 +00:00
Guillermo.Arrieta 2abe296b9e close #200: Se guardan los docx y pdf con el nombre del plan/asignatura 2026-03-20 17:44:36 -06:00
Guillermo.Arrieta 1bce226d15 Se descargan correctamente los docx del plan y de la asignatura 2026-03-20 17:31:59 -06:00
Guillermo.Arrieta b986ec343e Se visualiza y descarga el pdf de la asignatura 2026-03-20 17:31:07 -06:00
alexrg 379e2d3826 Actualizar src/routes/planes/$planId/_detalle/mapa.tsx
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m20s
2026-03-20 21:30:16 +00:00
alexrg cb5422f57c Merge pull request 'Refactor AsignaturaCardItem to use Tooltip and improve styling; update color mapping for lineas' (#208) from mejorar-diseño-de-tarjetas into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m27s
Reviewed-on: #208
2026-03-20 21:17:37 +00:00
Your Name 67724181fd Refactor AsignaturaCardItem to use Tooltip and improve styling; update color mapping for lineas 2026-03-20 15:17:17 -06:00
Guillermo.Arrieta d9a5cec3c5 En el body se manda el parámetro para convertir el documento a pdf 2026-03-20 13:22:23 -06:00
Guillermo.Arrieta 96848e1793 Se utiliza la edge function de carbone para obtener el pdf del anexo del plan de estudios a partir del id del plan 2026-03-20 12:24:17 -06:00
alexrg cbaf96c6b5 Merge pull request 'Add letter-spacing to font-bold class in styles.css' (#206) from agregar-tipografía into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m13s
Reviewed-on: #206
2026-03-20 17:37:05 +00:00
alexrg 0fb831fb58 Merge branch 'main' into agregar-tipografía 2026-03-20 17:36:58 +00:00
alexrg 0d1aa61022 Add letter-spacing to font-bold class in styles.css 2026-03-20 11:35:51 -06:00
alexrg 84281a88f2 Merge pull request 'Add Indivisa font family and update styles.css' (#205) from agregar-tipografía into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m30s
Reviewed-on: #205
2026-03-20 17:33:03 +00:00
alexrg d91018c612 Add Indivisa font family and update styles.css 2026-03-20 11:30:39 -06:00
30 changed files with 1577 additions and 723 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,13 +7,6 @@ import type { AsignaturaDetail } from '@/data'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
@@ -21,8 +14,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { usePlanAsignaturas } from '@/data'
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
import { columnParsers } from '@/lib/asignaturaColumnParsers'
export interface BibliografiaEntry {
id: string
@@ -46,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
@@ -67,12 +64,8 @@ export default function AsignaturaDetailPage() {
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: asignaturaApi } = useSubject(asignaturaId)
const { data: asignaturasApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId)
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
const updateAsignatura = useUpdateAsignatura()
@@ -93,54 +86,16 @@ export default function AsignaturaDetailPage() {
},
})
}
const asignaturaSeriada = useMemo(() => {
if (!asignaturaApi?.prerrequisito_asignatura_id || !asignaturasApi)
return null
return asignaturasApi.find(
(asig) => asig.id === asignaturaApi.prerrequisito_asignatura_id,
)
}, [asignaturaApi, asignaturasApi])
const requisitosFormateados = useMemo(() => {
if (!asignaturaSeriada) return []
return [
{
type: 'Pre-requisito',
code: asignaturaSeriada.codigo,
name: asignaturaSeriada.nombre,
id: asignaturaSeriada.id, // Guardamos el ID para el select
},
]
}, [asignaturaSeriada])
const handleUpdatePrerrequisito = (newId: string | null) => {
updateAsignatura.mutate({
asignaturaId,
patch: {
prerrequisito_asignatura_id: newId,
},
})
}
/* ---------- sincronizar API ---------- */
useEffect(() => {
console.log(requisitosFormateados)
if (asignaturaApi) setAsignatura(asignaturaApi)
}, [asignaturaApi, requisitosFormateados])
}, [asignaturaApi])
return (
<DatosGenerales
pre={requisitosFormateados}
availableSubjects={asignaturasApi}
onPersistDato={handlePersistDatoGeneral}
/>
)
return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
}
function DatosGenerales({
onPersistDato,
pre,
availableSubjects,
}: {
onPersistDato: (clave: string, value: string) => void
}) {
@@ -315,19 +270,18 @@ function DatosGenerales({
<InfoCard
title="Requisitos y Seriación"
type="requirements"
initialContent={pre}
// Pasamos las materias del plan para el Select (excluyendo la actual)
availableSubjects={
availableSubjects?.filter((a) => a.id !== asignaturaId) || []
}
onPersist={({ value }) => {
updateAsignatura.mutate({
asignaturaId,
patch: {
prerrequisito_asignatura_id: value, // value ya viene como ID o null desde handleSave
},
})
}}
initialContent={[
{
type: 'Pre-requisito',
code: 'PA-301',
name: 'Programación Avanzada',
},
{
type: 'Co-requisito',
code: 'MAT-201',
name: 'Matemáticas Discretas',
},
]}
/>
{/* Tarjeta de Evaluación */}
@@ -367,7 +321,6 @@ interface InfoCardProps {
containerRef?: React.RefObject<HTMLDivElement | null>
forceEditToken?: number
highlightToken?: number
availableSubjects?: any
}
function InfoCard({
@@ -384,7 +337,6 @@ function InfoCard({
containerRef,
forceEditToken,
highlightToken,
availableSubjects,
}: InfoCardProps) {
const [isEditing, setIsEditing] = useState(false)
const [isHighlighted, setIsHighlighted] = useState(false)
@@ -402,8 +354,7 @@ function InfoCard({
useEffect(() => {
setData(initialContent)
setTempText(initialContent)
console.log(data)
console.log(initialContent)
if (type === 'evaluation') {
const raw = Array.isArray(initialContent) ? initialContent : []
const rows: Array<CriterioEvaluacionRowDraft> = raw
@@ -446,8 +397,6 @@ function InfoCard({
const handleSave = () => {
console.log('clave, valor:', clave, String(tempText ?? ''))
console.log(clave)
console.log(tempText)
if (type === 'evaluation') {
const cleaned: Array<CriterioEvaluacionRow> = []
@@ -478,25 +427,6 @@ function InfoCard({
void onPersist?.({ type, clave, value: cleaned })
return
}
if (type === 'requirements') {
console.log('entre aqui ')
// Si tempText es un array y tiene elementos, tomamos el ID del primero
// Si es "none" o está vacío, mandamos null (para limpiar la seriación)
const prerequisiteId =
Array.isArray(tempText) && tempText.length > 0 ? tempText[0].id : null
setData(tempText) // Actualiza la vista local
setIsEditing(false)
// Mandamos el ID específico a la base de datos
void onPersist?.({
type,
clave: 'prerrequisito_asignatura_id', // Forzamos la columna correcta
value: prerequisiteId,
})
return
}
setData(tempText)
setIsEditing(false)
@@ -616,52 +546,7 @@ function InfoCard({
<CardContent className="pt-4">
{isEditing ? (
<div className="space-y-3">
{/* Condicionales de edición según el tipo */}
{type === 'requirements' ? (
<div className="space-y-3">
<label className="text-xs font-medium text-slate-500">
Materia de Seriación
</label>
<Select
value={tempText?.[0]?.id || 'none'}
onValueChange={(val) => {
const selected = availableSubjects?.find(
(s) => s.id === val,
)
if (val === 'none' || !selected) {
console.log('guardando')
setTempText([])
} else {
console.log('hola')
setTempText([
{
id: selected.id,
type: 'Pre-requisito',
code: selected.codigo,
name: selected.nombre,
},
])
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Selecciona una materia" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
Ninguna (Sin seriación)
</SelectItem>
{availableSubjects?.map((asig) => (
<SelectItem key={asig.id} value={asig.id}>
{asig.codigo} - {asig.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : type === 'evaluation' ? (
{type === 'evaluation' ? (
<div className="space-y-3">
<div className="space-y-2">
{evalRows.map((row) => (
@@ -683,36 +568,85 @@ function InfoCard({
)
}}
/>
<Input
value={row.porcentaje}
placeholder="%"
type="number"
min={1}
max={100}
step={1}
inputMode="numeric"
onChange={(e) => {
const raw = e.target.value
// Solo permitir '' o dígitos
if (raw !== '' && !/^\d+$/.test(raw)) return
if (raw === '') {
setEvalRows((prev) =>
prev.map((r) =>
r.id === row.id
? {
id: r.id,
criterio: r.criterio,
porcentaje: '',
}
: r,
),
)
return
}
const n = Number(raw)
if (!Number.isFinite(n)) return
const porcentaje = Math.trunc(n)
if (porcentaje < 1 || porcentaje > 100) return
// No permitir suma > 100
setEvalRows((prev) => {
const next = prev.map((r) =>
r.id === row.id ? { ...r, porcentaje: raw } : r,
)
const total = next.reduce(
(acc, r) => acc + (Number(r.porcentaje) || 0),
0,
r.id === row.id
? {
id: r.id,
criterio: r.criterio,
porcentaje: raw,
}
: r,
)
const total = next.reduce((acc, r) => {
const v = String(r.porcentaje).trim()
if (!v) return acc
const nn = Number(v)
if (!Number.isFinite(nn)) return acc
const vv = Math.trunc(nn)
if (vv < 1 || vv > 100) return acc
return acc + vv
}, 0)
return total > 100 ? prev : next
})
}}
/>
<div className="text-sm text-slate-600">%</div>
<div
className="flex w-[1ch] items-center justify-center text-sm text-slate-600"
aria-hidden
>
%
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600 hover:bg-red-50"
onClick={() =>
onClick={() => {
setEvalRows((prev) =>
prev.filter((r) => r.id !== row.id),
)
}
}}
aria-label="Quitar renglón"
title="Quitar"
>
<Minus className="h-4 w-4" />
</Button>
@@ -722,15 +656,22 @@ function InfoCard({
<div className="flex items-center justify-between">
<span
className={`text-sm ${evaluationTotal === 100 ? 'text-muted-foreground' : 'text-destructive font-semibold'}`}
className={
'text-sm ' +
(evaluationTotal === 100
? 'text-muted-foreground'
: 'text-destructive font-semibold')
}
>
Total: {evaluationTotal}/100
</span>
<Button
variant="ghost"
size="sm"
className="text-emerald-700 hover:bg-emerald-50"
onClick={() =>
onClick={() => {
// Agregar una fila vacía (siempre permitido)
setEvalRows((prev) => [
...prev,
{
@@ -739,7 +680,7 @@ function InfoCard({
porcentaje: '',
},
])
}
}}
>
<Plus className="mr-2 h-4 w-4" /> Agregar renglón
</Button>
@@ -753,15 +694,28 @@ function InfoCard({
className="min-h-30 text-sm leading-relaxed"
/>
)}
{/* Botones de acción comunes */}
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => {
setIsEditing(false)
// Lógica de reset si es necesario...
if (type === 'evaluation') {
const raw = Array.isArray(data) ? data : []
setEvalRows(
raw.map((r: CriterioEvaluacionRow) => ({
id: crypto.randomUUID(),
criterio:
typeof r.criterio === 'string' ? r.criterio : '',
porcentaje:
typeof r.porcentaje === 'number'
? String(Math.trunc(r.porcentaje))
: typeof r.porcentaje === 'string'
? String(Math.trunc(Number(r.porcentaje)))
: '',
})),
)
}
}}
>
Cancelar
@@ -777,7 +731,6 @@ function InfoCard({
</div>
</div>
) : (
/* Modo Visualización */
<div className="text-sm leading-relaxed text-slate-600">
{type === 'text' &&
(data ? (
@@ -786,7 +739,9 @@ function InfoCard({
<p className="text-slate-400 italic">Sin información.</p>
))}
{type === 'requirements' && <RequirementsView items={data} />}
{type === 'evaluation' && <EvaluationView items={data} />}
{type === 'evaluation' && (
<EvaluationView items={data as Array<CriterioEvaluacionRow>} />
)}
</div>
)}
</CardContent>
@@ -841,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,
}
@@ -18,7 +18,8 @@ import { Card } from '@/components/ui/card'
interface DocumentoSEPTabProps {
pdfUrl: string | null
isLoading: boolean
onDownload: () => void
onDownloadPdf: () => void
onDownloadWord: () => void
onRegenerate: () => void
isRegenerating: boolean
}
@@ -26,7 +27,8 @@ interface DocumentoSEPTabProps {
export function DocumentoSEPTab({
pdfUrl,
isLoading,
onDownload,
onDownloadPdf,
onDownloadWord,
onRegenerate,
isRegenerating,
}: DocumentoSEPTabProps) {
@@ -52,25 +54,23 @@ export function DocumentoSEPTab({
</div>
<div className="flex items-center gap-2">
{pdfUrl && !isLoading && (
<Button variant="outline" onClick={onDownload}>
<Download className="mr-2 h-4 w-4" />
Descargar
</Button>
)}
<AlertDialog
open={showConfirmDialog}
onOpenChange={setShowConfirmDialog}
>
<AlertDialogTrigger asChild>
<Button disabled={isRegenerating}>
<Button
variant="outline"
size="sm"
className="gap-2"
disabled={isRegenerating}
>
{isRegenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
<RefreshCw className="h-4 w-4" />
)}
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
{isRegenerating ? 'Generando...' : 'Regenerar'}
</Button>
</AlertDialogTrigger>
@@ -91,11 +91,31 @@ export function DocumentoSEPTab({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{pdfUrl && !isLoading && (
<>
<Button
size="sm"
className="gap-2 bg-teal-700 hover:bg-teal-800"
onClick={onDownloadWord}
>
<Download className="h-4 w-4" /> Descargar Word
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={onDownloadPdf}
>
<Download className="h-4 w-4" /> Descargar PDF
</Button>
</>
)}
</div>
</div>
{/* PDF Preview */}
<Card className="h-[800px] overflow-hidden">
<Card className="h-200 overflow-hidden">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-10 w-10 animate-spin" />
@@ -20,12 +20,15 @@ import { useState, useEffect, useRef, useMemo } from 'react'
import { ImprovementCard } from './SaveAsignatura/ImprovementCardProps'
import type { IASugerencia } from '@/types/asignatura'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer'
import {
Drawer,
DrawerContent,
DrawerOverlay,
DrawerPortal,
} from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import {
@@ -50,16 +53,7 @@ interface SelectedField {
value: string
}
interface IAAsignaturaTabProps {
asignatura?: Record<string, any>
onAcceptSuggestion: (sugerencia: IASugerencia) => void
onRejectSuggestion: (messageId: string) => void
}
export function IAAsignaturaTab({
onAcceptSuggestion,
onRejectSuggestion,
}: IAAsignaturaTabProps) {
export function IAAsignaturaTab() {
const queryClient = useQueryClient()
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
@@ -75,6 +69,7 @@ export function IAAsignaturaTab({
const [showSuggestions, setShowSuggestions] = useState(false)
const [isSending, setIsSending] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
// --- DATA QUERIES ---
const { data: datosGenerales } = useSubject(asignaturaId)
@@ -141,7 +136,7 @@ export function IAAsignaturaTab({
const dynamicFields = datosGenerales?.datos
? Object.keys(datosGenerales.datos).map((key) => {
const estructuraProps =
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
datosGenerales.estructuras_asignatura?.definicion?.properties || {}
return {
key,
label:
@@ -234,12 +229,38 @@ export function IAAsignaturaTab({
}
}, [activeChats, loadingConv])
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
const selectionStart = e.target.selectionStart // Posición del cursor
setInput(value)
// Buscamos si el carácter anterior al cursor es ':'
const lastChar = value.slice(selectionStart - 1, selectionStart)
if (lastChar === ':') {
setShowSuggestions(true)
} else if (!value.includes(':')) {
// Si borran todos los dos puntos, cerramos
setShowSuggestions(false)
}
}
const filteredFields = useMemo(() => {
if (!showSuggestions) return availableFields
if (!showSuggestions || !input) return availableFields
// 1. Encontrar el ":" más cercano a la IZQUIERDA del cursor
// Usamos una posición de referencia (si no tienes ref, usaremos el final del string,
// pero para mayor precisión lo ideal es usar e.target.selectionStart en el onChange)
// Extraemos lo que hay después del último ':' para filtrar
const lastColonIndex = input.lastIndexOf(':')
const query = input.slice(lastColonIndex + 1).toLowerCase()
if (lastColonIndex === -1) return availableFields
// 2. Extraer solo el fragmento de "búsqueda"
// Cortamos desde el ":" hasta el final, y luego tomamos solo la primera palabra
const textAfterColon = input.slice(lastColonIndex + 1)
const query = textAfterColon.split(/\s/)[0].toLowerCase() // Se detiene al encontrar un espacio
if (!query) return availableFields
return availableFields.filter(
(f) =>
@@ -259,24 +280,28 @@ export function IAAsignaturaTab({
// 3. Función para insertar el campo y limpiar el prompt
const handleSelectField = (field: SelectedField) => {
// 1. Agregamos al array de objetos (para tu lógica de API)
if (!selectedFields.find((f) => f.key === field.key)) {
setSelectedFields((prev) => [...prev, field])
}
// 2. Lógica de autocompletado en el texto
const lastColonIndex = input.lastIndexOf(':')
if (lastColonIndex !== -1) {
// Tomamos lo que había antes del ":" + el Nombre del Campo + un espacio
const nuevoTexto = input.slice(0, lastColonIndex) + `${field.label} `
const parteAntesDelColon = input.slice(0, lastColonIndex)
// Buscamos si hay texto después de la palabra que estamos escribiendo
const textoDespuesDelColon = input.slice(lastColonIndex + 1)
const espacioIndex = textoDespuesDelColon.indexOf(' ')
// Si hay un espacio, guardamos lo que sigue. Si no, es el final del texto.
const parteRestante =
espacioIndex !== -1 ? textoDespuesDelColon.slice(espacioIndex) : ''
// Reconstruimos: [Antes] + [Label] + [Lo que ya estaba después]
const nuevoTexto = `${parteAntesDelColon}${field.label}${parteRestante}`
setInput(nuevoTexto)
}
// 3. Cerramos el buscador y devolvemos el foco al textarea
setShowSuggestions(false)
// Opcional: Si tienes una ref del textarea, puedes hacer:
// textareaRef.current?.focus()
}
const handleSaveName = (id: string) => {
@@ -355,10 +380,35 @@ export function IAAsignaturaTab({
]
return (
<div className="flex h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
{/* PANEL IZQUIERDO */}
<div className="flex w-64 flex-col border-r pr-4">
<div className="mb-4 flex items-center justify-between px-2">
<div className="flex h-full w-full overflow-hidden bg-white">
<div className="fixed top-0 z-40 flex w-full items-center justify-between border-b bg-white/80 p-2 backdrop-blur-md">
<Button
variant="ghost"
size="sm"
className="text-slate-600"
onClick={() => setIsSidebarOpen(true)}
>
<History size={18} className="mr-2" /> Historial
</Button>
<div className="flex flex-col items-center">
<span className="text-[9px] font-bold tracking-wider text-teal-600 uppercase">
Asistente
</span>
</div>
<Button
variant="ghost"
size="sm"
className="text-slate-600"
onClick={() => setOpenIA(true)} // O el drawer de acciones/referencias
>
<FileText size={18} className="mr-2 text-teal-600" /> Referencias
</Button>
</div>
{/* 1. PANEL IZQUIERDO (HISTORIAL) - Desktop */}
<aside className="hidden h-full w-64 shrink-0 flex-col border-r bg-white pr-4 md:flex">
<div className="mb-4 flex items-center justify-between px-2 pt-4">
<h2 className="flex items-center gap-2 text-xs font-bold text-slate-500 uppercase">
<History size={14} /> Historial
</h2>
@@ -378,25 +428,19 @@ export function IAAsignaturaTab({
<Button
onClick={() => {
setActiveChatId(undefined)
hasInitialSelected.current = true
setIsCreatingNewChat(true)
setInput('')
setSelectedFields([])
// 4. Opcional: Limpiar el caché de mensajes actual para que la pantalla se vea vacía al instante
queryClient.setQueryData(['subject-messages', undefined], [])
}}
variant="outline"
className="mb-4 w-full justify-start gap-2 border-dashed border-slate-300 hover:border-teal-500"
className="mb-4 w-full justify-start gap-2 border-dashed border-slate-300 text-slate-600 hover:border-teal-500 hover:bg-teal-50/50"
>
<MessageSquarePlus size={18} /> Nuevo Chat
</Button>
{/* PANEL IZQUIERDO - Cambios en ScrollArea y contenedor */}
<ScrollArea className="flex-1">
<div className="flex flex-col gap-1 pr-3">
{' '}
{/* Eliminado space-y-1 para mejor control con gap */}
{(showArchived ? archivedChats : activeChats).map((chat: any) => (
<div
key={chat.id}
@@ -507,31 +551,51 @@ export function IAAsignaturaTab({
))}
</div>
</ScrollArea>
</div>
</aside>
{/* PANEL CENTRAL */}
<div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm">
{/* 2. PANEL CENTRAL (CHAT) - EL RECUADRO ESTILIZADO */}
<main className="relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm md:m-2">
{/* Header Interno del Recuadro */}
<div className="flex shrink-0 items-center justify-between border-b bg-white p-3">
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
Asistente IA
</span>
<button
onClick={() => setOpenIA(true)}
className="flex items-center gap-2 rounded-md bg-slate-100 px-3 py-1.5 text-xs font-medium transition hover:bg-slate-200"
>
<FileText size={14} className="text-slate-500" />
Referencias
{totalReferencias > 0 && (
<span className="animate-in zoom-in flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
{totalReferencias}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setIsSidebarOpen(true)}
>
<History size={20} className="text-slate-500" />
</Button>
<div className="flex flex-col">
<span className="text-[10px] font-bold tracking-wider text-teal-600 uppercase">
Asistente Académico
</span>
)}
</button>
<span className="text-[11px] text-slate-400">
Personalizado para tu asignatura
</span>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setOpenIA(true)}
className="flex items-center gap-2 rounded-lg bg-slate-100 px-3 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-200"
>
<FileText size={14} />
<span className="xs:inline hidden">Referencias</span>
{totalReferencias > 0 && (
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
{totalReferencias}
</span>
)}
</button>
</div>
</div>
{/* Área de Mensajes */}
<div className="relative min-h-0 flex-1">
<ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto max-w-3xl space-y-8 p-6">
<div className="mx-auto max-w-3xl space-y-6 p-3 md:p-6">
{messages.map((msg) => (
<div
key={msg.id}
@@ -642,120 +706,144 @@ export function IAAsignaturaTab({
</ScrollArea>
</div>
{/* INPUT */}
<div className="shrink-0 border-t bg-white p-4">
<div className="relative mx-auto max-w-4xl">
{showSuggestions && (
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full left-0 z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl">
<div className="flex justify-between border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<span>Filtrando campos...</span>
<span className="rounded bg-slate-200 px-1 text-[9px] text-slate-400">
ESC para cerrar
</span>
</div>
<div className="max-h-60 overflow-y-auto p-1">
{filteredFields.length > 0 ? (
filteredFields.map((field) => (
<button
key={field.key}
onClick={() => handleSelectField(field)}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50"
>
<div className="flex flex-col">
<span className="font-medium text-slate-700">
{field.label}
</span>
</div>
{selectedFields.find((f) => f.key === field.key) && (
<Check size={14} className="text-teal-600" />
)}
</button>
))
) : (
<div className="p-4 text-center text-xs text-slate-400 italic">
No se encontraron coincidencias
</div>
)}
</div>
</div>
)}
<div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500">
{selectedFields.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-2 pt-1">
{selectedFields.map((field) => (
<div
key={field.key}
className="animate-in zoom-in-95 flex items-center gap-1 rounded-md border border-teal-200 bg-teal-50 px-2 py-0.5 text-[11px] font-bold text-teal-700 shadow-sm"
>
<Target size={10} />
{field.label}
<button
onClick={() => toggleField(field)}
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-teal-200/50"
>
<X size={10} />
</button>
</div>
))}
{/* Input de Chat */}
<footer className="shrink-0 border-t bg-white p-3 md:p-4">
<div className="shrink-0 border-t bg-white p-2 md:p-4">
<div className="relative mx-auto max-w-4xl">
{showSuggestions && (
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full left-0 z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl">
<div className="flex justify-between border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<span>Filtrando campos...</span>
<span className="rounded bg-slate-200 px-1 text-[9px] text-slate-400">
ESC para cerrar
</span>
</div>
<div className="max-h-60 overflow-y-auto p-1">
{filteredFields.length > 0 ? (
filteredFields.map((field) => (
<button
key={field.key}
onClick={() => handleSelectField(field)}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50"
>
<div className="flex flex-col">
<span className="font-medium text-slate-700">
{field.label}
</span>
</div>
{selectedFields.find((f) => f.key === field.key) && (
<Check size={14} className="text-teal-600" />
)}
</button>
))
) : (
<div className="p-4 text-center text-xs text-slate-400 italic">
No se encontraron coincidencias
</div>
)}
</div>
</div>
)}
<div className="flex items-end gap-2">
<Textarea
value={input}
onChange={(e) => {
setInput(e.target.value)
if (e.target.value.endsWith(':')) setShowSuggestions(true)
else if (showSuggestions && !e.target.value.includes(':'))
setShowSuggestions(false)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
<div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500">
{selectedFields.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-2 pt-1">
{selectedFields.map((field) => (
<div
key={field.key}
className="animate-in zoom-in-95 flex items-center gap-1 rounded-md border border-teal-200 bg-teal-50 px-2 py-0.5 text-[11px] font-bold text-teal-700 shadow-sm"
>
<Target size={10} />
{field.label}
<button
onClick={() => toggleField(field)}
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-teal-200/50"
>
<X size={10} />
</button>
</div>
))}
</div>
)}
<div className="flex items-end gap-2">
<Textarea
value={input}
onChange={(e) => {
const val = e.target.value
const cursor = e.target.selectionStart
setInput(val)
const textBeforeCursor = val.slice(0, cursor)
const lastColonIndex = textBeforeCursor.lastIndexOf(':')
if (lastColonIndex !== -1) {
const textSinceColon = textBeforeCursor.slice(
lastColonIndex + 1,
)
// Si hay un espacio después del ":", cerramos sugerencias (ya no es un comando)
// Si no hay espacio, activamos
if (!textSinceColon.includes(' ')) {
setShowSuggestions(true)
} else {
setShowSuggestions(false)
}
} else {
setShowSuggestions(false)
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
placeholder='Escribe ":" para referenciar un campo...'
className="max-h-[120px] min-h-[40px] flex-1 resize-none border-none bg-transparent text-sm shadow-none focus-visible:ring-0"
/>
<Button
onClick={() => handleSend()}
disabled={
(!input.trim() && selectedFields.length === 0) ||
isSending
}
}}
placeholder='Escribe ":" para referenciar un campo...'
className="max-h-[120px] min-h-[40px] flex-1 resize-none border-none bg-transparent text-sm shadow-none focus-visible:ring-0"
/>
<Button
onClick={() => handleSend()}
disabled={
(!input.trim() && selectedFields.length === 0) || isSending
}
size="icon"
className="h-9 w-9 bg-teal-600 hover:bg-teal-700"
>
<Send size={16} className="text-white" />
</Button>
size="icon"
className="h-9 w-9 bg-teal-600 hover:bg-teal-700"
>
<Send size={16} className="text-white" />
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</footer>
</main>
{/* PANEL DERECHO ACCIONES */}
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
{/* 3. PANEL DERECHO (ATAJOS) */}
<aside className="hidden w-64 shrink-0 flex-col gap-4 overflow-y-auto p-4 lg:flex">
<h4 className="flex items-center gap-2 text-sm font-bold text-slate-800">
<Lightbulb size={18} className="text-orange-500" /> Atajos
<Lightbulb size={18} className="text-orange-500" /> Atajos Rápidos
</h4>
<div className="space-y-2">
{PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => handleSend(preset.prompt)}
className="group flex w-full items-center gap-3 rounded-xl border bg-white p-3 text-left text-sm transition-all hover:border-teal-500 hover:bg-teal-50"
className="group flex w-full items-center gap-3 rounded-xl border border-slate-100 bg-white p-3 text-left text-sm transition-all hover:border-teal-500 hover:shadow-sm"
>
<div className="rounded-lg bg-slate-100 p-2 group-hover:bg-teal-100 group-hover:text-teal-600">
<div className="rounded-lg bg-slate-50 p-2 group-hover:bg-teal-50 group-hover:text-teal-600">
<preset.icon size={16} />
</div>
<span className="font-medium text-slate-700">{preset.label}</span>
<span className="font-medium text-slate-600 group-hover:text-slate-900">
{preset.label}
</span>
</button>
))}
</div>
</div>
{/* --- DRAWER DE REFERENCIAS --- */}
</aside>
{/* DRAWERS (Referencias e Historial Móvil) */}
<Drawer open={openIA} onOpenChange={setOpenIA}>
<DrawerContent className="fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border bg-white shadow-2xl">
<div className="flex items-center justify-between border-b bg-slate-50/50 px-4 py-3">
@@ -790,6 +878,140 @@ export function IAAsignaturaTab({
</div>
</DrawerContent>
</Drawer>
<Drawer open={isSidebarOpen} onOpenChange={setIsSidebarOpen}>
<DrawerPortal>
<DrawerOverlay className="fixed inset-0 bg-black/40" />
<DrawerContent className="fixed right-0 bottom-0 left-0 h-[70vh] p-4 outline-none">
<div className="flex h-full flex-col overflow-hidden">
{/* Reutiliza aquí el componente de la lista de chats */}
<Button
onClick={() => {
setActiveChatId(undefined)
setIsCreatingNewChat(true)
setInput('')
setSelectedFields([])
queryClient.setQueryData(['subject-messages', undefined], [])
setIsSidebarOpen(false) // Cierra el drawer al crear nuevo
}}
variant="outline"
className="mb-6 w-full justify-center gap-2 border-dashed border-teal-500 bg-teal-50/50 text-teal-700 hover:bg-teal-100"
>
<MessageSquarePlus size={18} /> Nuevo Chat
</Button>
<h2 className="mb-4 font-bold">Historial de Chats</h2>
{(showArchived ? archivedChats : activeChats).map((chat: any) => (
<div
key={chat.id}
className={cn(
// Agregamos 'overflow-hidden' para que nada salga de este cuadro
'group relative flex w-full min-w-0 items-center justify-between gap-2 overflow-hidden rounded-lg px-3 py-2 text-sm transition-all',
activeChatId === chat.id
? 'bg-teal-50 text-teal-900'
: 'text-slate-600 hover:bg-slate-100',
)}
onDoubleClick={() => {
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || 'Conversacion')
}}
>
{editingId === chat.id ? (
<div className="flex min-w-0 flex-1 items-center">
<input
autoFocus
className="w-full rounded border-none bg-white px-1 text-xs ring-1 ring-teal-400 outline-none"
value={tempName}
onChange={(e) => setTempName(e.target.value)}
onBlur={() => handleSaveName(chat.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveName(chat.id)
if (e.key === 'Escape') setEditingId(null)
}}
/>
</div>
) : (
<>
{/* CLAVE 2: 'truncate' y 'min-w-0' en el span para que ceda ante los botones */}
<span
onClick={() => setActiveChatId(chat.id)}
className="block max-w-[140px] min-w-0 flex-1 cursor-pointer truncate pr-1"
title={chat.nombre || chat.titulo}
>
{chat.nombre || chat.titulo || 'Conversación'}
</span>
{/* CLAVE 3: 'shrink-0' asegura que los botones NUNCA desaparezcan */}
<div
className={cn(
'z-10 flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100',
activeChatId === chat.id
? 'bg-teal-50'
: 'bg-slate-100',
)}
>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation()
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || '')
}}
className="rounded-md p-1 transition-colors hover:bg-slate-200 hover:text-teal-600"
>
<Edit2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-[10px]">
Editar nombre
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation()
const nuevoEstado =
chat.estado === 'ACTIVA'
? 'ARCHIVADA'
: 'ACTIVA'
updateStatus({
id: chat.id,
estado: nuevoEstado,
})
}}
className={cn(
'rounded-md p-1 transition-colors hover:bg-slate-200',
chat.estado === 'ACTIVA'
? 'hover:text-red-500'
: 'hover:text-teal-600',
)}
>
{chat.estado === 'ACTIVA' ? (
<Archive size={14} />
) : (
<History size={14} className="scale-x-[-1]" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-[10px]">
{chat.estado === 'ACTIVA'
? 'Archivar'
: 'Desarchivar'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</>
)}
</div>
))}
</div>
</DrawerContent>
</DrawerPortal>
</Drawer>
</div>
)
}
+62 -28
View File
@@ -1,52 +1,86 @@
// document.api.ts
const DOCUMENT_PDF_URL =
'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434'
import { supabaseBrowser } from '../supabase/client'
import { invokeEdge } from '../supabase/invokeEdge'
const DOCUMENT_PDF_ASIGNATURA_URL =
'https://n8n.app.lci.ulsa.mx/webhook/041a68be-7568-46d0-bc08-09ded12d017d'
import { requireData, throwIfError } from './_helpers'
import type { Tables } from '@/types/supabase'
const EDGE = {
carbone_io_wrapper: 'carbone-io-wrapper',
} as const
interface GeneratePdfParams {
plan_estudio_id: string
convertTo?: 'pdf'
}
interface GeneratePdfParamsAsignatura {
asignatura_id: string
convertTo?: 'pdf'
}
export async function fetchPlanPdf({
plan_estudio_id,
convertTo,
}: GeneratePdfParams): Promise<Blob> {
const response = await fetch(DOCUMENT_PDF_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
return await invokeEdge<Blob>(
EDGE.carbone_io_wrapper,
{
action: 'downloadReport',
plan_estudio_id,
body: convertTo ? { convertTo } : {},
},
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()
{
headers: {
'Content-Type': 'application/json',
},
responseType: 'blob',
},
)
}
export async function fetchAsignaturaPdf({
asignatura_id,
convertTo,
}: GeneratePdfParamsAsignatura): Promise<Blob> {
const response = await fetch(DOCUMENT_PDF_ASIGNATURA_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ asignatura_id }),
})
const supabase = supabaseBrowser()
if (!response.ok) {
throw new Error('Error al generar el PDF')
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
// n8n devuelve el archivo → lo tratamos como blob
return await response.blob()
return await invokeEdge<Blob>(
EDGE.carbone_io_wrapper,
{
action: 'downloadReport',
asignatura_id,
body: {
...body,
},
},
{
headers: {
'Content-Type': 'application/json',
},
responseType: 'blob',
},
)
}
+1 -1
View File
@@ -191,7 +191,7 @@ export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
.from('asignaturas')
.select(
`
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,prerrequisito_asignatura_id,
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,
planes_estudio(
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
+72 -1
View File
@@ -12,6 +12,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
export type EdgeInvokeOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
headers?: Record<string, string>
responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer'
}
export class EdgeFunctionError extends Error {
@@ -26,6 +27,55 @@ export class EdgeFunctionError extends Error {
}
}
// Soporta base64 puro o data:...;base64,...
function decodeBase64ToUint8Array(input: string): Uint8Array {
const trimmed = input.trim()
const base64 = trimmed.startsWith('data:')
? trimmed.slice(trimmed.indexOf(',') + 1)
: trimmed
const bin = atob(base64)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
return bytes
}
function stripDataUrlPrefix(input: string): string {
const trimmed = input.trim()
if (!trimmed.startsWith('data:')) return trimmed
const commaIdx = trimmed.indexOf(',')
return commaIdx >= 0 ? trimmed.slice(commaIdx + 1) : trimmed
}
function looksLikeBase64(s: string): boolean {
const t = stripDataUrlPrefix(s).replace(/\s+/g, '').replace(/=+$/g, '')
// base64 típico: solo chars permitidos y longitud razonable
if (t.length < 64) return false
return /^[A-Za-z0-9+/]+$/.test(t)
}
function startsWithZip(bytes: Uint8Array): boolean {
return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b // "PK"
}
function startsWithPdf(bytes: Uint8Array): boolean {
return (
bytes.length >= 5 &&
bytes[0] === 0x25 &&
bytes[1] === 0x50 &&
bytes[2] === 0x44 &&
bytes[3] === 0x46 &&
bytes[4] === 0x2d
) // "%PDF-"
}
function binaryStringToUint8Array(input: string): Uint8Array {
const bytes = new Uint8Array(input.length)
for (let i = 0; i < input.length; i++) bytes[i] = input.charCodeAt(i) & 0xff
return bytes
}
export async function invokeEdge<TOut>(
functionName: string,
body?:
@@ -42,10 +92,16 @@ export async function invokeEdge<TOut>(
): Promise<TOut> {
const supabase = client ?? supabaseBrowser()
const { data, error } = await supabase.functions.invoke(functionName, {
// Nota: algunas versiones/defs de @supabase/supabase-js no tipan `responseType`
// aunque el runtime lo soporte. Usamos `any` para no bloquear el uso de Blob.
const invoke: any = (supabase.functions as any).invoke.bind(
supabase.functions,
)
const { data, error } = await invoke(functionName, {
body,
method: opts.method ?? 'POST',
headers: opts.headers,
responseType: opts.responseType,
})
if (error) {
@@ -104,5 +160,20 @@ export async function invokeEdge<TOut>(
throw new EdgeFunctionError(message, functionName, status, details)
}
if (opts.responseType === 'blob') {
const anyData: unknown = data
if (anyData instanceof Blob) {
return anyData as TOut
}
throw new EdgeFunctionError(
'La Edge Function no devolvió un binario (Blob) válido.',
functionName,
undefined,
{ receivedType: typeof anyData, received: anyData },
)
}
return data as TOut
}
+78
View File
@@ -0,0 +1,78 @@
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function parseContenidoTematicoToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
const blocks: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const unidad =
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
? item.unidad
: undefined
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
if (!header) continue
const lines: Array<string> = [header]
const temas = Array.isArray(item.temas) ? item.temas : []
temas.forEach((tema, idx) => {
const temaNombre =
typeof tema === 'string'
? tema
: isRecord(tema) && typeof tema.nombre === 'string'
? tema.nombre
: ''
if (!temaNombre) return
if (unidad != null) {
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
} else {
lines.push(`${idx + 1}. ${temaNombre}`)
}
})
blocks.push(lines.join('\n'))
}
return blocks.join('\n\n').trimEnd()
}
export function parseCriteriosEvaluacionToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
const lines: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const label = typeof item.criterio === 'string' ? item.criterio.trim() : ''
const valueNum =
typeof item.porcentaje === 'number'
? item.porcentaje
: typeof item.porcentaje === 'string'
? Number(item.porcentaje)
: NaN
if (!label) continue
if (!Number.isFinite(valueNum)) continue
const v = Math.trunc(valueNum)
if (v < 1 || v > 100) continue
lines.push(`${label}: ${v}%`)
}
return lines.join('\n')
}
export const columnParsers: Partial<
Record<string, (value: unknown) => string>
> = {
contenido_tematico: parseContenidoTematicoToPlainText,
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
}
@@ -218,26 +218,30 @@ function AsignaturasPage() {
<Table>
<TableHeader>
<TableRow className="bg-slate-50/50">
<TableHead className="w-30">Clave</TableHead>
<TableHead>Nombre</TableHead>
<TableHead className="text-center">Créditos</TableHead>
<TableHead className="text-center">Ciclo</TableHead>
<TableHead>Línea Curricular</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Estado</TableHead>
<TableHead className="w-12.5"></TableHead>
<TableHead className="w-30 px-6 py-4">Clave</TableHead>
<TableHead className="px-6 py-4">Nombre</TableHead>
<TableHead className="px-6 py-4 text-center">Créditos</TableHead>
<TableHead className="px-6 py-4 text-center">Ciclo</TableHead>
<TableHead className="px-6 py-4">Línea Curricular</TableHead>
<TableHead className="px-6 py-4">Tipo</TableHead>
<TableHead className="px-6 py-4">Estado</TableHead>
<TableHead className="w-12.5 px-6 py-4"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAsignaturas.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="h-40 text-center">
<div className="text-muted-foreground flex flex-col items-center justify-center">
<BookOpen className="mb-2 h-10 w-10 opacity-20" />
<p className="font-medium">No se encontraron asignaturas</p>
<p className="text-xs">
Intenta cambiar los filtros de búsqueda
</p>
<TableCell colSpan={8} className="h-40 px-6 py-8 text-center">
<div className="text-muted-foreground flex flex-col items-center justify-center gap-3">
<BookOpen className="h-10 w-10 opacity-20" />
<div>
<p className="font-medium">
No se encontraron asignaturas
</p>
<p className="mt-1 text-xs">
Intenta cambiar los filtros de búsqueda
</p>
</div>
</div>
</TableCell>
</TableRow>
@@ -251,25 +255,25 @@ function AsignaturasPage() {
to: '/planes/$planId/asignaturas/$asignaturaId',
params: {
planId,
asignaturaId: asignatura.id, // 👈 puede ser índice, consecutivo o slug
asignaturaId: asignatura.id,
},
state: {
realId: asignatura.id, // 👈 ID largo oculto
realId: asignatura.id,
asignaturaId: asignatura.id,
} as any,
})
}
>
<TableCell className="font-mono text-xs font-bold text-slate-400">
<TableCell className="px-6 py-4 font-mono text-xs font-bold text-slate-400">
{asignatura.clave}
</TableCell>
<TableCell className="font-semibold text-slate-700">
<TableCell className="px-6 py-4 font-semibold text-slate-700">
{asignatura.nombre}
</TableCell>
<TableCell className="text-center font-medium">
<TableCell className="px-6 py-4 text-center font-medium">
{asignatura.creditos}
</TableCell>
<TableCell className="text-center">
<TableCell className="px-6 py-4 text-center">
{asignatura.ciclo ? (
<Badge variant="outline" className="font-normal">
Ciclo {asignatura.ciclo}
@@ -278,10 +282,10 @@ function AsignaturasPage() {
<span className="text-slate-300"></span>
)}
</TableCell>
<TableCell className="text-sm text-slate-600">
<TableCell className="px-6 py-4 text-sm text-slate-600">
{getLineaNombre(asignatura.lineaCurricularId)}
</TableCell>
<TableCell>
<TableCell className="px-6 py-4">
<Badge
variant="outline"
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`}
@@ -289,7 +293,7 @@ function AsignaturasPage() {
{tipoConfig[asignatura.tipo].label}
</Badge>
</TableCell>
<TableCell>
<TableCell className="px-6 py-4">
<Badge
variant="outline"
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`}
@@ -297,7 +301,7 @@ function AsignaturasPage() {
{statusConfig[asignatura.estado].label}
</Badge>
</TableCell>
<TableCell>
<TableCell className="px-6 py-4">
<div className="opacity-0 transition-opacity group-hover:opacity-100">
<ChevronRight className="h-5 w-5 text-slate-400" />
</div>
@@ -8,10 +8,11 @@ import {
Clock,
FileJson,
} from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { usePlan } from '@/data'
import { fetchPlanPdf } from '@/data/api/document.api'
export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
@@ -20,30 +21,41 @@ export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
function RouteComponent() {
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
const { data: plan } = usePlan(planId)
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const pdfUrlRef = useRef<string | null>(null)
const isMountedRef = useRef<boolean>(false)
const [isLoading, setIsLoading] = useState(true)
const planFileBaseName = sanitizeFileBaseName(plan?.nombre ?? 'plan_estudios')
const loadPdfPreview = useCallback(async () => {
try {
setIsLoading(true)
const pdfBlob = await fetchPlanPdf({ plan_estudio_id: planId })
if (isMountedRef.current) setIsLoading(true)
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
convertTo: 'pdf',
})
if (!isMountedRef.current) return
const url = window.URL.createObjectURL(pdfBlob)
// Limpiar URL anterior si existe para evitar fugas de memoria
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
pdfUrlRef.current = url
setPdfUrl(url)
} catch (error) {
console.error('Error cargando preview:', error)
} finally {
setIsLoading(false)
if (isMountedRef.current) setIsLoading(false)
}
}, [planId])
useEffect(() => {
isMountedRef.current = true
loadPdfPreview()
return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
isMountedRef.current = false
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
}
}, [loadPdfPreview])
@@ -51,12 +63,13 @@ function RouteComponent() {
try {
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
convertTo: 'pdf',
})
const url = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a')
link.href = url
link.download = 'plan_estudios.pdf'
link.download = `${planFileBaseName}.pdf`
document.body.appendChild(link)
link.click()
@@ -67,6 +80,27 @@ function RouteComponent() {
alert('No se pudo generar el PDF')
}
}
const handleDownloadWord = async () => {
try {
const docBlob = await fetchPlanPdf({
plan_estudio_id: planId,
})
const url = window.URL.createObjectURL(docBlob)
const link = document.createElement('a')
link.href = url
link.download = `${planFileBaseName}.docx`
document.body.appendChild(link)
link.click()
link.remove()
setTimeout(() => window.URL.revokeObjectURL(url), 1000)
} catch (error) {
console.error(error)
alert('No se pudo generar el Word')
}
}
return (
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
{/* HEADER DE ACCIONES */}
@@ -88,12 +122,17 @@ function RouteComponent() {
>
<RefreshCcw size={16} /> Regenerar
</Button>
<Button variant="outline" size="sm" className="gap-2">
<Download size={16} /> Descargar Word
</Button>
<Button
size="sm"
className="gap-2 bg-teal-700 hover:bg-teal-800"
onClick={handleDownloadWord}
>
<Download size={16} /> Descargar Word
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={handleDownloadPdf}
>
<Download size={16} /> Descargar PDF
@@ -139,7 +178,7 @@ function RouteComponent() {
)}
</div>
<CardContent className="flex min-h-[800px] justify-center bg-slate-500 p-0">
<CardContent className="flex min-h-200 justify-center bg-slate-500 p-0">
{isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 text-white">
<RefreshCcw size={40} className="animate-spin opacity-50" />
@@ -149,7 +188,7 @@ function RouteComponent() {
/* 3. VISOR DE PDF REAL */
<iframe
src={`${pdfUrl}#toolbar=0&navpanes=0`}
className="h-[1000px] w-full max-w-[1000px] border-none shadow-2xl"
className="h-250 w-full max-w-250 border-none shadow-2xl"
title="PDF Preview"
/>
) : (
@@ -163,6 +202,24 @@ function RouteComponent() {
)
}
function sanitizeFileBaseName(input: string): string {
const text = String(input)
const withoutControlChars = Array.from(text)
.filter((ch) => {
const code = ch.charCodeAt(0)
return code >= 32 && code !== 127
})
.join('')
const cleaned = withoutControlChars
.replace(/[<>:"/\\|?*]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/g, '')
return (cleaned || 'documento').slice(0, 150)
}
// Componente pequeño para las tarjetas de estado superior
function StatusCard({
icon,
+103 -37
View File
@@ -139,6 +139,10 @@ function RouteComponent() {
null,
)
const [filterQuery, setFilterQuery] = useState('')
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
const [isActionsOpen, setIsActionsOpen] = useState(false)
const availableFields = useMemo(() => {
const definicion = data?.estructuras_plan
?.definicion as EstructuraDefinicion
@@ -206,7 +210,7 @@ function RouteComponent() {
return messages
})
}, [mensajesDelChat, activeChatId, availableFields])
const scrollToBottom = (behavior = 'smooth') => {
const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
if (scrollRef.current) {
const scrollContainer = scrollRef.current.querySelector(
'[data-radix-scroll-area-viewport]',
@@ -214,7 +218,7 @@ function RouteComponent() {
if (scrollContainer) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: behavior, // 'instant' para carga inicial, 'smooth' para mensajes nuevos
behavior,
})
}
}
@@ -478,37 +482,38 @@ function RouteComponent() {
}
return (
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
{/* --- PANEL IZQUIERDO: HISTORIAL --- */}
<div className="flex w-64 flex-col border-r pr-4">
<div className="mb-4">
<div className="mb-4 flex items-center justify-between px-2">
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
Chats
</h2>
{/* Botón de toggle archivados movido aquí arriba */}
<button
onClick={() => setShowArchived(!showArchived)}
className={`rounded-md p-1.5 transition-colors ${
showArchived
? 'bg-teal-50 text-teal-600'
: 'text-slate-400 hover:bg-slate-100'
}`}
title={showArchived ? 'Ver chats activos' : 'Ver archivados'}
>
<Archive size={16} />
</button>
</div>
<Button
onClick={createNewChat}
variant="outline"
className="mb-4 w-full justify-start gap-2 border-slate-200 hover:bg-teal-50 hover:text-teal-700"
>
<MessageSquarePlus size={18} /> Nuevo chat
</Button>
</div>
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full flex-col gap-6 overflow-hidden p-4 md:flex-row">
{/* --- HEADER MÓVIL (Solo visible en < md) --- */}
<div className="flex items-center justify-between rounded-lg border bg-white p-2 md:hidden">
<Button
variant="ghost"
size="sm"
onClick={() => setIsHistoryOpen(true)}
>
<Archive size={18} className="mr-2" /> Historial
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setIsActionsOpen(true)}
>
<Lightbulb size={18} className="mr-2 text-orange-500" /> Acciones
</Button>
</div>
{/* --- PANEL IZQUIERDO: HISTORIAL (Escritorio) --- */}
<div className="hidden w-64 flex-col border-r pr-4 md:flex">
{/* ... (Tu código actual del historial de chats) ... */}
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
Chats
</h2>
<Button
onClick={createNewChat}
variant="outline"
className="mt-2 mb-4 w-full justify-start gap-2"
>
<MessageSquarePlus size={18} /> Nuevo chat
</Button>
<ScrollArea className="flex-1">
<div className="space-y-1 pr-2">
{' '}
@@ -571,7 +576,7 @@ function RouteComponent() {
onBlur={(e) => {
if (editingChatId === chat.id) {
const newTitle =
e.currentTarget.textContent?.trim() || ''
e.currentTarget.textContent.trim() || ''
if (newTitle && newTitle !== chat.nombre) {
updateTitleMutation({
id: chat.id,
@@ -655,8 +660,9 @@ function RouteComponent() {
</div>
</ScrollArea>
</div>
{/* PANEL DE CHAT PRINCIPAL */}
<div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm">
{/* --- PANEL DE CHAT PRINCIPAL (Centro) --- */}
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm md:flex-[3]">
{/* NUEVO: Barra superior de campos seleccionados */}
<div className="shrink-0 border-b bg-white p-3">
<div className="flex items-center justify-between">
@@ -931,8 +937,9 @@ function RouteComponent() {
</div>
</div>
</div>
{/* PANEL LATERAL */}
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
{/* --- PANEL LATERAL: ACCIONES RÁPIDAS (Escritorio) --- */}
<div className="hidden flex-[1] flex-col gap-4 overflow-y-auto pr-2 md:flex">
<h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800">
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
</h4>
@@ -953,6 +960,65 @@ function RouteComponent() {
))}
</div>
</div>
{/* --- DRAWER: HISTORIAL (Móvil) --- */}
<Drawer open={isHistoryOpen} onOpenChange={setIsHistoryOpen}>
<DrawerContent className="h-[80vh] p-4">
<Button
onClick={() => {
createNewChat()
setIsHistoryOpen(false)
}}
className="mb-4 w-full bg-teal-600 text-white"
>
<MessageSquarePlus size={18} className="mr-2" /> Nuevo Chat
</Button>
<ScrollArea className="flex-1">
{/* Reutiliza aquí el mapeo de chats que tienes en el panel izquierdo */}
<p className="mb-4 text-xs font-bold text-slate-400 uppercase">
Historial Reciente
</p>
{activeChats.map((chat) => (
<div
key={chat.id}
onClick={() => {
setActiveChatId(chat.id)
setIsHistoryOpen(false)
}}
className="border-b p-3 text-sm"
>
{chat.nombre || 'Chat sin nombre'}
</div>
))}
</ScrollArea>
</DrawerContent>
</Drawer>
{/* --- DRAWER: ACCIONES RÁPIDAS (Móvil) --- */}
<Drawer open={isActionsOpen} onOpenChange={setIsActionsOpen}>
<DrawerContent className="h-[60vh] p-4">
<h4 className="mb-4 flex items-center gap-2 font-bold">
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
</h4>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => {
handleSend(preset.prompt)
setIsActionsOpen(false)
}}
className="flex items-center gap-3 rounded-xl border p-4 text-left text-sm"
>
<preset.icon size={16} />
<span>{preset.label}</span>
</button>
))}
</div>
</DrawerContent>
</Drawer>
{/* Tu Drawer de Referencias IA se queda igual */}
<Drawer open={openIA} onOpenChange={setOpenIA}>
<DrawerContent className="fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border bg-white shadow-2xl">
{/* Cabecera más compacta */}
+435 -202
View File
@@ -1,14 +1,8 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/label-has-associated-control */
import { createFileRoute } from '@tanstack/react-router'
import {
Plus,
ChevronDown,
AlertTriangle,
GripVertical,
Trash2,
Pencil,
} from 'lucide-react'
import { Plus, ChevronDown, AlertTriangle, Trash2, Pencil } from 'lucide-react'
import * as Icons from 'lucide-react'
import { useMemo, useState, useEffect, Fragment } from 'react'
import type { TipoAsignatura } from '@/data'
@@ -37,6 +31,12 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
useCreateLinea,
useDeleteLinea,
@@ -48,14 +48,25 @@ import {
} from '@/data'
// --- Mapeadores (Fuera del componente para mayor limpieza) ---
const palette = [
'#4F46E5', // índigo
'#7C3AED', // violeta
'#EA580C', // naranja
'#059669', // esmeralda
'#DC2626', // rojo
'#0891B2', // cyan
'#CA8A04', // ámbar
'#C026D3', // fucsia
]
const mapLineasToLineaCurricular = (
lineasApi: Array<any> = [],
): Array<LineaCurricular> => {
return lineasApi.map((linea) => ({
return lineasApi.map((linea, index) => ({
id: linea.id,
nombre: linea.nombre,
orden: linea.orden ?? 0,
color: '#1976d2',
color: palette[index % palette.length],
}))
}
@@ -121,52 +132,212 @@ function StatItem({
)
}
const estadoConfig: Record<
Asignatura['estado'],
{
label: string
dot: string
soft: string
icon: React.ComponentType<{ className?: string }>
}
> = {
borrador: {
label: 'Borrador',
dot: 'bg-slate-500',
soft: 'bg-slate-100 text-slate-700',
icon: Icons.FileText,
},
revisada: {
label: 'Revisada',
dot: 'bg-amber-500',
soft: 'bg-amber-100 text-amber-700',
icon: Icons.ScanSearch,
},
aprobada: {
label: 'Aprobada',
dot: 'bg-emerald-500',
soft: 'bg-emerald-100 text-emerald-700',
icon: Icons.BadgeCheck,
},
generando: {
label: 'Generando',
dot: 'bg-sky-500',
soft: 'bg-sky-100 text-sky-700',
icon: Icons.LoaderCircle,
},
}
function hexToRgba(hex: string, alpha: number) {
const clean = hex.replace('#', '')
const bigint = parseInt(clean, 16)
const r = (bigint >> 16) & 255
const g = (bigint >> 8) & 255
const b = bigint & 255
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
function AsignaturaCardItem({
asignatura,
lineaColor,
lineaNombre,
onDragStart,
isDragging,
onClick,
}: {
asignatura: Asignatura
lineaColor: string
lineaNombre?: string
onDragStart: (e: React.DragEvent, id: string) => void
isDragging: boolean
onClick: () => void
}) {
const estado = estadoConfig[asignatura.estado]
const EstadoIcon = estado.icon
return (
<button
draggable
onDragStart={(e) => onDragStart(e, asignatura.id)}
onClick={onClick}
className={`group cursor-grab rounded-lg border bg-white p-3 shadow-sm transition-all active:cursor-grabbing ${
isDragging
? 'scale-95 opacity-40'
: 'hover:border-teal-400 hover:shadow-md'
}`}
>
<div className="mb-1 flex items-start justify-between">
<span className="font-mono text-[10px] font-bold text-slate-400">
{asignatura.clave}
</span>
<Badge
variant="outline"
className={`px-1 py-0 text-[9px] uppercase ${statusBadge[asignatura.estado] || ''}`}
>
{asignatura.estado}
</Badge>
</div>
<p className="mb-1 text-xs leading-tight font-bold text-slate-700">
{asignatura.nombre}
</p>
<div className="mt-2 flex items-center justify-between">
<span className="text-[10px] text-slate-500">
{asignatura.creditos} CR HD:{asignatura.hd} HI:{asignatura.hi}
</span>
<GripVertical
size={12}
className="text-slate-300 opacity-0 transition-opacity group-hover:opacity-100"
/>
</div>
</button>
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
draggable
onDragStart={(e) => onDragStart(e, asignatura.id)}
onClick={onClick}
className={[
'group relative h-50 w-40 shrink-0 overflow-hidden rounded-[22px] border text-left',
'transition-all duration-300 ease-out',
'focus-visible:ring-ring/30 focus-visible:ring-2 focus-visible:outline-none',
'cursor-grab active:cursor-grabbing',
isDragging
? 'scale-[0.985] opacity-45 shadow-none'
: 'hover:-translate-y-1 hover:shadow-lg',
].join(' ')}
style={{
borderColor: hexToRgba(lineaColor, 0.18),
background: `
radial-gradient(circle at top right, ${hexToRgba(lineaColor, 0.22)} 0%, transparent 34%),
linear-gradient(180deg, ${hexToRgba(lineaColor, 0.12)} 0%, ${hexToRgba(lineaColor, 0.04)} 42%, var(--card) 100%)
`,
}}
title={asignatura.nombre}
>
{/* franja */}
<div
className="absolute inset-x-0 top-0 h-2"
style={{ backgroundColor: lineaColor }}
/>
{/* glow decorativo */}
<div
className="absolute -top-10 -right-10 h-28 w-28 rounded-full blur-2xl"
style={{ backgroundColor: hexToRgba(lineaColor, 0.22) }}
/>
<div className="relative flex h-full flex-col p-4">
{/* top */}
<div className="flex items-start justify-between gap-2">
<div
className="inline-flex h-8 max-w-32 items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-semibold"
style={{
borderColor: hexToRgba(lineaColor, 0.2),
backgroundColor: hexToRgba(lineaColor, 0.1),
color: lineaColor,
}}
>
<Icons.KeyRound className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
{asignatura.clave || 'Sin clave'}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="bg-background/70 flex h-8 items-center rounded-full px-2 backdrop-blur-sm">
<EstadoIcon className="text-foreground/65 h-3.5 w-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="left">
<span className="text-xs font-semibold">
{estado.label}
</span>
</TooltipContent>
</Tooltip>
</div>
{/* titulo */}
<div className="mt-4 min-h-18">
<h3
className="text-foreground text-md overflow-hidden leading-[1.08] font-bold"
style={{
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
}}
>
{asignatura.nombre}
</h3>
</div>
{/* bottom */}
<div className="mt-auto grid grid-cols-3 gap-2">
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
<div className="text-muted-foreground mb-1 flex items-center gap-1.5">
<Icons.Award className="h-3.5 w-3.5" />
<span className="text-[10px] font-medium tracking-wide uppercase">
CR
</span>
</div>
<div className="text-foreground text-sm font-bold">
{asignatura.creditos}
</div>
</div>
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
<div className="text-muted-foreground mb-1 flex items-center gap-1.5">
<Icons.Clock3 className="h-3.5 w-3.5" />
<span className="text-[10px] font-medium tracking-wide uppercase">
HD
</span>
</div>
<div className="text-foreground text-sm font-bold">
{asignatura.hd}
</div>
</div>
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
<div className="text-muted-foreground mb-1 flex items-center gap-1.5">
<Icons.BookOpenText className="h-3.5 w-3.5" />
<span className="text-[10px] font-medium tracking-wide uppercase">
HI
</span>
</div>
<div className="text-foreground text-sm font-bold">
{asignatura.hi}
</div>
</div>
</div>
{/* drag affordance */}
<div className="bg-background/70 pointer-events-none absolute right-3 bottom-3 rounded-full p-1.5 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:opacity-100">
<Icons.GripVertical className="text-muted-foreground/55 h-4 w-4" />
</div>
</div>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="text-lg">
{/* ciclo */}
{asignatura.ciclo ? (
<span className="font-bold">C{asignatura.ciclo} · </span>
) : null}
{lineaNombre ? (
<span className="font-medium">{lineaNombre} · </span>
) : null}
{asignatura.nombre}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
@@ -509,7 +680,7 @@ function MapaCurricularPage() {
return <div className="p-10 text-center">Cargando mapa curricular...</div>
return (
<div className="container mx-auto px-2 py-6">
<div className="container">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
@@ -588,184 +759,246 @@ function MapaCurricularPage() {
</div>
<div className="overflow-x-auto pb-6">
<div className="min-w-[1500px]">
<div
className="grid gap-3"
style={{
gridTemplateColumns: `220px repeat(${ciclosTotales}, minmax(auto, 1fr)) 120px`,
}}
>
<div className="self-end px-2 text-xs font-bold text-slate-400">
LÍNEA CURRICULAR
<div
className="grid gap-2"
style={{
gridTemplateColumns: `140px repeat(${ciclosTotales}, minmax(auto, 1fr)) 120px`,
}}
>
<div className="self-end px-2 text-xs font-bold text-slate-400">
LÍNEA CURRICULAR
</div>
{ciclosArray.map((n) => (
<div
key={`header-${n}`}
className="rounded-lg bg-slate-100 p-2 text-center text-sm font-bold text-slate-600"
>
Ciclo {n}
</div>
))}
{ciclosArray.map((n) => (
<div
key={`header-${n}`}
className="rounded-lg bg-slate-100 p-2 text-center text-sm font-bold text-slate-600"
>
Ciclo {n}
</div>
))}
<div className="self-end text-center text-xs font-bold text-slate-400">
SUBTOTAL
</div>
<div className="self-end text-center text-xs font-bold text-slate-400">
SUBTOTAL
</div>
{lineas.map((linea, idx) => {
const sub = getSubtotalLinea(linea.id)
{lineas.map((linea, idx) => {
const sub = getSubtotalLinea(linea.id)
return (
<Fragment key={linea.id}>
<div
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${
lineColors[idx % lineColors.length]
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`}
>
<div className="flex-1 overflow-hidden">
<span
contentEditable={editingLineaId === linea.id}
suppressContentEditableWarning
spellCheck={false}
onKeyDown={(e) => handleKeyDownLinea(e, linea.id)}
onBlur={(e) => handleBlurLinea(e, linea.id)}
onClick={() => {
if (editingLineaId !== linea.id) {
setEditingLineaId(linea.id)
setTempNombreLinea(linea.nombre)
}
}}
className={`block w-full text-xs font-bold break-words outline-none ${
editingLineaId === linea.id
? 'cursor-text border-b border-teal-500/50 pb-1'
: 'cursor-pointer'
}`}
>
{linea.nombre}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setEditingLineaId(linea.id)}
className="..."
>
{' '}
<Pencil size={12} />{' '}
</button>
<Trash2
onClick={() => borrarLinea(linea.id)}
className="..."
size={14}
/>
</div>
</div>
{ciclosArray.map((ciclo) => (
<div
key={`${linea.id}-${ciclo}`}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, ciclo, linea.id)}
className="min-h-[140px] space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 p-2"
>
{asignaturas
.filter(
(m) =>
m.ciclo === ciclo &&
m.lineaCurricularId === linea.id,
)
.map((m) => (
<AsignaturaCardItem
key={m.id}
asignatura={m}
isDragging={draggedAsignatura === m.id}
onDragStart={handleDragStart}
onClick={() => {
setEditingData(m)
setIsEditModalOpen(true)
}}
/>
))}
</div>
))}
<div className="flex flex-col justify-center rounded-xl border border-slate-100 bg-slate-50 p-4 text-[10px] font-medium text-slate-500">
<div>Cr: {sub.cr}</div>
<div>HD: {sub.hd}</div>
<div>HI: {sub.hi}</div>
</div>
</Fragment>
)
})}
<div className="col-span-full my-2 border-t border-slate-200"></div>
<div className="self-center p-2 font-bold text-slate-600">
Totales por Ciclo
</div>
{ciclosArray.map((ciclo) => {
const t = getTotalesCiclo(ciclo)
return (
return (
<Fragment key={linea.id}>
<div
key={`footer-${ciclo}`}
className="rounded-lg bg-slate-50 p-2 text-center text-[10px]"
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-3 transition-all ${
lineColors[idx % lineColors.length]
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`}
>
<div className="font-bold text-slate-700">Cr: {t.cr}</div>
<div>
HD: {t.hd} HI: {t.hi}
<div className="min-w-0 flex-1 overflow-hidden">
<span
contentEditable={editingLineaId === linea.id}
suppressContentEditableWarning
spellCheck={false}
onKeyDown={(e) => handleKeyDownLinea(e, linea.id)}
onBlur={(e) => handleBlurLinea(e, linea.id)}
onClick={() => {
if (editingLineaId !== linea.id) {
setEditingLineaId(linea.id)
setTempNombreLinea(linea.nombre)
}
}}
className={`block w-full truncate text-xs font-bold break-words outline-none ${
editingLineaId === linea.id
? 'cursor-text border-b border-teal-500/50 pb-1'
: 'cursor-pointer'
}`}
>
{linea.nombre}
</span>
</div>
<div className="ml-2 flex flex-shrink-0 items-center gap-1">
<button
onClick={() => setEditingLineaId(linea.id)}
className="..."
>
{' '}
<Pencil size={12} />{' '}
</button>
<Trash2
onClick={() => borrarLinea(linea.id)}
className="..."
size={14}
/>
</div>
</div>
)
})}
<div className="flex flex-col justify-center rounded-lg bg-teal-50 p-2 text-center text-xs font-bold text-teal-800">
<div>{stats.cr} Cr</div>
<div>{stats.hd + stats.hi} Hrs</div>
</div>
{ciclosArray.map((ciclo) => (
<div
key={`${linea.id}-${ciclo}`}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, ciclo, linea.id)}
className="min-h-35 space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20"
>
{asignaturas
.filter(
(m) =>
m.ciclo === ciclo && m.lineaCurricularId === linea.id,
)
.map((m) => (
<AsignaturaCardItem
key={m.id}
asignatura={m}
lineaColor={linea.color || '#1976d2'}
lineaNombre={linea.nombre}
isDragging={draggedAsignatura === m.id}
onDragStart={handleDragStart}
onClick={() => {
setEditingData(m)
setIsEditModalOpen(true)
}}
/>
))}
</div>
))}
<div
className={`flex flex-col justify-center rounded-xl border p-4 text-[10px] font-medium ${
sub.cr === 0 && sub.hd === 0 && sub.hi === 0
? 'border-slate-100 bg-slate-50/50 text-slate-300'
: 'border-slate-200 bg-slate-50 text-slate-600'
}`}
>
{sub.cr === 0 && sub.hd === 0 && sub.hi === 0 ? (
<div className="text-slate-400"></div>
) : (
<>
<div className="space-y-1">
<div className="font-bold text-slate-700">
Cr: {sub.cr}
</div>
<div className="text-slate-600">
HD: {sub.hd} HI: {sub.hi}
</div>
</div>
</>
)}
</div>
</Fragment>
)
})}
<div className="col-span-full my-2 border-t border-slate-200"></div>
<div className="self-center p-2 font-bold text-slate-600">
Totales por Ciclo
</div>
{ciclosArray.map((ciclo) => {
const t = getTotalesCiclo(ciclo)
const isEmpty = t.cr === 0 && t.hd === 0 && t.hi === 0
return (
<div
key={`footer-${ciclo}`}
className={`rounded-lg p-2 text-center text-[10px] ${
isEmpty ? 'bg-slate-100/50 text-slate-400' : 'bg-slate-50'
}`}
>
{isEmpty ? (
<div className="py-1 text-xs text-slate-400"></div>
) : (
<>
<div className="font-bold text-slate-700">Cr: {t.cr}</div>
<div>
HD: {t.hd} HI: {t.hi}
</div>
</>
)}
</div>
)
})}
<div className="flex flex-col justify-center rounded-lg bg-teal-50 p-2 text-center text-xs font-bold text-teal-800">
<div>{stats.cr} Cr</div>
<div>{stats.hd + stats.hi} Hrs</div>
</div>
</div>
</div>
{/* Asignaturas Sin Asignar */}
<div className="mt-10 rounded-2xl border border-slate-200 bg-slate-50 p-6">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 text-slate-600">
<h3 className="text-sm font-bold tracking-wider uppercase">
Bandeja de Entrada / Asignaturas sin asignar
</h3>
<Badge variant="secondary">{unassignedAsignaturas.length}</Badge>
<div className="border-border bg-card/80 mt-12 rounded-[28px] border p-5 shadow-sm backdrop-blur-sm">
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="bg-muted text-muted-foreground flex h-9 w-9 items-center justify-center rounded-2xl">
<Icons.Inbox className="h-4.5 w-4.5" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-foreground text-sm font-bold tracking-wide uppercase">
Bandeja de entrada
</h3>
<div className="bg-muted text-muted-foreground inline-flex h-6 min-w-6 items-center justify-center rounded-full px-2 text-[11px] font-semibold">
{unassignedAsignaturas.length}
</div>
</div>
<p className="text-muted-foreground mt-0.5 text-sm">
Asignaturas sin ciclo o línea curricular
</p>
</div>
</div>
</div>
<div className="border-border bg-background/80 text-muted-foreground flex items-center gap-2 rounded-full border border-dashed px-3 py-1.5 text-xs">
<Icons.MoveDown className="h-3.5 w-3.5" />
<span>Arrastra aquí para desasignar</span>
</div>
<p className="text-xs text-slate-400">
Arrastra una asignatura aquí para quitarla del mapa
</p>
</div>
<div
className={`flex min-h-[120px] flex-wrap gap-4 rounded-xl border-2 border-dashed p-4 transition-colors ${
draggedAsignatura
? 'border-teal-300 bg-teal-50/50'
: 'border-slate-200 bg-white/50'
}`}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, null, null)} // Limpia ciclo y línea
onDrop={(e) => handleDrop(e, null, null)}
className={[
'rounded-[24px] border-2 border-dashed p-4 transition-all duration-300',
'min-h-[220px]',
draggedAsignatura
? 'border-primary/35 bg-primary/6 shadow-inner'
: 'border-border bg-muted/20',
].join(' ')}
>
{unassignedAsignaturas.map((m) => (
<div key={m.id} className="w-[200px]">
<AsignaturaCardItem
asignatura={m}
isDragging={draggedAsignatura === m.id}
onDragStart={handleDragStart}
onClick={() => {
setEditingData(m) // Cargamos los datos en el estado de edición
setIsEditModalOpen(true)
}}
/>
{unassignedAsignaturas.length > 0 ? (
<div className="flex flex-wrap gap-4">
{unassignedAsignaturas.map((m) => (
<div key={m.id} className="w-[272px] shrink-0">
<AsignaturaCardItem
asignatura={m}
lineaColor="#94A3B8"
lineaNombre="Sin asignar"
isDragging={draggedAsignatura === m.id}
onDragStart={handleDragStart}
onClick={() => {
setEditingData(m)
setIsEditModalOpen(true)
}}
/>
</div>
))}
</div>
))}
{unassignedAsignaturas.length === 0 && (
<div className="flex w-full items-center justify-center text-sm text-slate-400">
No hay asignaturas pendientes. Arrastra una asignatura aquí para
desasignarla.
) : (
<div className="border-border/70 bg-background/70 flex min-h-[188px] flex-col items-center justify-center rounded-[20px] border px-6 text-center">
<div className="bg-muted text-muted-foreground mb-3 flex h-12 w-12 items-center justify-center rounded-2xl">
<Icons.CheckCheck className="h-5 w-5" />
</div>
<p className="text-foreground text-sm font-semibold">
No hay asignaturas pendientes
</p>
<p className="text-muted-foreground mt-1 max-w-md text-sm">
Todo está colocado en el mapa. Arrastra una asignatura aquí para
quitarle ciclo y línea curricular.
</p>
</div>
)}
</div>
@@ -1,7 +1,8 @@
import { createFileRoute, useParams } from '@tanstack/react-router'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
import { useSubject } from '@/data'
import { fetchAsignaturaPdf } from '@/data/api/document.api'
export const Route = createFileRoute(
@@ -15,48 +16,75 @@ function RouteComponent() {
from: '/planes/$planId/asignaturas/$asignaturaId/documento',
})
const { data: asignatura } = useSubject(asignaturaId)
const asignaturaFileBaseName = sanitizeFileBaseName(
asignatura?.nombre ?? 'documento_sep',
)
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const pdfUrlRef = useRef<string | null>(null)
const isMountedRef = useRef<boolean>(false)
const [isLoading, setIsLoading] = useState(true)
const [isRegenerating, setIsRegenerating] = useState(false)
const loadPdfPreview = useCallback(async () => {
try {
setIsLoading(true)
if (isMountedRef.current) setIsLoading(true)
const pdfBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId,
convertTo: 'pdf',
})
if (!isMountedRef.current) return
const url = window.URL.createObjectURL(pdfBlob)
setPdfUrl((prev) => {
if (prev) window.URL.revokeObjectURL(prev)
return url
})
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
pdfUrlRef.current = url
setPdfUrl(url)
} catch (error) {
console.error('Error cargando PDF:', error)
} finally {
setIsLoading(false)
if (isMountedRef.current) setIsLoading(false)
}
}, [asignaturaId])
useEffect(() => {
isMountedRef.current = true
loadPdfPreview()
return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
isMountedRef.current = false
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
}
}, [loadPdfPreview])
const handleDownload = async () => {
const handleDownloadPdf = async () => {
const pdfBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId,
convertTo: 'pdf',
})
const url = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a')
link.href = url
link.download = 'documento_sep.pdf'
link.download = `${asignaturaFileBaseName}.pdf`
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
}
const handleDownloadWord = async () => {
const docBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId,
})
const url = window.URL.createObjectURL(docBlob)
const link = document.createElement('a')
link.href = url
link.download = `${asignaturaFileBaseName}.docx`
document.body.appendChild(link)
link.click()
link.remove()
@@ -77,9 +105,28 @@ function RouteComponent() {
<DocumentoSEPTab
pdfUrl={pdfUrl}
isLoading={isLoading}
onDownload={handleDownload}
onDownloadPdf={handleDownloadPdf}
onDownloadWord={handleDownloadWord}
onRegenerate={handleRegenerate}
isRegenerating={isRegenerating}
/>
)
}
function sanitizeFileBaseName(input: string): string {
const text = String(input)
const withoutControlChars = Array.from(text)
.filter((ch) => {
const code = ch.charCodeAt(0)
return code >= 32 && code !== 127
})
.join('')
const cleaned = withoutControlChars
.replace(/[<>:"/\\|?*]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/g, '')
return (cleaned || 'documento').slice(0, 150)
}
+139 -12
View File
@@ -4,18 +4,145 @@
@custom-variant dark (&:is(.dark *));
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-Light.otf') format('opentype');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-LightItalic.otf')
format('opentype');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-Regular.otf') format('opentype');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-RegularItalic.otf')
format('opentype');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-Bold.otf') format('opentype');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-BoldItalic.otf') format('opentype');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-Black.otf') format('opentype');
font-weight: 900;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-BlackItalic.otf')
format('opentype');
font-weight: 900;
font-style: italic;
}
/* Serif */
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-Light.otf') format('opentype');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-LightItalic.otf')
format('opentype');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-Regular.otf') format('opentype');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-RegularItalic.otf')
format('opentype');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-Bold.otf') format('opentype');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-BoldItalic.otf')
format('opentype');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-Black.otf') format('opentype');
font-weight: 900;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-BlackItalic.otf')
format('opentype');
font-weight: 900;
font-style: italic;
}
body {
@apply m-0;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family:
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
font-family: var(--font-mono);
}
strong,
b,
.font-bold {
font-family: 'Indivisa Sans', serif;
font-weight: 900;
/* Inter letter space */
letter-spacing: -0.025em;
}
:root {
@@ -51,9 +178,9 @@ code {
--sidebar-accent-foreground: oklch(0.6304 0.2472 28.2698);
--sidebar-border: oklch(0.9401 0 0);
--sidebar-ring: oklch(0 0 0);
--font-sans: Plus Jakarta Sans, sans-serif;
--font-serif: Lora, serif;
--font-mono: IBM Plex Mono, monospace;
--font-sans: 'Indivisa Sans', sans-serif;
--font-serif: 'Indivisa Serif', serif;
--font-mono: 'Indivisa Sans', monospace;
--radius: 1.4rem;
--shadow-x: 0px;
--shadow-y: 2px;
@@ -101,7 +228,7 @@ code {
--chart-1: oklch(0.6686 0.1794 251.7436);
--chart-2: oklch(0.6342 0.2516 22.4415);
--chart-3: oklch(0.8718 0.1716 90.9505);
--chart-4: oklch(0.4503 0.229 263.0881);
--chart-4: oklch(11.492% 0.00001 271.152);
--chart-5: oklch(0.8322 0.146 185.9404);
--sidebar: oklch(0.1564 0.0688 261.2771);
--sidebar-foreground: oklch(0.9551 0 0);
@@ -111,9 +238,9 @@ code {
--sidebar-accent-foreground: oklch(0.6786 0.2095 24.6583);
--sidebar-border: oklch(0.3289 0.0092 268.3843);
--sidebar-ring: oklch(0.6048 0.2166 257.2136);
--font-sans: Plus Jakarta Sans, sans-serif;
--font-serif: Lora, serif;
--font-mono: IBM Plex Mono, monospace;
--font-sans: 'Indivisa Sans', sans-serif;
--font-serif: 'Indivisa Serif', serif;
--font-mono: 'Indivisa Sans', monospace;
--radius: 1.4rem;
--shadow-x: 0px;
--shadow-y: 2px;
+18 -1
View File
@@ -154,9 +154,9 @@ 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
prerrequisito_asignatura_id?: string
}
Insert: {
actualizado_en?: string
@@ -180,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
}
@@ -205,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
}
@@ -258,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: {
@@ -1377,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 }
}