feat: add AdjustAIButton, EditPlanButton, and AsignaturaPreviewCard components

- Implemented AdjustAIButton for AI-driven plan adjustments with a confetti effect on success.
- Created EditPlanButton to allow editing of plan details with form validation and optimistic updates.
- Added AsignaturaPreviewCard to display course previews with relevant statistics and details.
- Introduced Field component for consistent form field labeling.
- Developed GradientMesh for dynamic background effects based on color input.
- Added Pulse component for skeleton loading states.
- Created SmallStat and StatCard components for displaying statistical information in a card format.
- Implemented utility functions in planHelpers for color manipulation and formatting.
- Established planQueries for fetching plan and course data from the database.
- Updated the plan detail route to utilize new components and queries for improved user experience.
This commit is contained in:
2025-08-29 11:14:34 -06:00
parent 3bc4498e4f
commit a487a8c293
13 changed files with 609 additions and 214 deletions

View File

@@ -0,0 +1,93 @@
import { supabase } from "@/auth/supabase"
export const planKeys = {
byId: (id: string) => ["plan", id] as const,
}
export const asignaturaKeys = {
count: (planId: string) => ["asignaturas", "count", planId] as const,
preview: (planId: string) => ["asignaturas", "preview", planId] as const,
extra: (asigId: string) => ["asignatura", "extra", asigId] as const,
}
export type PlanFull = {
id: string; nombre: string; nivel: string | null;
objetivo_general: string | null; perfil_ingreso: string | null; perfil_egreso: string | null;
duracion: string | null; total_creditos: number | null;
competencias_genericas: string | null; competencias_especificas: string | null;
sistema_evaluacion: string | null; indicadores_desempeno: string | null;
pertinencia: string | null; prompt: string | null;
estado: string | null; fecha_creacion: string | null;
carreras: { id: string; nombre: string; facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null } | null
}
export type AsignaturaLite = { id: string; nombre: string; semestre: number | null; creditos: number | null }
export function planByIdOptions(planId: string) {
return {
queryKey: planKeys.byId(planId),
queryFn: async (): Promise<PlanFull> => {
const { data, error } = await supabase
.from("plan_estudios")
.select(`
id, nombre, nivel, objetivo_general, perfil_ingreso, perfil_egreso, duracion, total_creditos,
competencias_genericas, competencias_especificas, sistema_evaluacion, indicadores_desempeno,
pertinencia, prompt, estado, fecha_creacion,
carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) )
`)
.eq("id", planId)
.maybeSingle()
if (error || !data) throw error ?? new Error("Plan no encontrado")
return data as unknown as PlanFull
},
staleTime: 60_000,
} as const
}
export function asignaturasCountOptions(planId: string) {
return {
queryKey: asignaturaKeys.count(planId),
queryFn: async (): Promise<number> => {
const { count, error } = await supabase
.from("asignaturas")
.select("*", { count: "exact", head: true })
.eq("plan_id", planId)
if (error) throw error
return count ?? 0
},
staleTime: 30_000,
} as const
}
export function asignaturasPreviewOptions(planId: string) {
return {
queryKey: asignaturaKeys.preview(planId),
queryFn: async (): Promise<AsignaturaLite[]> => {
const { data, error } = await supabase
.from("asignaturas")
.select("id, nombre, semestre, creditos")
.eq("plan_id", planId)
.order("semestre", { ascending: true })
.order("nombre", { ascending: true })
.limit(8)
if (error) throw error
return (data ?? []) as unknown as AsignaturaLite[]
},
staleTime: 30_000,
} as const
}
export function asignaturaExtraOptions(asigId: string) {
return {
queryKey: asignaturaKeys.extra(asigId),
queryFn: async (): Promise<{
tipo: string | null
horas_teoricas: number | null
horas_practicas: number | null
contenidos: Record<string, Record<string, string>> | null
} | null> => {
const { data, error } = await supabase
.from("asignaturas")
.select("tipo, horas_teoricas, horas_practicas, contenidos")
.eq("id", asigId)
.maybeSingle()
if (error) throw error
return (data as any) ?? null
},
} as const
}