Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54200986ab | |||
| bfafb8999e | |||
| 883e6e7fa4 | |||
| dd11c2707e | |||
| 60c2e8402d | |||
| 350d7c0f69 | |||
| f2900f3d22 | |||
| 1f27ea6252 | |||
| 0e710f9637 | |||
| 3bb3a11779 | |||
| 75784658bf | |||
| d2f12d96de | |||
| 7f9c3162a0 | |||
| 03ef51d8a6 | |||
| f81b404223 | |||
| 614150f8d1 | |||
| 54a3b74836 | |||
| 0820bf19a5 | |||
| 7ddc5c7f1f | |||
| ad7a741d1e | |||
| 60bfa31186 | |||
| 1bd37c7e27 | |||
| 7eb9ca0bec | |||
| 10dc299311 | |||
| b3954ab16c | |||
| 88a2a28a8d | |||
| 36a11e3793 | |||
| 33efaed03f | |||
| d481e9706c | |||
| ee3b7a56ec | |||
| 2359e38f85 | |||
| ed318fa67b | |||
| c262cd16be | |||
| a08b2abf87 | |||
| a07213d959 | |||
| a2234e5022 | |||
| 658c392f96 | |||
| 4c730fa0ab | |||
| 2abe296b9e | |||
| 1bce226d15 | |||
| b986ec343e | |||
| 379e2d3826 | |||
| cb5422f57c | |||
| 67724181fd | |||
| d9a5cec3c5 | |||
| 96848e1793 | |||
| cbaf96c6b5 | |||
| 0fb831fb58 | |||
| 0d1aa61022 | |||
| 84281a88f2 | |||
| d91018c612 |
@@ -41,7 +41,7 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.0.6",
|
"tailwindcss": "^4.0.6",
|
||||||
"tw-animate-css": "^1.3.6",
|
"tw-animate-css": "^1.3.6",
|
||||||
"use-debounce": "^10.1.0",
|
"use-debounce": "^10.1.0",
|
||||||
@@ -1327,7 +1327,7 @@
|
|||||||
|
|
||||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||||
|
|
||||||
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -54,7 +54,7 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.0.6",
|
"tailwindcss": "^4.0.6",
|
||||||
"tw-animate-css": "^1.3.6",
|
"tw-animate-css": "^1.3.6",
|
||||||
"use-debounce": "^10.1.0",
|
"use-debounce": "^10.1.0",
|
||||||
|
|||||||
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.
@@ -23,6 +23,7 @@ import {
|
|||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { usePlanAsignaturas } from '@/data'
|
import { usePlanAsignaturas } from '@/data'
|
||||||
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
|
||||||
@@ -46,6 +47,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
|
||||||
@@ -227,7 +232,7 @@ function DatosGenerales({
|
|||||||
if (isLoading) return <p>Cargando información...</p>
|
if (isLoading) return <p>Cargando información...</p>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
|
<div className="animate-in fade-in space-y-8 pb-8 duration-500">
|
||||||
{/* Encabezado de la Sección */}
|
{/* Encabezado de la Sección */}
|
||||||
<div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center">
|
<div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -313,12 +318,18 @@ function DatosGenerales({
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Tarjeta de Requisitos */}
|
{/* Tarjeta de Requisitos */}
|
||||||
<InfoCard
|
<InfoCard
|
||||||
|
asignaturaId={asignaturaId}
|
||||||
title="Requisitos y Seriación"
|
title="Requisitos y Seriación"
|
||||||
type="requirements"
|
type="requirements"
|
||||||
initialContent={pre}
|
initialContent={pre}
|
||||||
// Pasamos las materias del plan para el Select (excluyendo la actual)
|
// Pasamos las materias del plan para el Select (excluyendo la actual)
|
||||||
availableSubjects={
|
availableSubjects={
|
||||||
availableSubjects?.filter((a) => a.id !== asignaturaId) || []
|
availableSubjects?.filter(
|
||||||
|
(a) =>
|
||||||
|
a.id !== asignaturaId &&
|
||||||
|
a.numero_ciclo < data?.numero_ciclo &&
|
||||||
|
a.numero_ciclo,
|
||||||
|
) || []
|
||||||
}
|
}
|
||||||
onPersist={({ value }) => {
|
onPersist={({ value }) => {
|
||||||
updateAsignatura.mutate({
|
updateAsignatura.mutate({
|
||||||
@@ -332,6 +343,7 @@ function DatosGenerales({
|
|||||||
|
|
||||||
{/* Tarjeta de Evaluación */}
|
{/* Tarjeta de Evaluación */}
|
||||||
<InfoCard
|
<InfoCard
|
||||||
|
asignaturaId={asignaturaId}
|
||||||
title="Sistema de Evaluación"
|
title="Sistema de Evaluación"
|
||||||
type="evaluation"
|
type="evaluation"
|
||||||
initialContent={criteriosEvaluacion}
|
initialContent={criteriosEvaluacion}
|
||||||
@@ -507,16 +519,25 @@ function InfoCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleIARequest = (campoClave: string) => {
|
const handleIARequest = (campoClave: string) => {
|
||||||
console.log(placeholder)
|
console.log(campoClave)
|
||||||
|
|
||||||
|
let targetClave = campoClave
|
||||||
|
if (type === 'evaluation' && !targetClave) {
|
||||||
|
targetClave = 'criterios_de_evaluacion'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetClave === 'contenido') {
|
||||||
|
targetClave = 'contenido_tematico'
|
||||||
|
}
|
||||||
|
console.log(targetClave)
|
||||||
|
console.log(asignaturaId)
|
||||||
|
|
||||||
// Añadimos un timestamp a la state para forzar que la navegación
|
|
||||||
// genere una nueva ubicación incluso si la ruta y los params son iguales.
|
|
||||||
navigate({
|
navigate({
|
||||||
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
|
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
|
||||||
params: { planId, asignaturaId: asignaturaId! },
|
params: { planId, asignaturaId: asignaturaId! },
|
||||||
state: {
|
state: {
|
||||||
activeTab: 'ia',
|
activeTab: 'ia',
|
||||||
prefillCampo: campoClave,
|
prefillCampo: targetClave,
|
||||||
prefillContenido: data,
|
prefillContenido: data,
|
||||||
_ts: Date.now(),
|
_ts: Date.now(),
|
||||||
} as any,
|
} as any,
|
||||||
@@ -571,19 +592,21 @@ function InfoCard({
|
|||||||
|
|
||||||
{!isEditing && (
|
{!isEditing && (
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
{type !== 'requirements' && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
|
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
|
||||||
onClick={() => clave && handleIARequest(clave)}
|
onClick={() => handleIARequest(clave)}
|
||||||
>
|
>
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Mejorar con IA</TooltipContent>
|
<TooltipContent>Mejorar con IA</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -623,8 +646,14 @@ function InfoCard({
|
|||||||
Materia de Seriación
|
Materia de Seriación
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={tempText?.[0]?.id || 'none'}
|
value={
|
||||||
|
Array.isArray(tempText) && tempText.length > 0
|
||||||
|
? tempText[0].id
|
||||||
|
: 'none'
|
||||||
|
}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
|
console.log(availableSubjects)
|
||||||
|
|
||||||
const selected = availableSubjects?.find(
|
const selected = availableSubjects?.find(
|
||||||
(s) => s.id === val,
|
(s) => s.id === val,
|
||||||
)
|
)
|
||||||
@@ -646,16 +675,24 @@ function InfoCard({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full">
|
||||||
|
<div className="flex-1 truncate text-left">
|
||||||
<SelectValue placeholder="Selecciona una materia" />
|
<SelectValue placeholder="Selecciona una materia" />
|
||||||
|
</div>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">
|
<SelectItem value="none">
|
||||||
Ninguna (Sin seriación)
|
Ninguna (Sin seriación)
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
{availableSubjects?.map((asig) => (
|
{availableSubjects?.map((asig) => (
|
||||||
<SelectItem key={asig.id} value={asig.id}>
|
<SelectItem
|
||||||
|
key={asig.id}
|
||||||
|
value={asig.id}
|
||||||
|
className="max-w-[300px] sm:max-w-[500px]"
|
||||||
|
>
|
||||||
|
<span className="block truncate">
|
||||||
{asig.codigo} - {asig.nombre}
|
{asig.codigo} - {asig.nombre}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -841,80 +878,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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ export function BibliographyItem() {
|
|||||||
return <div className="p-10 text-center">Cargando bibliografía...</div>
|
return <div className="p-10 text-center">Cargando bibliografía...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500">
|
<div className="animate-in fade-in space-y-8 pb-8 duration-500">
|
||||||
<div className="flex items-center justify-between border-b pb-4">
|
<div className="flex flex-col gap-4 border-b pb-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||||
Bibliografía
|
Bibliografía
|
||||||
|
|||||||
@@ -662,7 +662,7 @@ export function ContenidoTematico() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in mx-auto max-w-5xl space-y-6 py-10 duration-500">
|
<div className="animate-in fade-in space-y-6 pb-8 duration-500">
|
||||||
<div className="flex items-center justify-between border-b pb-4">
|
<div className="flex items-center justify-between border-b pb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useParams } from '@tanstack/react-router'
|
import { useLocation, useParams } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Send,
|
Send,
|
||||||
@@ -14,18 +14,22 @@ import {
|
|||||||
MessageSquarePlus,
|
MessageSquarePlus,
|
||||||
Archive,
|
Archive,
|
||||||
History,
|
History,
|
||||||
Edit2, // Agregado
|
Edit2,
|
||||||
|
Loader2, // Agregado
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
|
|
||||||
import { ImprovementCard } from './SaveAsignatura/ImprovementCardProps'
|
import { ImprovementCard } from './SaveAsignatura/ImprovementCardProps'
|
||||||
|
|
||||||
import type { IASugerencia } from '@/types/asignatura'
|
|
||||||
|
|
||||||
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
|
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||||
import { Button } from '@/components/ui/button'
|
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 { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
@@ -39,8 +43,10 @@ import {
|
|||||||
useConversationBySubject,
|
useConversationBySubject,
|
||||||
useMessagesBySubjectChat,
|
useMessagesBySubjectChat,
|
||||||
useSubject,
|
useSubject,
|
||||||
|
useUpdateAsignatura,
|
||||||
useUpdateSubjectConversationName,
|
useUpdateSubjectConversationName,
|
||||||
useUpdateSubjectConversationStatus,
|
useUpdateSubjectConversationStatus,
|
||||||
|
useUpdateSubjectRecommendation,
|
||||||
} from '@/data'
|
} from '@/data'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -50,21 +56,12 @@ interface SelectedField {
|
|||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IAAsignaturaTabProps {
|
export function IAAsignaturaTab() {
|
||||||
asignatura?: Record<string, any>
|
|
||||||
onAcceptSuggestion: (sugerencia: IASugerencia) => void
|
|
||||||
onRejectSuggestion: (messageId: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IAAsignaturaTab({
|
|
||||||
onAcceptSuggestion,
|
|
||||||
onRejectSuggestion,
|
|
||||||
}: IAAsignaturaTabProps) {
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { asignaturaId } = useParams({
|
const { asignaturaId } = useParams({
|
||||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
})
|
})
|
||||||
|
const location = useLocation()
|
||||||
// --- ESTADOS ---
|
// --- ESTADOS ---
|
||||||
const [activeChatId, setActiveChatId] = useState<string | undefined>(
|
const [activeChatId, setActiveChatId] = useState<string | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
@@ -75,6 +72,10 @@ export function IAAsignaturaTab({
|
|||||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||||
const [isSending, setIsSending] = useState(false)
|
const [isSending, setIsSending] = useState(false)
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||||
|
|
||||||
|
const updateAsignatura = useUpdateAsignatura()
|
||||||
|
const updateRecommendation = useUpdateSubjectRecommendation()
|
||||||
|
|
||||||
// --- DATA QUERIES ---
|
// --- DATA QUERIES ---
|
||||||
const { data: datosGenerales } = useSubject(asignaturaId)
|
const { data: datosGenerales } = useSubject(asignaturaId)
|
||||||
@@ -99,6 +100,81 @@ export function IAAsignaturaTab({
|
|||||||
Array<string>
|
Array<string>
|
||||||
>([])
|
>([])
|
||||||
const [uploadedFiles, setUploadedFiles] = useState<Array<File>>([])
|
const [uploadedFiles, setUploadedFiles] = useState<Array<File>>([])
|
||||||
|
const [selectedImprovements, setSelectedImprovements] = useState<
|
||||||
|
Array<string>
|
||||||
|
>([])
|
||||||
|
|
||||||
|
const handleApplyMultiple = async (sugerencias: Array<any>) => {
|
||||||
|
if (!asignaturaId || !datosGenerales || sugerencias.length === 0) return
|
||||||
|
|
||||||
|
setIsSending(true)
|
||||||
|
try {
|
||||||
|
// 1. Consolidar el Patch
|
||||||
|
const patchData: any = {
|
||||||
|
datos: { ...datosGenerales.datos },
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sug of sugerencias) {
|
||||||
|
if (sug.campoKey === 'contenido_tematico') {
|
||||||
|
patchData.contenido_tematico = sug.valorSugerido
|
||||||
|
} else if (sug.campoKey === 'criterios_de_evaluacion') {
|
||||||
|
patchData.criterios_de_evaluacion = sug.valorSugerido
|
||||||
|
} else {
|
||||||
|
patchData.datos[sug.campoKey] = sug.valorSugerido
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Actualización única de la asignatura
|
||||||
|
await updateAsignatura.mutateAsync({
|
||||||
|
asignaturaId: asignaturaId as any,
|
||||||
|
patch: patchData,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Marcar recomendaciones una por una (Secuencialmente para evitar colisiones)
|
||||||
|
for (const sug of sugerencias) {
|
||||||
|
await updateRecommendation.mutateAsync({
|
||||||
|
mensajeId: sug.messageId,
|
||||||
|
campoAfectado: sug.campoKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Limpieza de estados
|
||||||
|
const appliedKeys = sugerencias.map((s) => s.campoKey)
|
||||||
|
setSelectedFields((prev) =>
|
||||||
|
prev.filter((f) => !appliedKeys.includes(f.key)),
|
||||||
|
)
|
||||||
|
setSelectedImprovements([])
|
||||||
|
|
||||||
|
// Forzar actualización visual
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subject', asignaturaId] })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error en aplicación masiva:', error)
|
||||||
|
} finally {
|
||||||
|
setIsSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const toggleImprovementSelection = (sugId: string) => {
|
||||||
|
setSelectedImprovements((prev) =>
|
||||||
|
prev.includes(sugId)
|
||||||
|
? prev.filter((id) => id !== sugId)
|
||||||
|
: [...prev, sugId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAllFromMessage = (sugerencias: Array<any>) => {
|
||||||
|
const allIds = sugerencias.map((s) => s.id)
|
||||||
|
const allSelected = allIds.every((id) => selectedImprovements.includes(id))
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedImprovements((prev) =>
|
||||||
|
prev.filter((id) => !allIds.includes(id)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setSelectedImprovements((prev) =>
|
||||||
|
Array.from(new Set([...prev, ...allIds])),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cálculo del total para el Badge del botón
|
// Cálculo del total para el Badge del botón
|
||||||
const totalReferencias =
|
const totalReferencias =
|
||||||
@@ -141,7 +217,7 @@ export function IAAsignaturaTab({
|
|||||||
const dynamicFields = datosGenerales?.datos
|
const dynamicFields = datosGenerales?.datos
|
||||||
? Object.keys(datosGenerales.datos).map((key) => {
|
? Object.keys(datosGenerales.datos).map((key) => {
|
||||||
const estructuraProps =
|
const estructuraProps =
|
||||||
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
|
datosGenerales.estructuras_asignatura?.definicion?.properties || {}
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
label:
|
label:
|
||||||
@@ -234,12 +310,38 @@ export function IAAsignaturaTab({
|
|||||||
}
|
}
|
||||||
}, [activeChats, loadingConv])
|
}, [activeChats, loadingConv])
|
||||||
|
|
||||||
const filteredFields = useMemo(() => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
if (!showSuggestions) return availableFields
|
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 || !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 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(
|
return availableFields.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
@@ -248,6 +350,30 @@ export function IAAsignaturaTab({
|
|||||||
)
|
)
|
||||||
}, [availableFields, input, showSuggestions])
|
}, [availableFields, input, showSuggestions])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const state = location.state as any
|
||||||
|
|
||||||
|
// Verificamos si existe el timestamp (_ts) para saber que es una acción nueva
|
||||||
|
if (state?.activeTab === 'ia' && state?._ts) {
|
||||||
|
// Si el campo no está ya seleccionado, lo agregamos
|
||||||
|
if (state.prefillCampo) {
|
||||||
|
const fieldToSelect = availableFields.find(
|
||||||
|
(f) => f.key === state.prefillCampo,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (fieldToSelect) {
|
||||||
|
setSelectedFields([fieldToSelect]) // Reemplaza o añade según prefieras
|
||||||
|
|
||||||
|
// Generamos un prompt inicial automático
|
||||||
|
const autoPrompt = `Mejora el contenido de: ${fieldToSelect.label}`
|
||||||
|
setInput(autoPrompt)
|
||||||
|
|
||||||
|
// Opcional: Si quieres que dispare la IA inmediatamente al llegar:
|
||||||
|
// handleSend(autoPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [location.state, availableFields])
|
||||||
// 2. Efecto para cerrar con ESC
|
// 2. Efecto para cerrar con ESC
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -259,24 +385,28 @@ export function IAAsignaturaTab({
|
|||||||
|
|
||||||
// 3. Función para insertar el campo y limpiar el prompt
|
// 3. Función para insertar el campo y limpiar el prompt
|
||||||
const handleSelectField = (field: SelectedField) => {
|
const handleSelectField = (field: SelectedField) => {
|
||||||
// 1. Agregamos al array de objetos (para tu lógica de API)
|
|
||||||
if (!selectedFields.find((f) => f.key === field.key)) {
|
if (!selectedFields.find((f) => f.key === field.key)) {
|
||||||
setSelectedFields((prev) => [...prev, field])
|
setSelectedFields((prev) => [...prev, field])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Lógica de autocompletado en el texto
|
|
||||||
const lastColonIndex = input.lastIndexOf(':')
|
const lastColonIndex = input.lastIndexOf(':')
|
||||||
if (lastColonIndex !== -1) {
|
if (lastColonIndex !== -1) {
|
||||||
// Tomamos lo que había antes del ":" + el Nombre del Campo + un espacio
|
const parteAntesDelColon = input.slice(0, lastColonIndex)
|
||||||
const nuevoTexto = input.slice(0, lastColonIndex) + `${field.label} `
|
|
||||||
|
// 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)
|
setInput(nuevoTexto)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Cerramos el buscador y devolvemos el foco al textarea
|
|
||||||
setShowSuggestions(false)
|
setShowSuggestions(false)
|
||||||
|
|
||||||
// Opcional: Si tienes una ref del textarea, puedes hacer:
|
|
||||||
// textareaRef.current?.focus()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveName = (id: string) => {
|
const handleSaveName = (id: string) => {
|
||||||
@@ -355,10 +485,35 @@ export function IAAsignaturaTab({
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
|
<div className="flex h-full w-full overflow-hidden bg-white">
|
||||||
{/* PANEL IZQUIERDO */}
|
<div className="fixed top-0 z-40 flex w-full items-center justify-between border-b bg-white/80 p-2 backdrop-blur-md">
|
||||||
<div className="flex w-64 flex-col border-r pr-4">
|
<Button
|
||||||
<div className="mb-4 flex items-center justify-between px-2">
|
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">
|
<h2 className="flex items-center gap-2 text-xs font-bold text-slate-500 uppercase">
|
||||||
<History size={14} /> Historial
|
<History size={14} /> Historial
|
||||||
</h2>
|
</h2>
|
||||||
@@ -378,25 +533,19 @@ export function IAAsignaturaTab({
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveChatId(undefined)
|
setActiveChatId(undefined)
|
||||||
hasInitialSelected.current = true
|
|
||||||
setIsCreatingNewChat(true)
|
setIsCreatingNewChat(true)
|
||||||
setInput('')
|
setInput('')
|
||||||
setSelectedFields([])
|
setSelectedFields([])
|
||||||
|
|
||||||
// 4. Opcional: Limpiar el caché de mensajes actual para que la pantalla se vea vacía al instante
|
|
||||||
queryClient.setQueryData(['subject-messages', undefined], [])
|
queryClient.setQueryData(['subject-messages', undefined], [])
|
||||||
}}
|
}}
|
||||||
variant="outline"
|
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
|
<MessageSquarePlus size={18} /> Nuevo Chat
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* PANEL IZQUIERDO - Cambios en ScrollArea y contenedor */}
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="flex flex-col gap-1 pr-3">
|
<div className="flex flex-col gap-1 pr-3">
|
||||||
{' '}
|
|
||||||
{/* Eliminado space-y-1 para mejor control con gap */}
|
|
||||||
{(showArchived ? archivedChats : activeChats).map((chat: any) => (
|
{(showArchived ? archivedChats : activeChats).map((chat: any) => (
|
||||||
<div
|
<div
|
||||||
key={chat.id}
|
key={chat.id}
|
||||||
@@ -507,31 +656,57 @@ export function IAAsignaturaTab({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 2. PANEL CENTRAL (CHAT) - EL RECUADRO ESTILIZADO */}
|
||||||
|
<main
|
||||||
|
className={cn(
|
||||||
|
'relative flex min-w-0 flex-1 flex-col overflow-hidden bg-slate-50/50 shadow-sm',
|
||||||
|
'mt-[50px] h-[calc(100svh-50px)]', // 50px es la altura aproximada de tu header fixed
|
||||||
|
'md:m-2 md:mt-0 md:h-[calc(100vh-160px)] md:rounded-xl md:border md:border-slate-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header Interno del Recuadro */}
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b bg-white p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="md:hidden"
|
||||||
|
onClick={() => setIsSidebarOpen(true)}
|
||||||
|
>
|
||||||
|
<History size={20} className="text-slate-500" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-bold tracking-wider text-teal-600 uppercase">
|
||||||
|
Asistente Académico
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-slate-400">
|
||||||
|
Personalizado para tu asignatura
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PANEL CENTRAL */}
|
<div className="flex gap-2">
|
||||||
<div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm">
|
|
||||||
<div className="flex shrink-0 items-center justify-between border-b bg-white p-3">
|
|
||||||
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
|
|
||||||
Asistente IA
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpenIA(true)}
|
onClick={() => setOpenIA(true)}
|
||||||
className="flex items-center gap-2 rounded-md bg-slate-100 px-3 py-1.5 text-xs font-medium transition hover:bg-slate-200"
|
className="flex items-center gap-2 rounded-lg bg-slate-100 px-3 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-200"
|
||||||
>
|
>
|
||||||
<FileText size={14} className="text-slate-500" />
|
<FileText size={14} />
|
||||||
Referencias
|
<span className="xs:inline hidden">Referencias</span>
|
||||||
{totalReferencias > 0 && (
|
{totalReferencias > 0 && (
|
||||||
<span className="animate-in zoom-in flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
|
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
|
||||||
{totalReferencias}
|
{totalReferencias}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative min-h-0 flex-1">
|
{/* Área de Mensajes */}
|
||||||
|
<div className="relative min-h-0 w-full flex-1 overflow-hidden">
|
||||||
<ScrollArea ref={scrollRef} className="h-full w-full">
|
<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) => (
|
{messages.map((msg) => (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
@@ -584,31 +759,90 @@ export function IAAsignaturaTab({
|
|||||||
|
|
||||||
{/* CONTENEDOR DE SUGERENCIAS INTEGRADO */}
|
{/* CONTENEDOR DE SUGERENCIAS INTEGRADO */}
|
||||||
{msg.role === 'assistant' &&
|
{msg.role === 'assistant' &&
|
||||||
msg.sugerencias &&
|
msg.sugerencias?.length > 0 && (
|
||||||
msg.sugerencias.length > 0 && (
|
|
||||||
<div className="space-y-3 border-t bg-slate-50/50 p-3">
|
<div className="space-y-3 border-t bg-slate-50/50 p-3">
|
||||||
<p className="mb-1 text-[10px] font-bold text-slate-400 uppercase">
|
<div className="flex items-center justify-between">
|
||||||
Mejoras disponibles:
|
<p className="text-[10px] font-bold text-slate-400 uppercase">
|
||||||
|
Mejoras disponibles
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Solo mostramos "Seleccionar todo" si hay sugerencias pendientes en ESTE mensaje */}
|
||||||
|
{msg.sugerencias.some((s) => !s.aceptada) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 text-[10px] text-teal-600 hover:bg-teal-100"
|
||||||
|
onClick={() =>
|
||||||
|
toggleAllFromMessage(msg.sugerencias)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{msg.sugerencias
|
||||||
|
.filter((s) => !s.aceptada)
|
||||||
|
.every((s) =>
|
||||||
|
selectedImprovements.includes(s.id),
|
||||||
|
)
|
||||||
|
? 'Desmarcar todas'
|
||||||
|
: 'Seleccionar todas'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{msg.sugerencias.map((sug: any) => (
|
{msg.sugerencias.map((sug: any) => (
|
||||||
|
<div key={sug.id} className="flex gap-2">
|
||||||
|
{!sug.aceptada && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-4 h-4 w-4 rounded border-slate-300 accent-teal-600"
|
||||||
|
checked={selectedImprovements.includes(
|
||||||
|
sug.id,
|
||||||
|
)}
|
||||||
|
onChange={() =>
|
||||||
|
toggleImprovementSelection(sug.id)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
<ImprovementCard
|
<ImprovementCard
|
||||||
key={sug.id}
|
|
||||||
sug={sug}
|
sug={sug}
|
||||||
asignaturaId={asignaturaId}
|
asignaturaId={asignaturaId}
|
||||||
onApplied={(campoFinalizado) => {
|
onApplied={(key) =>
|
||||||
// Filtramos el array para conservar todos MENOS el que se aplicó
|
|
||||||
console.log(campoFinalizado)
|
|
||||||
console.log('campos:', selectedFields)
|
|
||||||
|
|
||||||
setSelectedFields((prev) =>
|
setSelectedFields((prev) =>
|
||||||
prev.filter((fieldObj) => {
|
prev.filter((f) => f.key !== key),
|
||||||
// Accedemos a .key porque fieldObj es { key: "...", label: "..." }
|
)
|
||||||
return fieldObj.key !== campoFinalizado
|
}
|
||||||
}),
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* EL BOTÓN DE APLICAR: Lo movemos para que solo aparezca si hay algo seleccionado de ESTE mensaje */}
|
||||||
|
{msg.sugerencias.some((s) =>
|
||||||
|
selectedImprovements.includes(s.id),
|
||||||
|
) && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={isSending}
|
||||||
|
className="w-full bg-teal-600 text-xs font-bold shadow-md hover:bg-teal-700"
|
||||||
|
onClick={() => {
|
||||||
|
const seleccionadasDeEsteMensaje =
|
||||||
|
msg.sugerencias.filter((s) =>
|
||||||
|
selectedImprovements.includes(s.id),
|
||||||
|
)
|
||||||
|
handleApplyMultiple(
|
||||||
|
seleccionadasDeEsteMensaje,
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
{isSending ? (
|
||||||
|
<Loader2
|
||||||
|
className="mr-2 animate-spin"
|
||||||
|
size={14}
|
||||||
/>
|
/>
|
||||||
))}
|
) : (
|
||||||
|
<Check className="mr-2" size={14} />
|
||||||
|
)}
|
||||||
|
Aplicar mejoras seleccionadas
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -642,8 +876,9 @@ export function IAAsignaturaTab({
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* INPUT */}
|
{/* Input de Chat */}
|
||||||
<div className="shrink-0 border-t bg-white p-4">
|
<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">
|
<div className="relative mx-auto max-w-4xl">
|
||||||
{showSuggestions && (
|
{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="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">
|
||||||
@@ -705,10 +940,28 @@ export function IAAsignaturaTab({
|
|||||||
<Textarea
|
<Textarea
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setInput(e.target.value)
|
const val = e.target.value
|
||||||
if (e.target.value.endsWith(':')) setShowSuggestions(true)
|
const cursor = e.target.selectionStart
|
||||||
else if (showSuggestions && !e.target.value.includes(':'))
|
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)
|
setShowSuggestions(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setShowSuggestions(false)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
@@ -722,7 +975,8 @@ export function IAAsignaturaTab({
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => handleSend()}
|
onClick={() => handleSend()}
|
||||||
disabled={
|
disabled={
|
||||||
(!input.trim() && selectedFields.length === 0) || isSending
|
(!input.trim() && selectedFields.length === 0) ||
|
||||||
|
isSending
|
||||||
}
|
}
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-9 w-9 bg-teal-600 hover:bg-teal-700"
|
className="h-9 w-9 bg-teal-600 hover:bg-teal-700"
|
||||||
@@ -733,29 +987,33 @@ export function IAAsignaturaTab({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
{/* PANEL DERECHO ACCIONES */}
|
{/* 3. PANEL DERECHO (ATAJOS) */}
|
||||||
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
|
<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">
|
<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>
|
</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{PRESETS.map((preset) => (
|
{PRESETS.map((preset) => (
|
||||||
<button
|
<button
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
onClick={() => handleSend(preset.prompt)}
|
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} />
|
<preset.icon size={16} />
|
||||||
</div>
|
</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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
{/* --- DRAWER DE REFERENCIAS --- */}
|
|
||||||
|
{/* DRAWERS (Referencias e Historial Móvil) */}
|
||||||
<Drawer open={openIA} onOpenChange={setOpenIA}>
|
<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">
|
<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">
|
<div className="flex items-center justify-between border-b bg-slate-50/50 px-4 py-3">
|
||||||
@@ -790,6 +1048,140 @@ export function IAAsignaturaTab({
|
|||||||
</div>
|
</div>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</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 pt-8">
|
||||||
|
{/* 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface ImprovementCardProps {
|
|||||||
sug: IASugerencia
|
sug: IASugerencia
|
||||||
asignaturaId: string
|
asignaturaId: string
|
||||||
onApplied: (campoKey: string) => void
|
onApplied: (campoKey: string) => void
|
||||||
|
isSelected?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImprovementCard({
|
export function ImprovementCard({
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||||
|
|
||||||
|
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { useSubject } from '@/data'
|
||||||
|
|
||||||
|
export function PasoBasicosClonadoInterno({
|
||||||
|
wizard,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
wizard: NewSubjectWizardState
|
||||||
|
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||||
|
}) {
|
||||||
|
const sourceId = wizard.clonInterno?.asignaturaOrigenId ?? null
|
||||||
|
const { data: source, isLoading, isError } = useSubject(sourceId)
|
||||||
|
|
||||||
|
const lastAppliedRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!source) return
|
||||||
|
if (lastAppliedRef.current === source.id) return
|
||||||
|
|
||||||
|
lastAppliedRef.current = source.id
|
||||||
|
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
datosBasicos: {
|
||||||
|
...w.datosBasicos,
|
||||||
|
nombre: source.nombre,
|
||||||
|
codigo: source.codigo ?? '',
|
||||||
|
tipo: (source.tipo as any) ?? null,
|
||||||
|
creditos: source.creditos,
|
||||||
|
horasAcademicas: (source as any).horas_academicas ?? null,
|
||||||
|
horasIndependientes: (source as any).horas_independientes ?? null,
|
||||||
|
estructuraId: (source.estructura_id ??
|
||||||
|
w.datosBasicos.estructuraId) as any,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}, [onChange, source])
|
||||||
|
|
||||||
|
if (!sourceId) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Datos básicos</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-muted-foreground text-sm">
|
||||||
|
Selecciona una asignatura fuente para continuar.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Datos básicos</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-muted-foreground text-sm">
|
||||||
|
Cargando información de la asignatura fuente…
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !source) {
|
||||||
|
return (
|
||||||
|
<Card className="border-destructive/40">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-destructive flex items-center gap-2 text-base">
|
||||||
|
<Icons.AlertTriangle className="h-5 w-5" />
|
||||||
|
No se pudo cargar la fuente
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-muted-foreground text-sm">
|
||||||
|
Intenta seleccionar otra asignatura.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PasoBasicosForm
|
||||||
|
wizard={wizard}
|
||||||
|
onChange={onChange}
|
||||||
|
estructuraFuenteId={source.estructura_id ?? null}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as Icons from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import PasoSugerenciasForm from './PasoSugerenciasForm'
|
import PasoSugerenciasForm from './PasoSugerenciasForm'
|
||||||
@@ -21,9 +22,11 @@ import { cn } from '@/lib/utils'
|
|||||||
export function PasoBasicosForm({
|
export function PasoBasicosForm({
|
||||||
wizard,
|
wizard,
|
||||||
onChange,
|
onChange,
|
||||||
|
estructuraFuenteId,
|
||||||
}: {
|
}: {
|
||||||
wizard: NewSubjectWizardState
|
wizard: NewSubjectWizardState
|
||||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||||
|
estructuraFuenteId?: string | null
|
||||||
}) {
|
}) {
|
||||||
const { data: estructuras } = useSubjectEstructuras()
|
const { data: estructuras } = useSubjectEstructuras()
|
||||||
|
|
||||||
@@ -258,6 +261,17 @@ export function PasoBasicosForm({
|
|||||||
)}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{estructuraFuenteId &&
|
||||||
|
wizard.datosBasicos.estructuraId &&
|
||||||
|
wizard.datosBasicos.estructuraId !== estructuraFuenteId ? (
|
||||||
|
<div className="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-2 rounded-md border p-2 text-xs">
|
||||||
|
<Icons.AlertTriangle className="mt-0.5 h-4 w-4 flex-none" />
|
||||||
|
<span>
|
||||||
|
Es posible que se pierdan datos generales al seleccionar otra
|
||||||
|
estructura.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
|
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import { useDebounce } from 'use-debounce'
|
||||||
|
|
||||||
|
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||||
|
import type { Database } from '@/types/supabase'
|
||||||
|
|
||||||
|
import Pagination03 from '@/components/shadcn-studio/pagination/pagination-03'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { supabaseBrowser, useCatalogosPlanes, usePlanes } from '@/data'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type SourceSubjectRow = {
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
codigo: string | null
|
||||||
|
creditos: number
|
||||||
|
tipo: any
|
||||||
|
plan_estudio_id: string
|
||||||
|
estructura_id: string | null
|
||||||
|
rank?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL = '__all__'
|
||||||
|
|
||||||
|
type SearchAsignaturasRow =
|
||||||
|
Database['public']['Functions']['search_asignaturas']['Returns'][number]
|
||||||
|
|
||||||
|
export function PasoFuenteClonadoInterno({
|
||||||
|
wizard,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
wizard: NewSubjectWizardState
|
||||||
|
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||||
|
}) {
|
||||||
|
const pageSize = 20
|
||||||
|
|
||||||
|
const facultadId = wizard.clonInterno?.facultadId ?? null
|
||||||
|
const carreraId = wizard.clonInterno?.carreraId ?? null
|
||||||
|
const planOrigenId = wizard.clonInterno?.planOrigenId ?? null
|
||||||
|
const search = wizard.clonInterno?.search ?? ''
|
||||||
|
const page = Math.max(1, wizard.clonInterno?.page ?? 1)
|
||||||
|
|
||||||
|
const [debouncedSearch] = useDebounce(search, 350)
|
||||||
|
|
||||||
|
const { data: catalogos } = useCatalogosPlanes()
|
||||||
|
|
||||||
|
const carrerasOptions = useMemo(() => {
|
||||||
|
const raw = catalogos?.carreras ?? []
|
||||||
|
return facultadId ? raw.filter((c) => c.facultad_id === facultadId) : raw
|
||||||
|
}, [catalogos?.carreras, facultadId])
|
||||||
|
|
||||||
|
const planesQuery = usePlanes({
|
||||||
|
search: '',
|
||||||
|
facultadId: facultadId ?? 'todas',
|
||||||
|
carreraId: carreraId ?? 'todas',
|
||||||
|
estadoId: 'todos',
|
||||||
|
limit: 500,
|
||||||
|
offset: 0,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
const needPlansForFilter = Boolean((facultadId || carreraId) && !planOrigenId)
|
||||||
|
const plansForFilter = planesQuery.data?.data ?? []
|
||||||
|
|
||||||
|
const { data: subjectsPaged, isLoading: subjectsLoading } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
'asignaturas',
|
||||||
|
'clone-source',
|
||||||
|
{
|
||||||
|
facultadId,
|
||||||
|
carreraId,
|
||||||
|
planOrigenId,
|
||||||
|
search: debouncedSearch,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
planIdsKey: needPlansForFilter
|
||||||
|
? plansForFilter.map((p) => p.id).join(',')
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
enabled: !needPlansForFilter || !planesQuery.isLoading,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: async () => {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const from = (page - 1) * pageSize
|
||||||
|
const term = debouncedSearch.trim()
|
||||||
|
|
||||||
|
// Una sola llamada limpia. RPC maneja tanto búsqueda vacía como llena.
|
||||||
|
const { data, error } = await supabase.rpc('search_asignaturas', {
|
||||||
|
p_search: term,
|
||||||
|
p_facultad_id: facultadId ?? undefined, // Corrección TS 2322
|
||||||
|
p_carrera_id: carreraId ?? undefined, // Corrección TS 2322
|
||||||
|
p_plan_estudio_id: planOrigenId ?? undefined, // Corrección TS 2322
|
||||||
|
p_limit: pageSize,
|
||||||
|
p_offset: from,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
|
||||||
|
// Extraemos el conteo total de la primera fila.
|
||||||
|
// ESLint feliz: "data" ya no puede ser null a este punto.
|
||||||
|
const count = data.length > 0 ? Number(data[0].total_count) : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
// ESLint feliz: map directo sobre data
|
||||||
|
data: data.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
nombre: r.nombre,
|
||||||
|
codigo: r.codigo,
|
||||||
|
creditos: Number(r.creditos),
|
||||||
|
tipo: r.tipo,
|
||||||
|
plan_estudio_id: r.plan_estudio_id,
|
||||||
|
estructura_id: null,
|
||||||
|
rank: r.rank,
|
||||||
|
})),
|
||||||
|
count,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const subjects = subjectsPaged?.data ?? []
|
||||||
|
const total = subjectsPaged?.count ?? 0
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// clamp page if results shrink
|
||||||
|
if (page > pageCount) {
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
clonInterno: { ...(w.clonInterno ?? {}), page: pageCount },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, [onChange, page, pageCount])
|
||||||
|
|
||||||
|
const patchClonInterno = (
|
||||||
|
patch: Partial<NonNullable<NewSubjectWizardState['clonInterno']>>,
|
||||||
|
) =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
clonInterno: { ...(w.clonInterno ?? {}), ...patch },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const hasAnyFilter = Boolean(
|
||||||
|
facultadId || carreraId || planOrigenId || search.trim().length,
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearDisabled = !hasAnyFilter
|
||||||
|
|
||||||
|
const selectedId = wizard.clonInterno?.asignaturaOrigenId ?? null
|
||||||
|
|
||||||
|
const resetSelection = () => patchClonInterno({ asignaturaOrigenId: null })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<Card className="gap-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Fuente</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label>Facultad</Label>
|
||||||
|
<Select
|
||||||
|
value={facultadId ?? ALL}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const next = val === ALL ? null : val
|
||||||
|
patchClonInterno({
|
||||||
|
facultadId: next,
|
||||||
|
carreraId: null,
|
||||||
|
planOrigenId: null,
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
resetSelection()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full min-w-0 [&>span]:block! [&>span]:truncate!">
|
||||||
|
<SelectValue placeholder="Todas" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>Todas</SelectItem>
|
||||||
|
{(catalogos?.facultades ?? []).map((f) => (
|
||||||
|
<SelectItem key={f.id} value={f.id}>
|
||||||
|
{f.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label>Carrera</Label>
|
||||||
|
<Select
|
||||||
|
value={carreraId ?? ALL}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const next = val === ALL ? null : val
|
||||||
|
patchClonInterno({
|
||||||
|
carreraId: next,
|
||||||
|
planOrigenId: null,
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
resetSelection()
|
||||||
|
}}
|
||||||
|
disabled={!facultadId}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full min-w-0 [&>span]:block! [&>span]:truncate!">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={facultadId ? 'Todas' : 'Selecciona facultad'}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>Todas</SelectItem>
|
||||||
|
{carrerasOptions.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label>Plan</Label>
|
||||||
|
<Select
|
||||||
|
value={planOrigenId ?? ALL}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const next = val === ALL ? null : val
|
||||||
|
patchClonInterno({ planOrigenId: next, page: 1 })
|
||||||
|
resetSelection()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||||
|
disabled={!carreraId && !facultadId}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Todos" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>Todos</SelectItem>
|
||||||
|
{(planesQuery.data?.data ?? []).map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label>Buscar</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Nombre o código..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchClonInterno({ search: e.target.value, page: 1 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
patchClonInterno({
|
||||||
|
facultadId: null,
|
||||||
|
carreraId: null,
|
||||||
|
planOrigenId: null,
|
||||||
|
search: '',
|
||||||
|
page: 1,
|
||||||
|
asignaturaOrigenId: null,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={clearDisabled}
|
||||||
|
>
|
||||||
|
<Icons.X className="mr-2 h-4 w-4" />
|
||||||
|
Limpiar filtros
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Selecciona una asignatura fuente (solo una).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid max-h-80 gap-2 overflow-y-auto">
|
||||||
|
{subjectsLoading ? (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
Cargando asignaturas…
|
||||||
|
</div>
|
||||||
|
) : subjects.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
No hay asignaturas con esos filtros.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
subjects.map((m) => {
|
||||||
|
const active = String(selectedId) === String(m.id)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={m.id}
|
||||||
|
className={cn(
|
||||||
|
'hover:bg-accent flex cursor-pointer items-center justify-between rounded-md border p-3 text-left',
|
||||||
|
active && 'border-primary bg-primary/5 ring-primary ring-1',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="sr-only"
|
||||||
|
type="radio"
|
||||||
|
name="asignaturaFuente"
|
||||||
|
checked={active}
|
||||||
|
onChange={() =>
|
||||||
|
patchClonInterno({ asignaturaOrigenId: m.id, page })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium">{m.nombre}</div>
|
||||||
|
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
{(m.codigo ? m.codigo : '—') +
|
||||||
|
' • ' +
|
||||||
|
String(m.creditos) +
|
||||||
|
' créditos'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{active ? (
|
||||||
|
<Icons.CheckCircle2 className="text-primary h-5 w-5 flex-none" />
|
||||||
|
) : (
|
||||||
|
<span className="h-5 w-5 flex-none" aria-hidden />
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pageCount > 1 ? (
|
||||||
|
<Pagination03
|
||||||
|
page={page}
|
||||||
|
pageCount={pageCount}
|
||||||
|
onPageChange={(nextPage) => patchClonInterno({ page: nextPage })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
qk,
|
qk,
|
||||||
useCreateSubjectManual,
|
useCreateSubjectManual,
|
||||||
subjects_get_maybe,
|
subjects_get_maybe,
|
||||||
|
subjects_get,
|
||||||
} from '@/data'
|
} from '@/data'
|
||||||
|
|
||||||
export function WizardControls({
|
export function WizardControls({
|
||||||
@@ -201,6 +202,87 @@ export function WizardControls({
|
|||||||
let startedWaiting = false
|
let startedWaiting = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
|
||||||
|
if (!wizard.plan_estudio_id) {
|
||||||
|
throw new Error('Plan de estudio inválido.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const asignaturaOrigenId = wizard.clonInterno?.asignaturaOrigenId
|
||||||
|
if (!asignaturaOrigenId) {
|
||||||
|
throw new Error('Selecciona una asignatura fuente.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wizard.datosBasicos.estructuraId) {
|
||||||
|
throw new Error('Estructura inválida.')
|
||||||
|
}
|
||||||
|
if (!wizard.datosBasicos.nombre.trim()) {
|
||||||
|
throw new Error('Nombre inválido.')
|
||||||
|
}
|
||||||
|
if (wizard.datosBasicos.tipo == null) {
|
||||||
|
throw new Error('Tipo inválido.')
|
||||||
|
}
|
||||||
|
if (wizard.datosBasicos.creditos == null) {
|
||||||
|
throw new Error('Créditos inválidos.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fuente = await subjects_get(asignaturaOrigenId as any)
|
||||||
|
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
const codigo = (wizard.datosBasicos.codigo ?? '').trim()
|
||||||
|
|
||||||
|
const payload: TablesInsert<'asignaturas'> = {
|
||||||
|
plan_estudio_id: wizard.plan_estudio_id,
|
||||||
|
estructura_id: wizard.datosBasicos.estructuraId,
|
||||||
|
codigo: codigo ? codigo : null,
|
||||||
|
nombre: wizard.datosBasicos.nombre,
|
||||||
|
tipo: wizard.datosBasicos.tipo,
|
||||||
|
creditos: wizard.datosBasicos.creditos,
|
||||||
|
datos: (fuente as any).datos,
|
||||||
|
contenido_tematico: (fuente as any).contenido_tematico,
|
||||||
|
criterios_de_evaluacion: (fuente as any).criterios_de_evaluacion,
|
||||||
|
tipo_origen: 'CLONADO_INTERNO',
|
||||||
|
meta_origen: {
|
||||||
|
...(fuente as any).meta_origen,
|
||||||
|
asignatura_origen_id: fuente.id,
|
||||||
|
plan_origen_id: (fuente as any).plan_estudio_id,
|
||||||
|
},
|
||||||
|
horas_academicas:
|
||||||
|
wizard.datosBasicos.horasAcademicas ??
|
||||||
|
(fuente as any).horas_academicas ??
|
||||||
|
null,
|
||||||
|
horas_independientes:
|
||||||
|
wizard.datosBasicos.horasIndependientes ??
|
||||||
|
(fuente as any).horas_independientes ??
|
||||||
|
null,
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('payload:', payload)
|
||||||
|
|
||||||
|
const { data: inserted, error: insertError } = await supabase
|
||||||
|
.from('asignaturas')
|
||||||
|
.insert(payload)
|
||||||
|
.select('id,plan_estudio_id')
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (insertError) throw new Error(insertError.message)
|
||||||
|
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: qk.planAsignaturas(wizard.plan_estudio_id),
|
||||||
|
})
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: qk.planHistorial(wizard.plan_estudio_id),
|
||||||
|
})
|
||||||
|
|
||||||
|
navigate({
|
||||||
|
to: `/planes/${inserted.plan_estudio_id}/asignaturas/${inserted.id}`,
|
||||||
|
state: { showConfetti: true },
|
||||||
|
resetScroll: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
||||||
if (!wizard.plan_estudio_id) {
|
if (!wizard.plan_estudio_id) {
|
||||||
throw new Error('Plan de estudio inválido.')
|
throw new Error('Plan de estudio inválido.')
|
||||||
|
|||||||
@@ -4,20 +4,17 @@ import type { LucideIcon } from 'lucide-react'
|
|||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
||||||
import { cn } from '@/lib/utils' // Asegúrate de tener tu utilidad cn
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface PlanEstudiosCardProps {
|
interface PlanEstudiosCardProps {
|
||||||
/** El componente del ícono importado de lucide-react (ej. BookOpen) */
|
|
||||||
Icono: LucideIcon
|
Icono: LucideIcon
|
||||||
nombrePrograma: string
|
nombrePrograma: string
|
||||||
nivel: string
|
nivel: string
|
||||||
ciclos: string | number // Acepta "8" o "8 semestres"
|
ciclos: string | number
|
||||||
facultad: string
|
facultad: string
|
||||||
estado: string
|
estado: string
|
||||||
/** Código hex o variable CSS (ej. "#ef4444" o "var(--primary)") */
|
|
||||||
claseColorEstado?: string
|
claseColorEstado?: string
|
||||||
colorFacultad: string
|
colorFacultad: string
|
||||||
/** Opcional: para manejar el click en la tarjeta */
|
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,18 +33,14 @@ export default function PlanEstudiosCard({
|
|||||||
<Card
|
<Card
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative flex h-full cursor-pointer flex-col justify-between gap-2 overflow-hidden border-l-4 transition-all hover:shadow-lg',
|
'group relative flex h-full cursor-pointer flex-col justify-between overflow-hidden transition-all hover:shadow-lg',
|
||||||
)}
|
)}
|
||||||
// Aplicamos el color de la facultad dinámicamente al borde y un fondo muy sutil
|
|
||||||
style={{
|
|
||||||
borderLeftColor: colorFacultad,
|
|
||||||
backgroundColor: `color-mix(in srgb, ${colorFacultad}, transparent 95%)`, // Truco CSS moderno para fondo tintado
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
<div className="flex flex-grow flex-col">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
{/* Ícono con el color de la facultad */}
|
{/* Círculo del ícono con el color de la facultad */}
|
||||||
<div
|
<div
|
||||||
className="mb-2 w-fit rounded-md p-2"
|
className="mb-2 w-fit rounded-full p-2.5"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `color-mix(in srgb, ${colorFacultad}, transparent 85%)`,
|
backgroundColor: `color-mix(in srgb, ${colorFacultad}, transparent 85%)`,
|
||||||
}}
|
}}
|
||||||
@@ -67,14 +60,12 @@ export default function PlanEstudiosCard({
|
|||||||
</p>
|
</p>
|
||||||
<p>{facultad}</p>
|
<p>{facultad}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CardFooter className="bg-background/50 flex items-center justify-between border-t px-6 pb-3 backdrop-blur-sm [.border-t]:pt-3">
|
<CardFooter className="flex items-center justify-between pt-0 pb-6">
|
||||||
<Badge className={`text-sm font-semibold ${claseColorEstado}`}>
|
<Badge className={cn('text-sm font-semibold', claseColorEstado)}>
|
||||||
{estado}
|
{estado}
|
||||||
</Badge>
|
</Badge>
|
||||||
{/* <span className="text-foreground/80 text-sm font-semibold">
|
|
||||||
{estado}
|
|
||||||
</span> */}
|
|
||||||
|
|
||||||
{/* Flecha animada */}
|
{/* Flecha animada */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from '@/components/ui/pagination'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type Pagination03Props = {
|
||||||
|
page: number
|
||||||
|
pageCount: number
|
||||||
|
onPageChange: (page: number) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const toInt = (n: unknown, fallback: number) => {
|
||||||
|
const x = typeof n === 'number' ? n : Number(n)
|
||||||
|
return Number.isFinite(x) ? Math.floor(x) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPageItems(page: number, pageCount: number): Array<number | '...'> {
|
||||||
|
if (pageCount <= 7) {
|
||||||
|
return Array.from({ length: pageCount }, (_, i) => i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: Array<number | '...'> = []
|
||||||
|
const safePage = Math.min(Math.max(page, 1), pageCount)
|
||||||
|
|
||||||
|
items.push(1)
|
||||||
|
|
||||||
|
const start = Math.max(2, safePage - 1)
|
||||||
|
const end = Math.min(pageCount - 1, safePage + 1)
|
||||||
|
|
||||||
|
if (start > 2) items.push('...')
|
||||||
|
for (let p = start; p <= end; p++) items.push(p)
|
||||||
|
if (end < pageCount - 1) items.push('...')
|
||||||
|
|
||||||
|
items.push(pageCount)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pagination03({
|
||||||
|
page,
|
||||||
|
pageCount,
|
||||||
|
onPageChange,
|
||||||
|
className,
|
||||||
|
}: Pagination03Props) {
|
||||||
|
const safePageCount = Math.max(1, toInt(pageCount, 1))
|
||||||
|
const safePage = Math.min(Math.max(toInt(page, 1), 1), safePageCount)
|
||||||
|
|
||||||
|
const items = getPageItems(safePage, safePageCount)
|
||||||
|
const canPrev = safePage > 1
|
||||||
|
const canNext = safePage < safePageCount
|
||||||
|
|
||||||
|
const go = (p: number) => {
|
||||||
|
const next = Math.min(Math.max(p, 1), safePageCount)
|
||||||
|
if (next === safePage) return
|
||||||
|
onPageChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pagination className={className}>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
href="#"
|
||||||
|
className={cn(!canPrev && 'pointer-events-none opacity-50')}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canPrev) return
|
||||||
|
go(safePage - 1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{items.map((it, idx) =>
|
||||||
|
it === '...' ? (
|
||||||
|
<PaginationItem key={`ellipsis-${idx}`}>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
) : (
|
||||||
|
<PaginationItem key={it}>
|
||||||
|
<PaginationLink
|
||||||
|
href="#"
|
||||||
|
isActive={it === safePage}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
go(it)
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
it === safePage
|
||||||
|
? cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: 'default',
|
||||||
|
size: 'icon',
|
||||||
|
}),
|
||||||
|
'hover:text-primary-foreground! dark:bg-primary dark:text-primary-foreground dark:hover:text-primary-foreground dark:hover:bg-primary/90 shadow-none! dark:border-transparent',
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{it}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
href="#"
|
||||||
|
className={cn(!canNext && 'pointer-events-none opacity-50')}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canNext) return
|
||||||
|
go(safePage + 1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Pagination03
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants, type Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
data-slot="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="pagination-content"
|
||||||
|
className={cn("flex flex-row items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||||
|
return <li data-slot="pagination-item" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean
|
||||||
|
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
React.ComponentProps<"a">
|
||||||
|
|
||||||
|
function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-slot="pagination-link"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: isActive ? "outline" : "ghost",
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationPrevious({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
<span className="hidden sm:block">Previous</span>
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationNext({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:block">Next</span>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationPrevious,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationEllipsis,
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ export function WizardLayout({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="z-10 flex-none border-b bg-white">
|
<div className="z-10 flex-none border-b bg-white">
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-4 p-6 pb-4">
|
<CardHeader className="flex flex-row items-center justify-between gap-4 p-6 pb-3">
|
||||||
<CardTitle>{title}</CardTitle>
|
<CardTitle>{title}</CardTitle>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -36,10 +36,10 @@ export function WizardLayout({
|
|||||||
</button>
|
</button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{headerSlot ? <div className="px-6 pb-6">{headerSlot}</div> : null}
|
{headerSlot ? <div className="px-6 pb-3">{headerSlot}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
|
<div className="flex-1 overflow-y-auto bg-gray-50/30 px-4 py-3 xl:px-6">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,35 @@ const cleanText = (text: string) => {
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recalculoVectoresAsignaturasInFlight = new Set<string>()
|
||||||
|
|
||||||
|
function triggerRecalculoVectoresAsignaturasNonBlocking(
|
||||||
|
supabase: ReturnType<typeof supabaseBrowser>,
|
||||||
|
planId: UUID,
|
||||||
|
) {
|
||||||
|
const key = String(planId)
|
||||||
|
if (recalculoVectoresAsignaturasInFlight.has(key)) return
|
||||||
|
|
||||||
|
recalculoVectoresAsignaturasInFlight.add(key)
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const { error } = await supabase.rpc('recalcular_vectores_asignaturas')
|
||||||
|
if (error) {
|
||||||
|
// No debe bloquear ni romper el flujo principal.
|
||||||
|
console.warn(
|
||||||
|
'[recalcular_vectores_asignaturas] RPC error:',
|
||||||
|
error.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.warn('[recalcular_vectores_asignaturas] RPC failed:', err)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
recalculoVectoresAsignaturasInFlight.delete(key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function plans_list(
|
export async function plans_list(
|
||||||
filters: PlanListFilters = {},
|
filters: PlanListFilters = {},
|
||||||
): Promise<Paged<PlanEstudio>> {
|
): Promise<Paged<PlanEstudio>> {
|
||||||
@@ -207,7 +236,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,prerrequisito_asignatura_id',
|
'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,criterios_de_evaluacion,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,prerrequisito_asignatura_id,search_vector',
|
||||||
)
|
)
|
||||||
.eq('plan_estudio_id', planId)
|
.eq('plan_estudio_id', planId)
|
||||||
.order('numero_ciclo', { ascending: true, nullsFirst: false })
|
.order('numero_ciclo', { ascending: true, nullsFirst: false })
|
||||||
@@ -215,6 +244,13 @@ export async function plan_asignaturas_list(
|
|||||||
.order('nombre', { ascending: true })
|
.order('nombre', { ascending: true })
|
||||||
|
|
||||||
throwIfError(error)
|
throwIfError(error)
|
||||||
|
|
||||||
|
// No bloqueante: si el primer registro viene sin vector, dispara el recalculo.
|
||||||
|
const first: any = (data as any)?.[0]
|
||||||
|
if (first && first.search_vector === null) {
|
||||||
|
triggerRecalculoVectoresAsignaturasNonBlocking(supabase, planId)
|
||||||
|
}
|
||||||
|
|
||||||
return data ?? []
|
return data ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import * as Icons from 'lucide-react'
|
|||||||
|
|
||||||
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
|
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
|
||||||
|
|
||||||
|
import { PasoBasicosClonadoInterno } from '@/components/asignaturas/wizard/PasoBasicosClonadoInterno.tsx'
|
||||||
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
|
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
|
||||||
import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel'
|
import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel'
|
||||||
|
import { PasoFuenteClonadoInterno } from '@/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx'
|
||||||
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
|
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
|
||||||
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
|
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
|
||||||
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
|
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
|
||||||
@@ -63,6 +65,11 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
|||||||
basicos: 'Sugerencias',
|
basicos: 'Sugerencias',
|
||||||
detalles: 'Estructura',
|
detalles: 'Estructura',
|
||||||
}
|
}
|
||||||
|
: wizard.tipoOrigen === 'CLONADO_INTERNO'
|
||||||
|
? {
|
||||||
|
basicos: 'Fuente',
|
||||||
|
detalles: 'Datos básicos',
|
||||||
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -99,6 +106,21 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
|||||||
>
|
>
|
||||||
{({ methods }) => {
|
{({ methods }) => {
|
||||||
const idx = Wizard.utils.getIndex(methods.current.id)
|
const idx = Wizard.utils.getIndex(methods.current.id)
|
||||||
|
const stepId = methods.current.id
|
||||||
|
|
||||||
|
const disableNext =
|
||||||
|
wizard.isLoading ||
|
||||||
|
(stepId === 'metodo'
|
||||||
|
? !canContinueDesdeMetodo
|
||||||
|
: stepId === 'basicos'
|
||||||
|
? wizard.tipoOrigen === 'CLONADO_INTERNO'
|
||||||
|
? !canContinueDesdeDetalles
|
||||||
|
: !canContinueDesdeBasicos
|
||||||
|
: stepId === 'detalles'
|
||||||
|
? wizard.tipoOrigen === 'CLONADO_INTERNO'
|
||||||
|
? !canContinueDesdeBasicos
|
||||||
|
: !canContinueDesdeDetalles
|
||||||
|
: false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WizardLayout
|
<WizardLayout
|
||||||
@@ -118,12 +140,7 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
|||||||
onPrev={() => methods.prev()}
|
onPrev={() => methods.prev()}
|
||||||
onNext={() => methods.next()}
|
onNext={() => methods.next()}
|
||||||
disablePrev={idx === 0 || wizard.isLoading}
|
disablePrev={idx === 0 || wizard.isLoading}
|
||||||
disableNext={
|
disableNext={disableNext}
|
||||||
wizard.isLoading ||
|
|
||||||
(idx === 0 && !canContinueDesdeMetodo) ||
|
|
||||||
(idx === 1 && !canContinueDesdeBasicos) ||
|
|
||||||
(idx === 2 && !canContinueDesdeDetalles)
|
|
||||||
}
|
|
||||||
disableCreate={wizard.isLoading}
|
disableCreate={wizard.isLoading}
|
||||||
isLastStep={idx >= Wizard.steps.length - 1}
|
isLastStep={idx >= Wizard.steps.length - 1}
|
||||||
wizard={wizard}
|
wizard={wizard}
|
||||||
@@ -141,13 +158,27 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
|||||||
|
|
||||||
{idx === 1 && (
|
{idx === 1 && (
|
||||||
<Wizard.Stepper.Panel>
|
<Wizard.Stepper.Panel>
|
||||||
|
{wizard.tipoOrigen === 'CLONADO_INTERNO' ? (
|
||||||
|
<PasoFuenteClonadoInterno
|
||||||
|
wizard={wizard}
|
||||||
|
onChange={setWizard}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
|
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
|
||||||
|
)}
|
||||||
</Wizard.Stepper.Panel>
|
</Wizard.Stepper.Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{idx === 2 && (
|
{idx === 2 && (
|
||||||
<Wizard.Stepper.Panel>
|
<Wizard.Stepper.Panel>
|
||||||
|
{wizard.tipoOrigen === 'CLONADO_INTERNO' ? (
|
||||||
|
<PasoBasicosClonadoInterno
|
||||||
|
wizard={wizard}
|
||||||
|
onChange={setWizard}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<PasoDetallesPanel wizard={wizard} onChange={setWizard} />
|
<PasoDetallesPanel wizard={wizard} onChange={setWizard} />
|
||||||
|
)}
|
||||||
</Wizard.Stepper.Panel>
|
</Wizard.Stepper.Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -50,10 +50,12 @@ export type NewSubjectWizardState = {
|
|||||||
}
|
}
|
||||||
sugerencias: Array<AsignaturaSugerida>
|
sugerencias: Array<AsignaturaSugerida>
|
||||||
clonInterno?: {
|
clonInterno?: {
|
||||||
facultadId?: string
|
facultadId?: string | null
|
||||||
carreraId?: string
|
carreraId?: string | null
|
||||||
planOrigenId?: string
|
planOrigenId?: string | null
|
||||||
asignaturaOrigenId?: string | null
|
asignaturaOrigenId?: string | null
|
||||||
|
search?: string
|
||||||
|
page?: number
|
||||||
}
|
}
|
||||||
clonTradicional?: {
|
clonTradicional?: {
|
||||||
archivoWordAsignaturaId: string | null
|
archivoWordAsignaturaId: string | null
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ function RouteComponent() {
|
|||||||
4. px-4 md:px-6: Padding RESPONSIVO interno (seguro para móviles y desktop).
|
4. px-4 md:px-6: Padding RESPONSIVO interno (seguro para móviles y desktop).
|
||||||
5. py-6: Padding vertical (opcional, para separarse del header).
|
5. py-6: Padding vertical (opcional, para separarse del header).
|
||||||
*/}
|
*/}
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
|
<div className="mx-auto flex w-full max-w-400 flex-col gap-4 p-4 md:px-6 md:pb-6 lg:px-8 lg:pb-8">
|
||||||
<DashboardHeader
|
<DashboardHeader
|
||||||
nombre="Dr. Carlos Mendoza"
|
nombre="Dr. Carlos Mendoza"
|
||||||
rol="Jefe de Carrera"
|
rol="Jefe de Carrera"
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto max-w-400 space-y-8 p-8">
|
<div className="mx-auto max-w-400 space-y-8 p-4 md:px-6 md:pb-6 lg:px-8 lg:pb-8">
|
||||||
{/* 2. Header del Plan */}
|
{/* 2. Header del Plan */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
/* ===== SKELETON ===== */
|
/* ===== SKELETON ===== */
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const mapAsignaturas = (
|
|||||||
estado: asig.estado,
|
estado: asig.estado,
|
||||||
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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ function AsignaturasPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto space-y-6 px-6 py-6">
|
<div className="w-full space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -218,27 +218,31 @@ function AsignaturasPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-slate-50/50">
|
<TableRow className="bg-slate-50/50">
|
||||||
<TableHead className="w-30">Clave</TableHead>
|
<TableHead className="w-30 px-6 py-4">Clave</TableHead>
|
||||||
<TableHead>Nombre</TableHead>
|
<TableHead className="px-6 py-4">Nombre</TableHead>
|
||||||
<TableHead className="text-center">Créditos</TableHead>
|
<TableHead className="px-6 py-4 text-center">Créditos</TableHead>
|
||||||
<TableHead className="text-center">Ciclo</TableHead>
|
<TableHead className="px-6 py-4 text-center">Ciclo</TableHead>
|
||||||
<TableHead>Línea Curricular</TableHead>
|
<TableHead className="px-6 py-4">Línea Curricular</TableHead>
|
||||||
<TableHead>Tipo</TableHead>
|
<TableHead className="px-6 py-4">Tipo</TableHead>
|
||||||
<TableHead>Estado</TableHead>
|
<TableHead className="px-6 py-4">Estado</TableHead>
|
||||||
<TableHead className="w-12.5"></TableHead>
|
<TableHead className="w-12.5 px-6 py-4"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredAsignaturas.length === 0 ? (
|
{filteredAsignaturas.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="h-40 text-center">
|
<TableCell colSpan={8} className="h-40 px-6 py-8 text-center">
|
||||||
<div className="text-muted-foreground flex flex-col items-center justify-center">
|
<div className="text-muted-foreground flex flex-col items-center justify-center gap-3">
|
||||||
<BookOpen className="mb-2 h-10 w-10 opacity-20" />
|
<BookOpen className="h-10 w-10 opacity-20" />
|
||||||
<p className="font-medium">No se encontraron asignaturas</p>
|
<div>
|
||||||
<p className="text-xs">
|
<p className="font-medium">
|
||||||
|
No se encontraron asignaturas
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs">
|
||||||
Intenta cambiar los filtros de búsqueda
|
Intenta cambiar los filtros de búsqueda
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
@@ -251,25 +255,25 @@ function AsignaturasPage() {
|
|||||||
to: '/planes/$planId/asignaturas/$asignaturaId',
|
to: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
params: {
|
params: {
|
||||||
planId,
|
planId,
|
||||||
asignaturaId: asignatura.id, // 👈 puede ser índice, consecutivo o slug
|
asignaturaId: asignatura.id,
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
realId: asignatura.id, // 👈 ID largo oculto
|
realId: asignatura.id,
|
||||||
asignaturaId: asignatura.id,
|
asignaturaId: asignatura.id,
|
||||||
} as any,
|
} 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}
|
{asignatura.clave}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-semibold text-slate-700">
|
<TableCell className="px-6 py-4 font-semibold text-slate-700">
|
||||||
{asignatura.nombre}
|
{asignatura.nombre}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center font-medium">
|
<TableCell className="px-6 py-4 text-center font-medium">
|
||||||
{asignatura.creditos}
|
{asignatura.creditos}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="px-6 py-4 text-center">
|
||||||
{asignatura.ciclo ? (
|
{asignatura.ciclo ? (
|
||||||
<Badge variant="outline" className="font-normal">
|
<Badge variant="outline" className="font-normal">
|
||||||
Ciclo {asignatura.ciclo}
|
Ciclo {asignatura.ciclo}
|
||||||
@@ -278,10 +282,10 @@ function AsignaturasPage() {
|
|||||||
<span className="text-slate-300">—</span>
|
<span className="text-slate-300">—</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-slate-600">
|
<TableCell className="px-6 py-4 text-sm text-slate-600">
|
||||||
{getLineaNombre(asignatura.lineaCurricularId)}
|
{getLineaNombre(asignatura.lineaCurricularId)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="px-6 py-4">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`}
|
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`}
|
||||||
@@ -289,7 +293,7 @@ function AsignaturasPage() {
|
|||||||
{tipoConfig[asignatura.tipo].label}
|
{tipoConfig[asignatura.tipo].label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="px-6 py-4">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`}
|
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`}
|
||||||
@@ -297,7 +301,7 @@ function AsignaturasPage() {
|
|||||||
{statusConfig[asignatura.estado].label}
|
{statusConfig[asignatura.estado].label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="px-6 py-4">
|
||||||
<div className="opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<ChevronRight className="h-5 w-5 text-slate-400" />
|
<ChevronRight className="h-5 w-5 text-slate-400" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,8 +80,29 @@ 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">
|
||||||
{/* HEADER DE ACCIONES */}
|
{/* HEADER DE ACCIONES */}
|
||||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function RouteComponent() {
|
|||||||
console.log(rawData)
|
console.log(rawData)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Header Informativo (Opcional, si no viene del layout padre) */}
|
{/* Header Informativo (Opcional, si no viene del layout padre) */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ function RouteComponent() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl p-6">
|
<div className="mx-auto">
|
||||||
<div className="mb-8 flex items-end justify-between">
|
<div className="mb-8 flex items-end justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="flex items-center gap-2 text-xl font-bold text-slate-800">
|
<h1 className="flex items-center gap-2 text-xl font-bold text-slate-800">
|
||||||
@@ -139,13 +139,16 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative space-y-0">
|
<div className="relative space-y-0">
|
||||||
<div className="absolute top-0 bottom-0 left-9 w-px bg-slate-200" />
|
<div className="absolute top-0 bottom-0 left-6 w-px bg-slate-200 md:left-9" />
|
||||||
{historyEvents.length === 0 ? (
|
{historyEvents.length === 0 ? (
|
||||||
<div className="ml-20 py-10 text-slate-500">No hay registros.</div>
|
<div className="ml-20 py-10 text-slate-500">No hay registros.</div>
|
||||||
) : (
|
) : (
|
||||||
historyEvents.map((event) => (
|
historyEvents.map((event) => (
|
||||||
<div key={event.id} className="group relative flex gap-6 pb-8">
|
<div
|
||||||
<div className="relative z-10 flex h-18 flex-col items-center">
|
key={event.id}
|
||||||
|
className="group relative flex gap-3 pb-8 md:gap-6"
|
||||||
|
>
|
||||||
|
<div className="relative z-10 flex flex-col items-center">
|
||||||
<div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm transition-colors group-hover:bg-teal-50 group-hover:text-teal-600">
|
<div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm transition-colors group-hover:bg-teal-50 group-hover:text-teal-600">
|
||||||
{event.icon}
|
{event.icon}
|
||||||
</div>
|
</div>
|
||||||
@@ -153,10 +156,10 @@ function RouteComponent() {
|
|||||||
|
|
||||||
<Card className="flex-1 border-slate-200 shadow-none transition-colors hover:border-teal-200">
|
<Card className="flex-1 border-slate-200 shadow-none transition-colors hover:border-teal-200">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-2">
|
||||||
{/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */}
|
{/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col justify-between gap-2 md:flex-row md:items-center">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm font-bold text-slate-800">
|
<span className="text-sm font-bold text-slate-800">
|
||||||
{event.type}
|
{event.type}
|
||||||
</span>
|
</span>
|
||||||
@@ -172,9 +175,9 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grupo de elementos alineados a la derecha */}
|
{/* Grupo de elementos alineados a la derecha */}
|
||||||
<div className="flex items-center gap-4 text-slate-500">
|
<div className="flex flex-wrap items-center gap-3 text-slate-500 md:gap-4">
|
||||||
{/* Usuario e Icono */}
|
{/* Usuario e Icono */}
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<User className="h-3.5 w-3.5" />
|
<User className="h-3.5 w-3.5" />
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{event.user}
|
{event.user}
|
||||||
@@ -184,14 +187,14 @@ function RouteComponent() {
|
|||||||
{/* Botón Ver Cambios */}
|
{/* Botón Ver Cambios */}
|
||||||
<button
|
<button
|
||||||
onClick={() => openCompareModal(event)}
|
onClick={() => openCompareModal(event)}
|
||||||
className="group/btn flex items-center gap-1.5 text-xs transition-colors hover:text-teal-600"
|
className="group/btn flex items-center gap-1.5 text-xs font-medium text-teal-600 md:text-slate-500 md:hover:text-teal-600"
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4 text-slate-400 group-hover/btn:text-teal-600" />
|
<Eye className="h-4 w-4 text-slate-400 group-hover/btn:text-teal-600" />
|
||||||
<span>Ver cambios</span>
|
<span>Ver cambios</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Fecha exacta (Solo visible en desktop para no amontonar) */}
|
{/* Fecha exacta (Solo visible en desktop para no amontonar) */}
|
||||||
<span className="hidden text-[11px] text-slate-400 md:block">
|
<span className="hidden text-[11px] text-slate-400 lg:block">
|
||||||
{format(event.date, 'yyyy-MM-dd HH:mm')}
|
{format(event.date, 'yyyy-MM-dd HH:mm')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,7 +236,7 @@ function RouteComponent() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
{historyEvents.length > 0 && (
|
{historyEvents.length > 0 && (
|
||||||
<div className="mt-10 ml-20 flex items-center justify-between border-t pt-4">
|
<div className="mt-10 ml-12 flex flex-col gap-3 border-t pt-4 md:ml-20 md:flex-row md:items-center md:justify-between">
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">
|
||||||
Mostrando {rawData.length} de {totalRecords} cambios
|
Mostrando {rawData.length} de {totalRecords} cambios
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import {
|
|||||||
useMessagesByChat,
|
useMessagesByChat,
|
||||||
useUpdateConversationStatus,
|
useUpdateConversationStatus,
|
||||||
useUpdateConversationTitle,
|
useUpdateConversationTitle,
|
||||||
|
useUpdatePlanFields,
|
||||||
|
useUpdateRecommendationApplied,
|
||||||
} from '@/data'
|
} from '@/data'
|
||||||
import { usePlan } from '@/data/hooks/usePlans'
|
import { usePlan } from '@/data/hooks/usePlans'
|
||||||
|
|
||||||
@@ -139,6 +141,16 @@ function RouteComponent() {
|
|||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
const [filterQuery, setFilterQuery] = useState('')
|
const [filterQuery, setFilterQuery] = useState('')
|
||||||
|
|
||||||
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
|
||||||
|
const [isActionsOpen, setIsActionsOpen] = useState(false)
|
||||||
|
|
||||||
|
const [selectedImprovements, setSelectedImprovements] = useState<
|
||||||
|
Array<string>
|
||||||
|
>([])
|
||||||
|
const updatePlan = useUpdatePlanFields()
|
||||||
|
const updateAppliedStatus = useUpdateRecommendationApplied()
|
||||||
|
|
||||||
const availableFields = useMemo(() => {
|
const availableFields = useMemo(() => {
|
||||||
const definicion = data?.estructuras_plan
|
const definicion = data?.estructuras_plan
|
||||||
?.definicion as EstructuraDefinicion
|
?.definicion as EstructuraDefinicion
|
||||||
@@ -206,7 +218,97 @@ function RouteComponent() {
|
|||||||
return messages
|
return messages
|
||||||
})
|
})
|
||||||
}, [mensajesDelChat, activeChatId, availableFields])
|
}, [mensajesDelChat, activeChatId, availableFields])
|
||||||
const scrollToBottom = (behavior = 'smooth') => {
|
|
||||||
|
// En tu componente principal (el padre)
|
||||||
|
|
||||||
|
const handleApplyMultiple = async (
|
||||||
|
sugerencias: Array<any>,
|
||||||
|
dbMessageId: string,
|
||||||
|
) => {
|
||||||
|
if (!planId || !data?.datos || sugerencias.length === 0) return
|
||||||
|
|
||||||
|
setIsSending(true)
|
||||||
|
try {
|
||||||
|
const datosActualizados = { ...data.datos }
|
||||||
|
|
||||||
|
for (const sug of sugerencias) {
|
||||||
|
const key = sug.key
|
||||||
|
const newValue = sug.newValue
|
||||||
|
const currentValue = datosActualizados[key]
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof currentValue === 'object' &&
|
||||||
|
currentValue !== null &&
|
||||||
|
'description' in currentValue
|
||||||
|
) {
|
||||||
|
datosActualizados[key] = { ...currentValue, description: newValue }
|
||||||
|
} else {
|
||||||
|
datosActualizados[key] = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updatePlan.mutateAsync({
|
||||||
|
planId: planId as any,
|
||||||
|
patch: { datos: datosActualizados },
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const sug of sugerencias) {
|
||||||
|
try {
|
||||||
|
await updateAppliedStatus.mutateAsync({
|
||||||
|
conversacionId: dbMessageId,
|
||||||
|
campoAfectado: sug.key,
|
||||||
|
})
|
||||||
|
console.log(sug.key)
|
||||||
|
|
||||||
|
removeSelectedField(sug.key)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Error al marcar aplicada la sugerencia: ${sug.key}`,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
// Si una falla, el bucle sigue con las demás
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedImprovements([])
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plan', planId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['conversation-messages'] }),
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error crítico en aplicación masiva:', error)
|
||||||
|
} finally {
|
||||||
|
setIsSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleImprovementSelection = (sugKey: string) => {
|
||||||
|
setSelectedImprovements((prev) =>
|
||||||
|
prev.includes(sugKey)
|
||||||
|
? prev.filter((key) => key !== sugKey)
|
||||||
|
: [...prev, sugKey],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAllFromMessage = (suggestions: Array<any>) => {
|
||||||
|
const pending = suggestions.filter((s) => !s.applied)
|
||||||
|
const allKeys = pending.map((s) => s.key)
|
||||||
|
const allSelected = allKeys.every((key) =>
|
||||||
|
selectedImprovements.includes(key),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedImprovements((prev) =>
|
||||||
|
prev.filter((key) => !allKeys.includes(key)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setSelectedImprovements((prev) =>
|
||||||
|
Array.from(new Set([...prev, ...allKeys])),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
const scrollContainer = scrollRef.current.querySelector(
|
const scrollContainer = scrollRef.current.querySelector(
|
||||||
'[data-radix-scroll-area-viewport]',
|
'[data-radix-scroll-area-viewport]',
|
||||||
@@ -214,7 +316,7 @@ function RouteComponent() {
|
|||||||
if (scrollContainer) {
|
if (scrollContainer) {
|
||||||
scrollContainer.scrollTo({
|
scrollContainer.scrollTo({
|
||||||
top: scrollContainer.scrollHeight,
|
top: scrollContainer.scrollHeight,
|
||||||
behavior: behavior, // 'instant' para carga inicial, 'smooth' para mensajes nuevos
|
behavior,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -474,41 +576,43 @@ function RouteComponent() {
|
|||||||
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
|
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
|
||||||
|
|
||||||
const removeSelectedField = (fieldKey: string) => {
|
const removeSelectedField = (fieldKey: string) => {
|
||||||
|
console.log(fieldKey)
|
||||||
setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey))
|
setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
|
<div className="flex h-[calc(100vh-80px)] w-full flex-col gap-4 pb-1 md:h-[calc(100vh-160px)] md:max-h-[calc(100vh-160px)] md:flex-row md:overflow-hidden">
|
||||||
{/* --- PANEL IZQUIERDO: HISTORIAL --- */}
|
{/* --- HEADER MÓVIL (Solo visible en < md) --- */}
|
||||||
<div className="flex w-64 flex-col border-r pr-4">
|
<div className="flex shrink-0 items-center justify-between rounded-lg border bg-white p-2 shadow-sm md:hidden">
|
||||||
<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
|
<Button
|
||||||
onClick={createNewChat}
|
variant="ghost"
|
||||||
variant="outline"
|
size="sm"
|
||||||
className="mb-4 w-full justify-start gap-2 border-slate-200 hover:bg-teal-50 hover:text-teal-700"
|
onClick={() => setIsHistoryOpen(true)}
|
||||||
>
|
>
|
||||||
<MessageSquarePlus size={18} /> Nuevo chat
|
<Archive size={18} className="mr-2" /> Historial
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsActionsOpen(true)}
|
||||||
|
>
|
||||||
|
<Lightbulb size={18} className="mr-2 text-orange-500" /> Acciones
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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">
|
<ScrollArea className="flex-1">
|
||||||
<div className="space-y-1 pr-2">
|
<div className="space-y-1 pr-2">
|
||||||
{' '}
|
{' '}
|
||||||
@@ -571,7 +675,7 @@ function RouteComponent() {
|
|||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
if (editingChatId === chat.id) {
|
if (editingChatId === chat.id) {
|
||||||
const newTitle =
|
const newTitle =
|
||||||
e.currentTarget.textContent?.trim() || ''
|
e.currentTarget.textContent.trim() || ''
|
||||||
if (newTitle && newTitle !== chat.nombre) {
|
if (newTitle && newTitle !== chat.nombre) {
|
||||||
updateTitleMutation({
|
updateTitleMutation({
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
@@ -655,10 +759,11 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</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:h-full md:flex-[3]">
|
||||||
{/* NUEVO: Barra superior de campos seleccionados */}
|
{/* NUEVO: Barra superior de campos seleccionados */}
|
||||||
<div className="shrink-0 border-b bg-white p-3">
|
<div className="z-10 shrink-0 border-b bg-white p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase">
|
<span className="text-[10px] font-bold text-slate-400 uppercase">
|
||||||
Mejorar con IA
|
Mejorar con IA
|
||||||
@@ -679,9 +784,9 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CONTENIDO DEL CHAT */}
|
{/* CONTENIDO DEL CHAT */}
|
||||||
<div className="relative min-h-0 flex-1">
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||||
<ScrollArea ref={scrollRef} className="h-full w-full">
|
<ScrollArea ref={scrollRef} className="h-full w-full">
|
||||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
|
||||||
{!activeChatId &&
|
{!activeChatId &&
|
||||||
chatMessages.length === 0 &&
|
chatMessages.length === 0 &&
|
||||||
!optimisticMessage ? (
|
!optimisticMessage ? (
|
||||||
@@ -746,9 +851,50 @@ function RouteComponent() {
|
|||||||
|
|
||||||
{/* Recomendaciones */}
|
{/* Recomendaciones */}
|
||||||
{isAI && msg.suggestions?.length > 0 && (
|
{isAI && msg.suggestions?.length > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4 w-full space-y-3 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase">
|
||||||
|
Sugerencias de mejora
|
||||||
|
</span>
|
||||||
|
{/* Botón Seleccionar Todo */}
|
||||||
|
{msg.suggestions.some(
|
||||||
|
(s: any) => !s.applied,
|
||||||
|
) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 px-2 text-[10px] text-teal-600 hover:bg-teal-100"
|
||||||
|
onClick={() =>
|
||||||
|
toggleAllFromMessage(msg.suggestions)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{msg.suggestions
|
||||||
|
.filter((s: any) => !s.applied)
|
||||||
|
.every((s: any) =>
|
||||||
|
selectedImprovements.includes(s.key),
|
||||||
|
)
|
||||||
|
? 'Desmarcar todo'
|
||||||
|
: 'Seleccionar todo'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{msg.suggestions.map((sug: any) => (
|
||||||
|
<div key={sug.key} className="flex gap-2">
|
||||||
|
{!sug.applied && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-4 h-4 w-4 shrink-0 rounded border-slate-300 accent-teal-600"
|
||||||
|
checked={selectedImprovements.includes(
|
||||||
|
sug.key,
|
||||||
|
)}
|
||||||
|
onChange={() =>
|
||||||
|
toggleImprovementSelection(sug.key)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
<ImprovementCard
|
<ImprovementCard
|
||||||
suggestions={msg.suggestions} // Usamos el nombre normalizado en el flatMap
|
suggestions={[sug]} // Pasamos solo una para que la card actúe individualmente
|
||||||
dbMessageId={msg.dbMessageId}
|
dbMessageId={msg.dbMessageId}
|
||||||
planId={planId}
|
planId={planId}
|
||||||
currentDatos={data?.datos}
|
currentDatos={data?.datos}
|
||||||
@@ -758,6 +904,46 @@ function RouteComponent() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Botón de Acción Masiva específico para este mensaje */}
|
||||||
|
{msg.suggestions.some((s: any) =>
|
||||||
|
selectedImprovements.includes(s.key),
|
||||||
|
) && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={isSending}
|
||||||
|
className="w-full bg-teal-600 py-1 text-xs font-bold text-white hover:bg-teal-700"
|
||||||
|
onClick={() => {
|
||||||
|
const seleccionadas =
|
||||||
|
msg.suggestions.filter((s: any) =>
|
||||||
|
selectedImprovements.includes(s.key),
|
||||||
|
)
|
||||||
|
handleApplyMultiple(
|
||||||
|
seleccionadas,
|
||||||
|
msg.dbMessageId,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSending ? (
|
||||||
|
<Loader2
|
||||||
|
className="mr-2 animate-spin"
|
||||||
|
size={12}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Check className="mr-2" size={12} />
|
||||||
|
)}
|
||||||
|
Aplicar seleccionadas (
|
||||||
|
{
|
||||||
|
msg.suggestions.filter((s: any) =>
|
||||||
|
selectedImprovements.includes(s.key),
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -931,12 +1117,13 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 md:flex">
|
||||||
<h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800">
|
<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
|
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 p-1">
|
||||||
{PRESETS.map((preset) => (
|
{PRESETS.map((preset) => (
|
||||||
<button
|
<button
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
@@ -953,6 +1140,65 @@ function RouteComponent() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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}>
|
<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">
|
<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 */}
|
{/* Cabecera más compacta */}
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ function DatosGeneralesPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in container mx-auto px-6 py-6 duration-500">
|
<div className="animate-in fade-in duration-500">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-foreground text-lg font-semibold">
|
<h2 className="text-foreground text-lg font-semibold">
|
||||||
Datos Generales del Plan
|
Datos Generales del Plan
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
|
||||||
/* eslint-disable jsx-a11y/label-has-associated-control */
|
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import {
|
import { Plus, AlertTriangle, Trash2, Download } from 'lucide-react'
|
||||||
Plus,
|
import * as Icons from 'lucide-react'
|
||||||
ChevronDown,
|
|
||||||
AlertTriangle,
|
|
||||||
GripVertical,
|
|
||||||
Trash2,
|
|
||||||
Pencil,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useMemo, useState, useEffect, Fragment } from 'react'
|
import { useMemo, useState, useEffect, Fragment } from 'react'
|
||||||
|
|
||||||
import type { TipoAsignatura } from '@/data'
|
import type { TipoAsignatura } from '@/data'
|
||||||
@@ -23,12 +16,6 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -37,6 +24,12 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
import {
|
import {
|
||||||
useCreateLinea,
|
useCreateLinea,
|
||||||
useDeleteLinea,
|
useDeleteLinea,
|
||||||
@@ -48,14 +41,25 @@ import {
|
|||||||
} from '@/data'
|
} from '@/data'
|
||||||
|
|
||||||
// --- 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],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,19 +85,6 @@ const mapAsignaturasToAsignaturas = (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const lineColors = [
|
|
||||||
'bg-blue-50 border-blue-200 text-blue-700',
|
|
||||||
'bg-purple-50 border-purple-200 text-purple-700',
|
|
||||||
'bg-orange-50 border-orange-200 text-orange-700',
|
|
||||||
'bg-emerald-50 border-emerald-200 text-emerald-700',
|
|
||||||
]
|
|
||||||
|
|
||||||
const statusBadge: Record<string, string> = {
|
|
||||||
borrador: 'bg-slate-100 text-slate-600',
|
|
||||||
revisada: 'bg-amber-100 text-amber-700',
|
|
||||||
aprobada: 'bg-emerald-100 text-emerald-700',
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Subcomponentes ---
|
// --- Subcomponentes ---
|
||||||
function StatItem({
|
function StatItem({
|
||||||
label,
|
label,
|
||||||
@@ -105,14 +96,16 @@ function StatItem({
|
|||||||
total?: number
|
total?: number
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="border-border/70 bg-background/75 rounded-xl border p-3">
|
||||||
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
|
<span className="text-muted-foreground block text-[11px] font-semibold tracking-wide uppercase">
|
||||||
{label}:
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-bold text-slate-700">
|
<span className="text-foreground mt-1 block text-lg leading-none font-bold">
|
||||||
{value}
|
{value}
|
||||||
{total ? (
|
{total ? (
|
||||||
<span className="font-normal text-slate-400">/{total}</span>
|
<span className="text-muted-foreground ml-1 text-sm font-medium">
|
||||||
|
/{total}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
''
|
''
|
||||||
)}
|
)}
|
||||||
@@ -121,52 +114,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({
|
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]
|
||||||
|
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-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
|
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] || ''}`}
|
|
||||||
>
|
|
||||||
{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"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 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="right">
|
||||||
|
<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]"
|
||||||
|
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>
|
</div>
|
||||||
</button>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,9 +330,8 @@ export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({
|
|||||||
function MapaCurricularPage() {
|
function MapaCurricularPage() {
|
||||||
const { planId } = Route.useParams() // Idealmente usa el ID de la ruta
|
const { planId } = Route.useParams() // Idealmente usa el ID de la ruta
|
||||||
const { data } = usePlan(planId)
|
const { data } = usePlan(planId)
|
||||||
const [ciclo, setCiclo] = useState(0)
|
const [totalCiclos, setTotalCiclos] = useState(0)
|
||||||
const [editingLineaId, setEditingLineaId] = useState<string | null>(null)
|
const [editingLineaId, setEditingLineaId] = useState<string | null>(null)
|
||||||
const [tempNombreLinea, setTempNombreLinea] = useState('')
|
|
||||||
const { mutate: createLinea } = useCreateLinea()
|
const { mutate: createLinea } = useCreateLinea()
|
||||||
const { mutate: updateLineaApi } = useUpdateLinea()
|
const { mutate: updateLineaApi } = useUpdateLinea()
|
||||||
const { mutate: deleteLineaApi } = useDeleteLinea()
|
const { mutate: deleteLineaApi } = useDeleteLinea()
|
||||||
@@ -194,11 +346,10 @@ function MapaCurricularPage() {
|
|||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||||
const [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado
|
const [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado
|
||||||
const { mutate: updateAsignatura } = useUpdateAsignatura()
|
const { mutate: updateAsignatura } = useUpdateAsignatura()
|
||||||
const [seriacionValue, setSeriacionValue] = useState<string>('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.numero_ciclos) {
|
if (data?.numero_ciclos) {
|
||||||
setCiclo(data.numero_ciclos)
|
setTotalCiclos(data.numero_ciclos)
|
||||||
}
|
}
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
@@ -244,11 +395,8 @@ function MapaCurricularPage() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const guardarEdicionLinea = (id: string, nuevoNombre?: string) => {
|
const guardarEdicionLinea = (id: string, nuevoNombre: string) => {
|
||||||
// Usamos el nombre que viene por parámetro o el del estado como fallback
|
const nombreAFijar = nuevoNombre.trim()
|
||||||
const nombreAFijar = (
|
|
||||||
nuevoNombre !== undefined ? nuevoNombre : tempNombreLinea
|
|
||||||
).trim()
|
|
||||||
|
|
||||||
if (!nombreAFijar) {
|
if (!nombreAFijar) {
|
||||||
setEditingLineaId(null)
|
setEditingLineaId(null)
|
||||||
@@ -268,7 +416,6 @@ function MapaCurricularPage() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
setEditingLineaId(null)
|
setEditingLineaId(null)
|
||||||
setTempNombreLinea('')
|
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error('Error al actualizar linea:', err)
|
console.error('Error al actualizar linea:', err)
|
||||||
@@ -294,7 +441,7 @@ function MapaCurricularPage() {
|
|||||||
if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi))
|
if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi))
|
||||||
}, [lineasApi])
|
}, [lineasApi])
|
||||||
|
|
||||||
const ciclosTotales = Number(ciclo)
|
const ciclosTotales = Number(totalCiclos)
|
||||||
const ciclosArray = Array.from({ length: ciclosTotales }, (_, i) => i + 1)
|
const ciclosArray = Array.from({ length: ciclosTotales }, (_, i) => i + 1)
|
||||||
const [editingData, setEditingData] = useState<Asignatura | null>(null)
|
const [editingData, setEditingData] = useState<Asignatura | null>(null)
|
||||||
const handleIntegerChange = (value: string) => {
|
const handleIntegerChange = (value: string) => {
|
||||||
@@ -367,6 +514,7 @@ function MapaCurricularPage() {
|
|||||||
const unassignedAsignaturas = asignaturas.filter(
|
const unassignedAsignaturas = asignaturas.filter(
|
||||||
(m) => m.ciclo === null || m.lineaCurricularId === null,
|
(m) => m.ciclo === null || m.lineaCurricularId === null,
|
||||||
)
|
)
|
||||||
|
const unassignedCount = unassignedAsignaturas.length
|
||||||
|
|
||||||
const borrarLinea = (id: string) => {
|
const borrarLinea = (id: string) => {
|
||||||
if (
|
if (
|
||||||
@@ -397,9 +545,9 @@ function MapaCurricularPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Selectores/Cálculos ---
|
// --- Selectores/Cálculos ---
|
||||||
const getTotalesCiclo = (ciclo: number) => {
|
const getTotalesCiclo = (cicloNumero: number) => {
|
||||||
return asignaturas
|
return asignaturas
|
||||||
.filter((m) => m.ciclo === ciclo)
|
.filter((m) => m.ciclo === cicloNumero)
|
||||||
.reduce(
|
.reduce(
|
||||||
(acc, m) => ({
|
(acc, m) => ({
|
||||||
cr: acc.cr + (m.creditos || 0),
|
cr: acc.cr + (m.creditos || 0),
|
||||||
@@ -430,7 +578,7 @@ function MapaCurricularPage() {
|
|||||||
const handleDragOver = (e: React.DragEvent) => e.preventDefault()
|
const handleDragOver = (e: React.DragEvent) => e.preventDefault()
|
||||||
const handleDrop = (
|
const handleDrop = (
|
||||||
e: React.DragEvent,
|
e: React.DragEvent,
|
||||||
ciclo: number | null,
|
cicloDestino: number | null,
|
||||||
lineaId: string | null,
|
lineaId: string | null,
|
||||||
) => {
|
) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -439,12 +587,12 @@ function MapaCurricularPage() {
|
|||||||
setAsignaturas((prev) =>
|
setAsignaturas((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === draggedAsignatura
|
m.id === draggedAsignatura
|
||||||
? { ...m, ciclo, lineaCurricularId: lineaId }
|
? { ...m, ciclo: cicloDestino, lineaCurricularId: lineaId }
|
||||||
: m,
|
: m,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const patch = {
|
const patch = {
|
||||||
numero_ciclo: ciclo,
|
numero_ciclo: cicloDestino,
|
||||||
linea_plan_id: lineaId,
|
linea_plan_id: lineaId,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,109 +626,98 @@ function MapaCurricularPage() {
|
|||||||
[asignaturas],
|
[asignaturas],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleKeyDownLinea = (
|
const confirmarEdicionLinea = (id: string, nuevoNombreRaw: string) => {
|
||||||
e: React.KeyboardEvent<HTMLSpanElement>,
|
const nuevoNombre = nuevoNombreRaw.trim()
|
||||||
id: string,
|
|
||||||
) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
e.currentTarget.blur()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBlurLinea = (
|
|
||||||
e: React.FocusEvent<HTMLSpanElement>,
|
|
||||||
id: string,
|
|
||||||
) => {
|
|
||||||
const nuevoNombre = e.currentTarget.textContent.trim() || ''
|
|
||||||
|
|
||||||
// Buscamos la línea original para comparar
|
|
||||||
const lineaOriginal = lineas.find((l) => l.id === id)
|
const lineaOriginal = lineas.find((l) => l.id === id)
|
||||||
|
|
||||||
if (nuevoNombre !== lineaOriginal?.nombre) {
|
if (!nuevoNombre) {
|
||||||
// IMPORTANTE: Pasamos nuevoNombre directamente
|
|
||||||
guardarEdicionLinea(id, nuevoNombre)
|
|
||||||
} else {
|
|
||||||
setEditingLineaId(null)
|
setEditingLineaId(null)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nuevoNombre !== lineaOriginal?.nombre) {
|
||||||
|
guardarEdicionLinea(id, nuevoNombre)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingLineaId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadingAsig || loadingLineas)
|
if (loadingAsig || loadingLineas)
|
||||||
return <div className="p-10 text-center">Cargando mapa curricular...</div>
|
return <div className="p-10 text-center">Cargando mapa curricular...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-2 py-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="border-border bg-card/70 rounded-2xl border p-4 shadow-sm">
|
||||||
<div>
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-start">
|
||||||
<h2 className="text-xl font-bold">Mapa Curricular</h2>
|
<div className="space-y-1.5">
|
||||||
<p className="text-sm text-slate-500">
|
<h2 className="text-2xl font-bold">Mapa Curricular</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
Organiza las asignaturas de la petición por línea y ciclo
|
Organiza las asignaturas de la petición por línea y ciclo
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
{unassignedCount > 0 && (
|
||||||
<Button className="bg-teal-700 text-white hover:bg-teal-800">
|
<Badge className="border-border bg-accent/50 text-accent-foreground hover:bg-accent/50 mt-2 inline-flex">
|
||||||
<Plus size={16} className="mr-2" /> Exportar{' '}
|
<AlertTriangle size={14} className="mr-1" />
|
||||||
</Button>
|
{unassignedCount} sin asignar
|
||||||
{asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId).length >
|
|
||||||
0 && (
|
|
||||||
<Badge className="border-amber-100 bg-amber-50 text-amber-600 hover:bg-amber-50">
|
|
||||||
<AlertTriangle size={14} className="mr-1" />{' '}
|
|
||||||
{
|
|
||||||
asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId)
|
|
||||||
.length
|
|
||||||
}{' '}
|
|
||||||
sin asignar
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<DropdownMenu>
|
</div>
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button className="bg-teal-700 text-white hover:bg-teal-800">
|
<div className="flex items-center justify-start gap-2 lg:justify-end">
|
||||||
<Plus size={16} className="mr-2" /> Agregar{' '}
|
<Button variant="outline" className="gap-2">
|
||||||
<ChevronDown size={14} className="ml-2" />
|
<Download size={16} /> Exportar
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{!tieneAreaComun && (
|
{!tieneAreaComun && (
|
||||||
<>
|
<Button
|
||||||
<DropdownMenuItem
|
variant="outline"
|
||||||
|
className="text-primary border-primary/30 hover:bg-primary/8"
|
||||||
onClick={() => manejarAgregarLinea('Área Común')}
|
onClick={() => manejarAgregarLinea('Área Común')}
|
||||||
className="font-bold text-teal-700"
|
|
||||||
>
|
>
|
||||||
+ Agregar Área Común
|
+ Área Común
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
<div className="my-1 border-t border-slate-100" />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{/* Input para nombre personalizado */}
|
</div>
|
||||||
<div className="p-2">
|
|
||||||
<label className="text-[10px] font-bold text-slate-400 uppercase">
|
<div className="border-border/70 bg-background/70 rounded-xl border p-3 lg:col-span-2">
|
||||||
Nombre de Línea
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase">
|
||||||
|
Nueva Línea Curricular
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 flex gap-1">
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Crea una línea personalizada sin abrir menús adicionales.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full gap-2 sm:w-auto sm:min-w-90">
|
||||||
<Input
|
<Input
|
||||||
value={nombreNuevaLinea}
|
value={nombreNuevaLinea}
|
||||||
onChange={(e) => setNombreNuevaLinea(e.target.value)}
|
onChange={(e) => setNombreNuevaLinea(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && nombreNuevaLinea.trim()) {
|
||||||
|
manejarAgregarLinea(nombreNuevaLinea)
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder="Ej: Optativas"
|
placeholder="Ej: Optativas"
|
||||||
className="h-8 text-xs"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
className="h-9 gap-1.5"
|
||||||
className="h-8 px-2"
|
|
||||||
onClick={() => manejarAgregarLinea(nombreNuevaLinea)}
|
onClick={() => manejarAgregarLinea(nombreNuevaLinea)}
|
||||||
disabled={!nombreNuevaLinea.trim()}
|
disabled={!nombreNuevaLinea.trim()}
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} /> Agregar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuContent>
|
</div>
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Barra Totales */}
|
{/* Barra Totales */}
|
||||||
<div className="mb-8 flex gap-10 rounded-xl border border-slate-200 bg-slate-50/80 p-4">
|
<div className="border-border bg-card/60 mb-8 grid grid-cols-2 gap-3 rounded-2xl border p-3 shadow-sm md:grid-cols-4">
|
||||||
<StatItem label="Total Créditos" value={stats.cr} total={320} />
|
<StatItem label="Total Créditos" value={stats.cr} total={320} />
|
||||||
<StatItem label="Total HD" value={stats.hd} />
|
<StatItem label="Total HD" value={stats.hd} />
|
||||||
<StatItem label="Total HI" value={stats.hi} />
|
<StatItem label="Total HI" value={stats.hi} />
|
||||||
@@ -588,95 +725,117 @@ function MapaCurricularPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto pb-6">
|
<div className="overflow-x-auto pb-6">
|
||||||
<div className="min-w-[1500px]">
|
|
||||||
<div
|
<div
|
||||||
className="grid gap-3"
|
className="grid gap-3"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `220px repeat(${ciclosTotales}, minmax(auto, 1fr)) 120px`,
|
gridTemplateColumns: `140px repeat(${ciclosTotales}, minmax(auto, 1fr)) 120px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="self-end px-2 text-xs font-bold text-slate-400">
|
<div className="text-muted-foreground self-end px-2 text-xs font-bold">
|
||||||
LÍNEA CURRICULAR
|
LÍNEA CURRICULAR
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ciclosArray.map((n) => (
|
{ciclosArray.map((n) => (
|
||||||
<div
|
<div
|
||||||
key={`header-${n}`}
|
key={`header-${n}`}
|
||||||
className="rounded-lg bg-slate-100 p-2 text-center text-sm font-bold text-slate-600"
|
className="bg-muted/70 text-muted-foreground border-border/70 rounded-xl border p-2 text-center text-sm font-bold"
|
||||||
>
|
>
|
||||||
Ciclo {n}
|
Ciclo {n}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="self-end text-center text-xs font-bold text-slate-400">
|
<div className="text-muted-foreground self-end text-center text-xs font-bold">
|
||||||
SUBTOTAL
|
SUBTOTAL
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{lineas.map((linea, idx) => {
|
{lineas.map((linea) => {
|
||||||
const sub = getSubtotalLinea(linea.id)
|
const sub = getSubtotalLinea(linea.id)
|
||||||
|
|
||||||
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-start justify-between gap-2 rounded-xl border p-3 transition-all ${editingLineaId === linea.id ? 'ring-primary/30 ring-2' : 'cursor-text'}`}
|
||||||
lineColors[idx % lineColors.length]
|
style={{
|
||||||
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`}
|
borderColor: hexToRgba(linea.color || '#1976d2', 0.24),
|
||||||
|
backgroundColor:
|
||||||
|
editingLineaId === linea.id
|
||||||
|
? hexToRgba(linea.color || '#1976d2', 0.12)
|
||||||
|
: hexToRgba(linea.color || '#1976d2', 0.08),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="min-w-0 flex-1">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<span
|
<span
|
||||||
contentEditable={editingLineaId === linea.id}
|
contentEditable
|
||||||
|
role="textbox"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Nombre de línea ${linea.nombre}`}
|
||||||
suppressContentEditableWarning
|
suppressContentEditableWarning
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
onKeyDown={(e) => handleKeyDownLinea(e, linea.id)}
|
onFocus={() => setEditingLineaId(linea.id)}
|
||||||
onBlur={(e) => handleBlurLinea(e, linea.id)}
|
onKeyDown={(e) => {
|
||||||
onClick={() => {
|
if (e.key === 'Enter') {
|
||||||
if (editingLineaId !== linea.id) {
|
e.preventDefault()
|
||||||
setEditingLineaId(linea.id)
|
e.currentTarget.blur()
|
||||||
setTempNombreLinea(linea.nombre)
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.currentTarget.textContent = linea.nombre
|
||||||
|
e.currentTarget.blur()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full text-xs font-bold break-words outline-none ${
|
onBlur={(e) =>
|
||||||
editingLineaId === linea.id
|
confirmarEdicionLinea(
|
||||||
? 'cursor-text border-b border-teal-500/50 pb-1'
|
linea.id,
|
||||||
: 'cursor-pointer'
|
e.currentTarget.textContent,
|
||||||
}`}
|
)
|
||||||
|
}
|
||||||
|
className="text-foreground hover:text-foreground/85 block w-full cursor-text text-sm leading-snug wrap-break-word transition-colors outline-none"
|
||||||
>
|
>
|
||||||
{linea.nombre}
|
{linea.nombre}
|
||||||
</span>
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs text-sm">
|
||||||
|
{linea.nombre}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="ml-1 flex shrink-0 items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingLineaId(linea.id)}
|
type="button"
|
||||||
className="..."
|
aria-label="Eliminar línea"
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
<Pencil size={12} />{' '}
|
|
||||||
</button>
|
|
||||||
<Trash2
|
|
||||||
onClick={() => borrarLinea(linea.id)}
|
onClick={() => borrarLinea(linea.id)}
|
||||||
className="..."
|
className="text-destructive/80 hover:text-destructive hover:bg-destructive/10 inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors"
|
||||||
size={14}
|
>
|
||||||
/>
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ciclosArray.map((ciclo) => (
|
{ciclosArray.map((cicloNumero) => (
|
||||||
<div
|
<div
|
||||||
key={`${linea.id}-${ciclo}`}
|
key={`${linea.id}-${cicloNumero}`}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={(e) => handleDrop(e, ciclo, linea.id)}
|
onDrop={(e) => handleDrop(e, cicloNumero, linea.id)}
|
||||||
className="min-h-[140px] space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 p-2"
|
className={`min-h-35 space-y-2 rounded-xl border border-dashed p-1.5 transition-colors ${
|
||||||
|
draggedAsignatura
|
||||||
|
? 'border-primary/35 bg-primary/6'
|
||||||
|
: 'border-border/70 bg-muted/15'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{asignaturas
|
{asignaturas
|
||||||
.filter(
|
.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.ciclo === ciclo &&
|
m.ciclo === cicloNumero &&
|
||||||
m.lineaCurricularId === linea.id,
|
m.lineaCurricularId === linea.id,
|
||||||
)
|
)
|
||||||
.map((m) => (
|
.map((m) => (
|
||||||
<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={() => {
|
||||||
@@ -688,84 +847,148 @@ function MapaCurricularPage() {
|
|||||||
</div>
|
</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
|
||||||
<div>Cr: {sub.cr}</div>
|
className={`flex flex-col justify-center rounded-xl border p-4 text-[11px] font-medium ${
|
||||||
<div>HD: {sub.hd}</div>
|
sub.cr === 0 && sub.hd === 0 && sub.hi === 0
|
||||||
<div>HI: {sub.hi}</div>
|
? 'border-border/50 bg-muted/20 text-muted-foreground/70'
|
||||||
|
: 'border-border bg-card text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sub.cr === 0 && sub.hd === 0 && sub.hi === 0 ? (
|
||||||
|
<div className="text-muted-foreground">—</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-foreground font-bold">
|
||||||
|
Cr: {sub.cr}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
HD: {sub.hd} • HI: {sub.hi}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div className="col-span-full my-2 border-t border-slate-200"></div>
|
<div className="border-border col-span-full my-2 border-t"></div>
|
||||||
|
|
||||||
<div className="self-center p-2 font-bold text-slate-600">
|
<div className="text-foreground self-center p-2 font-bold">
|
||||||
Totales por Ciclo
|
Totales por Ciclo
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ciclosArray.map((ciclo) => {
|
{ciclosArray.map((cicloNumero) => {
|
||||||
const t = getTotalesCiclo(ciclo)
|
const t = getTotalesCiclo(cicloNumero)
|
||||||
|
const isEmpty = t.cr === 0 && t.hd === 0 && t.hi === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`footer-${ciclo}`}
|
key={`footer-${cicloNumero}`}
|
||||||
className="rounded-lg bg-slate-50 p-2 text-center text-[10px]"
|
className={`rounded-xl border p-2 text-center text-[11px] ${
|
||||||
|
isEmpty
|
||||||
|
? 'border-border/50 bg-muted/30 text-muted-foreground'
|
||||||
|
: 'border-border bg-card'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-bold text-slate-700">Cr: {t.cr}</div>
|
{isEmpty ? (
|
||||||
|
<div className="text-muted-foreground py-1 text-xs">—</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-foreground font-bold">Cr: {t.cr}</div>
|
||||||
<div>
|
<div>
|
||||||
HD: {t.hd} • HI: {t.hi}
|
HD: {t.hd} • HI: {t.hi}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</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 className="text-primary-foreground border-primary/40 bg-primary flex flex-col justify-center rounded-xl border p-2 text-center text-xs font-bold shadow-sm">
|
||||||
<div>{stats.cr} Cr</div>
|
<div>{stats.cr} Cr</div>
|
||||||
<div>{stats.hd + stats.hi} Hrs</div>
|
<div>{stats.hd + stats.hi} Hrs</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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="border-border bg-card/80 mt-12 rounded-[28px] border 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="bg-muted text-muted-foreground flex h-9 w-9 items-center justify-center rounded-2xl">
|
||||||
</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-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>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
</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-3xl border-2 border-dashed p-4 transition-all duration-300',
|
||||||
|
'min-h-55',
|
||||||
|
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-fit 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="border-border/70 bg-background/70 flex min-h-47 flex-col items-center justify-center rounded-[20px] border px-6 text-center">
|
||||||
desasignarla.
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -774,11 +997,11 @@ function MapaCurricularPage() {
|
|||||||
{/* Modal de Edición */}
|
{/* Modal de Edición */}
|
||||||
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="sm:max-w-[550px]"
|
className="sm:max-w-137.5"
|
||||||
onInteractOutside={(e) => e.preventDefault()}
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="font-bold text-slate-700">
|
<DialogTitle className="text-foreground font-bold">
|
||||||
Editar Asignatura
|
Editar Asignatura
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -976,7 +1199,7 @@ function MapaCurricularPage() {
|
|||||||
)
|
)
|
||||||
.map((asig) => (
|
.map((asig) => (
|
||||||
<SelectItem key={asig.id} value={asig.id}>
|
<SelectItem key={asig.id} value={asig.id}>
|
||||||
<span className="font-bold text-teal-600">
|
<span className="text-primary font-bold">
|
||||||
[C{asig.ciclo}]
|
[C{asig.ciclo}]
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
{asig.nombre}
|
{asig.nombre}
|
||||||
@@ -1016,12 +1239,7 @@ function MapaCurricularPage() {
|
|||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={handleSaveChanges}>Guardar</Button>
|
||||||
className="bg-teal-700 text-white"
|
|
||||||
onClick={handleSaveChanges}
|
|
||||||
>
|
|
||||||
Guardar
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ function AsignaturaLayout() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<section className="bg-linear-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
|
<section className="bg-linear-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
|
||||||
<div className="mx-auto max-w-7xl px-6 py-10">
|
<div className="mx-auto max-w-400 p-4 py-10 md:px-6 lg:px-8">
|
||||||
<Link
|
<Link
|
||||||
to="/planes/$planId/asignaturas"
|
to="/planes/$planId/asignaturas"
|
||||||
params={{ planId }}
|
params={{ planId }}
|
||||||
@@ -215,9 +215,14 @@ function AsignaturaLayout() {
|
|||||||
|
|
||||||
{/* TABS */}
|
{/* TABS */}
|
||||||
|
|
||||||
<nav className="border-b bg-white">
|
<nav className="sticky top-0 z-20 border-b bg-white">
|
||||||
<div className="mx-auto max-w-7xl px-6">
|
<div className="mx-auto max-w-400 p-4 py-2 md:px-6 lg:px-8">
|
||||||
<div className="flex justify-center gap-8">
|
{/* CAMBIOS CLAVE:
|
||||||
|
1. overflow-x-auto: Permite scroll horizontal.
|
||||||
|
2. scrollbar-hide: (Opcional) para que no se vea la barra fea.
|
||||||
|
3. justify-start md:justify-center: Alineado a la izquierda en móvil para que el scroll funcione, centrado en desktop.
|
||||||
|
*/}
|
||||||
|
<div className="no-scrollbar flex items-center justify-start gap-8 overflow-x-auto whitespace-nowrap md:justify-start">
|
||||||
{[
|
{[
|
||||||
{ label: 'Datos', to: '' },
|
{ label: 'Datos', to: '' },
|
||||||
{ label: 'Contenido', to: 'contenido' },
|
{ label: 'Contenido', to: 'contenido' },
|
||||||
@@ -241,7 +246,7 @@ function AsignaturaLayout() {
|
|||||||
}
|
}
|
||||||
from="/planes/$planId/asignaturas/$asignaturaId"
|
from="/planes/$planId/asignaturas/$asignaturaId"
|
||||||
params={{ planId, asignaturaId }}
|
params={{ planId, asignaturaId }}
|
||||||
className={`border-b-2 py-3 text-sm font-medium ${
|
className={`shrink-0 border-b-2 py-4 text-sm font-medium transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'border-blue-600 text-blue-600'
|
? 'border-blue-600 text-blue-600'
|
||||||
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700'
|
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700'
|
||||||
@@ -255,7 +260,7 @@ function AsignaturaLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
<div className="mx-auto max-w-400 p-4 py-8 md:px-6 lg:px-8">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,13 +101,20 @@ function RouteComponent() {
|
|||||||
setPage(0) // Resetear página al buscar
|
setPage(0) // Resetear página al buscar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deshabilitar el botón 'Limpiar' si no hay filtros distintos al valor por defecto
|
||||||
|
const isClearDisabled =
|
||||||
|
cleanSearchTerm === '' &&
|
||||||
|
facultadSel === 'todas' &&
|
||||||
|
carreraSel === 'todas' &&
|
||||||
|
estadoSel === 'todos'
|
||||||
|
|
||||||
// Renderizado condicional básico
|
// Renderizado condicional básico
|
||||||
if (isError)
|
if (isError)
|
||||||
return <div className="p-8 text-red-500">Error cargando planes.</div>
|
return <div className="p-8 text-red-500">Error cargando planes.</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-background min-h-screen w-full">
|
<main className="bg-background min-h-screen w-full">
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
|
<div className="mx-auto flex w-full max-w-400 flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
|
||||||
<div className="flex flex-col gap-4 lg:col-span-3">
|
<div className="flex flex-col gap-4 lg:col-span-3">
|
||||||
{/* Header y Botón Nuevo */}
|
{/* Header y Botón Nuevo */}
|
||||||
<div className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center">
|
<div className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center">
|
||||||
@@ -184,7 +191,10 @@ function RouteComponent() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={resetFilters}
|
onClick={resetFilters}
|
||||||
className="ring-offset-background bg-secondary text-secondary-foreground hover:bg-secondary/90 inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium shadow-md transition-colors"
|
disabled={isClearDisabled}
|
||||||
|
className={`ring-offset-background bg-secondary text-secondary-foreground hover:bg-secondary/90 inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium shadow-md transition-colors ${
|
||||||
|
isClearDisabled ? 'opacity-50 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Icons.X className="h-4 w-4" /> Limpiar
|
<Icons.X className="h-4 w-4" /> Limpiar
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+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;
|
||||||
|
|||||||
+50
-1
@@ -154,9 +154,10 @@ 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
|
||||||
|
search_vector: unknown
|
||||||
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
|
||||||
prerrequisito_asignatura_id?: string
|
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
actualizado_en?: string
|
actualizado_en?: string
|
||||||
@@ -180,6 +181,8 @@ 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
|
||||||
|
search_vector?: unknown
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -205,6 +208,8 @@ 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
|
||||||
|
search_vector?: unknown
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -258,6 +263,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: {
|
||||||
@@ -1377,6 +1396,36 @@ export type Database = {
|
|||||||
Args: { p_append: Json; p_id: string }
|
Args: { p_append: Json; p_id: string }
|
||||||
Returns: undefined
|
Returns: undefined
|
||||||
}
|
}
|
||||||
|
build_asignaturas_prefix_tsquery: {
|
||||||
|
Args: { p_search: string }
|
||||||
|
Returns: unknown
|
||||||
|
}
|
||||||
|
recalcular_vectores_asignaturas: { Args: never; Returns: undefined }
|
||||||
|
search_asignaturas: {
|
||||||
|
Args: {
|
||||||
|
p_carrera_id?: string
|
||||||
|
p_facultad_id?: string
|
||||||
|
p_limit?: number
|
||||||
|
p_offset?: number
|
||||||
|
p_plan_estudio_id?: string
|
||||||
|
p_search?: string
|
||||||
|
}
|
||||||
|
Returns: Array<{
|
||||||
|
codigo: string
|
||||||
|
contenido_tematico: Json
|
||||||
|
creditos: number
|
||||||
|
datos: Json
|
||||||
|
estado: Database['public']['Enums']['estado_asignatura']
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
numero_ciclo: number
|
||||||
|
plan_estudio_id: string
|
||||||
|
rank: number
|
||||||
|
tipo: Database['public']['Enums']['tipo_asignatura']
|
||||||
|
total_count: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user