feat: add CreatePlanDialog and InfoChip components; implement chipTint utility; enhance styles for aurora effects
This commit is contained in:
106
src/components/planes/CreatePlanDialog.tsx
Normal file
106
src/components/planes/CreatePlanDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
src/components/planes/InfoChip.tsx
Normal file
30
src/components/planes/InfoChip.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
src/components/planes/chipTint.ts
Normal file
14
src/components/planes/chipTint.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -7,12 +7,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import * as Icons from "lucide-react"
|
import * as Icons from "lucide-react"
|
||||||
import { Plus, RefreshCcw, Building2 } from "lucide-react"
|
import { Plus, RefreshCcw, Building2 } from "lucide-react"
|
||||||
|
import { InfoChip } from "@/components/planes/InfoChip"
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
import { CreatePlanDialog } from "@/components/planes/CreatePlanDialog"
|
||||||
import { Label } from "@/components/ui/label"
|
import { chipTint } from "@/components/planes/chipTint"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
|
||||||
import { postAPI } from "@/lib/api"
|
|
||||||
|
|
||||||
|
|
||||||
export type PlanDeEstudios = {
|
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() {
|
function RouteComponent() {
|
||||||
const auth = useSupabaseAuth()
|
const auth = useSupabaseAuth()
|
||||||
@@ -271,7 +129,7 @@ function RouteComponent() {
|
|||||||
<InfoChip
|
<InfoChip
|
||||||
icon={<Icons.Building2 className="h-3 w-3" />}
|
icon={<Icons.Building2 className="h-3 w-3" />}
|
||||||
label={fac.nombre}
|
label={fac.nombre}
|
||||||
tint={fac.color} // tinte sutil por facultad
|
tint={fac.color}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ function App() {
|
|||||||
const auth = useSupabaseAuth()
|
const auth = useSupabaseAuth()
|
||||||
const isAuth = !!auth.user
|
const isAuth = !!auth.user
|
||||||
return (
|
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 */}
|
{/* Navbar */}
|
||||||
<header className="flex items-center justify-between px-10 py-6 border-b border-slate-700/50">
|
<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>
|
<h1 className="text-2xl font-mono tracking-tight">Acad-IA</h1>
|
||||||
{isAuth ? (
|
{isAuth ? (
|
||||||
<Link to="/planes">
|
<Link to="/planes">
|
||||||
<Button className="border-slate-500 hover:bg-slate-700/50 relative overflow-hidden">
|
<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
|
Comenzar
|
||||||
</Button>
|
</Button>
|
||||||
@@ -36,6 +36,7 @@ function App() {
|
|||||||
<h2 className="text-5xl md:text-6xl font-mono font-bold mb-6">
|
<h2 className="text-5xl md:text-6xl font-mono font-bold mb-6">
|
||||||
Bienvenido a <span className="text-primary">Acad-IA</span>
|
Bienvenido a <span className="text-primary">Acad-IA</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
<span className="-z-1 absolute inset-0 bg-aurora-effect"></span>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
@@ -138,11 +138,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.animate-aurora {
|
.animate-aurora {
|
||||||
background: radial-gradient(at 20% 30%, rgba(59, 130, 246, .5), transparent 50%),
|
background: radial-gradient(at 20% 30%, oklch(27.5% 0.13488 262.73), transparent 50%),
|
||||||
radial-gradient(at 80% 70%, rgba(228, 3, 190, 0.671), transparent 50%),
|
radial-gradient(at 80% 70%, oklch(0.704 0.191 22.216), transparent 50%),
|
||||||
radial-gradient(at 50% 100%, rgba(34, 197, 94, .5), transparent 50%);
|
radial-gradient(at 50% 100%, oklch(0.398 0.07 227.392), transparent 50%);
|
||||||
background-size: 200% 200%;
|
background-size: 200% 200%;
|
||||||
animation: aurora 3s ease infinite;
|
animation: aurora 3s ease infinite;
|
||||||
filter: blur(12px) opacity(0.8);
|
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);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user