feat: update dialog titles to use font-mono style for consistency across components

This commit is contained in:
2025-09-01 09:02:11 -06:00
parent 5a113ca603
commit 5181306b93
17 changed files with 274 additions and 114 deletions

View File

@@ -58,7 +58,7 @@ export function CarreraDetailDialog({
<Dialog open={!!carrera} onOpenChange={(o) => !o && onOpenChange(null)}> <Dialog open={!!carrera} onOpenChange={(o) => !o && onOpenChange(null)}>
<DialogContent className="max-w-3xl"> <DialogContent className="max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{carrera?.nombre}</DialogTitle> <DialogTitle className="font-mono" >{carrera?.nombre}</DialogTitle>
<DialogDescription> <DialogDescription>
{carrera?.facultades?.nombre ?? "—"} · {carrera?.semestres} semestres{" "} {carrera?.facultades?.nombre ?? "—"} · {carrera?.semestres} semestres{" "}
{typeof carrera?.activo === "boolean" && ( {typeof carrera?.activo === "boolean" && (

View File

@@ -75,7 +75,7 @@ export function CarreraFormDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>{mode === "create" ? "Nueva carrera" : "Editar carrera"}</DialogTitle> <DialogTitle className="font-mono" >{mode === "create" ? "Nueva carrera" : "Editar carrera"}</DialogTitle>
<DialogDescription> <DialogDescription>
{mode === "create" ? "Crea una nueva carrera en la base de datos." : "Actualiza los datos de la carrera."} {mode === "create" ? "Crea una nueva carrera en la base de datos." : "Actualiza los datos de la carrera."}
</DialogDescription> </DialogDescription>

View File

@@ -68,7 +68,7 @@ export function CriterioFormDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Nuevo criterio</DialogTitle> <DialogTitle className="font-mono" >Nuevo criterio</DialogTitle>
<DialogDescription>Agrega un criterio para esta carrera.</DialogDescription> <DialogDescription>Agrega un criterio para esta carrera.</DialogDescription>
</DialogHeader> </DialogHeader>

View File

@@ -33,7 +33,7 @@ export function useDeleteCarreraDialog(carreraId: string, onDeleted?: () => void
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>¿Eliminar carrera?</DialogTitle> <DialogTitle className="font-mono" >¿Eliminar carrera?</DialogTitle>
<DialogDescription> <DialogDescription>
Esta acción no se puede deshacer. ¿Seguro que quieres eliminar esta carrera? Esta acción no se puede deshacer. ¿Seguro que quieres eliminar esta carrera?
</DialogDescription> </DialogDescription>

View File

@@ -84,7 +84,7 @@ export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdd
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-[min(92vw,760px)]"> <DialogContent className="w-[min(92vw,760px)]">
<DialogHeader> <DialogHeader>
<DialogTitle>Nueva asignatura</DialogTitle> <DialogTitle className="font-mono" >Nueva asignatura</DialogTitle>
<DialogDescription>Elige cómo crearla: manual o generada por IA.</DialogDescription> <DialogDescription>Elige cómo crearla: manual o generada por IA.</DialogDescription>
</DialogHeader> </DialogHeader>

View File

@@ -31,7 +31,7 @@ export function AdjustAIButton({ plan }: { plan: PlanFull }) {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Ajustar con IA</DialogTitle> <DialogTitle className="font-mono" >Ajustar con IA</DialogTitle>
<DialogDescription>Describe cómo quieres modificar el plan actual.</DialogDescription> <DialogDescription>Describe cómo quieres modificar el plan actual.</DialogDescription>
</DialogHeader> </DialogHeader>
<Textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Ej.: Enfatiza ciberseguridad y proyectos prácticos…" className="min-h-[120px]" /> <Textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Ej.: Enfatiza ciberseguridad y proyectos prácticos…" className="min-h-[120px]" />

View File

@@ -57,7 +57,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(92vw,760px)]"> <DialogContent className="w-[min(92vw,760px)]">
<DialogHeader> <DialogHeader>
<DialogTitle>Nuevo plan de estudios (IA)</DialogTitle> <DialogTitle className="font-mono" >Nuevo plan de estudios (IA)</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2 space-y-1"> <div className="md:col-span-2 space-y-1">

View File

@@ -54,7 +54,7 @@ export function EditPlanButton({ plan }: { plan: PlanFull }) {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Editar plan</DialogTitle> <DialogTitle className="font-mono" >Editar plan</DialogTitle>
<DialogDescription>Actualiza datos básicos.</DialogDescription> <DialogDescription>Actualiza datos básicos.</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-3"> <div className="grid gap-3">

View File

@@ -196,7 +196,7 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}> <Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{editing ? `Editar: ${sections.find((x) => x.key === editing.key)?.title}` : ""}</DialogTitle> <DialogTitle className="font-mono" >{editing ? `Editar: ${sections.find((x) => x.key === editing.key)?.title}` : ""}</DialogTitle>
</DialogHeader> </DialogHeader>
<Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`} placeholder="Escribe aquí…" /> <Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`} placeholder="Escribe aquí…" />
<DialogFooter> <DialogFooter>

View File

@@ -45,7 +45,7 @@ function CommandDialog({
return ( return (
<Dialog {...props}> <Dialog {...props}>
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle> <DialogTitle className="font-mono" >{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription> <DialogDescription>{description}</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogContent <DialogContent

View File

@@ -206,7 +206,7 @@ function DetailDialog({ row, onClose }: { row: RefRow | null; onClose: () => voi
<Dialog open={!!row} onOpenChange={(o) => !o && onClose()}> <Dialog open={!!row} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-3xl"> <DialogContent className="max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{row?.titulo_archivo ?? "(Sin título)"}</DialogTitle> <DialogTitle className="font-mono" >{row?.titulo_archivo ?? "(Sin título)"}</DialogTitle>
<DialogDescription> <DialogDescription>
{row?.descripcion || "Sin descripción"} {row?.descripcion || "Sin descripción"}
</DialogDescription> </DialogDescription>
@@ -333,7 +333,7 @@ function UploadDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl"> <DialogContent className="max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Nuevo archivo de referencia</DialogTitle> <DialogTitle className="font-mono" >Nuevo archivo de referencia</DialogTitle>
<DialogDescription> <DialogDescription>
Sube un documento y escribe instrucciones para su procesamiento. Se guardará en la base y se marcará como Sube un documento y escribe instrucciones para su procesamiento. Se guardará en la base y se marcará como
<em> procesado </em> cuando termine el flujo. <em> procesado </em> cuando termine el flujo.

View File

@@ -1,11 +1,18 @@
// routes/_authenticated/asignatura/$asignaturaId.tsx // routes/_authenticated/asignatura/$asignaturaId.tsx
import { createFileRoute, Link, useRouter } from "@tanstack/react-router" import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
import * as Icons from "lucide-react" import * as Icons from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { supabase } from "@/auth/supabase" import { supabase } from "@/auth/supabase"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue
} from "@/components/ui/select"
import confetti from "canvas-confetti" import confetti from "canvas-confetti"
import { import {
@@ -16,6 +23,8 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { ScrollArea } from "@/components/ui/scroll-area"
/* ================== Tipos ================== */ /* ================== Tipos ================== */
type Asignatura = { type Asignatura = {
@@ -456,7 +465,7 @@ function EditAsignaturaButton({ asignatura, onUpdate }: {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-xl"> <DialogContent className="max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Editar asignatura</DialogTitle> <DialogTitle className="font-mono" >Editar asignatura</DialogTitle>
<DialogDescription>Actualiza campos básicos.</DialogDescription> <DialogDescription>Actualiza campos básicos.</DialogDescription>
</DialogHeader> </DialogHeader>
@@ -468,7 +477,28 @@ function EditAsignaturaButton({ asignatura, onUpdate }: {
<Input value={form.clave ?? ""} onChange={e => setForm(s => ({ ...s, clave: e.target.value }))} /> <Input value={form.clave ?? ""} onChange={e => setForm(s => ({ ...s, clave: e.target.value }))} />
</Field> </Field>
<Field label="Tipo"> <Field label="Tipo">
<Input value={form.tipo ?? ""} onChange={e => setForm(s => ({ ...s, tipo: e.target.value }))} /> <Select value={form.tipo ?? ""} onValueChange={v => setForm(s => ({ ...s, tipo: v }))}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Selecciona tipo…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Obligatoria">
<span className={typeStyle("Obligatoria").chip}>Obligatoria</span>
</SelectItem>
<SelectItem value="Optativa">
<span className={typeStyle("Optativa").chip}>Optativa</span>
</SelectItem>
<SelectItem value="Taller">
<span className={typeStyle("Taller").chip}>Taller</span>
</SelectItem>
<SelectItem value="Laboratorio">
<span className={typeStyle("Laboratorio").chip}>Laboratorio</span>
</SelectItem>
<SelectItem value="Otro">
<span className={typeStyle("Otro").chip}>Otro</span>
</SelectItem>
</SelectContent>
</Select>
</Field> </Field>
<Field label="Semestre"> <Field label="Semestre">
<Input value={String(form.semestre ?? "")} onChange={e => setForm(s => ({ ...s, semestre: Number(e.target.value) || null }))} /> <Input value={String(form.semestre ?? "")} onChange={e => setForm(s => ({ ...s, semestre: Number(e.target.value) || null }))} />
@@ -537,7 +567,7 @@ function MejorarAIButton({ asignaturaId, onApply }: {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-xl"> <DialogContent className="max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Mejorar asignatura con IA</DialogTitle> <DialogTitle className="font-mono" >Mejorar asignatura con IA</DialogTitle>
<DialogDescription> <DialogDescription>
Describe el ajuste que deseas (p. ej. refuerza contenidos prácticos y añade bibliografía reciente). Describe el ajuste que deseas (p. ej. refuerza contenidos prácticos y añade bibliografía reciente).
</DialogDescription> </DialogDescription>
@@ -589,7 +619,9 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
} }
function EditContenidosButton({ type UnitDraft = { title: string; temas: string[] }
export function EditContenidosButton({
asignaturaId, asignaturaId,
value, value,
onSaved, onSaved,
@@ -600,137 +632,264 @@ function EditContenidosButton({
}) { }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
type UnitDraft = { title: string; temas: string[] }
const [units, setUnits] = useState<UnitDraft[]>([]) const [units, setUnits] = useState<UnitDraft[]>([])
const [initialUnits, setInitialUnits] = useState<UnitDraft[]>([])
// Normaliza el JSON (acepta estructuras flexibles) // --- Normaliza entrada flexible a estructura estable
function normalize(v: any): UnitDraft[] { const normalize = useCallback((v: any): UnitDraft[] => {
try { try {
const entries = Object.entries(v ?? {}) const entries = Object.entries(v ?? {})
.sort(([a], [b]) => Number(a) - Number(b)) .sort(([a], [b]) => Number(a) - Number(b))
.map(([key, val]) => { .map(([key, val]) => {
const obj = val as any const obj = val as any
// soporta: { titulo, subtemas:{ "1":"t1" } } | { "1":"t1" } | ["t1","t2"] const title =
const title = (typeof obj?.titulo === 'string' && obj.titulo.trim()) ? obj.titulo.trim() : `Unidad ${key}` typeof obj?.titulo === "string" && obj.titulo.trim()
? obj.titulo.trim()
: `Unidad ${key}`
let temas: string[] = [] let temas: string[] = []
if (Array.isArray(obj?.subtemas)) temas = obj.subtemas.map(String) if (Array.isArray(obj?.subtemas)) temas = obj.subtemas.map(String)
else if (obj?.subtemas && typeof obj.subtemas === 'object') { else if (obj?.subtemas && typeof obj.subtemas === "object") {
temas = Object.entries(obj.subtemas).sort(([a], [b]) => Number(a) - Number(b)).map(([, t]) => String(t)) temas = Object.entries(obj.subtemas)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, t]) => String(t))
} else if (Array.isArray(obj)) { } else if (Array.isArray(obj)) {
temas = obj.map(String) temas = obj.map(String)
} else if (obj && typeof obj === 'object') { } else if (obj && typeof obj === "object") {
const nums = Object.entries(obj).filter(([k, v]) => /^\d+$/.test(String(k)) && (typeof v === "string" || typeof v === "number")) const nums = Object.entries(obj).filter(
if (nums.length) temas = nums.sort(([a], [b]) => Number(a) - Number(b)).map(([, t]) => String(t)) ([k, v]) => /^\d+$/.test(String(k)) && (typeof v === "string" || typeof v === "number"),
)
if (nums.length)
temas = nums.sort(([a], [b]) => Number(a) - Number(b)).map(([, t]) => String(t))
} }
return { title, temas } return { title, temas }
}) })
return entries.length ? entries : [{ title: 'Unidad 1', temas: [] }] return entries.length ? entries : [{ title: "Unidad 1", temas: [] }]
} catch { } catch {
return [{ title: 'Unidad 1', temas: [] }] return [{ title: "Unidad 1", temas: [] }]
} }
} }, [])
// Construye un JSON estable para guardar // --- Construye payload consistente { "1": { titulo, subtemas:{ "1": "t1" } } }
function buildPayload(us: UnitDraft[]) { const buildPayload = useCallback((us: UnitDraft[]) => {
const out: any = {} const out: Record<string, any> = {}
us.forEach((u, idx) => { us.forEach((u, idx) => {
const k = String(idx + 1) const k = String(idx + 1)
const sub: any = {} const sub: Record<string, string> = {}
u.temas.filter(t => t.trim()).forEach((t, i) => { sub[String(i + 1)] = t.trim() }) u.temas
out[k] = { titulo: u.title.trim() || `Unidad ${k}`, subtemas: sub } .map((t) => t.trim())
.filter(Boolean)
.forEach((t, i) => {
sub[String(i + 1)] = t
})
out[k] = { titulo: (u.title || "").trim() || `Unidad ${k}`, subtemas: sub }
}) })
return out return out
}, [])
// --- Limpia UI: recorta espacios, elimina líneas vacías/duplicadas (case-insensitive)
const cleanUnits = useCallback((us: UnitDraft[]) => {
return us.map((u, idx) => {
const seen = new Set<string>()
const temas = u.temas
.map((t) => t.trim())
.filter((t) => {
if (!t) return false
const key = t.toLowerCase()
if (seen.has(key)) return false
seen.add(key)
return true
})
return {
title: (u.title || "").trim() || `Unidad ${idx + 1}`,
temas,
}
})
}, [])
const openEditor = () => {
const base = normalize(value)
setUnits(cleanUnits(base))
setInitialUnits(cleanUnits(base))
setOpen(true)
} }
function openEditor() { const hasChanges = useMemo(
setUnits(normalize(value)) () => JSON.stringify(cleanUnits(units)) !== JSON.stringify(cleanUnits(initialUnits)),
setOpen(true) [units, initialUnits, cleanUnits],
)
// --- Atajos: Guardar con Ctrl/Cmd + Enter
useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => {
const ctrlOrCmd = e.ctrlKey || e.metaKey
if (ctrlOrCmd && e.key.toLowerCase() === "enter") {
e.preventDefault()
void save()
}
}
window.addEventListener("keydown", handler)
return () => window.removeEventListener("keydown", handler)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, units, saving])
// --- Acciones por unidad
const removeUnit = (idx: number) => {
if (!confirm("¿Eliminar esta unidad?")) return
setUnits((prev) => prev.filter((_, i) => i !== idx))
}
const moveUnit = (idx: number, dir: -1 | 1) => {
setUnits((prev) => {
const next = [...prev]
const j = idx + dir
if (j < 0 || j >= next.length) return prev
;[next[idx], next[j]] = [next[j], next[idx]]
return next
})
}
const duplicateUnit = (idx: number) => {
setUnits((prev) => {
const next = [...prev]
next.splice(idx + 1, 0, {
title: `${prev[idx].title} (copia)`,
temas: [...prev[idx].temas],
})
return next
})
} }
async function save() { async function save() {
setSaving(true) setSaving(true)
const contenidos = buildPayload(units) const cleaned = cleanUnits(units)
const contenidos = buildPayload(cleaned)
const { data, error } = await supabase const { data, error } = await supabase
.from('asignaturas') .from("asignaturas")
.update({ contenidos }) .update({ contenidos })
.eq('id', asignaturaId) .eq("id", asignaturaId)
.select() .select()
.maybeSingle() .maybeSingle()
setSaving(false) setSaving(false)
if (error) { alert(error.message || 'No se pudo guardar'); return } if (error) {
alert(error.message || "No se pudo guardar")
return
}
setInitialUnits(cleaned)
onSaved((data as any)?.contenidos ?? contenidos) onSaved((data as any)?.contenidos ?? contenidos)
setOpen(false) setOpen(false)
} }
const cancel = () => {
if (hasChanges && !confirm("Hay cambios sin guardar. ¿Cerrar de todos modos?")) return
setOpen(false)
}
return ( return (
<> <>
<Button size="sm" variant="outline" onClick={openEditor}> <Button size="sm" variant="outline" onClick={openEditor}>
<Icons.Pencil className="h-4 w-4 mr-2" /> Editar contenidos <Icons.Pencil className="h-4 w-4 mr-2" /> Editar contenidos
</Button> </Button>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={(o) => (o ? openEditor() : cancel())}>
<DialogContent className="max-w-3xl"> <DialogContent className="max-w-4xl p-0 overflow-hidden">
<DialogHeader> {/* Header sticky */}
<DialogTitle>Editar contenidos</DialogTitle> <div className="sticky top-0 z-10 border-b bg-background/80 backdrop-blur">
<DialogDescription> <DialogHeader className="px-6 pt-5 pb-3">
Títulos de unidad y temas (un tema por línea). Se guardará en un formato consistente con <code>titulo</code> y <code>subtemas</code>. <DialogTitle className="flex items-center gap-2">
</DialogDescription> <Icons.BookOpen className="h-5 w-5" />
</DialogHeader> Editar contenidos
{hasChanges && (
<div className="space-y-4 max-h-[60vh] overflow-auto pr-1"> <Badge variant="secondary" className="ml-1">Cambios sin guardar</Badge>
{units.map((u, i) => ( )}
<div key={i} className="rounded-2xl border p-3"> </DialogTitle>
<div className="flex items-center justify-between mb-2"> <DialogDescription>
<div className="font-medium text-sm">Unidad {i + 1}</div> Títulos de unidad y temas (uno por línea). Se guardará con <code>titulo</code> y <code>subtemas</code>.
<div className="flex items-center gap-2"> </DialogDescription>
<Button </DialogHeader>
size="icon" <div className="px-6 pb-3 flex items-center justify-between text-xs text-muted-foreground">
variant="ghost" <span>{units.length} unidad(es)</span>
title="Eliminar unidad" <span className="inline-flex items-center gap-1">
onClick={() => setUnits(prev => prev.filter((_, idx) => idx !== i))} <Icons.Keyboard className="h-3 w-3" /> Atajo: <kbd className="px-1 border rounded">Ctrl/</kbd>+<kbd className="px-1 border rounded">Enter</kbd>
> </span>
<Icons.Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
<div className="grid gap-2">
<div className="space-y-1">
<Label>Título</Label>
<Input
value={u.title}
onChange={(e) => setUnits(prev => prev.map((uu, idx) => idx === i ? { ...uu, title: e.target.value } : uu))}
placeholder={`Unidad ${i + 1}`}
/>
</div>
<div className="space-y-1">
<Label>Temas (uno por línea)</Label>
<Textarea
className="min-h-[120px]"
value={u.temas.join('\n')}
onChange={(e) => {
const lines = e.target.value.split('\n')
setUnits(prev => prev.map((uu, idx) => idx === i ? { ...uu, temas: lines } : uu))
}}
placeholder={`Tema 1\nTema 2\n…`}
/>
</div>
</div>
</div>
))}
<div className="flex justify-end">
<Button
variant="secondary"
onClick={() => setUnits(prev => [...prev, { title: `Unidad ${prev.length + 1}`, temas: [] }])}
>
<Icons.Plus className="w-4 h-4 mr-2" /> Agregar unidad
</Button>
</div> </div>
</div> </div>
<DialogFooter> <ScrollArea className="max-h-[65vh] px-6">
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button> <div className="space-y-4 py-4">
<Button onClick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button> {units.map((u, i) => (
<div key={i} className="rounded-2xl border p-4">
<div className="flex items-center justify-between mb-3 gap-2">
<div className="font-medium text-sm">Unidad {i + 1}</div>
<div className="flex items-center gap-1">
<Button size="icon" variant="ghost" title="Subir" onClick={() => moveUnit(i, -1)} disabled={i === 0}>
<Icons.ArrowUp className="w-4 h-4" />
</Button>
<Button size="icon" variant="ghost" title="Bajar" onClick={() => moveUnit(i, 1)} disabled={i === units.length - 1}>
<Icons.ArrowDown className="w-4 h-4" />
</Button>
<Button size="icon" variant="ghost" title="Duplicar unidad" onClick={() => duplicateUnit(i)}>
<Icons.Copy className="w-4 h-4" />
</Button>
<Separator orientation="vertical" className="mx-1 h-5" />
<Button size="icon" variant="ghost" title="Eliminar unidad" onClick={() => removeUnit(i)}>
<Icons.Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
<div className="grid md:grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Título</Label>
<Input
value={u.title}
onChange={(e) =>
setUnits((prev) => prev.map((uu, idx) => (idx === i ? { ...uu, title: e.target.value } : uu)))
}
placeholder={`Unidad ${i + 1}`}
/>
</div>
<div className="space-y-1 md:col-span-2">
<div className="flex items-center justify-between">
<Label>Temas (uno por línea)</Label>
<Badge variant="outline">{u.temas.filter((t) => t.trim()).length} tema(s)</Badge>
</div>
<Textarea
className="min-h-[140px]"
value={u.temas.join("\n")}
onChange={(e) => {
const lines = e.target.value.split("\n")
setUnits((prev) => prev.map((uu, idx) => (idx === i ? { ...uu, temas: lines } : uu)))
}}
placeholder={`Tema 1\nTema 2\n…`}
/>
</div>
</div>
</div>
))}
<div className="flex justify-end">
<Button
variant="secondary"
onClick={() =>
setUnits((prev) => [...prev, { title: `Unidad ${prev.length + 1}`, temas: [] }])
}
>
<Icons.Plus className="w-4 h-4 mr-2" /> Agregar unidad
</Button>
</div>
</div>
</ScrollArea>
<DialogFooter className="px-6 pb-5">
<Button variant="outline" onClick={cancel}>Cancelar</Button>
<Button onClick={save} disabled={saving}>
{saving ? (
<span className="inline-flex items-center gap-2">
<Icons.Loader2 className="h-4 w-4 animate-spin" /> Guardando
</span>
) : (
"Guardar"
)}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -738,6 +897,7 @@ function EditContenidosButton({
) )
} }
function EditBibliografiaButton({ function EditBibliografiaButton({
asignaturaId, asignaturaId,
value, value,
@@ -779,7 +939,7 @@ function EditBibliografiaButton({
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Editar bibliografía</DialogTitle> <DialogTitle className="font-mono" >Editar bibliografía</DialogTitle>
<DialogDescription>Escribe una referencia por línea.</DialogDescription> <DialogDescription>Escribe una referencia por línea.</DialogDescription>
</DialogHeader> </DialogHeader>
<Textarea <Textarea

View File

@@ -493,7 +493,7 @@ function RouteComponent() {
<Dialog open={cloneOpen} onOpenChange={setCloneOpen}> <Dialog open={cloneOpen} onOpenChange={setCloneOpen}>
<DialogContent className="w-[min(92vw,720px)] sm:max-w-xl"> <DialogContent className="w-[min(92vw,720px)] sm:max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Clonar asignatura</DialogTitle> <DialogTitle className="font-mono" >Clonar asignatura</DialogTitle>
</DialogHeader> </DialogHeader>
{cloneTarget && ( {cloneTarget && (
<div className="grid gap-3"> <div className="grid gap-3">
@@ -584,7 +584,7 @@ function RouteComponent() {
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}> <Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
<DialogContent className="w-[min(92vw,840px)]"> <DialogContent className="w-[min(92vw,840px)]">
<DialogHeader> <DialogHeader>
<DialogTitle>Carrito de asignaturas ({cart.length})</DialogTitle> <DialogTitle className="font-mono" >Carrito de asignaturas ({cart.length})</DialogTitle>
</DialogHeader> </DialogHeader>
{cart.length === 0 ? ( {cart.length === 0 ? (

View File

@@ -182,7 +182,7 @@ function Page() {
Plan Plan
</div> </div>
<DialogHeader className="p-0"> <DialogHeader className="p-0">
<DialogTitle className="truncate text-xl sm:text-2xl">{planNombre}</DialogTitle> <DialogTitle className="font-mono" className="truncate text-xl sm:text-2xl">{planNombre}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs"> <div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<KpiChip icon={Icons.BookOpen} label="Asignaturas" value={kpis.total} /> <KpiChip icon={Icons.BookOpen} label="Asignaturas" value={kpis.total} />

View File

@@ -199,7 +199,7 @@ function RouteComponent() {
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Nueva facultad</DialogTitle> <DialogTitle className="font-mono" >Nueva facultad</DialogTitle>
</DialogHeader> </DialogHeader>
<FormFields form={form} setForm={setForm} /> <FormFields form={form} setForm={setForm} />
@@ -223,7 +223,7 @@ function RouteComponent() {
<Dialog open={editOpen} onOpenChange={(o) => { setEditOpen(o); if (!o) setEditing(null) }}> <Dialog open={editOpen} onOpenChange={(o) => { setEditOpen(o); if (!o) setEditing(null) }}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Editar facultad</DialogTitle> <DialogTitle className="font-mono" >Editar facultad</DialogTitle>
</DialogHeader> </DialogHeader>
<FormFields form={form} setForm={setForm} /> <FormFields form={form} setForm={setForm} />

View File

@@ -236,7 +236,7 @@ function EditPlanButton({ plan }: { plan: PlanFull }) {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Editar plan</DialogTitle> <DialogTitle className="font-mono" >Editar plan</DialogTitle>
<DialogDescription>Actualiza datos básicos.</DialogDescription> <DialogDescription>Actualiza datos básicos.</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-3"> <div className="grid gap-3">
@@ -287,7 +287,7 @@ function AdjustAIButton({ plan }: { plan: PlanFull }) {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Ajustar con IA</DialogTitle> <DialogTitle className="font-mono" >Ajustar con IA</DialogTitle>
<DialogDescription>Describe cómo quieres modificar el plan actual.</DialogDescription> <DialogDescription>Describe cómo quieres modificar el plan actual.</DialogDescription>
</DialogHeader> </DialogHeader>
<Textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Ej.: Enfatiza ciberseguridad y proyectos prácticos…" className="min-h-[120px]" /> <Textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Ej.: Enfatiza ciberseguridad y proyectos prácticos…" className="min-h-[120px]" />
@@ -489,7 +489,7 @@ function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: ()
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-[min(92vw,760px)]"> <DialogContent className="w-[min(92vw,760px)]">
<DialogHeader> <DialogHeader>
<DialogTitle>Nueva asignatura</DialogTitle> <DialogTitle className="font-mono" >Nueva asignatura</DialogTitle>
<DialogDescription>Elige cómo crearla: manual o generada por IA.</DialogDescription> <DialogDescription>Elige cómo crearla: manual o generada por IA.</DialogDescription>
</DialogHeader> </DialogHeader>

View File

@@ -367,7 +367,7 @@ function RouteComponent() {
{/* Dialog de edición */} {/* Dialog de edición */}
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}> <Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
<DialogContent className="w-[min(92vw,720px)] sm:max-w-xl md:max-w-2xl"> <DialogContent className="w-[min(92vw,720px)] sm:max-w-xl md:max-w-2xl">
<DialogHeader><DialogTitle>Editar usuario</DialogTitle></DialogHeader> <DialogHeader><DialogTitle className="font-mono" >Editar usuario</DialogTitle></DialogHeader>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1"><Label>Nombre</Label><Input value={form.nombre ?? ""} onChange={(e) => setForm((s) => ({ ...s, nombre: e.target.value }))} /></div> <div className="space-y-1"><Label>Nombre</Label><Input value={form.nombre ?? ""} onChange={(e) => setForm((s) => ({ ...s, nombre: e.target.value }))} /></div>
<div className="space-y-1"><Label>Apellidos</Label><Input value={form.apellidos ?? ""} onChange={(e) => setForm((s) => ({ ...s, apellidos: e.target.value }))} /></div> <div className="space-y-1"><Label>Apellidos</Label><Input value={form.apellidos ?? ""} onChange={(e) => setForm((s) => ({ ...s, apellidos: e.target.value }))} /></div>
@@ -452,7 +452,7 @@ function RouteComponent() {
{/* Modal: Nuevo usuario */} {/* Modal: Nuevo usuario */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="w-[min(92vw,720px)] sm:max-w-xl md:max-w-2xl"> <DialogContent className="w-[min(92vw,720px)] sm:max-w-xl md:max-w-2xl">
<DialogHeader><DialogTitle>Nuevo usuario</DialogTitle></DialogHeader> <DialogHeader><DialogTitle className="font-mono" >Nuevo usuario</DialogTitle></DialogHeader>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1 md:col-span-2"> <div className="space-y-1 md:col-span-2">