feat: add CreatePlanDialog and InfoChip components; implement chipTint utility; enhance styles for aurora effects

This commit is contained in:
2025-08-29 16:05:41 -06:00
parent 6e84860230
commit f8de39e6d1
6 changed files with 170 additions and 151 deletions

View File

@@ -0,0 +1,106 @@
import { useRouter } from "@tanstack/react-router"
import { useSupabaseAuth } from "@/auth/supabase"
import { useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Input } from "@/components/ui/input"
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
import { Button } from "@/components/ui/button"
import { postAPI } from "@/lib/api"
export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
const router = useRouter()
const auth = useSupabaseAuth()
const role = auth.claims?.role
const [saving, setSaving] = useState(false)
const [err, setErr] = useState<string | null>(null)
const [facultadId, setFacultadId] = useState(auth.claims?.facultad_id ?? "")
const [carreraId, setCarreraId] = useState(auth.claims?.carrera_id ?? "")
const [nivel, setNivel] = useState("")
const [prompt, setPrompt] = useState(
"Genera un plan de estudios claro y realista: "
)
const lockFacultad = role === "secretario_academico" || role === "jefe_carrera"
const lockCarrera = role === "jefe_carrera"
async function crearConIA() {
setErr(null)
if (!carreraId) { setErr("Selecciona una carrera."); return }
setSaving(true)
try {
const res = await postAPI("/api/generar/plan", {
carreraId,
prompt,
insert: true,
})
const newId = res?.id || res?.plan?.id || res?.data?.id
if (newId) {
onOpenChange(false)
router.invalidate()
router.navigate({ to: "/plan/$planId", params: { planId: newId } })
} else {
onOpenChange(false)
router.invalidate()
}
} catch (e: any) {
setErr(typeof e?.message === "string" ? e.message : "Error al generar el plan.")
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(92vw,760px)]">
<DialogHeader>
<DialogTitle>Nuevo plan de estudios (IA)</DialogTitle>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2 space-y-1">
<Label>Prompt</Label>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[120px]"
placeholder="Describe cómo debe ser el plan…"
/>
</div>
<div className="space-y-1">
<Label>Nivel (opcional)</Label>
<Input value={nivel} onChange={(e) => setNivel(e.target.value)} />
</div>
<div className="space-y-1">
<Label>Facultad</Label>
<FacultadCombobox
value={facultadId}
onChange={(id) => { setFacultadId(id); setCarreraId("") }}
disabled={lockFacultad}
placeholder="Elige una facultad…"
/>
</div>
<div className="md:col-span-2 space-y-1">
<Label>Carrera *</Label>
<CarreraCombobox
facultadId={facultadId}
value={carreraId}
onChange={setCarreraId}
disabled={!facultadId || lockCarrera}
placeholder={facultadId ? "Elige una carrera…" : "Selecciona una facultad primero"}
/>
</div>
</div>
{err && <div className="text-sm text-red-600">{err}</div>}
<DialogFooter className="flex flex-col-reverse sm:flex-row gap-2">
<Button variant="outline" className="w-full sm:w-auto" onClick={() => onOpenChange(false)}>Cancelar</Button>
<Button className="w-full sm:w-auto" onClick={crearConIA} disabled={saving}>
{saving ? "Generando…" : "Generar y crear"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,30 @@
import React from "react"
function hexToRgb(hex?: string | null): [number, number, number] {
if (!hex) return [37, 99, 235]
const h = hex.replace('#', '')
const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h
const n = parseInt(v, 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
function chipTint(color?: string | null) {
const [r, g, b] = hexToRgb(color)
return {
borderColor: `rgba(${r},${g},${b},.30)`,
background: `rgba(${r},${g},${b},.10)`,
} as React.CSSProperties
}
export function InfoChip({ icon, label, tint }: { icon: React.ReactNode; label: string; tint?: string | null }) {
const style = tint ? chipTint(tint) : undefined
return (
<span
title={label}
className="inline-flex max-w-full items-center gap-1 rounded-lg border px-2.5 py-1 text-xs leading-none"
style={style}
>
{icon}
<span className="truncate">{label}</span>
</span>
)
}

View File

@@ -0,0 +1,14 @@
export function hexToRgb(hex?: string | null): [number, number, number] {
if (!hex) return [37, 99, 235]
const h = hex.replace('#', '')
const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h
const n = parseInt(v, 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
export function chipTint(color?: string | null) {
const [r, g, b] = hexToRgb(color)
return {
borderColor: `rgba(${r},${g},${b},.30)`,
background: `rgba(${r},${g},${b},.10)`,
} as React.CSSProperties
}

View File

@@ -7,12 +7,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import * as Icons from "lucide-react"
import { Plus, RefreshCcw, Building2 } from "lucide-react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
import { postAPI } from "@/lib/api"
import { InfoChip } from "@/components/planes/InfoChip"
import { CreatePlanDialog } from "@/components/planes/CreatePlanDialog"
import { chipTint } from "@/components/planes/chipTint"
export type PlanDeEstudios = {
@@ -47,145 +44,6 @@ export const Route = createFileRoute("/_authenticated/planes")({
})
/* ---------- helpers de estilo suave ---------- */
function hexToRgb(hex?: string | null): [number, number, number] {
if (!hex) return [37, 99, 235] // azul por defecto
const h = hex.replace('#', '')
const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h
const n = parseInt(v, 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
/* ---------- helpers ---------- */
function chipTint(color?: string | null) {
const [r, g, b] = hexToRgb(color)
return {
borderColor: `rgba(${r},${g},${b},.30)`,
background: `rgba(${r},${g},${b},.10)`,
} as React.CSSProperties
}
function InfoChip({
icon, label, tint,
}: { icon: React.ReactNode; label: string; tint?: string | null }) {
const style = tint ? chipTint(tint) : undefined
return (
<span
title={label}
className="inline-flex max-w-full items-center gap-1 rounded-lg border px-2.5 py-1 text-xs leading-none"
style={style}
>
{icon}
<span className="truncate">{label}</span>
</span>
)
}
function CreatePlanDialog({
open, onOpenChange
}: { open: boolean; onOpenChange: (v: boolean) => void }) {
const router = useRouter()
const auth = useSupabaseAuth()
const role = auth.claims?.role
const [saving, setSaving] = useState(false)
const [err, setErr] = useState<string | null>(null)
const [facultadId, setFacultadId] = useState(auth.claims?.facultad_id ?? "")
const [carreraId, setCarreraId] = useState(auth.claims?.carrera_id ?? "")
const [nivel, setNivel] = useState("")
const [prompt, setPrompt] = useState(
"Genera un plan de estudios claro y realista: "
)
const lockFacultad = role === "secretario_academico" || role === "jefe_carrera"
const lockCarrera = role === "jefe_carrera"
async function crearConIA() {
setErr(null)
if (!carreraId) { setErr("Selecciona una carrera."); return }
setSaving(true)
try {
// 1) Generar (e insertar) plan vía tu API
const res = await postAPI("/api/generar/plan", {
carreraId,
prompt,
insert: true, // 👈 hace que tu backend inserte en supabase
})
// 2) Si el backend devuelve el ID, vamos directo; si no, recargamos listado
const newId = res?.id || res?.plan?.id || res?.data?.id
if (newId) {
onOpenChange(false)
router.invalidate()
router.navigate({ to: "/plan/$planId", params: { planId: newId } })
} else {
onOpenChange(false)
router.invalidate()
}
} catch (e: any) {
setErr(typeof e?.message === "string" ? e.message : "Error al generar el plan.")
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(92vw,760px)]">
<DialogHeader>
<DialogTitle>Nuevo plan de estudios (IA)</DialogTitle>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2 space-y-1">
<Label>Prompt</Label>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[120px]"
placeholder="Describe cómo debe ser el plan…"
/>
</div>
<div className="space-y-1">
<Label>Nivel (opcional)</Label>
<Input value={nivel} onChange={(e) => setNivel(e.target.value)} />
</div>
<div className="space-y-1">
<Label>Facultad</Label>
<FacultadCombobox
value={facultadId}
onChange={(id) => { setFacultadId(id); setCarreraId("") }}
disabled={lockFacultad}
placeholder="Elige una facultad…"
/>
</div>
<div className="md:col-span-2 space-y-1">
<Label>Carrera *</Label>
<CarreraCombobox
facultadId={facultadId}
value={carreraId}
onChange={setCarreraId}
disabled={!facultadId || lockCarrera}
placeholder={facultadId ? "Elige una carrera…" : "Selecciona una facultad primero"}
/>
</div>
</div>
{err && <div className="text-sm text-red-600">{err}</div>}
<DialogFooter className="flex flex-col-reverse sm:flex-row gap-2">
<Button variant="outline" className="w-full sm:w-auto" onClick={() => onOpenChange(false)}>Cancelar</Button>
<Button className="w-full sm:w-auto" onClick={crearConIA} disabled={saving}>
{saving ? "Generando…" : "Generar y crear"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function RouteComponent() {
const auth = useSupabaseAuth()
@@ -271,7 +129,7 @@ function RouteComponent() {
<InfoChip
icon={<Icons.Building2 className="h-3 w-3" />}
label={fac.nombre}
tint={fac.color} // tinte sutil por facultad
tint={fac.color}
/>
)}
</div>

View File

@@ -10,14 +10,14 @@ function App() {
const auth = useSupabaseAuth()
const isAuth = !!auth.user
return (
<div className="min-h-screen flex flex-col bg-gradient-to-br from-white via-slate-200 to-primary">
<div className="min-h-screen flex flex-col ">
{/* Navbar */}
<header className="flex items-center justify-between px-10 py-6 border-b border-slate-700/50">
<h1 className="text-2xl font-mono tracking-tight">Acad-IA</h1>
{isAuth ? (
<Link to="/planes">
<Button className="border-slate-500 hover:bg-slate-700/50 relative overflow-hidden">
<span className="absolute inset-0 animate-aurora" />
<span className="-z-1 absolute inset-0 animate-aurora" />
Comenzar
</Button>
@@ -36,6 +36,7 @@ function App() {
<h2 className="text-5xl md:text-6xl font-mono font-bold mb-6">
Bienvenido a <span className="text-primary">Acad-IA</span>
</h2>
<span className="-z-1 absolute inset-0 bg-aurora-effect"></span>
</main>
{/* Footer */}

View File

@@ -138,11 +138,21 @@
}
}
.animate-aurora {
background: radial-gradient(at 20% 30%, rgba(59, 130, 246, .5), transparent 50%),
radial-gradient(at 80% 70%, rgba(228, 3, 190, 0.671), transparent 50%),
radial-gradient(at 50% 100%, rgba(34, 197, 94, .5), transparent 50%);
background: radial-gradient(at 20% 30%, oklch(27.5% 0.13488 262.73), transparent 50%),
radial-gradient(at 80% 70%, oklch(0.704 0.191 22.216), transparent 50%),
radial-gradient(at 50% 100%, oklch(0.398 0.07 227.392), transparent 50%);
background-size: 200% 200%;
animation: aurora 3s ease infinite;
filter: blur(12px) opacity(0.8);
}
.bg-aurora-effect {
background: radial-gradient(ellipse 60% 40% at 20% 30%, oklch(27.5% 0.13488 262.73 / 0.45), transparent 70%),
radial-gradient(ellipse 50% 30% at 80% 70%, oklch(0.704 0.191 22.216 / 0.35), transparent 70%),
radial-gradient(ellipse 40% 60% at 50% 100%, oklch(0.398 0.07 227.392 / 0.32), transparent 70%);
background-size: 120% 120%;
animation: aurora 8s ease-in-out infinite;
filter: blur(24px) opacity(0.7);
}