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:
2025-09-01 14:58:36 -06:00
parent 5181306b93
commit 1808ce6f81
13 changed files with 762 additions and 99 deletions

View File

@@ -25,6 +25,10 @@ import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { ScrollArea } from "@/components/ui/scroll-area"
import { typeStyle } from "@/components/asignaturas/typeStyle"
import { Stat } from "@/components/asignaturas/Stat"
import { Section } from "@/components/asignaturas/Section"
import { EditBibliografiaButton } from "@/components/asignaturas/EditBibliografíaButton"
/* ================== Tipos ================== */
type Asignatura = {
@@ -57,42 +61,7 @@ export const Route = createFileRoute("/_authenticated/asignatura/$asignaturaId")
})
/* ================== Helpers UI ================== */
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" }
}
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>
)
}
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>
)
}
/* ================== Página ================== */
function Page() {
const router = useRouter()
const { a: aFromLoader, plan } = Route.useLoaderData() as { a: Asignatura; plan: PlanMini | null }
@@ -256,7 +225,7 @@ function Page() {
const t = (u.temas || []).find(([k]: any[]) => String(k).toLowerCase() === "titulo")?.[1]
if (typeof t === "string" && t.trim()) return t
return /^\s*\d+/.test(String(u.key))
? (u.title && u.title !== u.key ? u.title : `Unidad ${u.key && Number(u.key) ? Number(u.key) + 1 : 1}`)
? (u.title && u.title !== u.key ? u.title : `Unidad ${u.key && Number(u.key) ? Number(u.key) : 1}`)
: (u.title || String(u.key))
}
const temasOf = (u: any): string[] => {
@@ -294,7 +263,7 @@ function Page() {
<div className="flex items-center justify-between w-full">
<span className="font-medium">
{/^\s*\d+/.test(String(u.key))
? `Unidad ${u.key && Number(u.key) ? Number(u.key) + 1 : 1}${u.__title ? `: ${u.__title}` : ""}`
? `Unidad ${u.key && Number(u.key) ? Number(u.key) : 1}${u.__title ? `: ${u.__title}` : ""}`
: u.__title}
</span>
<span className="text-[11px] text-neutral-500">{u.__temas.length} tema(s)</span>
@@ -895,65 +864,4 @@ export function EditContenidosButton({
</Dialog>
</>
)
}
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('')
function openEditor() {
setText((value ?? []).join('\n'))
setOpen(true)
}
async function save() {
setSaving(true)
const refs = text.split('\n').map(s => s.trim()).filter(Boolean)
const { data, error } = await supabase
.from('asignaturas')
.update({ bibliografia: refs })
.eq('id', asignaturaId)
.select()
.maybeSingle()
setSaving(false)
if (error) { alert(error.message || 'No se pudo guardar'); return }
onSaved((data as any)?.bibliografia ?? refs)
setOpen(false)
}
return (
<>
<Button size="sm" variant="outline" onClick={openEditor}>
<Icons.Pencil className="h-4 w-4 mr-2" /> Editar bibliografía
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="font-mono" >Editar bibliografía</DialogTitle>
<DialogDescription>Escribe una referencia por línea.</DialogDescription>
</DialogHeader>
<Textarea
className="min-h-[260px]"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={`Autor, Título, Editorial, Año\nDOI/URL\n…`}
/>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
<Button onClick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
}