2 Commits

5 changed files with 148 additions and 171 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,7 +14,6 @@ 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'
export interface BibliografiaEntry { export interface BibliografiaEntry {
@@ -67,12 +59,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 +81,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 +265,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, type: 'Co-requisito',
patch: { code: 'MAT-201',
prerrequisito_asignatura_id: value, // value ya viene como ID o null desde handleSave name: 'Matemáticas Discretas',
}, },
}) ]}
}}
/> />
{/* Tarjeta de Evaluación */} {/* Tarjeta de Evaluación */}
@@ -367,7 +316,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 +332,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 +349,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 +392,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 +422,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 +541,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 +563,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 +651,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 +675,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 +689,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 +726,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 +734,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>
+18 -15
View File
@@ -1,7 +1,10 @@
// document.api.ts // document.api.ts
const DOCUMENT_PDF_URL = import { invokeEdge } from '../supabase/invokeEdge'
'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434'
const EDGE = {
carbone_io_wrapper: 'carbone-io-wrapper',
} as const
const DOCUMENT_PDF_ASIGNATURA_URL = const DOCUMENT_PDF_ASIGNATURA_URL =
'https://n8n.app.lci.ulsa.mx/webhook/041a68be-7568-46d0-bc08-09ded12d017d' 'https://n8n.app.lci.ulsa.mx/webhook/041a68be-7568-46d0-bc08-09ded12d017d'
@@ -16,20 +19,20 @@ interface GeneratePdfParamsAsignatura {
export async function fetchPlanPdf({ export async function fetchPlanPdf({
plan_estudio_id, plan_estudio_id,
}: GeneratePdfParams): Promise<Blob> { }: GeneratePdfParams): Promise<Blob> {
const response = await fetch(DOCUMENT_PDF_URL, { return await invokeEdge<Blob>(
method: 'POST', EDGE.carbone_io_wrapper,
headers: { {
'Content-Type': 'application/json', action: 'downloadReport',
plan_estudio_id,
body: { convertTo: 'pdf' },
}, },
body: JSON.stringify({ plan_estudio_id }), {
}) headers: {
'Content-Type': 'application/json',
if (!response.ok) { },
throw new Error('Error al generar el PDF') responseType: 'blob',
} },
)
// n8n devuelve el archivo → lo tratamos como blob
return await response.blob()
} }
export async function fetchAsignaturaPdf({ export async function fetchAsignaturaPdf({
+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))
+8 -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 {
@@ -42,10 +43,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) {
+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 }
} }