Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fd816bfa1 |
@@ -7,6 +7,13 @@ import type { AsignaturaDetail } from '@/data'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -14,8 +21,8 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
|
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
|
||||||
@@ -39,10 +46,6 @@ 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
|
||||||
@@ -64,8 +67,12 @@ export default function AsignaturaDetailPage() {
|
|||||||
const { asignaturaId } = useParams({
|
const { asignaturaId } = useParams({
|
||||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
})
|
})
|
||||||
|
const { planId } = useParams({
|
||||||
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
|
})
|
||||||
const { data: asignaturaApi } = useSubject(asignaturaId)
|
const { data: asignaturaApi } = useSubject(asignaturaId)
|
||||||
|
const { data: asignaturasApi, isLoading: loadingAsig } =
|
||||||
|
usePlanAsignaturas(planId)
|
||||||
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
|
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
|
||||||
const updateAsignatura = useUpdateAsignatura()
|
const updateAsignatura = useUpdateAsignatura()
|
||||||
|
|
||||||
@@ -86,16 +93,54 @@ export default function AsignaturaDetailPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const asignaturaSeriada = useMemo(() => {
|
||||||
|
if (!asignaturaApi?.prerrequisito_asignatura_id || !asignaturasApi)
|
||||||
|
return null
|
||||||
|
return asignaturasApi.find(
|
||||||
|
(asig) => asig.id === asignaturaApi.prerrequisito_asignatura_id,
|
||||||
|
)
|
||||||
|
}, [asignaturaApi, asignaturasApi])
|
||||||
|
const requisitosFormateados = useMemo(() => {
|
||||||
|
if (!asignaturaSeriada) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'Pre-requisito',
|
||||||
|
code: asignaturaSeriada.codigo,
|
||||||
|
name: asignaturaSeriada.nombre,
|
||||||
|
id: asignaturaSeriada.id, // Guardamos el ID para el select
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}, [asignaturaSeriada])
|
||||||
|
|
||||||
|
const handleUpdatePrerrequisito = (newId: string | null) => {
|
||||||
|
updateAsignatura.mutate({
|
||||||
|
asignaturaId,
|
||||||
|
patch: {
|
||||||
|
prerrequisito_asignatura_id: newId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
/* ---------- sincronizar API ---------- */
|
/* ---------- sincronizar API ---------- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (asignaturaApi) setAsignatura(asignaturaApi)
|
console.log(requisitosFormateados)
|
||||||
}, [asignaturaApi])
|
|
||||||
|
|
||||||
return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
|
if (asignaturaApi) setAsignatura(asignaturaApi)
|
||||||
|
}, [asignaturaApi, requisitosFormateados])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DatosGenerales
|
||||||
|
pre={requisitosFormateados}
|
||||||
|
availableSubjects={asignaturasApi}
|
||||||
|
onPersistDato={handlePersistDatoGeneral}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DatosGenerales({
|
function DatosGenerales({
|
||||||
onPersistDato,
|
onPersistDato,
|
||||||
|
pre,
|
||||||
|
availableSubjects,
|
||||||
}: {
|
}: {
|
||||||
onPersistDato: (clave: string, value: string) => void
|
onPersistDato: (clave: string, value: string) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -270,18 +315,19 @@ function DatosGenerales({
|
|||||||
<InfoCard
|
<InfoCard
|
||||||
title="Requisitos y Seriación"
|
title="Requisitos y Seriación"
|
||||||
type="requirements"
|
type="requirements"
|
||||||
initialContent={[
|
initialContent={pre}
|
||||||
{
|
// Pasamos las materias del plan para el Select (excluyendo la actual)
|
||||||
type: 'Pre-requisito',
|
availableSubjects={
|
||||||
code: 'PA-301',
|
availableSubjects?.filter((a) => a.id !== asignaturaId) || []
|
||||||
name: 'Programación Avanzada',
|
}
|
||||||
},
|
onPersist={({ value }) => {
|
||||||
{
|
updateAsignatura.mutate({
|
||||||
type: 'Co-requisito',
|
asignaturaId,
|
||||||
code: 'MAT-201',
|
patch: {
|
||||||
name: 'Matemáticas Discretas',
|
prerrequisito_asignatura_id: value, // value ya viene como ID o null desde handleSave
|
||||||
},
|
},
|
||||||
]}
|
})
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Tarjeta de Evaluación */}
|
{/* Tarjeta de Evaluación */}
|
||||||
@@ -321,6 +367,7 @@ interface InfoCardProps {
|
|||||||
containerRef?: React.RefObject<HTMLDivElement | null>
|
containerRef?: React.RefObject<HTMLDivElement | null>
|
||||||
forceEditToken?: number
|
forceEditToken?: number
|
||||||
highlightToken?: number
|
highlightToken?: number
|
||||||
|
availableSubjects?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoCard({
|
function InfoCard({
|
||||||
@@ -337,6 +384,7 @@ function InfoCard({
|
|||||||
containerRef,
|
containerRef,
|
||||||
forceEditToken,
|
forceEditToken,
|
||||||
highlightToken,
|
highlightToken,
|
||||||
|
availableSubjects,
|
||||||
}: InfoCardProps) {
|
}: InfoCardProps) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [isHighlighted, setIsHighlighted] = useState(false)
|
const [isHighlighted, setIsHighlighted] = useState(false)
|
||||||
@@ -354,7 +402,8 @@ function InfoCard({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setData(initialContent)
|
setData(initialContent)
|
||||||
setTempText(initialContent)
|
setTempText(initialContent)
|
||||||
|
console.log(data)
|
||||||
|
console.log(initialContent)
|
||||||
if (type === 'evaluation') {
|
if (type === 'evaluation') {
|
||||||
const raw = Array.isArray(initialContent) ? initialContent : []
|
const raw = Array.isArray(initialContent) ? initialContent : []
|
||||||
const rows: Array<CriterioEvaluacionRowDraft> = raw
|
const rows: Array<CriterioEvaluacionRowDraft> = raw
|
||||||
@@ -397,6 +446,8 @@ function InfoCard({
|
|||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
console.log('clave, valor:', clave, String(tempText ?? ''))
|
console.log('clave, valor:', clave, String(tempText ?? ''))
|
||||||
|
console.log(clave)
|
||||||
|
console.log(tempText)
|
||||||
|
|
||||||
if (type === 'evaluation') {
|
if (type === 'evaluation') {
|
||||||
const cleaned: Array<CriterioEvaluacionRow> = []
|
const cleaned: Array<CriterioEvaluacionRow> = []
|
||||||
@@ -427,6 +478,25 @@ function InfoCard({
|
|||||||
void onPersist?.({ type, clave, value: cleaned })
|
void onPersist?.({ type, clave, value: cleaned })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (type === 'requirements') {
|
||||||
|
console.log('entre aqui ')
|
||||||
|
|
||||||
|
// Si tempText es un array y tiene elementos, tomamos el ID del primero
|
||||||
|
// Si es "none" o está vacío, mandamos null (para limpiar la seriación)
|
||||||
|
const prerequisiteId =
|
||||||
|
Array.isArray(tempText) && tempText.length > 0 ? tempText[0].id : null
|
||||||
|
|
||||||
|
setData(tempText) // Actualiza la vista local
|
||||||
|
setIsEditing(false)
|
||||||
|
|
||||||
|
// Mandamos el ID específico a la base de datos
|
||||||
|
void onPersist?.({
|
||||||
|
type,
|
||||||
|
clave: 'prerrequisito_asignatura_id', // Forzamos la columna correcta
|
||||||
|
value: prerequisiteId,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setData(tempText)
|
setData(tempText)
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
@@ -546,7 +616,52 @@ function InfoCard({
|
|||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{type === 'evaluation' ? (
|
{/* Condicionales de edición según el tipo */}
|
||||||
|
{type === 'requirements' ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-xs font-medium text-slate-500">
|
||||||
|
Materia de Seriación
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={tempText?.[0]?.id || 'none'}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const selected = availableSubjects?.find(
|
||||||
|
(s) => s.id === val,
|
||||||
|
)
|
||||||
|
if (val === 'none' || !selected) {
|
||||||
|
console.log('guardando')
|
||||||
|
|
||||||
|
setTempText([])
|
||||||
|
} else {
|
||||||
|
console.log('hola')
|
||||||
|
|
||||||
|
setTempText([
|
||||||
|
{
|
||||||
|
id: selected.id,
|
||||||
|
type: 'Pre-requisito',
|
||||||
|
code: selected.codigo,
|
||||||
|
name: selected.nombre,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecciona una materia" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">
|
||||||
|
Ninguna (Sin seriación)
|
||||||
|
</SelectItem>
|
||||||
|
{availableSubjects?.map((asig) => (
|
||||||
|
<SelectItem key={asig.id} value={asig.id}>
|
||||||
|
{asig.codigo} - {asig.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : type === 'evaluation' ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{evalRows.map((row) => (
|
{evalRows.map((row) => (
|
||||||
@@ -568,85 +683,36 @@ function InfoCard({
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
value={row.porcentaje}
|
value={row.porcentaje}
|
||||||
placeholder="%"
|
placeholder="%"
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
inputMode="numeric"
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const raw = e.target.value
|
const raw = e.target.value
|
||||||
// Solo permitir '' o dígitos
|
|
||||||
if (raw !== '' && !/^\d+$/.test(raw)) return
|
if (raw !== '' && !/^\d+$/.test(raw)) return
|
||||||
|
|
||||||
if (raw === '') {
|
|
||||||
setEvalRows((prev) =>
|
|
||||||
prev.map((r) =>
|
|
||||||
r.id === row.id
|
|
||||||
? {
|
|
||||||
id: r.id,
|
|
||||||
criterio: r.criterio,
|
|
||||||
porcentaje: '',
|
|
||||||
}
|
|
||||||
: r,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const n = Number(raw)
|
|
||||||
if (!Number.isFinite(n)) return
|
|
||||||
const porcentaje = Math.trunc(n)
|
|
||||||
if (porcentaje < 1 || porcentaje > 100) return
|
|
||||||
|
|
||||||
// No permitir suma > 100
|
|
||||||
setEvalRows((prev) => {
|
setEvalRows((prev) => {
|
||||||
const next = prev.map((r) =>
|
const next = prev.map((r) =>
|
||||||
r.id === row.id
|
r.id === row.id ? { ...r, porcentaje: raw } : r,
|
||||||
? {
|
)
|
||||||
id: r.id,
|
const total = next.reduce(
|
||||||
criterio: r.criterio,
|
(acc, r) => acc + (Number(r.porcentaje) || 0),
|
||||||
porcentaje: raw,
|
0,
|
||||||
}
|
|
||||||
: r,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const total = next.reduce((acc, r) => {
|
|
||||||
const v = String(r.porcentaje).trim()
|
|
||||||
if (!v) return acc
|
|
||||||
const nn = Number(v)
|
|
||||||
if (!Number.isFinite(nn)) return acc
|
|
||||||
const vv = Math.trunc(nn)
|
|
||||||
if (vv < 1 || vv > 100) return acc
|
|
||||||
return acc + vv
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
return total > 100 ? prev : next
|
return total > 100 ? prev : next
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="text-sm text-slate-600">%</div>
|
||||||
<div
|
|
||||||
className="flex w-[1ch] items-center justify-center text-sm text-slate-600"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
%
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-red-600 hover:bg-red-50"
|
className="h-8 w-8 text-red-600 hover:bg-red-50"
|
||||||
onClick={() => {
|
onClick={() =>
|
||||||
setEvalRows((prev) =>
|
setEvalRows((prev) =>
|
||||||
prev.filter((r) => r.id !== row.id),
|
prev.filter((r) => r.id !== row.id),
|
||||||
)
|
)
|
||||||
}}
|
}
|
||||||
aria-label="Quitar renglón"
|
|
||||||
title="Quitar"
|
|
||||||
>
|
>
|
||||||
<Minus className="h-4 w-4" />
|
<Minus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -656,22 +722,15 @@ function InfoCard({
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span
|
<span
|
||||||
className={
|
className={`text-sm ${evaluationTotal === 100 ? 'text-muted-foreground' : 'text-destructive font-semibold'}`}
|
||||||
'text-sm ' +
|
|
||||||
(evaluationTotal === 100
|
|
||||||
? 'text-muted-foreground'
|
|
||||||
: 'text-destructive font-semibold')
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Total: {evaluationTotal}/100
|
Total: {evaluationTotal}/100
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-emerald-700 hover:bg-emerald-50"
|
className="text-emerald-700 hover:bg-emerald-50"
|
||||||
onClick={() => {
|
onClick={() =>
|
||||||
// Agregar una fila vacía (siempre permitido)
|
|
||||||
setEvalRows((prev) => [
|
setEvalRows((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
@@ -680,7 +739,7 @@ function InfoCard({
|
|||||||
porcentaje: '',
|
porcentaje: '',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}}
|
}
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" /> Agregar renglón
|
<Plus className="mr-2 h-4 w-4" /> Agregar renglón
|
||||||
</Button>
|
</Button>
|
||||||
@@ -694,28 +753,15 @@ function InfoCard({
|
|||||||
className="min-h-30 text-sm leading-relaxed"
|
className="min-h-30 text-sm leading-relaxed"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Botones de acción comunes */}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
if (type === 'evaluation') {
|
// Lógica de reset si es necesario...
|
||||||
const raw = Array.isArray(data) ? data : []
|
|
||||||
setEvalRows(
|
|
||||||
raw.map((r: CriterioEvaluacionRow) => ({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
criterio:
|
|
||||||
typeof r.criterio === 'string' ? r.criterio : '',
|
|
||||||
porcentaje:
|
|
||||||
typeof r.porcentaje === 'number'
|
|
||||||
? String(Math.trunc(r.porcentaje))
|
|
||||||
: typeof r.porcentaje === 'string'
|
|
||||||
? String(Math.trunc(Number(r.porcentaje)))
|
|
||||||
: '',
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
@@ -731,6 +777,7 @@ function InfoCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
/* Modo Visualización */
|
||||||
<div className="text-sm leading-relaxed text-slate-600">
|
<div className="text-sm leading-relaxed text-slate-600">
|
||||||
{type === 'text' &&
|
{type === 'text' &&
|
||||||
(data ? (
|
(data ? (
|
||||||
@@ -739,9 +786,7 @@ function InfoCard({
|
|||||||
<p className="text-slate-400 italic">Sin información.</p>
|
<p className="text-slate-400 italic">Sin información.</p>
|
||||||
))}
|
))}
|
||||||
{type === 'requirements' && <RequirementsView items={data} />}
|
{type === 'requirements' && <RequirementsView items={data} />}
|
||||||
{type === 'evaluation' && (
|
{type === 'evaluation' && <EvaluationView items={data} />}
|
||||||
<EvaluationView items={data as Array<CriterioEvaluacionRow>} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -796,3 +841,80 @@ 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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ import { Card } from '@/components/ui/card'
|
|||||||
interface DocumentoSEPTabProps {
|
interface DocumentoSEPTabProps {
|
||||||
pdfUrl: string | null
|
pdfUrl: string | null
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
onDownloadPdf: () => void
|
onDownload: () => void
|
||||||
onDownloadWord: () => void
|
|
||||||
onRegenerate: () => void
|
onRegenerate: () => void
|
||||||
isRegenerating: boolean
|
isRegenerating: boolean
|
||||||
}
|
}
|
||||||
@@ -27,8 +26,7 @@ interface DocumentoSEPTabProps {
|
|||||||
export function DocumentoSEPTab({
|
export function DocumentoSEPTab({
|
||||||
pdfUrl,
|
pdfUrl,
|
||||||
isLoading,
|
isLoading,
|
||||||
onDownloadPdf,
|
onDownload,
|
||||||
onDownloadWord,
|
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
isRegenerating,
|
isRegenerating,
|
||||||
}: DocumentoSEPTabProps) {
|
}: DocumentoSEPTabProps) {
|
||||||
@@ -54,23 +52,25 @@ 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
|
<Button disabled={isRegenerating}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-2"
|
|
||||||
disabled={isRegenerating}
|
|
||||||
>
|
|
||||||
{isRegenerating ? (
|
{isRegenerating ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{isRegenerating ? 'Generando...' : 'Regenerar'}
|
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
|
|
||||||
@@ -91,31 +91,11 @@ 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-200 overflow-hidden">
|
<Card className="h-[800px] 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,86 +1,52 @@
|
|||||||
// document.api.ts
|
// document.api.ts
|
||||||
|
|
||||||
import { supabaseBrowser } from '../supabase/client'
|
const DOCUMENT_PDF_URL =
|
||||||
import { invokeEdge } from '../supabase/invokeEdge'
|
'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434'
|
||||||
|
|
||||||
import { requireData, throwIfError } from './_helpers'
|
const DOCUMENT_PDF_ASIGNATURA_URL =
|
||||||
|
'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> {
|
||||||
return await invokeEdge<Blob>(
|
const response = await fetch(DOCUMENT_PDF_URL, {
|
||||||
EDGE.carbone_io_wrapper,
|
method: 'POST',
|
||||||
{
|
headers: {
|
||||||
action: 'downloadReport',
|
'Content-Type': 'application/json',
|
||||||
plan_estudio_id,
|
|
||||||
body: convertTo ? { convertTo } : {},
|
|
||||||
},
|
},
|
||||||
{
|
body: JSON.stringify({ plan_estudio_id }),
|
||||||
headers: {
|
})
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
if (!response.ok) {
|
||||||
responseType: 'blob',
|
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 supabase = supabaseBrowser()
|
const response = await fetch(DOCUMENT_PDF_ASIGNATURA_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ asignatura_id }),
|
||||||
|
})
|
||||||
|
|
||||||
const { data, error } = await supabase
|
if (!response.ok) {
|
||||||
.from('asignaturas')
|
throw new Error('Error al generar el PDF')
|
||||||
.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>(
|
// n8n devuelve el archivo → lo tratamos como blob
|
||||||
EDGE.carbone_io_wrapper,
|
return await response.blob()
|
||||||
{
|
|
||||||
action: 'downloadReport',
|
|
||||||
asignatura_id,
|
|
||||||
body: {
|
|
||||||
...body,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
responseType: 'blob',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
|
|||||||
.from('asignaturas')
|
.from('asignaturas')
|
||||||
.select(
|
.select(
|
||||||
`
|
`
|
||||||
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,
|
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,prerrequisito_asignatura_id,
|
||||||
planes_estudio(
|
planes_estudio(
|
||||||
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
||||||
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
|
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ 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 {
|
||||||
@@ -27,55 +26,6 @@ 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?:
|
||||||
@@ -92,16 +42,10 @@ export async function invokeEdge<TOut>(
|
|||||||
): Promise<TOut> {
|
): Promise<TOut> {
|
||||||
const supabase = client ?? supabaseBrowser()
|
const supabase = client ?? supabaseBrowser()
|
||||||
|
|
||||||
// Nota: algunas versiones/defs de @supabase/supabase-js no tipan `responseType`
|
const { data, error } = await supabase.functions.invoke(functionName, {
|
||||||
// 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) {
|
||||||
@@ -160,20 +104,5 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseContenidoTematicoToPlainText(value: unknown): string {
|
|
||||||
if (!Array.isArray(value)) return ''
|
|
||||||
|
|
||||||
const blocks: Array<string> = []
|
|
||||||
|
|
||||||
for (const item of value) {
|
|
||||||
if (!isRecord(item)) continue
|
|
||||||
|
|
||||||
const unidad =
|
|
||||||
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
|
|
||||||
? item.unidad
|
|
||||||
: undefined
|
|
||||||
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
|
|
||||||
|
|
||||||
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
|
|
||||||
if (!header) continue
|
|
||||||
|
|
||||||
const lines: Array<string> = [header]
|
|
||||||
|
|
||||||
const temas = Array.isArray(item.temas) ? item.temas : []
|
|
||||||
temas.forEach((tema, idx) => {
|
|
||||||
const temaNombre =
|
|
||||||
typeof tema === 'string'
|
|
||||||
? tema
|
|
||||||
: isRecord(tema) && typeof tema.nombre === 'string'
|
|
||||||
? tema.nombre
|
|
||||||
: ''
|
|
||||||
if (!temaNombre) return
|
|
||||||
|
|
||||||
if (unidad != null) {
|
|
||||||
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
|
|
||||||
} else {
|
|
||||||
lines.push(`${idx + 1}. ${temaNombre}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
blocks.push(lines.join('\n'))
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks.join('\n\n').trimEnd()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseCriteriosEvaluacionToPlainText(value: unknown): string {
|
|
||||||
if (!Array.isArray(value)) return ''
|
|
||||||
|
|
||||||
const lines: Array<string> = []
|
|
||||||
for (const item of value) {
|
|
||||||
if (!isRecord(item)) continue
|
|
||||||
const label = typeof item.criterio === 'string' ? item.criterio.trim() : ''
|
|
||||||
const valueNum =
|
|
||||||
typeof item.porcentaje === 'number'
|
|
||||||
? item.porcentaje
|
|
||||||
: typeof item.porcentaje === 'string'
|
|
||||||
? Number(item.porcentaje)
|
|
||||||
: NaN
|
|
||||||
|
|
||||||
if (!label) continue
|
|
||||||
if (!Number.isFinite(valueNum)) continue
|
|
||||||
|
|
||||||
const v = Math.trunc(valueNum)
|
|
||||||
if (v < 1 || v > 100) continue
|
|
||||||
|
|
||||||
lines.push(`${label}: ${v}%`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const columnParsers: Partial<
|
|
||||||
Record<string, (value: unknown) => string>
|
|
||||||
> = {
|
|
||||||
contenido_tematico: parseContenidoTematicoToPlainText,
|
|
||||||
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
FileJson,
|
FileJson,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, 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'
|
||||||
@@ -21,23 +21,18 @@ 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 [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
||||||
const pdfUrlRef = useRef<string | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
const loadPdfPreview = useCallback(async () => {
|
const loadPdfPreview = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
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)
|
||||||
|
|
||||||
setPdfUrl((prev) => {
|
// Limpiar URL anterior si existe para evitar fugas de memoria
|
||||||
if (prev) window.URL.revokeObjectURL(prev)
|
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
|
||||||
pdfUrlRef.current = url
|
|
||||||
return url
|
setPdfUrl(url)
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando preview:', error)
|
console.error('Error cargando preview:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -48,7 +43,7 @@ function RouteComponent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPdfPreview()
|
loadPdfPreview()
|
||||||
return () => {
|
return () => {
|
||||||
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
|
||||||
}
|
}
|
||||||
}, [loadPdfPreview])
|
}, [loadPdfPreview])
|
||||||
|
|
||||||
@@ -56,7 +51,6 @@ 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)
|
||||||
@@ -73,27 +67,6 @@ 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 = 'plan_estudios.docx'
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
|
|
||||||
link.remove()
|
|
||||||
setTimeout(() => window.URL.revokeObjectURL(url), 1000)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
alert('No se pudo generar el Word')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
|
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
|
||||||
{/* HEADER DE ACCIONES */}
|
{/* HEADER DE ACCIONES */}
|
||||||
@@ -115,17 +88,12 @@ function RouteComponent() {
|
|||||||
>
|
>
|
||||||
<RefreshCcw size={16} /> Regenerar
|
<RefreshCcw size={16} /> Regenerar
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
size="sm"
|
|
||||||
className="gap-2 bg-teal-700 hover:bg-teal-800"
|
|
||||||
onClick={handleDownloadWord}
|
|
||||||
>
|
|
||||||
<Download size={16} /> Descargar Word
|
<Download size={16} /> Descargar Word
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-2"
|
className="gap-2 bg-teal-700 hover:bg-teal-800"
|
||||||
onClick={handleDownloadPdf}
|
onClick={handleDownloadPdf}
|
||||||
>
|
>
|
||||||
<Download size={16} /> Descargar PDF
|
<Download size={16} /> Descargar PDF
|
||||||
@@ -171,7 +139,7 @@ function RouteComponent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent className="flex min-h-200 justify-center bg-slate-500 p-0">
|
<CardContent className="flex min-h-[800px] 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" />
|
||||||
@@ -181,7 +149,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-250 w-full max-w-250 border-none shadow-2xl"
|
className="h-[1000px] w-full max-w-[1000px] border-none shadow-2xl"
|
||||||
title="PDF Preview"
|
title="PDF Preview"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
|
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
|
||||||
import { fetchAsignaturaPdf } from '@/data/api/document.api'
|
import { fetchAsignaturaPdf } from '@/data/api/document.api'
|
||||||
@@ -16,7 +16,6 @@ function RouteComponent() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
||||||
const pdfUrlRef = useRef<string | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isRegenerating, setIsRegenerating] = useState(false)
|
const [isRegenerating, setIsRegenerating] = useState(false)
|
||||||
|
|
||||||
@@ -26,14 +25,12 @@ function RouteComponent() {
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
setPdfUrl((prev) => {
|
setPdfUrl((prev) => {
|
||||||
if (prev) window.URL.revokeObjectURL(prev)
|
if (prev) window.URL.revokeObjectURL(prev)
|
||||||
pdfUrlRef.current = url
|
|
||||||
return url
|
return url
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -47,14 +44,13 @@ function RouteComponent() {
|
|||||||
loadPdfPreview()
|
loadPdfPreview()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
|
||||||
}
|
}
|
||||||
}, [loadPdfPreview])
|
}, [loadPdfPreview])
|
||||||
|
|
||||||
const handleDownloadPdf = async () => {
|
const handleDownload = 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)
|
||||||
@@ -67,21 +63,6 @@ function RouteComponent() {
|
|||||||
window.URL.revokeObjectURL(url)
|
window.URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownloadWord = async () => {
|
|
||||||
const docBlob = await fetchAsignaturaPdf({
|
|
||||||
asignatura_id: asignaturaId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(docBlob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.download = 'documento_sep.docx'
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
link.remove()
|
|
||||||
window.URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRegenerate = async () => {
|
const handleRegenerate = async () => {
|
||||||
try {
|
try {
|
||||||
setIsRegenerating(true)
|
setIsRegenerating(true)
|
||||||
@@ -96,8 +77,7 @@ function RouteComponent() {
|
|||||||
<DocumentoSEPTab
|
<DocumentoSEPTab
|
||||||
pdfUrl={pdfUrl}
|
pdfUrl={pdfUrl}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onDownloadPdf={handleDownloadPdf}
|
onDownload={handleDownload}
|
||||||
onDownloadWord={handleDownloadWord}
|
|
||||||
onRegenerate={handleRegenerate}
|
onRegenerate={handleRegenerate}
|
||||||
isRegenerating={isRegenerating}
|
isRegenerating={isRegenerating}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+1
-18
@@ -154,9 +154,9 @@ export type Database = {
|
|||||||
numero_ciclo: number | null
|
numero_ciclo: number | null
|
||||||
orden_celda: number | null
|
orden_celda: number | null
|
||||||
plan_estudio_id: string
|
plan_estudio_id: string
|
||||||
prerrequisito_asignatura_id: string | null
|
|
||||||
tipo: Database['public']['Enums']['tipo_asignatura']
|
tipo: Database['public']['Enums']['tipo_asignatura']
|
||||||
tipo_origen: Database['public']['Enums']['tipo_origen'] | null
|
tipo_origen: Database['public']['Enums']['tipo_origen'] | null
|
||||||
|
prerrequisito_asignatura_id?: string
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
actualizado_en?: string
|
actualizado_en?: string
|
||||||
@@ -180,7 +180,6 @@ export type Database = {
|
|||||||
numero_ciclo?: number | null
|
numero_ciclo?: number | null
|
||||||
orden_celda?: number | null
|
orden_celda?: number | null
|
||||||
plan_estudio_id: string
|
plan_estudio_id: string
|
||||||
prerrequisito_asignatura_id?: string | null
|
|
||||||
tipo?: Database['public']['Enums']['tipo_asignatura']
|
tipo?: Database['public']['Enums']['tipo_asignatura']
|
||||||
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
|
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
|
||||||
}
|
}
|
||||||
@@ -206,7 +205,6 @@ export type Database = {
|
|||||||
numero_ciclo?: number | null
|
numero_ciclo?: number | null
|
||||||
orden_celda?: number | null
|
orden_celda?: number | null
|
||||||
plan_estudio_id?: string
|
plan_estudio_id?: string
|
||||||
prerrequisito_asignatura_id?: string | null
|
|
||||||
tipo?: Database['public']['Enums']['tipo_asignatura']
|
tipo?: Database['public']['Enums']['tipo_asignatura']
|
||||||
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
|
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
|
||||||
}
|
}
|
||||||
@@ -260,20 +258,6 @@ 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: {
|
||||||
@@ -1393,7 +1377,6 @@ export type Database = {
|
|||||||
Args: { p_append: Json; p_id: string }
|
Args: { p_append: Json; p_id: string }
|
||||||
Returns: undefined
|
Returns: undefined
|
||||||
}
|
}
|
||||||
suma_porcentajes: { Args: { '': Json }; Returns: number }
|
|
||||||
unaccent: { Args: { '': string }; Returns: string }
|
unaccent: { Args: { '': string }; Returns: string }
|
||||||
unaccent_immutable: { Args: { '': string }; Returns: string }
|
unaccent_immutable: { Args: { '': string }; Returns: string }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user