Compare commits
3 Commits
852564776a
...
691b8911c5
| Author | SHA1 | Date | |
|---|---|---|---|
| 691b8911c5 | |||
| 073969b9bf | |||
| e173c3097c |
@@ -7,82 +7,9 @@ import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { generate_subject_suggestions, usePlan } from '@/data'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Interfaces
|
||||
interface Suggestion {
|
||||
id: string
|
||||
nombre: string
|
||||
tipo: 'Obligatoria' | 'Optativa'
|
||||
creditos: number
|
||||
horasAcademicas: number
|
||||
horasIndependientes: number
|
||||
descripcion: string
|
||||
}
|
||||
|
||||
// Datos Mock basados en tu imagen
|
||||
const MOCK_SUGGESTIONS: Array<Suggestion> = [
|
||||
{
|
||||
id: '1',
|
||||
nombre: 'Propiedad Intelectual en Entornos Digitales',
|
||||
tipo: 'Optativa',
|
||||
creditos: 4,
|
||||
horasAcademicas: 32,
|
||||
horasIndependientes: 16,
|
||||
descripcion:
|
||||
'Derechos de autor, patentes de software y marcas en el ecosistema digital.',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
nombre: 'Derecho Constitucional Digital',
|
||||
tipo: 'Obligatoria',
|
||||
creditos: 8,
|
||||
horasAcademicas: 64,
|
||||
horasIndependientes: 32,
|
||||
descripcion:
|
||||
'Marco constitucional aplicado al entorno digital y derechos fundamentales en línea.',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
nombre: 'Gobernanza de Internet',
|
||||
tipo: 'Optativa',
|
||||
creditos: 4,
|
||||
horasAcademicas: 32,
|
||||
horasIndependientes: 16,
|
||||
descripcion:
|
||||
'Políticas públicas, regulación internacional y gobernanza del ecosistema digital.',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
nombre: 'Protección de Datos Personales',
|
||||
tipo: 'Obligatoria',
|
||||
creditos: 6,
|
||||
horasAcademicas: 48,
|
||||
horasIndependientes: 24,
|
||||
descripcion:
|
||||
'Regulación y cumplimiento de leyes de protección de datos (GDPR, LFPDPPP).',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
nombre: 'Inteligencia Artificial y Ética Jurídica',
|
||||
tipo: 'Optativa',
|
||||
creditos: 4,
|
||||
horasAcademicas: 32,
|
||||
horasIndependientes: 16,
|
||||
descripcion:
|
||||
'Implicaciones legales y éticas del uso de IA en la práctica jurídica.',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
nombre: 'Ciberseguridad y Derecho Penal',
|
||||
tipo: 'Obligatoria',
|
||||
creditos: 6,
|
||||
horasAcademicas: 48,
|
||||
horasIndependientes: 24,
|
||||
descripcion:
|
||||
'Delitos informáticos, evidencia digital y marco penal en el ciberespacio.',
|
||||
},
|
||||
]
|
||||
export default function PasoSugerenciasForm({
|
||||
wizard,
|
||||
onChange,
|
||||
@@ -90,31 +17,102 @@ export default function PasoSugerenciasForm({
|
||||
wizard: NewSubjectWizardState
|
||||
onChange: Dispatch<SetStateAction<NewSubjectWizardState>>
|
||||
}) {
|
||||
const selectedIds = wizard.iaMultiple?.selectedIds ?? []
|
||||
const ciclo = wizard.iaMultiple?.ciclo ?? ''
|
||||
const enfoque = wizard.iaMultiple?.enfoque ?? ''
|
||||
const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 10
|
||||
|
||||
const setIaMultiple = (
|
||||
patch: Partial<NonNullable<NewSubjectWizardState['iaMultiple']>>,
|
||||
) =>
|
||||
onChange((w) => ({
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
iaMultiple: {
|
||||
ciclo: w.iaMultiple?.ciclo ?? '',
|
||||
ciclo: w.iaMultiple?.ciclo ?? null,
|
||||
enfoque: w.iaMultiple?.enfoque ?? '',
|
||||
selectedIds: w.iaMultiple?.selectedIds ?? [],
|
||||
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
|
||||
...patch,
|
||||
},
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
const { data: plan } = usePlan(wizard.plan_estudio_id)
|
||||
|
||||
const toggleAsignatura = (id: string, checked: boolean) => {
|
||||
const prev = selectedIds
|
||||
const next = checked ? [...prev, id] : prev.filter((x) => x !== id)
|
||||
setIaMultiple({ selectedIds: next })
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
sugerencias: w.sugerencias.map((s) =>
|
||||
s.id === id ? { ...s, selected: checked } : s,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
const onGenerarSugerencias = async () => {
|
||||
const sugerenciasConservadas = wizard.sugerencias.filter((s) => s.selected)
|
||||
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
isLoading: true,
|
||||
errorMessage: null,
|
||||
sugerencias: sugerenciasConservadas,
|
||||
}))
|
||||
|
||||
try {
|
||||
const numeroCiclo = wizard.iaMultiple?.ciclo
|
||||
if (!numeroCiclo || !Number.isFinite(numeroCiclo) || numeroCiclo <= 0) {
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage: 'Ingresa un número de ciclo válido.',
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
const cantidad = wizard.iaMultiple?.cantidadDeSugerencias ?? 10
|
||||
if (!Number.isFinite(cantidad) || cantidad <= 0 || cantidad > 50) {
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage: 'La cantidad de sugerencias debe ser entre 1 y 50.',
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
const enfoqueTrim = wizard.iaMultiple?.enfoque.trim() ?? ''
|
||||
|
||||
const nuevasSugerencias = await generate_subject_suggestions({
|
||||
plan_estudio_id: wizard.plan_estudio_id,
|
||||
numero_de_ciclo: numeroCiclo,
|
||||
enfoque: enfoqueTrim ? enfoqueTrim : undefined,
|
||||
cantidad_de_sugerencias: cantidad,
|
||||
sugerencias_conservadas: sugerenciasConservadas.map((s) => ({
|
||||
nombre: s.nombre,
|
||||
descripcion: s.descripcion,
|
||||
})),
|
||||
})
|
||||
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
sugerencias: [...nuevasSugerencias, ...sugerenciasConservadas],
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Error generando sugerencias.'
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage: message,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<>
|
||||
{/* --- BLOQUE SUPERIOR: PARÁMETROS --- */}
|
||||
<div className="border-border/60 bg-muted/30 mb-4 rounded-xl border p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
@@ -133,7 +131,21 @@ export default function PasoSugerenciasForm({
|
||||
<Input
|
||||
placeholder="Ej. 3"
|
||||
value={ciclo}
|
||||
onChange={(e) => setIaMultiple({ ciclo: e.target.value })}
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
if (raw === '') {
|
||||
setIaMultiple({ ciclo: null })
|
||||
return
|
||||
}
|
||||
const asNumber = Number(raw)
|
||||
if (!Number.isFinite(asNumber)) return
|
||||
const n = Math.floor(Math.abs(asNumber))
|
||||
const capped = Math.min(n >= 1 ? n : 1, 999)
|
||||
setIaMultiple({ ciclo: capped })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -148,13 +160,54 @@ export default function PasoSugerenciasForm({
|
||||
onChange={(e) => setIaMultiple({ enfoque: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botón Refrescar */}
|
||||
<Button type="button" variant="outline" className="h-9 gap-1.5">
|
||||
<div className="mt-3 flex w-full flex-col items-end gap-3 md:flex-row">
|
||||
<div className="w-full md:w-44">
|
||||
<Label className="text-muted-foreground mb-1 block text-xs">
|
||||
Cantidad de sugerencias
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="Ej. 10"
|
||||
value={cantidadDeSugerencias}
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
onKeyDown={(e) => {
|
||||
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
if (raw === '') return
|
||||
const asNumber = Number(raw)
|
||||
if (!Number.isFinite(asNumber)) return
|
||||
const n = Math.floor(Math.abs(asNumber))
|
||||
const capped = Math.min(n >= 1 ? n : 1, 50)
|
||||
setIaMultiple({ cantidadDeSugerencias: capped })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 gap-1.5"
|
||||
onClick={onGenerarSugerencias}
|
||||
disabled={wizard.isLoading}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Nuevas sugerencias
|
||||
Generar sugerencias
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
Al generar más sugerencias, solo se conservarán las asignaturas que
|
||||
hayas seleccionado.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* --- HEADER LISTA --- */}
|
||||
@@ -164,26 +217,25 @@ export default function PasoSugerenciasForm({
|
||||
Asignaturas sugeridas
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Basadas en el plan "Licenciatura en Derecho Digital"
|
||||
Basadas en el plan{' '}
|
||||
{plan ? `${plan.nivel} en ${plan.nombre}` : '...'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted text-foreground inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-semibold">
|
||||
{selectedIds.length} seleccionadas
|
||||
{wizard.sugerencias.filter((s) => s.selected).length} seleccionadas
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- LISTA DE ASIGNATURAS (CON EL ESTILO PEDIDO) --- */}
|
||||
<div className="max-h-80 space-y-1 overflow-y-auto pr-1">
|
||||
{MOCK_SUGGESTIONS.map((asignatura) => {
|
||||
const isSelected = selectedIds.includes(asignatura.id)
|
||||
{/* --- LISTA DE ASIGNATURAS --- */}
|
||||
<div className="max-h-100 space-y-1 overflow-y-auto pr-1">
|
||||
{wizard.sugerencias.map((asignatura) => {
|
||||
const isSelected = asignatura.selected
|
||||
|
||||
return (
|
||||
<Label
|
||||
key={asignatura.id}
|
||||
// Para que funcione el selector css `has-aria-checked` que tenías en tu snippet
|
||||
aria-checked={isSelected}
|
||||
className={cn(
|
||||
// Igual al patrón de ReferenciasParaIA
|
||||
'border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950',
|
||||
)}
|
||||
>
|
||||
@@ -193,9 +245,8 @@ export default function PasoSugerenciasForm({
|
||||
toggleAsignatura(asignatura.id, !!checked)
|
||||
}
|
||||
className={cn(
|
||||
// Igual al patrón de ReferenciasParaIA: invisible si no está seleccionado
|
||||
'peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring mt-0.5 h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isSelected ? '' : 'invisible',
|
||||
'peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring mt-0.5 h-5 w-5 shrink-0 border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
// isSelected ? '' : 'invisible',
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -210,7 +261,7 @@ export default function PasoSugerenciasForm({
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
|
||||
asignatura.tipo === 'Obligatoria'
|
||||
asignatura.tipo === 'OBLIGATORIA'
|
||||
? 'border-blue-200 bg-transparent text-blue-700 dark:border-blue-800 dark:text-blue-300'
|
||||
: 'border-yellow-200 bg-transparent text-yellow-700 dark:border-yellow-800 dark:text-yellow-300',
|
||||
)}
|
||||
@@ -224,7 +275,7 @@ export default function PasoSugerenciasForm({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{asignatura.descripcion}
|
||||
</p>
|
||||
</div>
|
||||
@@ -232,6 +283,6 @@ export default function PasoSugerenciasForm({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export function WizardControls({
|
||||
Anterior
|
||||
</Button>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="mx-2 flex-1">
|
||||
{(errorMessage ?? wizard.errorMessage) && (
|
||||
<span className="text-destructive text-sm font-medium">
|
||||
{errorMessage ?? wizard.errorMessage}
|
||||
|
||||
@@ -12,9 +12,14 @@ import type {
|
||||
UUID,
|
||||
} from '../types/domain'
|
||||
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
||||
import type {
|
||||
AsignaturaSugerida,
|
||||
DataAsignaturaSugerida,
|
||||
} from '@/features/asignaturas/nueva/types'
|
||||
import type { Database } from '@/types/supabase'
|
||||
|
||||
const EDGE = {
|
||||
generate_subject_suggestions: 'generate-subject-suggestions',
|
||||
subjects_create_manual: 'subjects_create_manual',
|
||||
ai_generate_subject: 'ai-generate-subject',
|
||||
subjects_persist_from_ai: 'subjects_persist_from_ai',
|
||||
@@ -36,7 +41,7 @@ export async function subjects_get(subjectId: UUID): Promise<Asignatura> {
|
||||
.from('asignaturas')
|
||||
.select(
|
||||
`
|
||||
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
||||
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,conversation_id,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
||||
planes_estudio(
|
||||
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
||||
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
|
||||
@@ -133,6 +138,40 @@ export type AIGenerateSubjectInput = {
|
||||
}
|
||||
}
|
||||
|
||||
export type GenerateSubjectSuggestionsInput = {
|
||||
plan_estudio_id: UUID
|
||||
numero_de_ciclo: number
|
||||
enfoque?: string
|
||||
cantidad_de_sugerencias: number
|
||||
sugerencias_conservadas: Array<{ nombre: string; descripcion: string }>
|
||||
}
|
||||
|
||||
export async function generate_subject_suggestions(
|
||||
input: GenerateSubjectSuggestionsInput,
|
||||
): Promise<Array<AsignaturaSugerida>> {
|
||||
const raw = await invokeEdge<Array<DataAsignaturaSugerida>>(
|
||||
EDGE.generate_subject_suggestions,
|
||||
input,
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
|
||||
return raw.map(
|
||||
(s): AsignaturaSugerida => ({
|
||||
id: crypto.randomUUID(),
|
||||
selected: false,
|
||||
source: 'IA',
|
||||
estructuraId: null,
|
||||
nombre: s.nombre,
|
||||
codigo: s.codigo,
|
||||
tipo: s.tipo ?? null,
|
||||
creditos: s.creditos ?? null,
|
||||
horasAcademicas: s.horasAcademicas ?? null,
|
||||
horasIndependientes: s.horasIndependientes ?? null,
|
||||
descripcion: s.descripcion,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function ai_generate_subject(
|
||||
input: AIGenerateSubjectInput,
|
||||
): Promise<any> {
|
||||
@@ -146,7 +185,7 @@ export async function ai_generate_subject(
|
||||
archivosAdjuntos: undefined, // los manejamos aparte
|
||||
}),
|
||||
)
|
||||
input.iaConfig?.archivosAdjuntos?.forEach((file, index) => {
|
||||
input.iaConfig?.archivosAdjuntos?.forEach((file) => {
|
||||
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
|
||||
})
|
||||
return invokeEdge<any>(
|
||||
|
||||
@@ -19,6 +19,7 @@ export const qk = {
|
||||
planHistorial: (planId: string) => ['planes', planId, 'historial'] as const,
|
||||
planDocumento: (planId: string) => ['planes', planId, 'documento'] as const,
|
||||
|
||||
sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const,
|
||||
asignatura: (asignaturaId: string) =>
|
||||
['asignaturas', 'detail', asignaturaId] as const,
|
||||
asignaturaBibliografia: (asignaturaId: string) =>
|
||||
|
||||
@@ -16,6 +16,7 @@ export function useNuevaAsignaturaWizard(planId: string) {
|
||||
horasIndependientes: null,
|
||||
estructuraId: '',
|
||||
},
|
||||
sugerencias: [],
|
||||
clonInterno: {},
|
||||
clonTradicional: {
|
||||
archivoWordAsignaturaId: null,
|
||||
@@ -29,9 +30,9 @@ export function useNuevaAsignaturaWizard(planId: string) {
|
||||
archivosAdjuntos: [],
|
||||
},
|
||||
iaMultiple: {
|
||||
ciclo: '',
|
||||
ciclo: null,
|
||||
enfoque: '',
|
||||
selectedIds: ['1', '3', '6'],
|
||||
cantidadDeSugerencias: 10,
|
||||
},
|
||||
resumen: {},
|
||||
isLoading: false,
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/
|
||||
import type { Asignatura } from '@/data'
|
||||
|
||||
export type ModoCreacion = 'MANUAL' | 'IA' | 'CLONADO'
|
||||
export type SubModoClonado = 'INTERNO' | 'TRADICIONAL'
|
||||
export type TipoAsignatura = 'OBLIGATORIA' | 'OPTATIVA' | 'TRONCAL' | 'OTRO'
|
||||
|
||||
export type AsignaturaPreview = {
|
||||
@@ -12,6 +11,23 @@ export type AsignaturaPreview = {
|
||||
bibliografiaCount: number
|
||||
}
|
||||
|
||||
export type DataAsignaturaSugerida = {
|
||||
nombre: Asignatura['nombre']
|
||||
codigo?: Asignatura['codigo']
|
||||
tipo: Asignatura['tipo'] | null
|
||||
creditos: Asignatura['creditos'] | null
|
||||
horasAcademicas?: number | null
|
||||
horasIndependientes?: number | null
|
||||
descripcion: string
|
||||
}
|
||||
|
||||
export type AsignaturaSugerida = {
|
||||
id: string
|
||||
selected: boolean
|
||||
source: 'IA' | 'MANUAL' | 'CLON'
|
||||
estructuraId: Asignatura['estructura_id'] | null
|
||||
} & DataAsignaturaSugerida
|
||||
|
||||
export type NewSubjectWizardState = {
|
||||
step: 1 | 2 | 3 | 4
|
||||
plan_estudio_id: Asignatura['plan_estudio_id']
|
||||
@@ -30,6 +46,7 @@ export type NewSubjectWizardState = {
|
||||
horasIndependientes?: Asignatura['horas_independientes'] | null
|
||||
estructuraId: Asignatura['estructura_id'] | null
|
||||
}
|
||||
sugerencias: Array<AsignaturaSugerida>
|
||||
clonInterno?: {
|
||||
facultadId?: string
|
||||
carreraId?: string
|
||||
@@ -48,9 +65,9 @@ export type NewSubjectWizardState = {
|
||||
archivosAdjuntos?: Array<UploadedFile>
|
||||
}
|
||||
iaMultiple?: {
|
||||
ciclo: string
|
||||
ciclo: number | null
|
||||
enfoque: string
|
||||
selectedIds: Array<string>
|
||||
cantidadDeSugerencias: number
|
||||
}
|
||||
resumen: {
|
||||
previewAsignatura?: AsignaturaPreview
|
||||
|
||||
Reference in New Issue
Block a user