feat: add EditAsignaturaButton and EditBibliografiaButton components for managing asignaturas
- Introduced EditAsignaturaButton for editing asignatura details with a dialog interface. - Added EditBibliografiaButton for managing bibliographic references with various utility actions (trim, dedupe, sort, import, export). - Created reusable Field, Section, and Stat components for better UI structure. - Implemented typeStyle utility for styling based on asignatura type. - Integrated new components into the existing asignatura route. - Updated package.json to include new dependencies for alert dialogs and tooltips. - Defined Asignatura type in a new types file for better type safety.
This commit is contained in:
107
src/components/asignaturas/EditAsignaturaButton.tsx
Normal file
107
src/components/asignaturas/EditAsignaturaButton.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState } from "react"
|
||||
import { supabase } from "@/auth/supabase"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
||||
import { typeStyle } from "./typeStyle"
|
||||
import type { Asignatura } from "@/types/asignatura"
|
||||
import { Field } from "./Field"
|
||||
|
||||
export function EditAsignaturaButton({ asignatura, onUpdate }: {
|
||||
asignatura: Asignatura; onUpdate: (a: Asignatura) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form, setForm] = useState<Partial<Asignatura>>({})
|
||||
|
||||
const openAndFill = () => { setForm(asignatura); setOpen(true) }
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
const payload = {
|
||||
nombre: form.nombre ?? asignatura.nombre,
|
||||
clave: form.clave ?? asignatura.clave,
|
||||
tipo: form.tipo ?? asignatura.tipo,
|
||||
semestre: form.semestre ?? asignatura.semestre,
|
||||
creditos: form.creditos ?? asignatura.creditos,
|
||||
horas_teoricas: form.horas_teoricas ?? asignatura.horas_teoricas,
|
||||
horas_practicas: form.horas_practicas ?? asignatura.horas_practicas,
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.update(payload)
|
||||
.eq("id", asignatura.id)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
setSaving(false)
|
||||
if (!error && data) { onUpdate(data as Asignatura); setOpen(false) }
|
||||
else alert(error?.message ?? "Error al guardar")
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={openAndFill}>
|
||||
Editar
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar asignatura</DialogTitle>
|
||||
<DialogDescription>Actualiza campos básicos.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Nombre">
|
||||
<Input value={form.nombre ?? ""} onChange={e => setForm((s: Partial<Asignatura>) => ({ ...s, nombre: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Clave">
|
||||
<Input value={form.clave ?? ""} onChange={e => setForm((s: Partial<Asignatura>) => ({ ...s, clave: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Tipo">
|
||||
<Select value={form.tipo ?? ""} onValueChange={v => setForm((s: Partial<Asignatura>) => ({ ...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 label="Semestre">
|
||||
<Input value={String(form.semestre ?? "")} onChange={e => setForm((s: Partial<Asignatura>) => ({ ...s, semestre: Number(e.target.value) || null }))} />
|
||||
</Field>
|
||||
<Field label="Créditos">
|
||||
<Input value={String(form.creditos ?? "")} onChange={e => setForm((s: Partial<Asignatura>) => ({ ...s, creditos: Number(e.target.value) || null }))} />
|
||||
</Field>
|
||||
<Field label="Horas teóricas">
|
||||
<Input value={String(form.horas_teoricas ?? "")} onChange={e => setForm((s: Partial<Asignatura>) => ({ ...s, horas_teoricas: Number(e.target.value) || null }))} />
|
||||
</Field>
|
||||
<Field label="Horas prácticas">
|
||||
<Input value={String(form.horas_practicas ?? "")} onChange={e => setForm((s: Partial<Asignatura>) => ({ ...s, horas_practicas: Number(e.target.value) || null }))} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : "Guardar"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
228
src/components/asignaturas/EditBibliografíaButton.tsx
Normal file
228
src/components/asignaturas/EditBibliografíaButton.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { supabase } from "@/auth/supabase"
|
||||
import { Button } from "../ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog"
|
||||
import { Textarea } from "../ui/textarea"
|
||||
import { Separator } from "../ui/separator"
|
||||
import { ScrollArea } from "../ui/scroll-area"
|
||||
import { Badge } from "../ui/badge"
|
||||
import * as Icons from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
/**
|
||||
* EditBibliografiaButton v3 (simple y aireado)
|
||||
* - Layout limpio: una sola columna + barra mínima.
|
||||
* - Acciones esenciales: Recortar, Dedupe, A–Z, Importar, Copiar.
|
||||
* - Toasts con sonner: toast.success / toast.error.
|
||||
*/
|
||||
|
||||
export function EditBibliografiaButton({
|
||||
asignaturaId,
|
||||
value,
|
||||
onSaved,
|
||||
}: {
|
||||
asignaturaId: string
|
||||
value: string[]
|
||||
onSaved: (refs: string[]) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [text, setText] = useState("")
|
||||
|
||||
const initialTextRef = useRef("")
|
||||
const lines = useMemo(() => parseLines(text), [text])
|
||||
const dirty = useMemo(() => initialTextRef.current !== text, [text])
|
||||
|
||||
function openEditor() {
|
||||
const start = (value ?? []).join("\n")
|
||||
setText(start)
|
||||
initialTextRef.current = start
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
setSaving(true)
|
||||
const refs = parseLines(text)
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.update({ bibliografia: refs })
|
||||
.eq("id", asignaturaId)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
|
||||
onSaved((data as any)?.bibliografia ?? refs)
|
||||
initialTextRef.current = refs.join("\n")
|
||||
toast.success(`${refs.length} referencia(s) guardada(s).`)
|
||||
setOpen(false)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "No se pudo guardar")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Acciones
|
||||
function actionTrim() {
|
||||
const next = parseLines(text).map((s) => s.replace(/\s+/g, " ").trim())
|
||||
setText(next.join("\n"))
|
||||
}
|
||||
function actionDedupe() {
|
||||
const seen = new Set<string>()
|
||||
const next: string[] = []
|
||||
for (const l of parseLines(text)) {
|
||||
const k = l.toLowerCase()
|
||||
if (!seen.has(k)) { seen.add(k); next.push(l) }
|
||||
}
|
||||
setText(next.join("\n"))
|
||||
}
|
||||
function actionSort() {
|
||||
const next = [...parseLines(text)].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||
setText(next.join("\n"))
|
||||
}
|
||||
async function actionImportClipboard() {
|
||||
try {
|
||||
const clip = await navigator.clipboard.readText()
|
||||
if (!clip) { toast("Portapapeles vacío"); return }
|
||||
const next = [...parseLines(text), ...parseLines(clip)]
|
||||
setText(next.join("\n"))
|
||||
toast.success("Texto importado")
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "No se pudo leer el portapapeles")
|
||||
}
|
||||
}
|
||||
async function actionExportClipboard() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(parseLines(text).join("\n"))
|
||||
toast.success("Copiado al portapapeles")
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "No se pudo copiar")
|
||||
}
|
||||
}
|
||||
|
||||
// Atajo guardar
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (!open) return
|
||||
if (e.key === "s" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault()
|
||||
if (!saving && dirty) void save()
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, saving, dirty, text])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={openEditor} className="gap-2">
|
||||
<Icons.Library className="h-4 w-4" /> Editar bibliografía
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-xl p-0 overflow-hidden">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<DialogTitle className="tracking-tight">Editar bibliografía</DialogTitle>
|
||||
<DialogDescription>
|
||||
Una referencia por línea. Guarda con <kbd className="border px-1 rounded">Ctrl/⌘+S</kbd>.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="rounded-full">{lines.length}</Badge>
|
||||
{dirty ? <Badge variant="destructive" className="rounded-full">Sin guardar</Badge> : <Badge className="rounded-full">OK</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Barra de acciones (compacta) */}
|
||||
<div className="px-6 pb-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" className="gap-2" onClick={actionTrim}>
|
||||
<Icons.Scissors className="h-4 w-4" /> Recortar
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="gap-2" onClick={actionDedupe}>
|
||||
<Icons.CopyMinus className="h-4 w-4" /> Quitar duplicados
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="gap-2" onClick={actionSort}>
|
||||
<Icons.SortAsc className="h-4 w-4" /> A–Z
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="gap-2" onClick={actionImportClipboard}>
|
||||
<Icons.ClipboardPaste className="h-4 w-4" /> Importar
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="gap-2" onClick={actionExportClipboard}>
|
||||
<Icons.ClipboardCopy className="h-4 w-4" /> Copiar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Editor */}
|
||||
<div className="p-6">
|
||||
<Textarea
|
||||
className="min-h-[320px] resize-y"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={`Autor, Título, Editorial, Año\nDOI/URL\n…`}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Consejo: pega desde tu gestor y usa los botones para limpiar.</span>
|
||||
<span>{new Intl.NumberFormat().format(text.length)} caracteres</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Previsualización (compacta) */}
|
||||
{lines.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="px-6 pb-6">
|
||||
<div className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<Icons.Eye className="h-4 w-4" /> Vista previa
|
||||
</div>
|
||||
<ScrollArea className="h-40 rounded-md border">
|
||||
<ol className="list-decimal pl-6 pr-3 py-3 space-y-1 text-sm">
|
||||
{lines.map((l, i) => (
|
||||
<li key={i} className="text-muted-foreground">{l}</li>
|
||||
))}
|
||||
</ol>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<DialogFooter className="px-6 py-4">
|
||||
<Button variant="outline" onClick={() => setOpen(false)} className="gap-2">
|
||||
<Icons.X className="h-4 w-4" /> Cerrar
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving || !dirty} className="gap-2">
|
||||
{saving ? <Icons.Loader2 className="h-4 w-4 animate-spin" /> : <Icons.Save className="h-4 w-4" />}
|
||||
{saving ? "Guardando…" : "Guardar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- Utilidades ---------- */
|
||||
function parseLines(text: string): string[] {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
10
src/components/asignaturas/Field.tsx
Normal file
10
src/components/asignaturas/Field.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
export function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-neutral-600">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
src/components/asignaturas/Section.tsx
Normal file
13
src/components/asignaturas/Section.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as React from "react"
|
||||
|
||||
export function Section({ id, title, icon: Icon, children }: { id: string; title: string; icon: any; children: React.ReactNode }) {
|
||||
return (
|
||||
<section id={id} className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 scroll-mt-24">
|
||||
<header className="flex items-center gap-2 mb-2">
|
||||
<div className="h-8 w-8 rounded-lg grid place-items-center border bg-white/80"><Icon className="h-4 w-4" /></div>
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
13
src/components/asignaturas/Stat.tsx
Normal file
13
src/components/asignaturas/Stat.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export function Stat({ icon: Icon, label, value }: { icon: any; label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-3 flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-neutral-500">{label}</div>
|
||||
<div className="text-lg font-semibold tabular-nums">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
src/components/asignaturas/typeStyle.ts
Normal file
8
src/components/asignaturas/typeStyle.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function typeStyle(tipo?: string | null) {
|
||||
const t = (tipo ?? "").toLowerCase()
|
||||
if (t.includes("oblig")) return { chip: "bg-emerald-50 text-emerald-700 border-emerald-200", halo: "from-emerald-100/60" }
|
||||
if (t.includes("opt")) return { chip: "bg-amber-50 text-amber-800 border-amber-200", halo: "from-amber-100/60" }
|
||||
if (t.includes("taller")) return { chip: "bg-indigo-50 text-indigo-700 border-indigo-200", halo: "from-indigo-100/60" }
|
||||
if (t.includes("lab")) return { chip: "bg-sky-50 text-sky-700 border-sky-200", halo: "from-sky-100/60" }
|
||||
return { chip: "bg-neutral-100 text-neutral-700 border-neutral-200", halo: "from-primary/10" }
|
||||
}
|
||||
Reference in New Issue
Block a user