feat: update dialog titles to use font-mono style for consistency across components
This commit is contained in:
@@ -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" && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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]" />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,117 +632,232 @@ 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">
|
||||||
|
<DialogHeader className="px-6 pt-5 pb-3">
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Icons.BookOpen className="h-5 w-5" />
|
||||||
|
Editar contenidos
|
||||||
|
{hasChanges && (
|
||||||
|
<Badge variant="secondary" className="ml-1">Cambios sin guardar</Badge>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
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>.
|
Títulos de unidad y temas (uno por línea). Se guardará con <code>titulo</code> y <code>subtemas</code>.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
<div className="px-6 pb-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{units.length} unidad(es)</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 max-h-[60vh] overflow-auto pr-1">
|
<ScrollArea className="max-h-[65vh] px-6">
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
{units.map((u, i) => (
|
{units.map((u, i) => (
|
||||||
<div key={i} className="rounded-2xl border p-3">
|
<div key={i} className="rounded-2xl border p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-3 gap-2">
|
||||||
<div className="font-medium text-sm">Unidad {i + 1}</div>
|
<div className="font-medium text-sm">Unidad {i + 1}</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button size="icon" variant="ghost" title="Subir" onClick={() => moveUnit(i, -1)} disabled={i === 0}>
|
||||||
size="icon"
|
<Icons.ArrowUp className="w-4 h-4" />
|
||||||
variant="ghost"
|
</Button>
|
||||||
title="Eliminar unidad"
|
<Button size="icon" variant="ghost" title="Bajar" onClick={() => moveUnit(i, 1)} disabled={i === units.length - 1}>
|
||||||
onClick={() => setUnits(prev => prev.filter((_, idx) => idx !== i))}
|
<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" />
|
<Icons.Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Título</Label>
|
<Label>Título</Label>
|
||||||
<Input
|
<Input
|
||||||
value={u.title}
|
value={u.title}
|
||||||
onChange={(e) => setUnits(prev => prev.map((uu, idx) => idx === i ? { ...uu, title: e.target.value } : uu))}
|
onChange={(e) =>
|
||||||
|
setUnits((prev) => prev.map((uu, idx) => (idx === i ? { ...uu, title: e.target.value } : uu)))
|
||||||
|
}
|
||||||
placeholder={`Unidad ${i + 1}`}
|
placeholder={`Unidad ${i + 1}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1 md:col-span-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<Label>Temas (uno por línea)</Label>
|
<Label>Temas (uno por línea)</Label>
|
||||||
|
<Badge variant="outline">{u.temas.filter((t) => t.trim()).length} tema(s)</Badge>
|
||||||
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
className="min-h-[120px]"
|
className="min-h-[140px]"
|
||||||
value={u.temas.join('\n')}
|
value={u.temas.join("\n")}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const lines = e.target.value.split('\n')
|
const lines = e.target.value.split("\n")
|
||||||
setUnits(prev => prev.map((uu, idx) => idx === i ? { ...uu, temas: lines } : uu))
|
setUnits((prev) => prev.map((uu, idx) => (idx === i ? { ...uu, temas: lines } : uu)))
|
||||||
}}
|
}}
|
||||||
placeholder={`Tema 1\nTema 2\n…`}
|
placeholder={`Tema 1\nTema 2\n…`}
|
||||||
/>
|
/>
|
||||||
@@ -718,19 +865,31 @@ function EditContenidosButton({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => setUnits(prev => [...prev, { title: `Unidad ${prev.length + 1}`, temas: [] }])}
|
onClick={() =>
|
||||||
|
setUnits((prev) => [...prev, { title: `Unidad ${prev.length + 1}`, temas: [] }])
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Icons.Plus className="w-4 h-4 mr-2" /> Agregar unidad
|
<Icons.Plus className="w-4 h-4 mr-2" /> Agregar unidad
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="px-6 pb-5">
|
||||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
<Button variant="outline" onClick={cancel}>Cancelar</Button>
|
||||||
<Button onClick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</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
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user