Merge branch 'feature/query-hooks'
This commit is contained in:
@@ -31,6 +31,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@stepperize/react": "^5.1.9",
|
||||
"@supabase/supabase-js": "^2.90.1",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@tanstack/react-devtools": "^0.7.0",
|
||||
"@tanstack/react-query": "^5.66.5",
|
||||
|
||||
45
src/data/api/_helpers.ts
Normal file
45
src/data/api/_helpers.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { PostgrestError, AuthError, SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "../types/database";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code?: string,
|
||||
public readonly details?: unknown,
|
||||
public readonly hint?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export function throwIfError(error: PostgrestError | AuthError | null): void {
|
||||
if (!error) return;
|
||||
|
||||
const anyErr = error as any;
|
||||
throw new ApiError(
|
||||
anyErr.message ?? "Error inesperado",
|
||||
anyErr.code,
|
||||
anyErr.details,
|
||||
anyErr.hint
|
||||
);
|
||||
}
|
||||
|
||||
export function requireData<T>(data: T | null | undefined, message = "Respuesta vacía"): T {
|
||||
if (data === null || data === undefined) throw new ApiError(message);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getUserIdOrThrow(supabase: SupabaseClient<Database>): Promise<string> {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
throwIfError(error);
|
||||
if (!data?.user?.id) throw new ApiError("No hay sesión activa (auth).");
|
||||
return data.user.id;
|
||||
}
|
||||
|
||||
export function buildRange(limit?: number, offset?: number): { from?: number; to?: number } {
|
||||
if (!limit) return {};
|
||||
const from = Math.max(0, offset ?? 0);
|
||||
const to = from + Math.max(1, limit) - 1;
|
||||
return { from, to };
|
||||
}
|
||||
81
src/data/api/ai.api.ts
Normal file
81
src/data/api/ai.api.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { invokeEdge } from "../supabase/invokeEdge";
|
||||
import type { InteraccionIA, UUID } from "../types/domain";
|
||||
|
||||
const EDGE = {
|
||||
ai_plan_improve: "ai_plan_improve",
|
||||
ai_plan_chat: "ai_plan_chat",
|
||||
ai_subject_improve: "ai_subject_improve",
|
||||
ai_subject_chat: "ai_subject_chat",
|
||||
|
||||
library_search: "library_search",
|
||||
} as const;
|
||||
|
||||
export async function ai_plan_improve(payload: {
|
||||
planId: UUID;
|
||||
sectionKey: string; // ej: "perfil_de_egreso" o tu key interna
|
||||
prompt: string;
|
||||
context?: Record<string, any>;
|
||||
fuentes?: {
|
||||
archivosIds?: UUID[];
|
||||
vectorStoresIds?: UUID[];
|
||||
usarMCP?: boolean;
|
||||
conversacionId?: string;
|
||||
};
|
||||
}): Promise<{ interaccion: InteraccionIA; propuesta: any }> {
|
||||
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(EDGE.ai_plan_improve, payload);
|
||||
}
|
||||
|
||||
export async function ai_plan_chat(payload: {
|
||||
planId: UUID;
|
||||
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
|
||||
fuentes?: {
|
||||
archivosIds?: UUID[];
|
||||
vectorStoresIds?: UUID[];
|
||||
usarMCP?: boolean;
|
||||
conversacionId?: string;
|
||||
};
|
||||
}): Promise<{ interaccion: InteraccionIA; reply: string; meta?: any }> {
|
||||
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(EDGE.ai_plan_chat, payload);
|
||||
}
|
||||
|
||||
export async function ai_subject_improve(payload: {
|
||||
subjectId: UUID;
|
||||
sectionKey: string;
|
||||
prompt: string;
|
||||
context?: Record<string, any>;
|
||||
fuentes?: {
|
||||
archivosIds?: UUID[];
|
||||
vectorStoresIds?: UUID[];
|
||||
usarMCP?: boolean;
|
||||
conversacionId?: string;
|
||||
};
|
||||
}): Promise<{ interaccion: InteraccionIA; propuesta: any }> {
|
||||
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(EDGE.ai_subject_improve, payload);
|
||||
}
|
||||
|
||||
export async function ai_subject_chat(payload: {
|
||||
subjectId: UUID;
|
||||
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
|
||||
fuentes?: {
|
||||
archivosIds?: UUID[];
|
||||
vectorStoresIds?: UUID[];
|
||||
usarMCP?: boolean;
|
||||
conversacionId?: string;
|
||||
};
|
||||
}): Promise<{ interaccion: InteraccionIA; reply: string; meta?: any }> {
|
||||
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(EDGE.ai_subject_chat, payload);
|
||||
}
|
||||
|
||||
/** Biblioteca (Edge; adapta a tu API real) */
|
||||
export type LibraryItem = {
|
||||
id: string;
|
||||
titulo: string;
|
||||
autor?: string;
|
||||
isbn?: string;
|
||||
citaSugerida?: string;
|
||||
disponibilidad?: string;
|
||||
};
|
||||
|
||||
export async function library_search(payload: { query: string; limit?: number }): Promise<LibraryItem[]> {
|
||||
return invokeEdge<LibraryItem[]>(EDGE.library_search, payload);
|
||||
}
|
||||
37
src/data/api/files.api.ts
Normal file
37
src/data/api/files.api.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { invokeEdge } from "../supabase/invokeEdge";
|
||||
import { throwIfError } from "./_helpers";
|
||||
import type { AppFile } from "./openaiFiles.api";
|
||||
|
||||
const EDGE = {
|
||||
signedUrl: "files_signed_url", // Edge: recibe archivoId o ruta_storage y devuelve URL
|
||||
} as const;
|
||||
|
||||
export async function files_list(params?: {
|
||||
temporal?: boolean;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
}): Promise<AppFile[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
let q = supabase
|
||||
.from("archivos")
|
||||
.select("id,openai_file_id,nombre,mime_type,bytes,ruta_storage,temporal,notas,subido_en")
|
||||
.order("subido_en", { ascending: false });
|
||||
|
||||
if (typeof params?.temporal === "boolean") q = q.eq("temporal", params.temporal);
|
||||
if (params?.search?.trim()) q = q.ilike("nombre", `%${params.search.trim()}%`);
|
||||
if (params?.limit) q = q.limit(params.limit);
|
||||
|
||||
const { data, error } = await q;
|
||||
throwIfError(error);
|
||||
return (data ?? []) as AppFile[];
|
||||
}
|
||||
|
||||
/** Para preview/descarga desde espejo — SIN tocar storage directo en el cliente */
|
||||
export async function files_get_signed_url(payload: {
|
||||
archivoId: string; // id interno (tabla archivos)
|
||||
expiresIn?: number; // segundos
|
||||
}): Promise<{ signedUrl: string }> {
|
||||
return invokeEdge<{ signedUrl: string }>(EDGE.signedUrl, payload);
|
||||
}
|
||||
66
src/data/api/meta.api.ts
Normal file
66
src/data/api/meta.api.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { throwIfError } from "./_helpers";
|
||||
import type { Carrera, EstadoPlan, EstructuraAsignatura, EstructuraPlan, Facultad } from "../types/domain";
|
||||
|
||||
export async function facultades_list(): Promise<Facultad[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("facultades")
|
||||
.select("id,nombre,nombre_corto,color,icono,creado_en,actualizado_en")
|
||||
.order("nombre", { ascending: true });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function carreras_list(params?: { facultadId?: string | null }): Promise<Carrera[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
let q = supabase
|
||||
.from("carreras")
|
||||
.select(
|
||||
"id,facultad_id,nombre,nombre_corto,clave_sep,activa,creado_en,actualizado_en, facultades(id,nombre,nombre_corto,color,icono)"
|
||||
)
|
||||
.order("nombre", { ascending: true });
|
||||
|
||||
if (params?.facultadId) q = q.eq("facultad_id", params.facultadId);
|
||||
|
||||
const { data, error } = await q;
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function estructuras_plan_list(params?: { nivel?: string | null }): Promise<EstructuraPlan[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
// Nota: en tu DDL no hay "nivel" en estructuras_plan; si luego lo agregas, filtra aquí.
|
||||
const { data, error } = await supabase
|
||||
.from("estructuras_plan")
|
||||
.select("id,nombre,tipo,version,definicion")
|
||||
.order("nombre", { ascending: true });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function estructuras_asignatura_list(): Promise<EstructuraAsignatura[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("estructuras_asignatura")
|
||||
.select("id,nombre,version,definicion")
|
||||
.order("nombre", { ascending: true });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function estados_plan_list(): Promise<EstadoPlan[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("estados_plan")
|
||||
.select("id,clave,etiqueta,orden,es_final")
|
||||
.order("orden", { ascending: true });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
31
src/data/api/notifications.api.ts
Normal file
31
src/data/api/notifications.api.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { throwIfError, getUserIdOrThrow, requireData } from "./_helpers";
|
||||
import type { Notificacion, UUID } from "../types/domain";
|
||||
|
||||
export async function notificaciones_mias_list(): Promise<Notificacion[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const userId = await getUserIdOrThrow(supabase);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("notificaciones")
|
||||
.select("id,usuario_id,tipo,payload,leida,creado_en,leida_en")
|
||||
.eq("usuario_id", userId as UUID)
|
||||
.order("creado_en", { ascending: false });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function notificaciones_marcar_leida(notificacionId: UUID): Promise<Notificacion> {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("notificaciones")
|
||||
.update({ leida: true, leida_en: new Date().toISOString() })
|
||||
.eq("id", notificacionId)
|
||||
.select("id,usuario_id,tipo,payload,leida,creado_en,leida_en")
|
||||
.single();
|
||||
|
||||
throwIfError(error);
|
||||
return requireData(data, "No se pudo marcar notificación.");
|
||||
}
|
||||
64
src/data/api/openaiFiles.api.ts
Normal file
64
src/data/api/openaiFiles.api.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { invokeEdge } from "../supabase/invokeEdge";
|
||||
import type { UUID } from "../types/domain";
|
||||
|
||||
/**
|
||||
* Metadata “canónica” para UI (archivo OpenAI + espejo en Supabase)
|
||||
* Se apoya en tu tabla `archivos`.
|
||||
*/
|
||||
export type AppFile = {
|
||||
id: UUID; // id interno (tabla archivos)
|
||||
openai_file_id: string; // id OpenAI
|
||||
nombre: string;
|
||||
mime_type: string | null;
|
||||
bytes: number | null;
|
||||
|
||||
// espejo Supabase para preview/descarga
|
||||
ruta_storage: string | null; // "bucket/path"
|
||||
signed_url?: string | null;
|
||||
|
||||
// auditoría/evidencia
|
||||
temporal: boolean;
|
||||
notas?: string | null;
|
||||
|
||||
subido_en: string;
|
||||
};
|
||||
|
||||
const EDGE = {
|
||||
upload: "openai_files_upload",
|
||||
remove: "openai_files_delete",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Sube archivo a OpenAI y (opcional) crea espejo en Storage
|
||||
* - El frontend NO toca Storage.
|
||||
*/
|
||||
export async function openai_files_upload(payload: {
|
||||
/**
|
||||
* Si tu Edge soporta multipart: manda File/Blob directo.
|
||||
* Si no, manda base64/bytes (según tu implementación).
|
||||
*/
|
||||
file: File;
|
||||
|
||||
/** “temporal” = evidencia usada para generar plan/materia */
|
||||
temporal?: boolean;
|
||||
|
||||
/** contexto para auditoría */
|
||||
contexto?: {
|
||||
planId?: UUID;
|
||||
asignaturaId?: UUID;
|
||||
motivo?: "WIZARD_PLAN" | "WIZARD_MATERIA" | "ADHOC";
|
||||
};
|
||||
|
||||
/** si quieres forzar espejo para preview siempre */
|
||||
mirrorToSupabase?: boolean;
|
||||
}): Promise<AppFile> {
|
||||
return invokeEdge<AppFile>(EDGE.upload, payload);
|
||||
}
|
||||
|
||||
export async function openai_files_delete(payload: {
|
||||
openaiFileId: string;
|
||||
/** si quieres borrar también espejo y registro */
|
||||
hardDelete?: boolean;
|
||||
}): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.remove, payload);
|
||||
}
|
||||
260
src/data/api/plans.api.ts
Normal file
260
src/data/api/plans.api.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { invokeEdge } from "../supabase/invokeEdge";
|
||||
import { buildRange, throwIfError, requireData } from "./_helpers";
|
||||
import type {
|
||||
Asignatura,
|
||||
CambioPlan,
|
||||
LineaPlan,
|
||||
NivelPlanEstudio,
|
||||
Paged,
|
||||
PlanDatosSep,
|
||||
PlanEstudio,
|
||||
TipoCiclo,
|
||||
UUID,
|
||||
} from "../types/domain";
|
||||
|
||||
const EDGE = {
|
||||
plans_create_manual: "plans_create_manual",
|
||||
ai_generate_plan: "ai_generate_plan",
|
||||
plans_persist_from_ai: "plans_persist_from_ai",
|
||||
plans_clone_from_existing: "plans_clone_from_existing",
|
||||
plans_import_from_files: "plans_import_from_files",
|
||||
|
||||
plans_update_fields: "plans_update_fields",
|
||||
plans_update_map: "plans_update_map",
|
||||
plans_transition_state: "plans_transition_state",
|
||||
|
||||
plans_generate_document: "plans_generate_document",
|
||||
plans_get_document: "plans_get_document",
|
||||
} as const;
|
||||
|
||||
export type PlanListFilters = {
|
||||
search?: string;
|
||||
carreraId?: UUID;
|
||||
facultadId?: UUID; // filtra por carreras.facultad_id
|
||||
estadoId?: UUID;
|
||||
activo?: boolean;
|
||||
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export async function plans_list(filters: PlanListFilters = {}): Promise<Paged<PlanEstudio>> {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
let q = supabase
|
||||
.from("planes_estudio")
|
||||
.select(
|
||||
`
|
||||
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)),
|
||||
estructuras_plan(id,nombre,tipo,version,definicion),
|
||||
estados_plan(id,clave,etiqueta,orden,es_final)
|
||||
`,
|
||||
{ count: "exact" }
|
||||
)
|
||||
.order("actualizado_en", { ascending: false });
|
||||
|
||||
if (filters.search?.trim()) q = q.ilike("nombre", `%${filters.search.trim()}%`);
|
||||
if (filters.carreraId) q = q.eq("carrera_id", filters.carreraId);
|
||||
if (filters.estadoId) q = q.eq("estado_actual_id", filters.estadoId);
|
||||
if (typeof filters.activo === "boolean") q = q.eq("activo", filters.activo);
|
||||
|
||||
// filtro por FK “hacia arriba” (PostgREST soporta filtros en recursos embebidos)
|
||||
if (filters.facultadId) q = q.eq("carreras.facultad_id", filters.facultadId);
|
||||
|
||||
const { from, to } = buildRange(filters.limit, filters.offset);
|
||||
if (typeof from === "number" && typeof to === "number") q = q.range(from, to);
|
||||
|
||||
const { data, error, count } = await q;
|
||||
throwIfError(error);
|
||||
|
||||
return { data: data ?? [], count: count ?? null };
|
||||
}
|
||||
|
||||
export async function plans_get(planId: UUID): Promise<PlanEstudio> {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("planes_estudio")
|
||||
.select(
|
||||
`
|
||||
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)),
|
||||
estructuras_plan(id,nombre,tipo,version,definicion),
|
||||
estados_plan(id,clave,etiqueta,orden,es_final)
|
||||
`
|
||||
)
|
||||
.eq("id", planId)
|
||||
.single();
|
||||
|
||||
throwIfError(error);
|
||||
return requireData(data, "Plan no encontrado.");
|
||||
}
|
||||
|
||||
export async function plan_lineas_list(planId: UUID): Promise<LineaPlan[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("lineas_plan")
|
||||
.select("id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en")
|
||||
.eq("plan_estudio_id", planId)
|
||||
.order("orden", { ascending: true });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function plan_asignaturas_list(planId: UUID): Promise<Asignatura[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.select(
|
||||
"id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en"
|
||||
)
|
||||
.eq("plan_estudio_id", planId)
|
||||
.order("numero_ciclo", { ascending: true, nullsFirst: false })
|
||||
.order("orden_celda", { ascending: true, nullsFirst: false })
|
||||
.order("nombre", { ascending: true });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function plans_history(planId: UUID): Promise<CambioPlan[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("cambios_plan")
|
||||
.select("id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,interaccion_ia_id")
|
||||
.eq("plan_estudio_id", planId)
|
||||
.order("cambiado_en", { ascending: false });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
/** Wizard: crear plan manual (Edge Function) */
|
||||
export type PlansCreateManualInput = {
|
||||
carreraId: UUID;
|
||||
estructuraId: UUID;
|
||||
nombre: string;
|
||||
nivel: NivelPlanEstudio;
|
||||
tipoCiclo: TipoCiclo;
|
||||
numCiclos: number;
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>;
|
||||
};
|
||||
|
||||
export async function plans_create_manual(input: PlansCreateManualInput): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_create_manual, input);
|
||||
}
|
||||
|
||||
/** Wizard: IA genera preview JSON (Edge Function) */
|
||||
export type AIGeneratePlanInput = {
|
||||
datosBasicos: {
|
||||
nombrePlan: string;
|
||||
carreraId: UUID;
|
||||
facultadId?: UUID;
|
||||
nivel: string;
|
||||
tipoCiclo: TipoCiclo;
|
||||
numCiclos: number;
|
||||
};
|
||||
iaConfig: {
|
||||
descripcionEnfoque: string;
|
||||
poblacionObjetivo?: string;
|
||||
notasAdicionales?: string;
|
||||
archivosReferencia?: UUID[];
|
||||
repositoriosIds?: UUID[];
|
||||
usarMCP?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export async function ai_generate_plan(input: AIGeneratePlanInput): Promise<any> {
|
||||
return invokeEdge<any>(EDGE.ai_generate_plan, input);
|
||||
}
|
||||
|
||||
export async function plans_persist_from_ai(payload: { jsonPlan: any }): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_persist_from_ai, payload);
|
||||
}
|
||||
|
||||
export async function plans_clone_from_existing(payload: {
|
||||
planOrigenId: UUID;
|
||||
overrides: Partial<Pick<PlanEstudio, "nombre" | "nivel" | "tipo_ciclo" | "numero_ciclos">> & {
|
||||
carrera_id?: UUID;
|
||||
estructura_id?: UUID;
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>;
|
||||
};
|
||||
}): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_clone_from_existing, payload);
|
||||
}
|
||||
|
||||
export async function plans_import_from_files(payload: {
|
||||
datosBasicos: {
|
||||
nombrePlan: string;
|
||||
carreraId: UUID;
|
||||
estructuraId: UUID;
|
||||
nivel: string;
|
||||
tipoCiclo: TipoCiclo;
|
||||
numCiclos: number;
|
||||
};
|
||||
archivoWordPlanId: UUID;
|
||||
archivoMapaExcelId?: UUID | null;
|
||||
archivoMateriasExcelId?: UUID | null;
|
||||
}): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_import_from_files, payload);
|
||||
}
|
||||
|
||||
/** Update de tarjetas/fields del plan (Edge Function: merge server-side) */
|
||||
export type PlansUpdateFieldsPatch = {
|
||||
nombre?: string;
|
||||
nivel?: NivelPlanEstudio;
|
||||
tipo_ciclo?: TipoCiclo;
|
||||
numero_ciclos?: number;
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>;
|
||||
};
|
||||
|
||||
export async function plans_update_fields(planId: UUID, patch: PlansUpdateFieldsPatch): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_update_fields, { planId, patch });
|
||||
}
|
||||
|
||||
/** Operaciones del mapa curricular (mover/reordenar) */
|
||||
export type PlanMapOperation =
|
||||
| {
|
||||
op: "MOVE_ASIGNATURA";
|
||||
asignaturaId: UUID;
|
||||
numero_ciclo: number | null;
|
||||
linea_plan_id: UUID | null;
|
||||
orden_celda?: number | null;
|
||||
}
|
||||
| {
|
||||
op: "REORDER_CELDA";
|
||||
linea_plan_id: UUID;
|
||||
numero_ciclo: number;
|
||||
asignaturaIdsOrdenados: UUID[];
|
||||
};
|
||||
|
||||
export async function plans_update_map(planId: UUID, ops: PlanMapOperation[]): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops });
|
||||
}
|
||||
|
||||
export async function plans_transition_state(payload: {
|
||||
planId: UUID;
|
||||
haciaEstadoId: UUID;
|
||||
comentario?: string;
|
||||
}): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.plans_transition_state, payload);
|
||||
}
|
||||
|
||||
/** Documento (Edge Function: genera y devuelve URL firmada o metadata) */
|
||||
export type DocumentoResult = {
|
||||
archivoId: UUID;
|
||||
signedUrl: string;
|
||||
mimeType?: string;
|
||||
nombre?: string;
|
||||
};
|
||||
|
||||
export async function plans_generate_document(planId: UUID): Promise<DocumentoResult> {
|
||||
return invokeEdge<DocumentoResult>(EDGE.plans_generate_document, { planId });
|
||||
}
|
||||
|
||||
export async function plans_get_document(planId: UUID): Promise<DocumentoResult | null> {
|
||||
return invokeEdge<DocumentoResult | null>(EDGE.plans_get_document, { planId });
|
||||
}
|
||||
44
src/data/api/repositories.api.ts
Normal file
44
src/data/api/repositories.api.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { invokeEdge } from "../supabase/invokeEdge";
|
||||
|
||||
export type Repository = {
|
||||
id: string; // id del vector store (OpenAI) o tu id interno
|
||||
nombre: string;
|
||||
creado_en?: string;
|
||||
meta?: Record<string, any>;
|
||||
};
|
||||
|
||||
const EDGE = {
|
||||
create: "repos_create",
|
||||
remove: "repos_delete",
|
||||
add: "repos_add_files",
|
||||
detach: "repos_remove_files",
|
||||
} as const;
|
||||
|
||||
export async function repos_create(payload: {
|
||||
nombre: string;
|
||||
descripcion?: string;
|
||||
/** si tu implementación crea también registro DB */
|
||||
persist?: boolean;
|
||||
}): Promise<Repository> {
|
||||
return invokeEdge<Repository>(EDGE.create, payload);
|
||||
}
|
||||
|
||||
export async function repos_delete(payload: { repoId: string }): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.remove, payload);
|
||||
}
|
||||
|
||||
/** Agrega archivos (OpenAI file ids) a un repositorio */
|
||||
export async function repos_add_files(payload: {
|
||||
repoId: string;
|
||||
openaiFileIds: string[];
|
||||
}): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.add, payload);
|
||||
}
|
||||
|
||||
/** Quita archivos (OpenAI file ids) del repositorio */
|
||||
export async function repos_remove_files(payload: {
|
||||
repoId: string;
|
||||
openaiFileIds: string[];
|
||||
}): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.detach, payload);
|
||||
}
|
||||
192
src/data/api/subjects.api.ts
Normal file
192
src/data/api/subjects.api.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { invokeEdge } from "../supabase/invokeEdge";
|
||||
import { throwIfError, requireData } from "./_helpers";
|
||||
import type {
|
||||
Asignatura,
|
||||
BibliografiaAsignatura,
|
||||
CambioAsignatura,
|
||||
TipoAsignatura,
|
||||
UUID,
|
||||
} from "../types/domain";
|
||||
import type { DocumentoResult } from "./plans.api";
|
||||
|
||||
const EDGE = {
|
||||
subjects_create_manual: "subjects_create_manual",
|
||||
ai_generate_subject: "ai_generate_subject",
|
||||
subjects_persist_from_ai: "subjects_persist_from_ai",
|
||||
subjects_clone_from_existing: "subjects_clone_from_existing",
|
||||
subjects_import_from_file: "subjects_import_from_file",
|
||||
|
||||
subjects_update_fields: "subjects_update_fields",
|
||||
subjects_update_contenido: "subjects_update_contenido",
|
||||
subjects_update_bibliografia: "subjects_update_bibliografia",
|
||||
|
||||
subjects_generate_document: "subjects_generate_document",
|
||||
subjects_get_document: "subjects_get_document",
|
||||
} as const;
|
||||
|
||||
export async function subjects_get(subjectId: UUID): Promise<Asignatura> {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.select(
|
||||
`
|
||||
id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,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))
|
||||
),
|
||||
estructuras_asignatura(id,nombre,version,definicion)
|
||||
`
|
||||
)
|
||||
.eq("id", subjectId)
|
||||
.single();
|
||||
|
||||
throwIfError(error);
|
||||
return requireData(data, "Materia no encontrada.");
|
||||
}
|
||||
|
||||
export async function subjects_history(subjectId: UUID): Promise<CambioAsignatura[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("cambios_asignatura")
|
||||
.select(
|
||||
"id,asignatura_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,fuente,interaccion_ia_id"
|
||||
)
|
||||
.eq("asignatura_id", subjectId)
|
||||
.order("cambiado_en", { ascending: false });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function subjects_bibliografia_list(subjectId: UUID): Promise<BibliografiaAsignatura[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("bibliografia_asignatura")
|
||||
.select("id,asignatura_id,tipo,cita,tipo_fuente,biblioteca_item_id,creado_por,creado_en,actualizado_en")
|
||||
.eq("asignatura_id", subjectId)
|
||||
.order("tipo", { ascending: true })
|
||||
.order("creado_en", { ascending: true });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
/** Wizard: crear materia manual (Edge Function) */
|
||||
export type SubjectsCreateManualInput = {
|
||||
planId: UUID;
|
||||
datosBasicos: {
|
||||
nombre: string;
|
||||
clave?: string;
|
||||
tipo: TipoAsignatura;
|
||||
creditos: number;
|
||||
horasSemana?: number;
|
||||
estructuraId: UUID;
|
||||
};
|
||||
};
|
||||
|
||||
export async function subjects_create_manual(payload: SubjectsCreateManualInput): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_create_manual, payload);
|
||||
}
|
||||
|
||||
export async function ai_generate_subject(payload: {
|
||||
planId: UUID;
|
||||
datosBasicos: {
|
||||
nombre: string;
|
||||
clave?: string;
|
||||
tipo: TipoAsignatura;
|
||||
creditos: number;
|
||||
horasSemana?: number;
|
||||
estructuraId: UUID;
|
||||
};
|
||||
iaConfig: {
|
||||
descripcionEnfoque: string;
|
||||
notasAdicionales?: string;
|
||||
archivosExistentesIds?: UUID[];
|
||||
repositoriosIds?: UUID[];
|
||||
archivosAdhocIds?: UUID[];
|
||||
usarMCP?: boolean;
|
||||
};
|
||||
}): Promise<any> {
|
||||
return invokeEdge<any>(EDGE.ai_generate_subject, payload);
|
||||
}
|
||||
|
||||
export async function subjects_persist_from_ai(payload: { planId: UUID; jsonMateria: any }): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_persist_from_ai, payload);
|
||||
}
|
||||
|
||||
export async function subjects_clone_from_existing(payload: {
|
||||
materiaOrigenId: UUID;
|
||||
planDestinoId: UUID;
|
||||
overrides?: Partial<{
|
||||
nombre: string;
|
||||
codigo: string;
|
||||
tipo: TipoAsignatura;
|
||||
creditos: number;
|
||||
horas_semana: number;
|
||||
}>;
|
||||
}): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_clone_from_existing, payload);
|
||||
}
|
||||
|
||||
export async function subjects_import_from_file(payload: {
|
||||
planId: UUID;
|
||||
archivoWordMateriaId: UUID;
|
||||
archivosAdicionalesIds?: UUID[];
|
||||
}): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_import_from_file, payload);
|
||||
}
|
||||
|
||||
/** Guardado de tarjetas/fields (Edge: merge server-side en asignaturas.datos y columnas) */
|
||||
export type SubjectsUpdateFieldsPatch = Partial<{
|
||||
codigo: string | null;
|
||||
nombre: string;
|
||||
tipo: TipoAsignatura;
|
||||
creditos: number;
|
||||
horas_semana: number | null;
|
||||
numero_ciclo: number | null;
|
||||
linea_plan_id: UUID | null;
|
||||
|
||||
datos: Record<string, any>;
|
||||
}>;
|
||||
|
||||
export async function subjects_update_fields(subjectId: UUID, patch: SubjectsUpdateFieldsPatch): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_update_fields, { subjectId, patch });
|
||||
}
|
||||
|
||||
export async function subjects_update_contenido(subjectId: UUID, unidades: any[]): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_update_contenido, { subjectId, unidades });
|
||||
}
|
||||
|
||||
export type BibliografiaUpsertInput = Array<{
|
||||
id?: UUID;
|
||||
tipo: "BASICA" | "COMPLEMENTARIA";
|
||||
cita: string;
|
||||
tipo_fuente?: "MANUAL" | "BIBLIOTECA";
|
||||
biblioteca_item_id?: string | null;
|
||||
}>;
|
||||
|
||||
export async function subjects_update_bibliografia(
|
||||
subjectId: UUID,
|
||||
entries: BibliografiaUpsertInput
|
||||
): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.subjects_update_bibliografia, { subjectId, entries });
|
||||
}
|
||||
|
||||
/** Documento SEP materia */
|
||||
/* export type DocumentoResult = {
|
||||
archivoId: UUID;
|
||||
signedUrl: string;
|
||||
mimeType?: string;
|
||||
nombre?: string;
|
||||
}; */
|
||||
|
||||
export async function subjects_generate_document(subjectId: UUID): Promise<DocumentoResult> {
|
||||
return invokeEdge<DocumentoResult>(EDGE.subjects_generate_document, { subjectId });
|
||||
}
|
||||
|
||||
export async function subjects_get_document(subjectId: UUID): Promise<DocumentoResult | null> {
|
||||
return invokeEdge<DocumentoResult | null>(EDGE.subjects_get_document, { subjectId });
|
||||
}
|
||||
31
src/data/api/tasks.api.ts
Normal file
31
src/data/api/tasks.api.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { throwIfError, getUserIdOrThrow, requireData } from "./_helpers";
|
||||
import type { TareaRevision, UUID } from "../types/domain";
|
||||
|
||||
export async function tareas_mias_list(): Promise<TareaRevision[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
const userId = await getUserIdOrThrow(supabase);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("tareas_revision")
|
||||
.select("id,plan_estudio_id,asignado_a,rol_id,estado_id,estatus,fecha_limite,creado_en,completado_en")
|
||||
.eq("asignado_a", userId as UUID)
|
||||
.order("creado_en", { ascending: false });
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function tareas_marcar_completada(tareaId: UUID): Promise<TareaRevision> {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("tareas_revision")
|
||||
.update({ estatus: "COMPLETADA", completado_en: new Date().toISOString() })
|
||||
.eq("id", tareaId)
|
||||
.select("id,plan_estudio_id,asignado_a,rol_id,estado_id,estatus,fecha_limite,creado_en,completado_en")
|
||||
.single();
|
||||
|
||||
throwIfError(error);
|
||||
return requireData(data, "No se pudo marcar tarea.");
|
||||
}
|
||||
28
src/data/hooks/useAI.ts
Normal file
28
src/data/hooks/useAI.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
ai_plan_chat,
|
||||
ai_plan_improve,
|
||||
ai_subject_chat,
|
||||
ai_subject_improve,
|
||||
library_search,
|
||||
} from "../api/ai.api";
|
||||
|
||||
export function useAIPlanImprove() {
|
||||
return useMutation({ mutationFn: ai_plan_improve });
|
||||
}
|
||||
|
||||
export function useAIPlanChat() {
|
||||
return useMutation({ mutationFn: ai_plan_chat });
|
||||
}
|
||||
|
||||
export function useAISubjectImprove() {
|
||||
return useMutation({ mutationFn: ai_subject_improve });
|
||||
}
|
||||
|
||||
export function useAISubjectChat() {
|
||||
return useMutation({ mutationFn: ai_subject_chat });
|
||||
}
|
||||
|
||||
export function useLibrarySearch() {
|
||||
return useMutation({ mutationFn: library_search });
|
||||
}
|
||||
59
src/data/hooks/useAuth.ts
Normal file
59
src/data/hooks/useAuth.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { qk } from "../query/keys";
|
||||
import { throwIfError } from "../api/_helpers";
|
||||
|
||||
export function useSession() {
|
||||
const supabase = supabaseBrowser();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: qk.session(),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase.auth.getSession();
|
||||
throwIfError(error);
|
||||
return data.session ?? null;
|
||||
},
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const { data } = supabase.auth.onAuthStateChange(() => {
|
||||
qc.invalidateQueries({ queryKey: qk.session() });
|
||||
qc.invalidateQueries({ queryKey: qk.meProfile() });
|
||||
qc.invalidateQueries({ queryKey: qk.auth });
|
||||
});
|
||||
|
||||
return () => data.subscription.unsubscribe();
|
||||
}, [supabase, qc]);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export function useMeProfile() {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: qk.meProfile(),
|
||||
queryFn: async () => {
|
||||
const { data: u, error: uErr } = await supabase.auth.getUser();
|
||||
throwIfError(uErr);
|
||||
const userId = u.user?.id;
|
||||
if (!userId) return null;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("usuarios_app")
|
||||
.select("id,nombre_completo,email,externo,creado_en,actualizado_en")
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
|
||||
// si aún no existe perfil en usuarios_app, permite null (tu seed/trigger puede crearlo)
|
||||
if (error && (error as any).code === "PGRST116") return null;
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? null;
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
43
src/data/hooks/useFiles.ts
Normal file
43
src/data/hooks/useFiles.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { files_get_signed_url, files_list } from "../api/files.api";
|
||||
import { openai_files_delete, openai_files_upload } from "../api/openaiFiles.api";
|
||||
|
||||
const qkFiles = {
|
||||
list: (filters: any) => ["files", "list", filters] as const,
|
||||
};
|
||||
|
||||
export function useFilesList(filters?: { temporal?: boolean; search?: string; limit?: number }) {
|
||||
return useQuery({
|
||||
queryKey: qkFiles.list(filters ?? {}),
|
||||
queryFn: () => files_list(filters),
|
||||
staleTime: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadOpenAIFile() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: openai_files_upload,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["files"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteOpenAIFile() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: openai_files_delete,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["files"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useFileSignedUrl() {
|
||||
return useMutation({
|
||||
mutationFn: files_get_signed_url,
|
||||
});
|
||||
}
|
||||
49
src/data/hooks/useMeta.ts
Normal file
49
src/data/hooks/useMeta.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../query/keys";
|
||||
import {
|
||||
carreras_list,
|
||||
estados_plan_list,
|
||||
estructuras_asignatura_list,
|
||||
estructuras_plan_list,
|
||||
facultades_list,
|
||||
} from "../api/meta.api";
|
||||
|
||||
export function useFacultades() {
|
||||
return useQuery({
|
||||
queryKey: qk.facultades(),
|
||||
queryFn: facultades_list,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCarreras(params?: { facultadId?: string | null }) {
|
||||
return useQuery({
|
||||
queryKey: qk.carreras(params?.facultadId ?? null),
|
||||
queryFn: () => carreras_list(params),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEstructurasPlan(params?: { nivel?: string | null }) {
|
||||
return useQuery({
|
||||
queryKey: qk.estructurasPlan(params?.nivel ?? null),
|
||||
queryFn: () => estructuras_plan_list(params),
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEstructurasAsignatura() {
|
||||
return useQuery({
|
||||
queryKey: qk.estructurasAsignatura(),
|
||||
queryFn: estructuras_asignatura_list,
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEstadosPlan() {
|
||||
return useQuery({
|
||||
queryKey: qk.estadosPlan(),
|
||||
queryFn: estados_plan_list,
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
}
|
||||
49
src/data/hooks/useNotifications.ts
Normal file
49
src/data/hooks/useNotifications.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../query/keys";
|
||||
import { notificaciones_marcar_leida, notificaciones_mias_list } from "../api/notifications.api";
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
|
||||
export function useMisNotificaciones() {
|
||||
return useQuery({
|
||||
queryKey: qk.notificaciones(),
|
||||
queryFn: notificaciones_mias_list,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** 🔥 Opcional: realtime (si tienes Realtime habilitado) */
|
||||
export function useRealtimeNotificaciones(enable = true) {
|
||||
const supabase = supabaseBrowser();
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enable) return;
|
||||
|
||||
const channel = supabase
|
||||
.channel("rt-notificaciones")
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "*", schema: "public", table: "notificaciones" },
|
||||
() => {
|
||||
qc.invalidateQueries({ queryKey: qk.notificaciones() });
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [enable, supabase, qc]);
|
||||
}
|
||||
|
||||
export function useMarcarNotificacionLeida() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: notificaciones_marcar_leida,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.notificaciones() });
|
||||
},
|
||||
});
|
||||
}
|
||||
210
src/data/hooks/usePlans.ts
Normal file
210
src/data/hooks/usePlans.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../query/keys";
|
||||
import type { PlanEstudio, UUID } from "../types/domain";
|
||||
import type { PlanListFilters, PlanMapOperation, PlansCreateManualInput, PlansUpdateFieldsPatch } from "../api/plans.api";
|
||||
import {
|
||||
ai_generate_plan,
|
||||
plan_asignaturas_list,
|
||||
plan_lineas_list,
|
||||
plans_clone_from_existing,
|
||||
plans_create_manual,
|
||||
plans_generate_document,
|
||||
plans_get,
|
||||
plans_get_document,
|
||||
plans_history,
|
||||
plans_import_from_files,
|
||||
plans_list,
|
||||
plans_persist_from_ai,
|
||||
plans_transition_state,
|
||||
plans_update_fields,
|
||||
plans_update_map,
|
||||
} from "../api/plans.api";
|
||||
|
||||
export function usePlanes(filters: PlanListFilters) {
|
||||
// 🧠 Tip: memoiza "filters" (useMemo) para que queryKey sea estable.
|
||||
return useQuery({
|
||||
queryKey: qk.planesList(filters),
|
||||
queryFn: () => plans_list(filters),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlan(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.plan(planId) : ["planes", "detail", null],
|
||||
queryFn: () => plans_get(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlanLineas(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.planLineas(planId) : ["planes", "lineas", null],
|
||||
queryFn: () => plan_lineas_list(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlanAsignaturas(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.planAsignaturas(planId) : ["planes", "asignaturas", null],
|
||||
queryFn: () => plan_asignaturas_list(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlanHistorial(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.planHistorial(planId) : ["planes", "historial", null],
|
||||
queryFn: () => plans_history(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlanDocumento(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.planDocumento(planId) : ["planes", "documento", null],
|
||||
queryFn: () => plans_get_document(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------ Mutations ------------------ */
|
||||
|
||||
export function useCreatePlanManual() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: PlansCreateManualInput) => plans_create_manual(input),
|
||||
onSuccess: (plan) => {
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.setQueryData(qk.plan(plan.id), plan);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGeneratePlanAI() {
|
||||
return useMutation({
|
||||
mutationFn: ai_generate_plan,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePersistPlanFromAI() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: { jsonPlan: any }) => plans_persist_from_ai(payload),
|
||||
onSuccess: (plan) => {
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.setQueryData(qk.plan(plan.id), plan);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useClonePlan() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: plans_clone_from_existing,
|
||||
onSuccess: (plan) => {
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.setQueryData(qk.plan(plan.id), plan);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportPlanFromFiles() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: plans_import_from_files,
|
||||
onSuccess: (plan) => {
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.setQueryData(qk.plan(plan.id), plan);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePlanFields() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { planId: UUID; patch: PlansUpdateFieldsPatch }) =>
|
||||
plans_update_fields(vars.planId, vars.patch),
|
||||
onSuccess: (updated) => {
|
||||
qc.setQueryData(qk.plan(updated.id), updated);
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(updated.id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePlanMapa() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { planId: UUID; ops: PlanMapOperation[] }) => plans_update_map(vars.planId, vars.ops),
|
||||
|
||||
// ✅ Optimista (rápida) para el caso MOVE_ASIGNATURA
|
||||
onMutate: async (vars) => {
|
||||
await qc.cancelQueries({ queryKey: qk.planAsignaturas(vars.planId) });
|
||||
const prev = qc.getQueryData<any>(qk.planAsignaturas(vars.planId));
|
||||
|
||||
// solo optimizamos MOVEs simples
|
||||
const moves = vars.ops.filter((x) => x.op === "MOVE_ASIGNATURA") as Array<
|
||||
Extract<PlanMapOperation, { op: "MOVE_ASIGNATURA" }>
|
||||
>;
|
||||
|
||||
if (prev && Array.isArray(prev) && moves.length) {
|
||||
const next = prev.map((a: any) => {
|
||||
const m = moves.find((x) => x.asignaturaId === a.id);
|
||||
if (!m) return a;
|
||||
return {
|
||||
...a,
|
||||
numero_ciclo: m.numero_ciclo,
|
||||
linea_plan_id: m.linea_plan_id,
|
||||
orden_celda: m.orden_celda ?? a.orden_celda,
|
||||
};
|
||||
});
|
||||
qc.setQueryData(qk.planAsignaturas(vars.planId), next);
|
||||
}
|
||||
|
||||
return { prev };
|
||||
},
|
||||
|
||||
onError: (_err, vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(qk.planAsignaturas(vars.planId), ctx.prev);
|
||||
},
|
||||
|
||||
onSuccess: (_ok, vars) => {
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(vars.planId) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTransitionPlanEstado() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: plans_transition_state,
|
||||
onSuccess: (_ok, vars) => {
|
||||
qc.invalidateQueries({ queryKey: qk.plan(vars.planId) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) });
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGeneratePlanDocumento() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (planId: UUID) => plans_generate_document(planId),
|
||||
onSuccess: (_doc, planId) => {
|
||||
qc.invalidateQueries({ queryKey: qk.planDocumento(planId) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(planId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
46
src/data/hooks/useRepositories.ts
Normal file
46
src/data/hooks/useRepositories.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { repos_add_files, repos_create, repos_delete, repos_remove_files } from "../api/repositories.api";
|
||||
|
||||
export function useCreateRepository() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: repos_create,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["repos"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRepository() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: repos_delete,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["repos"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRepoAddFiles() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: repos_add_files,
|
||||
onSuccess: (_ok, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ["repos", vars.repoId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRepoRemoveFiles() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: repos_remove_files,
|
||||
onSuccess: (_ok, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ["repos", vars.repoId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
166
src/data/hooks/useSubjects.ts
Normal file
166
src/data/hooks/useSubjects.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../query/keys";
|
||||
import type { UUID } from "../types/domain";
|
||||
import type {
|
||||
BibliografiaUpsertInput,
|
||||
SubjectsCreateManualInput,
|
||||
SubjectsUpdateFieldsPatch,
|
||||
} from "../api/subjects.api";
|
||||
import {
|
||||
ai_generate_subject,
|
||||
subjects_bibliografia_list,
|
||||
subjects_clone_from_existing,
|
||||
subjects_create_manual,
|
||||
subjects_generate_document,
|
||||
subjects_get,
|
||||
subjects_get_document,
|
||||
subjects_history,
|
||||
subjects_import_from_file,
|
||||
subjects_persist_from_ai,
|
||||
subjects_update_bibliografia,
|
||||
subjects_update_contenido,
|
||||
subjects_update_fields,
|
||||
} from "../api/subjects.api";
|
||||
|
||||
export function useSubject(subjectId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: subjectId ? qk.asignatura(subjectId) : ["asignaturas", "detail", null],
|
||||
queryFn: () => subjects_get(subjectId as UUID),
|
||||
enabled: Boolean(subjectId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubjectBibliografia(subjectId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: subjectId ? qk.asignaturaBibliografia(subjectId) : ["asignaturas", "bibliografia", null],
|
||||
queryFn: () => subjects_bibliografia_list(subjectId as UUID),
|
||||
enabled: Boolean(subjectId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubjectHistorial(subjectId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: subjectId ? qk.asignaturaHistorial(subjectId) : ["asignaturas", "historial", null],
|
||||
queryFn: () => subjects_history(subjectId as UUID),
|
||||
enabled: Boolean(subjectId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubjectDocumento(subjectId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: subjectId ? qk.asignaturaDocumento(subjectId) : ["asignaturas", "documento", null],
|
||||
queryFn: () => subjects_get_document(subjectId as UUID),
|
||||
enabled: Boolean(subjectId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------ Mutations ------------------ */
|
||||
|
||||
export function useCreateSubjectManual() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: SubjectsCreateManualInput) => subjects_create_manual(payload),
|
||||
onSuccess: (subject) => {
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateSubjectAI() {
|
||||
return useMutation({ mutationFn: ai_generate_subject });
|
||||
}
|
||||
|
||||
export function usePersistSubjectFromAI() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: { planId: UUID; jsonMateria: any }) => subjects_persist_from_ai(payload),
|
||||
onSuccess: (subject) => {
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCloneSubject() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: subjects_clone_from_existing,
|
||||
onSuccess: (subject) => {
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportSubjectFromFile() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: subjects_import_from_file,
|
||||
onSuccess: (subject) => {
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSubjectFields() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { subjectId: UUID; patch: SubjectsUpdateFieldsPatch }) =>
|
||||
subjects_update_fields(vars.subjectId, vars.patch),
|
||||
onSuccess: (updated) => {
|
||||
qc.setQueryData(qk.asignatura(updated.id), updated);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(updated.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSubjectContenido() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { subjectId: UUID; unidades: any[] }) =>
|
||||
subjects_update_contenido(vars.subjectId, vars.unidades),
|
||||
onSuccess: (updated) => {
|
||||
qc.setQueryData(qk.asignatura(updated.id), updated);
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSubjectBibliografia() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { subjectId: UUID; entries: BibliografiaUpsertInput }) =>
|
||||
subjects_update_bibliografia(vars.subjectId, vars.entries),
|
||||
onSuccess: (_ok, vars) => {
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaBibliografia(vars.subjectId) });
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(vars.subjectId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateSubjectDocumento() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (subjectId: UUID) => subjects_generate_document(subjectId),
|
||||
onSuccess: (_doc, subjectId) => {
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaDocumento(subjectId) });
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(subjectId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
22
src/data/hooks/useTasks.ts
Normal file
22
src/data/hooks/useTasks.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../query/keys";
|
||||
import { tareas_marcar_completada, tareas_mias_list } from "../api/tasks.api";
|
||||
|
||||
export function useMisTareas() {
|
||||
return useQuery({
|
||||
queryKey: qk.tareas(),
|
||||
queryFn: tareas_mias_list,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarcarTareaCompletada() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: tareas_marcar_completada,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.tareas() });
|
||||
},
|
||||
});
|
||||
}
|
||||
23
src/data/index.ts
Normal file
23
src/data/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export * from "./supabase/client";
|
||||
export * from "./supabase/invokeEdge";
|
||||
|
||||
export * from "./query/queryClient";
|
||||
export * from "./query/keys";
|
||||
|
||||
export * from "./types/domain";
|
||||
|
||||
export * from "./api/meta.api";
|
||||
export * from "./api/plans.api";
|
||||
export * from "./api/subjects.api";
|
||||
export * from "./api/files.api";
|
||||
export * from "./api/ai.api";
|
||||
export * from "./api/tasks.api";
|
||||
export * from "./api/notifications.api";
|
||||
|
||||
export * from "./hooks/useAuth";
|
||||
export * from "./hooks/useMeta";
|
||||
export * from "./hooks/usePlans";
|
||||
export * from "./hooks/useSubjects";
|
||||
export * from "./hooks/useAI";
|
||||
export * from "./hooks/useTasks";
|
||||
export * from "./hooks/useNotifications";
|
||||
31
src/data/query/keys.ts
Normal file
31
src/data/query/keys.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export const qk = {
|
||||
auth: ["auth"] as const,
|
||||
session: () => ["auth", "session"] as const,
|
||||
meProfile: () => ["auth", "meProfile"] as const,
|
||||
|
||||
facultades: () => ["meta", "facultades"] as const,
|
||||
carreras: (facultadId?: string | null) =>
|
||||
["meta", "carreras", { facultadId: facultadId ?? null }] as const,
|
||||
estructurasPlan: (nivel?: string | null) =>
|
||||
["meta", "estructurasPlan", { nivel: nivel ?? null }] as const,
|
||||
estructurasAsignatura: () => ["meta", "estructurasAsignatura"] as const,
|
||||
estadosPlan: () => ["meta", "estadosPlan"] as const,
|
||||
|
||||
planesList: (filters: unknown) => ["planes", "list", filters] as const,
|
||||
plan: (planId: string) => ["planes", "detail", planId] as const,
|
||||
planLineas: (planId: string) => ["planes", planId, "lineas"] as const,
|
||||
planAsignaturas: (planId: string) => ["planes", planId, "asignaturas"] as const,
|
||||
planHistorial: (planId: string) => ["planes", planId, "historial"] as const,
|
||||
planDocumento: (planId: string) => ["planes", planId, "documento"] as const,
|
||||
|
||||
asignatura: (asignaturaId: string) => ["asignaturas", "detail", asignaturaId] as const,
|
||||
asignaturaBibliografia: (asignaturaId: string) =>
|
||||
["asignaturas", asignaturaId, "bibliografia"] as const,
|
||||
asignaturaHistorial: (asignaturaId: string) =>
|
||||
["asignaturas", asignaturaId, "historial"] as const,
|
||||
asignaturaDocumento: (asignaturaId: string) =>
|
||||
["asignaturas", asignaturaId, "documento"] as const,
|
||||
|
||||
tareas: () => ["tareas", "mias"] as const,
|
||||
notificaciones: () => ["notificaciones", "mias"] as const,
|
||||
};
|
||||
14
src/data/query/queryClient.ts
Normal file
14
src/data/query/queryClient.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: (failureCount) => failureCount < 2,
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
31
src/data/supabase/client.ts
Normal file
31
src/data/supabase/client.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "../types/database";
|
||||
import { getEnv } from "./env";
|
||||
|
||||
let _client: SupabaseClient<Database> | null = null;
|
||||
|
||||
export function supabaseBrowser(): SupabaseClient<Database> {
|
||||
if (_client) return _client;
|
||||
|
||||
const url = getEnv(
|
||||
"VITE_SUPABASE_URL",
|
||||
"NEXT_PUBLIC_SUPABASE_URL",
|
||||
"SUPABASE_URL"
|
||||
);
|
||||
|
||||
const anonKey = getEnv(
|
||||
"VITE_SUPABASE_ANON_KEY",
|
||||
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
||||
"SUPABASE_ANON_KEY"
|
||||
);
|
||||
|
||||
_client = createClient<Database>(url, anonKey, {
|
||||
auth: {
|
||||
persistSession: true,
|
||||
autoRefreshToken: true,
|
||||
detectSessionInUrl: true,
|
||||
},
|
||||
});
|
||||
|
||||
return _client;
|
||||
}
|
||||
17
src/data/supabase/env.ts
Normal file
17
src/data/supabase/env.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function getEnv(...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const fromProcess =
|
||||
typeof process !== "undefined" ? (process as any).env?.[key] : undefined;
|
||||
|
||||
// Vite / bundlers
|
||||
const fromImportMeta =
|
||||
typeof import.meta !== "undefined" ? (import.meta as any).env?.[key] : undefined;
|
||||
|
||||
const value = fromProcess ?? fromImportMeta;
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Falta variable de entorno. Probé: ${keys.join(", ")}`
|
||||
);
|
||||
}
|
||||
47
src/data/supabase/invokeEdge.ts
Normal file
47
src/data/supabase/invokeEdge.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "../types/database";
|
||||
import { supabaseBrowser } from "./client";
|
||||
|
||||
export type EdgeInvokeOptions = {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export class EdgeFunctionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly functionName: string,
|
||||
public readonly status?: number,
|
||||
public readonly details?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = "EdgeFunctionError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function invokeEdge<TOut>(
|
||||
functionName: string,
|
||||
body?: unknown,
|
||||
opts: EdgeInvokeOptions = {},
|
||||
client?: SupabaseClient<Database>
|
||||
): Promise<TOut> {
|
||||
const supabase = client ?? supabaseBrowser();
|
||||
|
||||
const { data, error } = await supabase.functions.invoke(functionName, {
|
||||
body,
|
||||
method: opts.method ?? "POST",
|
||||
headers: opts.headers,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
const anyErr = error as any;
|
||||
throw new EdgeFunctionError(
|
||||
anyErr.message ?? "Error en Edge Function",
|
||||
functionName,
|
||||
anyErr.status,
|
||||
anyErr
|
||||
);
|
||||
}
|
||||
|
||||
return data as TOut;
|
||||
}
|
||||
9
src/data/types/database.ts
Normal file
9
src/data/types/database.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type Json =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: Json }
|
||||
| Json[];
|
||||
|
||||
export type Database = any; // ✅ Reemplaza por tipos generados (supabase gen types typescript)
|
||||
275
src/data/types/domain.ts
Normal file
275
src/data/types/domain.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import type { Json } from "./database";
|
||||
|
||||
export type UUID = string;
|
||||
|
||||
export type TipoEstructuraPlan = "CURRICULAR" | "NO_CURRICULAR";
|
||||
export type NivelPlanEstudio =
|
||||
| "LICENCIATURA"
|
||||
| "MAESTRIA"
|
||||
| "DOCTORADO"
|
||||
| "ESPECIALIDAD"
|
||||
| "DIPLOMADO"
|
||||
| "OTRO";
|
||||
|
||||
export type TipoCiclo = "SEMESTRE" | "CUATRIMESTRE" | "TRIMESTRE" | "OTRO";
|
||||
|
||||
export type TipoOrigen = "MANUAL" | "IA" | "CLONADO_INTERNO" | "TRADICIONAL" | "OTRO";
|
||||
|
||||
export type TipoAsignatura = "OBLIGATORIA" | "OPTATIVA" | "TRONCAL" | "OTRA";
|
||||
|
||||
export type TipoBibliografia = "BASICA" | "COMPLEMENTARIA";
|
||||
export type TipoFuenteBibliografia = "MANUAL" | "BIBLIOTECA";
|
||||
|
||||
export type EstadoTareaRevision = "PENDIENTE" | "COMPLETADA" | "OMITIDA";
|
||||
export type TipoNotificacion = "PLAN_ASIGNADO" | "ESTADO_CAMBIADO" | "TAREA_ASIGNADA" | "COMENTARIO" | "OTRA";
|
||||
|
||||
export type TipoInteraccionIA = "GENERAR" | "MEJORAR_SECCION" | "CHAT" | "OTRA";
|
||||
|
||||
export type ModalidadEducativa = "Escolar" | "No escolarizada" | "Mixta";
|
||||
export type DisenoCurricular = "Rígido" | "Flexible";
|
||||
|
||||
/** Basado en tu schema JSON (va típicamente dentro de planes_estudio.datos) */
|
||||
export type PlanDatosSep = {
|
||||
nivel?: string;
|
||||
nombre?: string;
|
||||
modalidad_educativa?: ModalidadEducativa;
|
||||
|
||||
antecedente_academico?: string;
|
||||
area_de_estudio?: string;
|
||||
clave_del_plan_de_estudios?: string;
|
||||
|
||||
diseno_curricular?: DisenoCurricular;
|
||||
|
||||
total_de_ciclos_del_plan_de_estudios?: string;
|
||||
duracion_del_ciclo_escolar?: string;
|
||||
carga_horaria_a_la_semana?: number;
|
||||
|
||||
fines_de_aprendizaje_o_formacion?: string;
|
||||
perfil_de_egreso?: string;
|
||||
|
||||
programa_de_investigacion?: string | null;
|
||||
curso_propedeutico?: string | null;
|
||||
|
||||
perfil_de_ingreso?: string;
|
||||
|
||||
administracion_y_operatividad_del_plan_de_estudios?: string | null;
|
||||
sustento_teorico_del_modelo_curricular?: string | null;
|
||||
justificacion_de_la_propuesta_curricular?: string | null;
|
||||
propuesta_de_evaluacion_periodica_del_plan_de_estudios?: string | null;
|
||||
};
|
||||
|
||||
export type Paged<T> = { data: T[]; count: number | null };
|
||||
|
||||
export type Facultad = {
|
||||
id: UUID;
|
||||
nombre: string;
|
||||
nombre_corto: string | null;
|
||||
color: string | null;
|
||||
icono: string | null;
|
||||
creado_en: string;
|
||||
actualizado_en: string;
|
||||
};
|
||||
|
||||
export type Carrera = {
|
||||
id: UUID;
|
||||
facultad_id: UUID;
|
||||
nombre: string;
|
||||
nombre_corto: string | null;
|
||||
clave_sep: string | null;
|
||||
activa: boolean;
|
||||
creado_en: string;
|
||||
actualizado_en: string;
|
||||
|
||||
facultades?: Facultad | null;
|
||||
};
|
||||
|
||||
export type EstructuraPlan = {
|
||||
id: UUID;
|
||||
nombre: string;
|
||||
tipo: TipoEstructuraPlan;
|
||||
version: string | null;
|
||||
definicion: Json;
|
||||
};
|
||||
|
||||
export type EstructuraAsignatura = {
|
||||
id: UUID;
|
||||
nombre: string;
|
||||
version: string | null;
|
||||
definicion: Json;
|
||||
};
|
||||
|
||||
export type EstadoPlan = {
|
||||
id: UUID;
|
||||
clave: string;
|
||||
etiqueta: string;
|
||||
orden: number;
|
||||
es_final: boolean;
|
||||
};
|
||||
|
||||
export type PlanEstudio = {
|
||||
id: UUID;
|
||||
carrera_id: UUID;
|
||||
estructura_id: UUID;
|
||||
|
||||
nombre: string;
|
||||
nivel: NivelPlanEstudio;
|
||||
tipo_ciclo: TipoCiclo;
|
||||
numero_ciclos: number;
|
||||
|
||||
datos: Json;
|
||||
|
||||
estado_actual_id: UUID | null;
|
||||
activo: boolean;
|
||||
|
||||
tipo_origen: TipoOrigen | null;
|
||||
meta_origen: Json;
|
||||
|
||||
creado_por: UUID | null;
|
||||
actualizado_por: UUID | null;
|
||||
|
||||
creado_en: string;
|
||||
actualizado_en: string;
|
||||
|
||||
carreras?: Carrera | null;
|
||||
estructuras_plan?: EstructuraPlan | null;
|
||||
estados_plan?: EstadoPlan | null;
|
||||
};
|
||||
|
||||
export type LineaPlan = {
|
||||
id: UUID;
|
||||
plan_estudio_id: UUID;
|
||||
nombre: string;
|
||||
orden: number;
|
||||
area: string | null;
|
||||
creado_en: string;
|
||||
actualizado_en: string;
|
||||
};
|
||||
|
||||
export type Asignatura = {
|
||||
id: UUID;
|
||||
plan_estudio_id: UUID;
|
||||
estructura_id: UUID | null;
|
||||
|
||||
facultad_propietaria_id: UUID | null;
|
||||
|
||||
codigo: string | null;
|
||||
nombre: string;
|
||||
|
||||
tipo: TipoAsignatura;
|
||||
creditos: number;
|
||||
horas_semana: number | null;
|
||||
|
||||
numero_ciclo: number | null;
|
||||
linea_plan_id: UUID | null;
|
||||
orden_celda: number | null;
|
||||
|
||||
datos: Json;
|
||||
contenido_tematico: Json;
|
||||
|
||||
tipo_origen: TipoOrigen | null;
|
||||
meta_origen: Json;
|
||||
|
||||
creado_por: UUID | null;
|
||||
actualizado_por: UUID | null;
|
||||
|
||||
creado_en: string;
|
||||
actualizado_en: string;
|
||||
|
||||
planes_estudio?: PlanEstudio | null;
|
||||
estructuras_asignatura?: EstructuraAsignatura | null;
|
||||
};
|
||||
|
||||
export type BibliografiaAsignatura = {
|
||||
id: UUID;
|
||||
asignatura_id: UUID;
|
||||
tipo: TipoBibliografia;
|
||||
cita: string;
|
||||
tipo_fuente: TipoFuenteBibliografia;
|
||||
biblioteca_item_id: string | null;
|
||||
|
||||
creado_por: UUID | null;
|
||||
creado_en: string;
|
||||
actualizado_en: string;
|
||||
};
|
||||
|
||||
export type CambioPlan = {
|
||||
id: UUID;
|
||||
plan_estudio_id: UUID;
|
||||
cambiado_por: UUID | null;
|
||||
cambiado_en: string;
|
||||
tipo: "ACTUALIZACION_CAMPO" | "ACTUALIZACION_MAPA" | "OTRO";
|
||||
campo: string | null;
|
||||
valor_anterior: Json | null;
|
||||
valor_nuevo: Json | null;
|
||||
interaccion_ia_id: UUID | null;
|
||||
};
|
||||
|
||||
export type CambioAsignatura = {
|
||||
id: UUID;
|
||||
asignatura_id: UUID;
|
||||
cambiado_por: UUID | null;
|
||||
cambiado_en: string;
|
||||
tipo: "ACTUALIZACION_CAMPO" | "ACTUALIZACION_MAPA" | "OTRO";
|
||||
campo: string | null;
|
||||
valor_anterior: Json | null;
|
||||
valor_nuevo: Json | null;
|
||||
fuente: "HUMANO" | "IA" | null;
|
||||
interaccion_ia_id: UUID | null;
|
||||
};
|
||||
|
||||
export type InteraccionIA = {
|
||||
id: UUID;
|
||||
usuario_id: UUID | null;
|
||||
plan_estudio_id: UUID | null;
|
||||
asignatura_id: UUID | null;
|
||||
|
||||
tipo: TipoInteraccionIA;
|
||||
modelo: string | null;
|
||||
temperatura: number | null;
|
||||
|
||||
prompt: Json;
|
||||
respuesta: Json;
|
||||
|
||||
aceptada: boolean;
|
||||
|
||||
conversacion_id: string | null;
|
||||
ids_archivos: Json;
|
||||
ids_vector_store: Json;
|
||||
|
||||
creado_en: string;
|
||||
};
|
||||
|
||||
export type TareaRevision = {
|
||||
id: UUID;
|
||||
plan_estudio_id: UUID;
|
||||
asignado_a: UUID;
|
||||
rol_id: UUID | null;
|
||||
estado_id: UUID | null;
|
||||
estatus: EstadoTareaRevision;
|
||||
fecha_limite: string | null;
|
||||
creado_en: string;
|
||||
completado_en: string | null;
|
||||
};
|
||||
|
||||
export type Notificacion = {
|
||||
id: UUID;
|
||||
usuario_id: UUID;
|
||||
tipo: TipoNotificacion;
|
||||
payload: Json;
|
||||
leida: boolean;
|
||||
creado_en: string;
|
||||
leida_en: string | null;
|
||||
};
|
||||
|
||||
export type Archivo = {
|
||||
id: UUID;
|
||||
ruta_storage: string;
|
||||
nombre: string;
|
||||
mime_type: string | null;
|
||||
bytes: number | null;
|
||||
subido_por: UUID | null;
|
||||
subido_en: string;
|
||||
temporal: boolean;
|
||||
openai_file_id: string | null;
|
||||
notas: string | null;
|
||||
};
|
||||
@@ -12,11 +12,9 @@ import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as MateriasIndexRouteImport } from './routes/materias/index'
|
||||
import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query'
|
||||
import { Route as PlanesListaRouteRouteImport } from './routes/planes/_lista/route'
|
||||
import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
|
||||
import { Route as MateriasMateriaIdMateriaIdRouteImport } from './routes/materias/$materiaId/$materiaId'
|
||||
import { Route as PlanesPlanIdAsignaturasRouteRouteImport } from './routes/planes/$planId/asignaturas/route'
|
||||
import { Route as PlanesPlanIdDetalleRouteRouteImport } from './routes/planes/$planId/_detalle/route'
|
||||
import { Route as PlanesPlanIdAsignaturasIndexRouteImport } from './routes/planes/$planId/asignaturas/index'
|
||||
@@ -46,11 +44,6 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const MateriasIndexRoute = MateriasIndexRouteImport.update({
|
||||
id: '/materias/',
|
||||
path: '/materias/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({
|
||||
id: '/demo/tanstack-query',
|
||||
path: '/demo/tanstack-query',
|
||||
@@ -66,12 +59,6 @@ const PlanesListaNuevoRoute = PlanesListaNuevoRouteImport.update({
|
||||
path: '/nuevo',
|
||||
getParentRoute: () => PlanesListaRouteRoute,
|
||||
} as any)
|
||||
const MateriasMateriaIdMateriaIdRoute =
|
||||
MateriasMateriaIdMateriaIdRouteImport.update({
|
||||
id: '/materias/$materiaId/$materiaId',
|
||||
path: '/materias/$materiaId/$materiaId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PlanesPlanIdAsignaturasRouteRoute =
|
||||
PlanesPlanIdAsignaturasRouteRouteImport.update({
|
||||
id: '/planes/$planId/asignaturas',
|
||||
@@ -155,10 +142,8 @@ export interface FileRoutesByFullPath {
|
||||
'/login': typeof LoginRoute
|
||||
'/planes': typeof PlanesListaRouteRouteWithChildren
|
||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||
'/materias': typeof MateriasIndexRoute
|
||||
'/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
|
||||
'/materias/$materiaId/$materiaId': typeof MateriasMateriaIdMateriaIdRoute
|
||||
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||
'/planes/$planId/datos': typeof PlanesPlanIdDetalleDatosRoute
|
||||
@@ -177,9 +162,7 @@ export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
'/planes': typeof PlanesListaRouteRouteWithChildren
|
||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||
'/materias': typeof MateriasIndexRoute
|
||||
'/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||
'/materias/$materiaId/$materiaId': typeof MateriasMateriaIdMateriaIdRoute
|
||||
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasIndexRoute
|
||||
@@ -199,10 +182,8 @@ export interface FileRoutesById {
|
||||
'/login': typeof LoginRoute
|
||||
'/planes/_lista': typeof PlanesListaRouteRouteWithChildren
|
||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||
'/materias/': typeof MateriasIndexRoute
|
||||
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
|
||||
'/materias/$materiaId/$materiaId': typeof MateriasMateriaIdMateriaIdRoute
|
||||
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||
'/planes/$planId/asignaturas/_lista': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
|
||||
@@ -224,10 +205,8 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/planes'
|
||||
| '/demo/tanstack-query'
|
||||
| '/materias'
|
||||
| '/planes/$planId'
|
||||
| '/planes/$planId/asignaturas'
|
||||
| '/materias/$materiaId/$materiaId'
|
||||
| '/planes/nuevo'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId/datos'
|
||||
@@ -246,9 +225,7 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/planes'
|
||||
| '/demo/tanstack-query'
|
||||
| '/materias'
|
||||
| '/planes/$planId'
|
||||
| '/materias/$materiaId/$materiaId'
|
||||
| '/planes/nuevo'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId/asignaturas'
|
||||
@@ -267,10 +244,8 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/planes/_lista'
|
||||
| '/demo/tanstack-query'
|
||||
| '/materias/'
|
||||
| '/planes/$planId/_detalle'
|
||||
| '/planes/$planId/asignaturas'
|
||||
| '/materias/$materiaId/$materiaId'
|
||||
| '/planes/_lista/nuevo'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId/asignaturas/_lista'
|
||||
@@ -291,10 +266,8 @@ export interface RootRouteChildren {
|
||||
LoginRoute: typeof LoginRoute
|
||||
PlanesListaRouteRoute: typeof PlanesListaRouteRouteWithChildren
|
||||
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
|
||||
MateriasIndexRoute: typeof MateriasIndexRoute
|
||||
PlanesPlanIdDetalleRouteRoute: typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||
PlanesPlanIdAsignaturasRouteRoute: typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
|
||||
MateriasMateriaIdMateriaIdRoute: typeof MateriasMateriaIdMateriaIdRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -320,13 +293,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/materias/': {
|
||||
id: '/materias/'
|
||||
path: '/materias'
|
||||
fullPath: '/materias'
|
||||
preLoaderRoute: typeof MateriasIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/demo/tanstack-query': {
|
||||
id: '/demo/tanstack-query'
|
||||
path: '/demo/tanstack-query'
|
||||
@@ -348,13 +314,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof PlanesListaNuevoRouteImport
|
||||
parentRoute: typeof PlanesListaRouteRoute
|
||||
}
|
||||
'/materias/$materiaId/$materiaId': {
|
||||
id: '/materias/$materiaId/$materiaId'
|
||||
path: '/materias/$materiaId/$materiaId'
|
||||
fullPath: '/materias/$materiaId/$materiaId'
|
||||
preLoaderRoute: typeof MateriasMateriaIdMateriaIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/planes/$planId/asignaturas': {
|
||||
id: '/planes/$planId/asignaturas'
|
||||
path: '/planes/$planId/asignaturas'
|
||||
@@ -527,11 +486,9 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
LoginRoute: LoginRoute,
|
||||
PlanesListaRouteRoute: PlanesListaRouteRouteWithChildren,
|
||||
DemoTanstackQueryRoute: DemoTanstackQueryRoute,
|
||||
MateriasIndexRoute: MateriasIndexRoute,
|
||||
PlanesPlanIdDetalleRouteRoute: PlanesPlanIdDetalleRouteRouteWithChildren,
|
||||
PlanesPlanIdAsignaturasRouteRoute:
|
||||
PlanesPlanIdAsignaturasRouteRouteWithChildren,
|
||||
MateriasMateriaIdMateriaIdRoute: MateriasMateriaIdMateriaIdRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usePlan } from '@/data';
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import type { DatosGeneralesField } from '@/types/plan'
|
||||
|
||||
Reference in New Issue
Block a user