4 Commits

9 changed files with 441 additions and 291 deletions
@@ -7,13 +7,6 @@ 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,
@@ -21,8 +14,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
@@ -46,6 +39,10 @@ export interface AsignaturaResponse {
datos: AsignaturaDatos datos: AsignaturaDatos
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
type CriterioEvaluacionRow = { type CriterioEvaluacionRow = {
criterio: string criterio: string
porcentaje: number porcentaje: number
@@ -67,12 +64,8 @@ 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()
@@ -93,54 +86,16 @@ export default function AsignaturaDetailPage() {
}, },
}) })
} }
const asignaturaSeriada = useMemo(() => {
if (!asignaturaApi?.prerrequisito_asignatura_id || !asignaturasApi)
return null
return asignaturasApi.find(
(asig) => asig.id === asignaturaApi.prerrequisito_asignatura_id,
)
}, [asignaturaApi, asignaturasApi])
const requisitosFormateados = useMemo(() => {
if (!asignaturaSeriada) return []
return [
{
type: 'Pre-requisito',
code: asignaturaSeriada.codigo,
name: asignaturaSeriada.nombre,
id: asignaturaSeriada.id, // Guardamos el ID para el select
},
]
}, [asignaturaSeriada])
const handleUpdatePrerrequisito = (newId: string | null) => {
updateAsignatura.mutate({
asignaturaId,
patch: {
prerrequisito_asignatura_id: newId,
},
})
}
/* ---------- sincronizar API ---------- */ /* ---------- sincronizar API ---------- */
useEffect(() => { useEffect(() => {
console.log(requisitosFormateados)
if (asignaturaApi) setAsignatura(asignaturaApi) if (asignaturaApi) setAsignatura(asignaturaApi)
}, [asignaturaApi, requisitosFormateados]) }, [asignaturaApi])
return ( return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
<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
}) { }) {
@@ -315,19 +270,18 @@ function DatosGenerales({
<InfoCard <InfoCard
title="Requisitos y Seriación" title="Requisitos y Seriación"
type="requirements" type="requirements"
initialContent={pre} initialContent={[
// Pasamos las materias del plan para el Select (excluyendo la actual) {
availableSubjects={ type: 'Pre-requisito',
availableSubjects?.filter((a) => a.id !== asignaturaId) || [] code: 'PA-301',
} name: 'Programación Avanzada',
onPersist={({ value }) => {
updateAsignatura.mutate({
asignaturaId,
patch: {
prerrequisito_asignatura_id: value, // value ya viene como ID o null desde handleSave
}, },
}) {
}} type: 'Co-requisito',
code: 'MAT-201',
name: 'Matemáticas Discretas',
},
]}
/> />
{/* Tarjeta de Evaluación */} {/* Tarjeta de Evaluación */}
@@ -367,7 +321,6 @@ 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({
@@ -384,7 +337,6 @@ 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)
@@ -402,8 +354,7 @@ 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
@@ -446,8 +397,6 @@ 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> = []
@@ -478,25 +427,6 @@ 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)
@@ -616,52 +546,7 @@ function InfoCard({
<CardContent className="pt-4"> <CardContent className="pt-4">
{isEditing ? ( {isEditing ? (
<div className="space-y-3"> <div className="space-y-3">
{/* Condicionales de edición según el tipo */} {type === 'evaluation' ? (
{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) => (
@@ -683,36 +568,85 @@ 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, porcentaje: raw } : r, r.id === row.id
) ? {
const total = next.reduce( id: r.id,
(acc, r) => acc + (Number(r.porcentaje) || 0), criterio: r.criterio,
0, porcentaje: raw,
}
: r,
) )
const total = next.reduce((acc, r) => {
const v = String(r.porcentaje).trim()
if (!v) return acc
const nn = Number(v)
if (!Number.isFinite(nn)) return acc
const vv = Math.trunc(nn)
if (vv < 1 || vv > 100) return acc
return acc + vv
}, 0)
return total > 100 ? prev : next 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>
@@ -722,15 +656,22 @@ function InfoCard({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span <span
className={`text-sm ${evaluationTotal === 100 ? 'text-muted-foreground' : 'text-destructive font-semibold'}`} className={
'text-sm ' +
(evaluationTotal === 100
? 'text-muted-foreground'
: 'text-destructive font-semibold')
}
> >
Total: {evaluationTotal}/100 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,
{ {
@@ -739,7 +680,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>
@@ -753,15 +694,28 @@ 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)
// Lógica de reset si es necesario... if (type === 'evaluation') {
const raw = Array.isArray(data) ? data : []
setEvalRows(
raw.map((r: CriterioEvaluacionRow) => ({
id: crypto.randomUUID(),
criterio:
typeof r.criterio === 'string' ? r.criterio : '',
porcentaje:
typeof r.porcentaje === 'number'
? String(Math.trunc(r.porcentaje))
: typeof r.porcentaje === 'string'
? String(Math.trunc(Number(r.porcentaje)))
: '',
})),
)
}
}} }}
> >
Cancelar Cancelar
@@ -777,7 +731,6 @@ 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 ? (
@@ -786,7 +739,9 @@ 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' && <EvaluationView items={data} />} {type === 'evaluation' && (
<EvaluationView items={data as Array<CriterioEvaluacionRow>} />
)}
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -841,80 +796,3 @@ function EvaluationView({ items }: { items: Array<CriterioEvaluacionRow> }) {
</div> </div>
) )
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function parseContenidoTematicoToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
const blocks: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const unidad =
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
? item.unidad
: undefined
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
if (!header) continue
const lines: Array<string> = [header]
const temas = Array.isArray(item.temas) ? item.temas : []
temas.forEach((tema, idx) => {
const temaNombre =
typeof tema === 'string'
? tema
: isRecord(tema) && typeof tema.nombre === 'string'
? tema.nombre
: ''
if (!temaNombre) return
if (unidad != null) {
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
} else {
lines.push(`${idx + 1}. ${temaNombre}`)
}
})
blocks.push(lines.join('\n'))
}
return blocks.join('\n\n').trimEnd()
}
function parseCriteriosEvaluacionToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
const lines: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const label = typeof item.criterio === 'string' ? item.criterio.trim() : ''
const valueNum =
typeof item.porcentaje === 'number'
? item.porcentaje
: typeof item.porcentaje === 'string'
? Number(item.porcentaje)
: NaN
if (!label) continue
if (!Number.isFinite(valueNum)) continue
const v = Math.trunc(valueNum)
if (v < 1 || v > 100) continue
lines.push(`${label}: ${v}%`)
}
return lines.join('\n')
}
const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
contenido_tematico: parseContenidoTematicoToPlainText,
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
}
@@ -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" />
+60 -26
View File
@@ -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()
} }
+1 -1
View File
@@ -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,prerrequisito_asignatura_id, id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,
planes_estudio( 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))
+72 -1
View File
@@ -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
} }
+78
View File
@@ -0,0 +1,78 @@
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function parseContenidoTematicoToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
const blocks: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const unidad =
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
? item.unidad
: undefined
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
if (!header) continue
const lines: Array<string> = [header]
const temas = Array.isArray(item.temas) ? item.temas : []
temas.forEach((tema, idx) => {
const temaNombre =
typeof tema === 'string'
? tema
: isRecord(tema) && typeof tema.nombre === 'string'
? tema.nombre
: ''
if (!temaNombre) return
if (unidad != null) {
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
} else {
lines.push(`${idx + 1}. ${temaNombre}`)
}
})
blocks.push(lines.join('\n'))
}
return blocks.join('\n\n').trimEnd()
}
export function parseCriteriosEvaluacionToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
const lines: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const label = typeof item.criterio === 'string' ? item.criterio.trim() : ''
const valueNum =
typeof item.porcentaje === 'number'
? item.porcentaje
: typeof item.porcentaje === 'string'
? Number(item.porcentaje)
: NaN
if (!label) continue
if (!Number.isFinite(valueNum)) continue
const v = Math.trunc(valueNum)
if (v < 1 || v > 100) continue
lines.push(`${label}: ${v}%`)
}
return lines.join('\n')
}
export const columnParsers: Partial<
Record<string, (value: unknown) => string>
> = {
contenido_tematico: parseContenidoTematicoToPlainText,
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
}
@@ -8,7 +8,7 @@ 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'
@@ -21,18 +21,23 @@ 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({ plan_estudio_id: planId }) const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
convertTo: 'pdf',
})
const url = window.URL.createObjectURL(pdfBlob) const url = window.URL.createObjectURL(pdfBlob)
// Limpiar URL anterior si existe para evitar fugas de memoria setPdfUrl((prev) => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl) if (prev) window.URL.revokeObjectURL(prev)
pdfUrlRef.current = url
setPdfUrl(url) return url
})
} catch (error) { } catch (error) {
console.error('Error cargando preview:', error) console.error('Error cargando preview:', error)
} finally { } finally {
@@ -43,7 +48,7 @@ function RouteComponent() {
useEffect(() => { useEffect(() => {
loadPdfPreview() loadPdfPreview()
return () => { return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl) if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
} }
}, [loadPdfPreview]) }, [loadPdfPreview])
@@ -51,6 +56,7 @@ 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)
@@ -67,6 +73,27 @@ function RouteComponent() {
alert('No se pudo generar el PDF') alert('No se pudo generar el PDF')
} }
} }
const handleDownloadWord = async () => {
try {
const docBlob = await fetchPlanPdf({
plan_estudio_id: planId,
})
const url = window.URL.createObjectURL(docBlob)
const link = document.createElement('a')
link.href = url
link.download = '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 */}
@@ -88,12 +115,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 +171,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 +181,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"
/> />
) : ( ) : (
@@ -1,5 +1,5 @@
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 { fetchAsignaturaPdf } from '@/data/api/document.api' import { fetchAsignaturaPdf } from '@/data/api/document.api'
@@ -16,6 +16,7 @@ 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)
@@ -25,12 +26,14 @@ 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) {
@@ -44,13 +47,14 @@ function RouteComponent() {
loadPdfPreview() loadPdfPreview()
return () => { return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl) 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)
@@ -63,6 +67,21 @@ 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)
@@ -77,7 +96,8 @@ 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}
/> />
+18 -1
View File
@@ -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,6 +180,7 @@ export type Database = {
numero_ciclo?: number | null numero_ciclo?: number | null
orden_celda?: number | null orden_celda?: number | null
plan_estudio_id: string plan_estudio_id: string
prerrequisito_asignatura_id?: string | null
tipo?: Database['public']['Enums']['tipo_asignatura'] tipo?: Database['public']['Enums']['tipo_asignatura']
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
} }
@@ -205,6 +206,7 @@ export type Database = {
numero_ciclo?: number | null numero_ciclo?: number | null
orden_celda?: number | null orden_celda?: number | null
plan_estudio_id?: string plan_estudio_id?: string
prerrequisito_asignatura_id?: string | null
tipo?: Database['public']['Enums']['tipo_asignatura'] tipo?: Database['public']['Enums']['tipo_asignatura']
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
} }
@@ -258,6 +260,20 @@ export type Database = {
referencedRelation: 'plantilla_plan' referencedRelation: 'plantilla_plan'
referencedColumns: ['plan_estudio_id'] referencedColumns: ['plan_estudio_id']
}, },
{
foreignKeyName: 'asignaturas_prerrequisito_asignatura_id_fkey'
columns: ['prerrequisito_asignatura_id']
isOneToOne: false
referencedRelation: 'asignaturas'
referencedColumns: ['id']
},
{
foreignKeyName: 'asignaturas_prerrequisito_asignatura_id_fkey'
columns: ['prerrequisito_asignatura_id']
isOneToOne: false
referencedRelation: 'plantilla_asignatura'
referencedColumns: ['asignatura_id']
},
] ]
} }
bibliografia_asignatura: { bibliografia_asignatura: {
@@ -1377,6 +1393,7 @@ export type Database = {
Args: { p_append: Json; p_id: string } Args: { p_append: Json; p_id: string }
Returns: undefined Returns: undefined
} }
suma_porcentajes: { Args: { '': Json }; Returns: number }
unaccent: { Args: { '': string }; Returns: string } unaccent: { Args: { '': string }; Returns: string }
unaccent_immutable: { Args: { '': string }; Returns: string } unaccent_immutable: { Args: { '': string }; Returns: string }
} }