Merge remote-tracking branch 'origin/feat/ai-generate-plan' into renderCarbone

This commit is contained in:
2026-01-21 12:10:10 -06:00
7 changed files with 96 additions and 67 deletions

View File

@@ -12,6 +12,7 @@ export interface UploadedFile {
} }
interface FileDropzoneProps { interface FileDropzoneProps {
persistentFiles?: Array<UploadedFile>
onFilesChange?: (files: Array<UploadedFile>) => void onFilesChange?: (files: Array<UploadedFile>) => void
acceptedTypes?: string acceptedTypes?: string
maxFiles?: number maxFiles?: number
@@ -20,6 +21,7 @@ interface FileDropzoneProps {
} }
export function FileDropzone({ export function FileDropzone({
persistentFiles,
onFilesChange, onFilesChange,
acceptedTypes = '.doc,.docx,.pdf', acceptedTypes = '.doc,.docx,.pdf',
maxFiles = 5, maxFiles = 5,
@@ -27,7 +29,7 @@ export function FileDropzone({
description = 'o haz clic para seleccionar', description = 'o haz clic para seleccionar',
}: FileDropzoneProps) { }: FileDropzoneProps) {
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [files, setFiles] = useState<Array<UploadedFile>>([]) const [files, setFiles] = useState<Array<UploadedFile>>(persistentFiles ?? [])
const onFilesChangeRef = useRef<typeof onFilesChange>(onFilesChange) const onFilesChangeRef = useRef<typeof onFilesChange>(onFilesChange)
const addFiles = useCallback( const addFiles = useCallback(

View File

@@ -23,12 +23,10 @@ import {
export function PasoDetallesPanel({ export function PasoDetallesPanel({
wizard, wizard,
onChange, onChange,
onGenerarIA,
isLoading, isLoading,
}: { }: {
wizard: NewPlanWizardState wizard: NewPlanWizardState
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>> onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
onGenerarIA: () => void
isLoading: boolean isLoading: boolean
}) { }) {
if (wizard.tipoOrigen === 'MANUAL') { if (wizard.tipoOrigen === 'MANUAL') {
@@ -87,6 +85,7 @@ export function PasoDetallesPanel({
<ReferenciasParaIA <ReferenciasParaIA
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []} selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []} selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
onToggleArchivo={(id, checked) => onToggleArchivo={(id, checked) =>
onChange((w) => { onChange((w) => {
const prev = w.iaConfig?.archivosReferencia || [] const prev = w.iaConfig?.archivosReferencia || []
@@ -133,7 +132,7 @@ export function PasoDetallesPanel({
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
Opcional: se pueden adjuntar recursos IA más adelante. Opcional: se pueden adjuntar recursos IA más adelante.
</div> </div>
<Button onClick={onGenerarIA} disabled={isLoading}> <Button disabled={isLoading}>
{isLoading ? 'Generando…' : 'Generar borrador con IA'} {isLoading ? 'Generando…' : 'Generar borrador con IA'}
</Button> </Button>
</div> </div>

View File

@@ -21,12 +21,14 @@ import { ARCHIVOS, REPOSITORIOS } from '@/features/planes/nuevo/catalogs'
const ReferenciasParaIA = ({ const ReferenciasParaIA = ({
selectedArchivoIds = [], selectedArchivoIds = [],
selectedRepositorioIds = [], selectedRepositorioIds = [],
uploadedFiles = [],
onToggleArchivo, onToggleArchivo,
onToggleRepositorio, onToggleRepositorio,
onFilesChange, onFilesChange,
}: { }: {
selectedArchivoIds?: Array<string> selectedArchivoIds?: Array<string>
selectedRepositorioIds?: Array<string> selectedRepositorioIds?: Array<string>
uploadedFiles?: Array<UploadedFile>
onToggleArchivo?: (id: string, checked: boolean) => void onToggleArchivo?: (id: string, checked: boolean) => void
onToggleRepositorio?: (id: string, checked: boolean) => void onToggleRepositorio?: (id: string, checked: boolean) => void
onFilesChange?: (files: Array<UploadedFile>) => void onFilesChange?: (files: Array<UploadedFile>) => void
@@ -163,6 +165,7 @@ const ReferenciasParaIA = ({
content: ( content: (
<div> <div>
<FileDropzone <FileDropzone
persistentFiles={uploadedFiles}
onFilesChange={onFilesChange} onFilesChange={onFilesChange}
title="Sube archivos de referencia" title="Sube archivos de referencia"
description="Documentos que serán usados como contexto para la generación" description="Documentos que serán usados como contexto para la generación"

View File

@@ -1,3 +1,4 @@
import type { UploadedFile } from './PasoDetallesPanel/FileDropZone'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types' import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
import { import {
@@ -166,7 +167,7 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
<div className="mt-2"> <div className="mt-2">
<div className="font-medium">Adjuntos</div> <div className="font-medium">Adjuntos</div>
<ul className="text-muted-foreground list-disc pl-5 text-xs"> <ul className="text-muted-foreground list-disc pl-5 text-xs">
{adjuntos.map((f) => ( {adjuntos.map((f: UploadedFile) => (
<li key={f.id}> <li key={f.id}>
<span className="text-foreground"> <span className="text-foreground">
{f.file.name} {f.file.name}

View File

@@ -1,7 +1,8 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "../types/database";
import { supabaseBrowser } from "./client"; import { supabaseBrowser } from "./client";
import type { Database } from "@/types/supabase";
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>;
@@ -12,7 +13,7 @@ export class EdgeFunctionError extends Error {
message: string, message: string,
public readonly functionName: string, public readonly functionName: string,
public readonly status?: number, public readonly status?: number,
public readonly details?: unknown public readonly details?: unknown,
) { ) {
super(message); super(message);
this.name = "EdgeFunctionError"; this.name = "EdgeFunctionError";
@@ -21,9 +22,17 @@ export class EdgeFunctionError extends Error {
export async function invokeEdge<TOut>( export async function invokeEdge<TOut>(
functionName: string, functionName: string,
body?: unknown, body?:
| string
| File
| Blob
| ArrayBuffer
| FormData
| ReadableStream<Uint8Array<ArrayBufferLike>>
| Record<string, unknown>
| undefined,
opts: EdgeInvokeOptions = {}, opts: EdgeInvokeOptions = {},
client?: SupabaseClient<Database> client?: SupabaseClient<Database>,
): Promise<TOut> { ): Promise<TOut> {
const supabase = client ?? supabaseBrowser(); const supabase = client ?? supabaseBrowser();
@@ -34,12 +43,12 @@ export async function invokeEdge<TOut>(
}); });
if (error) { if (error) {
const anyErr = error as any; const anyErr = error;
throw new EdgeFunctionError( throw new EdgeFunctionError(
anyErr.message ?? "Error en Edge Function", anyErr.message ?? "Error en Edge Function",
functionName, functionName,
anyErr.status, anyErr.status,
anyErr anyErr,
); );
} }

View File

@@ -25,6 +25,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { useGeneratePlanAI } from '@/data/hooks/usePlans'
// Mock de permisos/rol // Mock de permisos/rol
const auth_get_current_user_role = () => 'JEFE_CARRERA' as const const auth_get_current_user_role = () => 'JEFE_CARRERA' as const
@@ -47,6 +48,8 @@ const Wizard = defineStepper(
export default function NuevoPlanModalContainer() { export default function NuevoPlanModalContainer() {
const navigate = useNavigate() const navigate = useNavigate()
const role = auth_get_current_user_role() const role = auth_get_current_user_role()
const generatePlanAI = useGeneratePlanAI()
// const persistPlanFromAI = usePersistPlanFromAI()
const { const {
wizard, wizard,
@@ -54,7 +57,6 @@ export default function NuevoPlanModalContainer() {
canContinueDesdeModo, canContinueDesdeModo,
canContinueDesdeBasicos, canContinueDesdeBasicos,
canContinueDesdeDetalles, canContinueDesdeDetalles,
generarPreviewIA,
} = useNuevoPlanWizard() } = useNuevoPlanWizard()
const handleClose = () => { const handleClose = () => {
@@ -62,23 +64,72 @@ export default function NuevoPlanModalContainer() {
} }
const crearPlan = async () => { const crearPlan = async () => {
setWizard((w: NewPlanWizardState) => ({ setWizard(
...w, (w: NewPlanWizardState): NewPlanWizardState => ({
isLoading: true, ...w,
errorMessage: null, isLoading: true,
})) errorMessage: null,
await new Promise((r) => setTimeout(r, 900)) }),
const nuevoId = (() => { )
if (wizard.tipoOrigen === 'MANUAL') return 'plan_new_manual_001'
if (wizard.tipoOrigen === 'IA') return 'plan_new_ai_001' try {
if ( if (wizard.tipoOrigen === 'IA') {
wizard.tipoOrigen === 'CLONADO_INTERNO' || const tipoCicloSafe = (wizard.datosBasicos.tipoCiclo ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL' 'Semestre') as any
) const numCiclosSafe =
return 'plan_new_clone_001' typeof wizard.datosBasicos.numCiclos === 'number'
return 'plan_new_import_001' ? wizard.datosBasicos.numCiclos
})() : 1
navigate({ to: `/planes/${nuevoId}` })
const aiInput = {
datosBasicos: {
nombrePlan: wizard.datosBasicos.nombrePlan,
carreraId: wizard.datosBasicos.carreraId,
facultadId: wizard.datosBasicos.facultadId || undefined,
nivel: wizard.datosBasicos.nivel as string,
tipoCiclo: tipoCicloSafe,
numCiclos: numCiclosSafe,
estructuraPlanId: wizard.datosBasicos.estructuraPlanId as string,
},
iaConfig: {
descripcionEnfoque: wizard.iaConfig?.descripcionEnfoque || '',
notasAdicionales: wizard.iaConfig?.notasAdicionales || '',
archivosReferencia: wizard.iaConfig?.archivosReferencia || [],
repositoriosIds: wizard.iaConfig?.repositoriosReferencia || [],
archivosAdjuntos: wizard.iaConfig?.archivosAdjuntos || [],
},
}
const response = await generatePlanAI.mutateAsync(aiInput as any)
// const createdPlan = await persistPlanFromAI.mutateAsync({
// jsonPlan: generatedJson,
// })
// navigate({ to: `/planes/${createdPlan.id}` })
console.log('Plan generado por IA:', response)
return
}
// Fallback: comportamiento previo para otros modos (mock IDs)
await new Promise((r) => setTimeout(r, 900))
const nuevoId = (() => {
if (wizard.tipoOrigen === 'MANUAL') return 'plan_new_manual_001'
if (
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
)
return 'plan_new_clone_001'
return 'plan_new_import_001'
})()
navigate({ to: `/planes/${nuevoId}` })
} catch (err: any) {
setWizard((w) => ({
...w,
isLoading: false,
errorMessage: err?.message ?? 'Error generando el plan con IA',
}))
} finally {
setWizard((w) => ({ ...w, isLoading: false }))
}
} }
return ( return (
@@ -164,7 +215,6 @@ export default function NuevoPlanModalContainer() {
<PasoDetallesPanel <PasoDetallesPanel
wizard={wizard} wizard={wizard}
onChange={setWizard} onChange={setWizard}
onGenerarIA={generarPreviewIA}
isLoading={wizard.isLoading} isLoading={wizard.isLoading}
/> />
</Wizard.Stepper.Panel> </Wizard.Stepper.Panel>

View File

@@ -1,7 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import type { NewPlanWizardState, PlanPreview } from "../types"; import type { NewPlanWizardState } from "../types";
import type { NivelPlanEstudio, TipoCiclo } from "@/data/types/domain";
export function useNuevoPlanWizard() { export function useNuevoPlanWizard() {
const [wizard, setWizard] = useState<NewPlanWizardState>({ const [wizard, setWizard] = useState<NewPlanWizardState>({
@@ -81,45 +80,11 @@ export function useNuevoPlanWizard() {
return false; return false;
})(); })();
const generarPreviewIA = async () => {
setWizard((w) => ({ ...w, isLoading: true, errorMessage: null }));
await new Promise((r) => setTimeout(r, 800));
// Ensure preview has the stricter types required by `PlanPreview`.
let tipoCicloSafe: TipoCiclo;
if (wizard.datosBasicos.tipoCiclo === "") {
tipoCicloSafe = "Semestre";
} else {
tipoCicloSafe = wizard.datosBasicos.tipoCiclo;
}
const numCiclosSafe: number =
typeof wizard.datosBasicos.numCiclos === "number"
? wizard.datosBasicos.numCiclos
: 1;
const preview: PlanPreview = {
nombrePlan: wizard.datosBasicos.nombrePlan || "Plan sin nombre",
nivel: wizard.datosBasicos.nivel as NivelPlanEstudio,
tipoCiclo: tipoCicloSafe,
numCiclos: numCiclosSafe,
numAsignaturasAprox: numCiclosSafe * 6,
secciones: [
{ id: "obj", titulo: "Objetivos", resumen: "Borrador de objetivos…" },
{ id: "perfil", titulo: "Perfil de egreso", resumen: "Borrador…" },
],
};
setWizard((w: NewPlanWizardState) => ({
...w,
isLoading: false,
resumen: { previewPlan: preview },
}));
};
return { return {
wizard, wizard,
setWizard, setWizard,
canContinueDesdeModo, canContinueDesdeModo,
canContinueDesdeBasicos, canContinueDesdeBasicos,
canContinueDesdeDetalles, canContinueDesdeDetalles,
generarPreviewIA,
}; };
} }