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:
81
src/components/planes/AsignaturaPreviewCard.tsx
Normal file
81
src/components/planes/AsignaturaPreviewCard.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import * as Icons from "lucide-react"
|
||||
import { useSuspenseQuery } from "@tanstack/react-query"
|
||||
import React, { useMemo } from "react"
|
||||
import { asignaturaExtraOptions } from "./planQueries"
|
||||
import { SmallStat } from "./SmallStat"
|
||||
|
||||
export function AsignaturaPreviewCard({ asignatura }: { asignatura: { id: string; nombre: string; semestre: number | null; creditos: number | null } }) {
|
||||
const { data: extra } = useSuspenseQuery(asignaturaExtraOptions(asignatura.id))
|
||||
|
||||
const horasT = extra?.horas_teoricas ?? null
|
||||
const horasP = extra?.horas_practicas ?? null
|
||||
const horasTot = (horasT ?? 0) + (horasP ?? 0)
|
||||
|
||||
const resumenContenidos = useMemo(() => {
|
||||
const c = extra?.contenidos
|
||||
if (!c) return { unidades: 0, temas: 0 }
|
||||
const unidades = Object.keys(c).length
|
||||
const temas = Object.values(c).reduce((acc, temasObj) => acc + Object.keys(temasObj || {}).length, 0)
|
||||
return { unidades, temas }
|
||||
}, [extra?.contenidos])
|
||||
|
||||
const tipo = (extra?.tipo ?? "").toLowerCase()
|
||||
const tipoChip =
|
||||
tipo.includes("oblig") ? "bg-emerald-50 text-emerald-700 border-emerald-200" :
|
||||
tipo.includes("opt") ? "bg-amber-50 text-amber-800 border-amber-200" :
|
||||
tipo.includes("taller") ? "bg-indigo-50 text-indigo-700 border-indigo-200" :
|
||||
tipo.includes("lab") ? "bg-sky-50 text-sky-700 border-sky-200" :
|
||||
"bg-neutral-100 text-neutral-700 border-neutral-200"
|
||||
|
||||
return (
|
||||
<article className="group relative overflow-hidden rounded-2xl border bg-white/70 dark:bg-neutral-900/60 backdrop-blur p-4 shadow-sm hover:shadow-md transition-all hover:-translate-y-0.5" role="region" aria-label={asignatura.nombre}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
|
||||
<Icons.BookOpen className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate" title={asignatura.nombre}>{asignatura.nombre}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
{asignatura.semestre != null && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
|
||||
<Icons.Calendar className="h-3 w-3" /> S{asignatura.semestre}
|
||||
</span>
|
||||
)}
|
||||
{asignatura.creditos != null && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
|
||||
<Icons.Coins className="h-3 w-3" /> {asignatura.creditos} cr
|
||||
</span>
|
||||
)}
|
||||
{extra?.tipo && (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 ${tipoChip}`}>
|
||||
<Icons.Tag className="h-3 w-3" /> {extra.tipo}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-[11px]">
|
||||
<SmallStat icon={Icons.Clock} label="Horas" value={horasTot || "—"} />
|
||||
<SmallStat icon={Icons.BookMarked} label="Unidades" value={resumenContenidos.unidades || "—"} />
|
||||
<SmallStat icon={Icons.ListTree} label="Temas" value={resumenContenidos.temas || "—"} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="text-[11px] text-neutral-500">
|
||||
{horasT != null || horasP != null ? (
|
||||
<>H T/P: {horasT ?? "—"}/{horasP ?? "—"}</>
|
||||
) : (
|
||||
<span className="opacity-70">Resumen listo</span>
|
||||
)}
|
||||
</div>
|
||||
<Link to="/asignatura/$asignaturaId" params={{ asignaturaId: asignatura.id }} className="inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs hover:bg-neutral-50" title="Ver detalle">
|
||||
Ver detalle <Icons.ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity" style={{ background: "radial-gradient(600px 120px at 20% -10%, rgba(0,0,0,.06), transparent 60%)" }} />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user