Se hidrata de informacion las tabs de asignatura

This commit is contained in:
2026-01-14 15:52:25 -06:00
parent c4329785cc
commit b4b5134cb2
9 changed files with 1490 additions and 819 deletions

View File

@@ -1,14 +1,44 @@
import { useState } from 'react'; import { useEffect, useState } from 'react'
import { Plus, Search, BookOpen, Trash2, Library, Edit3, Save } from 'lucide-react'; import {
import { Card, CardContent } from '@/components/ui/card'; Plus,
import { Button } from '@/components/ui/button'; Search,
import { Input } from '@/components/ui/input'; BookOpen,
import { Textarea } from '@/components/ui/textarea'; Trash2,
import { Badge } from '@/components/ui/badge'; Library,
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; Edit3,
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; Save,
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; } from 'lucide-react'
import { cn } from '@/lib/utils'; import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { cn } from '@/lib/utils'
import { useSubjectBibliografia } from '@/data/hooks/useSubjects'
//import { toast } from 'sonner'; //import { toast } from 'sonner';
//import { mockLibraryResources } from '@/data/mockMateriaData'; //import { mockLibraryResources } from '@/data/mockMateriaData';
@@ -20,7 +50,7 @@ export const mockLibraryResources = [
editorial: 'MIT Press', editorial: 'MIT Press',
anio: 2016, anio: 2016,
isbn: '9780262035613', isbn: '9780262035613',
disponible: true disponible: true,
}, },
{ {
id: 'lib-2', id: 'lib-2',
@@ -29,102 +59,154 @@ export const mockLibraryResources = [
editorial: 'Pearson', editorial: 'Pearson',
anio: 2020, anio: 2020,
isbn: '9780134610993', isbn: '9780134610993',
disponible: true disponible: true,
}, },
{ {
id: 'lib-3', id: 'lib-3',
titulo: 'Hands-On Machine Learning', titulo: 'Hands-On Machine Learning',
autor: 'Aurélien Géron', autor: 'Aurélien Géron',
editorial: 'O\'Reilly Media', editorial: "O'Reilly Media",
anio: 2019, anio: 2019,
isbn: '9781492032649', isbn: '9781492032649',
disponible: false disponible: false,
} },
]; ]
// --- Interfaces --- // --- Interfaces ---
export interface BibliografiaEntry { export interface BibliografiaEntry {
id: string; id: string
tipo: 'BASICA' | 'COMPLEMENTARIA'; tipo: 'BASICA' | 'COMPLEMENTARIA'
cita: string; cita: string
fuenteBibliotecaId?: string; tipo_fuente?: 'MANUAL' | 'BIBLIOTECA'
fuenteBiblioteca?: any; biblioteca_item_id?: string | null
fuenteBibliotecaId?: string
fuenteBiblioteca?: any
} }
interface BibliografiaTabProps { interface BibliografiaTabProps {
bibliografia: BibliografiaEntry[]; bibliografia: BibliografiaEntry[]
onSave: (bibliografia: BibliografiaEntry[]) => void; onSave: (bibliografia: BibliografiaEntry[]) => void
isSaving: boolean; isSaving: boolean
} }
export function BibliographyItem({ bibliografia, onSave, isSaving }: BibliografiaTabProps) { export function BibliographyItem({
const [entries, setEntries] = useState<BibliografiaEntry[]>(bibliografia); bibliografia,
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); onSave,
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false); isSaving,
const [deleteId, setDeleteId] = useState<string | null>(null); }: BibliografiaTabProps) {
const [editingId, setEditingId] = useState<string | null>(null); const { data: bibliografia2, isLoading: loadinmateria } =
const [newEntryType, setNewEntryType] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA'); useSubjectBibliografia('9d4dda6a-488f-428a-8a07-38081592a641')
const [entries, setEntries] = useState<BibliografiaEntry[]>(bibliografia)
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [newEntryType, setNewEntryType] = useState<'BASICA' | 'COMPLEMENTARIA'>(
'BASICA',
)
const basicaEntries = entries.filter(e => e.tipo === 'BASICA'); useEffect(() => {
const complementariaEntries = entries.filter(e => e.tipo === 'COMPLEMENTARIA'); if (bibliografia2 && Array.isArray(bibliografia2)) {
setEntries(bibliografia2)
} else if (bibliografia) {
// Fallback a la prop inicial si la API no devuelve nada
setEntries(bibliografia)
}
}, [bibliografia2, bibliografia])
const basicaEntries = entries.filter((e) => e.tipo === 'BASICA')
const complementariaEntries = entries.filter(
(e) => e.tipo === 'COMPLEMENTARIA',
)
console.log(bibliografia2)
const handleAddManual = (cita: string) => { const handleAddManual = (cita: string) => {
const newEntry: BibliografiaEntry = { id: `manual-${Date.now()}`, tipo: newEntryType, cita }; const newEntry: BibliografiaEntry = {
setEntries([...entries, newEntry]); id: `manual-${Date.now()}`,
setIsAddDialogOpen(false); tipo: newEntryType,
cita,
}
setEntries([...entries, newEntry])
setIsAddDialogOpen(false)
//toast.success('Referencia manual añadida'); //toast.success('Referencia manual añadida');
}; }
const handleAddFromLibrary = (resource: any, tipo: 'BASICA' | 'COMPLEMENTARIA') => { const handleAddFromLibrary = (
const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.`; resource: any,
tipo: 'BASICA' | 'COMPLEMENTARIA',
) => {
const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.`
const newEntry: BibliografiaEntry = { const newEntry: BibliografiaEntry = {
id: `lib-ref-${Date.now()}`, id: `lib-ref-${Date.now()}`,
tipo, tipo,
cita, cita,
fuenteBibliotecaId: resource.id, fuenteBibliotecaId: resource.id,
fuenteBiblioteca: resource, fuenteBiblioteca: resource,
}; }
setEntries([...entries, newEntry]); setEntries([...entries, newEntry])
setIsLibraryDialogOpen(false); setIsLibraryDialogOpen(false)
//toast.success('Añadido desde biblioteca'); //toast.success('Añadido desde biblioteca');
}; }
const handleUpdateCita = (id: string, cita: string) => { const handleUpdateCita = (id: string, cita: string) => {
setEntries(entries.map(e => e.id === id ? { ...e, cita } : e)); setEntries(entries.map((e) => (e.id === id ? { ...e, cita } : e)))
}; }
return ( return (
<div className="max-w-5xl mx-auto py-10 space-y-8 animate-in fade-in duration-500"> <div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500">
<div className="flex items-center justify-between border-b pb-4"> <div className="flex items-center justify-between border-b pb-4">
<div> <div>
<h2 className="text-2xl font-bold text-slate-900 tracking-tight">Bibliografía</h2> <h2 className="text-2xl font-bold tracking-tight text-slate-900">
<p className="text-sm text-slate-500 mt-1"> Bibliografía
{basicaEntries.length} básica {complementariaEntries.length} complementaria </h2>
<p className="mt-1 text-sm text-slate-500">
{basicaEntries.length} básica {complementariaEntries.length}{' '}
complementaria
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Dialog open={isLibraryDialogOpen} onOpenChange={setIsLibraryDialogOpen}> <Dialog
open={isLibraryDialogOpen}
onOpenChange={setIsLibraryDialogOpen}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" className="border-blue-200 text-blue-700 hover:bg-blue-50"> <Button
<Library className="w-4 h-4 mr-2" /> Buscar en biblioteca variant="outline"
className="border-blue-200 text-blue-700 hover:bg-blue-50"
>
<Library className="mr-2 h-4 w-4" /> Buscar en biblioteca
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<LibrarySearchDialog onSelect={handleAddFromLibrary} existingIds={entries.map(e => e.fuenteBibliotecaId || '')} /> <LibrarySearchDialog
onSelect={handleAddFromLibrary}
existingIds={entries.map((e) => e.fuenteBibliotecaId || '')}
/>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}> <Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline"><Plus className="w-4 h-4 mr-2" /> Añadir manual</Button> <Button variant="outline">
<Plus className="mr-2 h-4 w-4" /> Añadir manual
</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<AddManualDialog tipo={newEntryType} onTypeChange={setNewEntryType} onAdd={handleAddManual} /> <AddManualDialog
tipo={newEntryType}
onTypeChange={setNewEntryType}
onAdd={handleAddManual}
/>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Button onClick={() => onSave(entries)} disabled={isSaving} className="bg-blue-600 hover:bg-blue-700"> <Button
<Save className="w-4 h-4 mr-2" /> {isSaving ? 'Guardando...' : 'Guardar'} onClick={() => onSave(entries)}
disabled={isSaving}
className="bg-blue-600 hover:bg-blue-700"
>
<Save className="mr-2 h-4 w-4" />{' '}
{isSaving ? 'Guardando...' : 'Guardar'}
</Button> </Button>
</div> </div>
</div> </div>
@@ -133,11 +215,13 @@ export function BibliographyItem({ bibliografia, onSave, isSaving }: Bibliografi
{/* BASICA */} {/* BASICA */}
<section className="space-y-4"> <section className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-4 w-1 bg-blue-600 rounded-full" /> <div className="h-4 w-1 rounded-full bg-blue-600" />
<h3 className="font-semibold text-slate-800">Bibliografía Básica</h3> <h3 className="font-semibold text-slate-800">
Bibliografía Básica
</h3>
</div> </div>
<div className="grid gap-3"> <div className="grid gap-3">
{basicaEntries.map(entry => ( {basicaEntries.map((entry) => (
<BibliografiaCard <BibliografiaCard
key={entry.id} key={entry.id}
entry={entry} entry={entry}
@@ -154,11 +238,13 @@ export function BibliographyItem({ bibliografia, onSave, isSaving }: Bibliografi
{/* COMPLEMENTARIA */} {/* COMPLEMENTARIA */}
<section className="space-y-4"> <section className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-4 w-1 bg-slate-400 rounded-full" /> <div className="h-4 w-1 rounded-full bg-slate-400" />
<h3 className="font-semibold text-slate-800">Bibliografía Complementaria</h3> <h3 className="font-semibold text-slate-800">
Bibliografía Complementaria
</h3>
</div> </div>
<div className="grid gap-3"> <div className="grid gap-3">
{complementariaEntries.map(entry => ( {complementariaEntries.map((entry) => (
<BibliografiaCard <BibliografiaCard
key={entry.id} key={entry.id}
entry={entry} entry={entry}
@@ -177,70 +263,143 @@ export function BibliographyItem({ bibliografia, onSave, isSaving }: Bibliografi
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>¿Eliminar referencia?</AlertDialogTitle> <AlertDialogTitle>¿Eliminar referencia?</AlertDialogTitle>
<AlertDialogDescription>La referencia será quitada del plan de estudios.</AlertDialogDescription> <AlertDialogDescription>
La referencia será quitada del plan de estudios.
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel> <AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={() => { setEntries(entries.filter(e => e.id !== deleteId)); setDeleteId(null); }} className="bg-red-600">Eliminar</AlertDialogAction> <AlertDialogAction
onClick={() => {
setEntries(entries.filter((e) => e.id !== deleteId))
setDeleteId(null)
}}
className="bg-red-600"
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div> </div>
); )
} }
// --- Subcomponentes --- // --- Subcomponentes ---
function BibliografiaCard({ entry, isEditing, onEdit, onStopEditing, onUpdateCita, onDelete }: any) { function BibliografiaCard({
const [localCita, setLocalCita] = useState(entry.cita); entry,
isEditing,
onEdit,
onStopEditing,
onUpdateCita,
onDelete,
}: any) {
const [localCita, setLocalCita] = useState(entry.cita)
return ( return (
<Card className={cn("group transition-all hover:shadow-md", isEditing && "ring-2 ring-blue-500")}> <Card
className={cn(
'group transition-all hover:shadow-md',
isEditing && 'ring-2 ring-blue-500',
)}
>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<BookOpen className={cn("w-5 h-5 mt-1", entry.tipo === 'BASICA' ? "text-blue-600" : "text-slate-400")} /> <BookOpen
<div className="flex-1 min-w-0"> className={cn(
'mt-1 h-5 w-5',
entry.tipo === 'BASICA' ? 'text-blue-600' : 'text-slate-400',
)}
/>
<div className="min-w-0 flex-1">
{isEditing ? ( {isEditing ? (
<div className="space-y-2"> <div className="space-y-2">
<Textarea value={localCita} onChange={(e) => setLocalCita(e.target.value)} className="min-h-[80px]" /> <Textarea
value={localCita}
onChange={(e) => setLocalCita(e.target.value)}
className="min-h-[80px]"
/>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onStopEditing}>Cancelar</Button> <Button variant="ghost" size="sm" onClick={onStopEditing}>
<Button size="sm" className="bg-emerald-600" onClick={() => { onUpdateCita(entry.id, localCita); onStopEditing(); }}>Guardar</Button> Cancelar
</Button>
<Button
size="sm"
className="bg-emerald-600"
onClick={() => {
onUpdateCita(entry.id, localCita)
onStopEditing()
}}
>
Guardar
</Button>
</div> </div>
</div> </div>
) : ( ) : (
<div onClick={onEdit} className="cursor-pointer"> <div onClick={onEdit} className="cursor-pointer">
<p className="text-sm leading-relaxed text-slate-700">{entry.cita}</p> <p className="text-sm leading-relaxed text-slate-700">
{entry.cita}
</p>
{entry.fuenteBiblioteca && ( {entry.fuenteBiblioteca && (
<div className="flex gap-2 mt-2"> <div className="mt-2 flex gap-2">
<Badge variant="secondary" className="text-[10px] bg-slate-100 text-slate-600">Biblioteca</Badge> <Badge
{entry.fuenteBiblioteca.disponible && <Badge className="text-[10px] bg-emerald-50 text-emerald-700 border-emerald-100">Disponible</Badge>} variant="secondary"
className="bg-slate-100 text-[10px] text-slate-600"
>
Biblioteca
</Badge>
{entry.fuenteBiblioteca.disponible && (
<Badge className="border-emerald-100 bg-emerald-50 text-[10px] text-emerald-700">
Disponible
</Badge>
)}
</div> </div>
)} )}
</div> </div>
)} )}
</div> </div>
{!isEditing && ( {!isEditing && (
<div className="flex opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-600" onClick={onEdit}><Edit3 className="w-4 h-4" /></Button> <Button
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-500" onClick={onDelete}><Trash2 className="w-4 h-4" /></Button> variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-600"
onClick={onEdit}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-500"
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />
</Button>
</div> </div>
)} )}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
); )
} }
function AddManualDialog({ tipo, onTypeChange, onAdd }: any) { function AddManualDialog({ tipo, onTypeChange, onAdd }: any) {
const [cita, setCita] = useState(''); const [cita, setCita] = useState('')
return ( return (
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<DialogHeader><DialogTitle>Referencia Manual</DialogTitle></DialogHeader> <DialogHeader>
<DialogTitle>Referencia Manual</DialogTitle>
</DialogHeader>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold uppercase text-slate-500">Tipo</label> <label className="text-xs font-bold text-slate-500 uppercase">
Tipo
</label>
<Select value={tipo} onValueChange={onTypeChange}> <Select value={tipo} onValueChange={onTypeChange}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="BASICA">Básica</SelectItem> <SelectItem value="BASICA">Básica</SelectItem>
<SelectItem value="COMPLEMENTARIA">Complementaria</SelectItem> <SelectItem value="COMPLEMENTARIA">Complementaria</SelectItem>
@@ -248,44 +407,78 @@ function AddManualDialog({ tipo, onTypeChange, onAdd }: any) {
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold uppercase text-slate-500">Cita APA</label> <label className="text-xs font-bold text-slate-500 uppercase">
<Textarea value={cita} onChange={(e) => setCita(e.target.value)} placeholder="Autor, A. (Año). Título..." className="min-h-[120px]" /> Cita APA
</label>
<Textarea
value={cita}
onChange={(e) => setCita(e.target.value)}
placeholder="Autor, A. (Año). Título..."
className="min-h-[120px]"
/>
</div> </div>
<Button onClick={() => onAdd(cita)} disabled={!cita.trim()} className="w-full bg-blue-600">Añadir a la lista</Button> <Button
onClick={() => onAdd(cita)}
disabled={!cita.trim()}
className="w-full bg-blue-600"
>
Añadir a la lista
</Button>
</div> </div>
); )
} }
function LibrarySearchDialog({ onSelect, existingIds }: any) { function LibrarySearchDialog({ onSelect, existingIds }: any) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('')
const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA'); const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA')
const filtered = mockLibraryResources.filter(r => const filtered = mockLibraryResources.filter(
!existingIds.includes(r.id) && r.titulo.toLowerCase().includes(search.toLowerCase()) (r) =>
); !existingIds.includes(r.id) &&
r.titulo.toLowerCase().includes(search.toLowerCase()),
)
return ( return (
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<DialogHeader><DialogTitle>Catálogo de Biblioteca</DialogTitle></DialogHeader> <DialogHeader>
<DialogTitle>Catálogo de Biblioteca</DialogTitle>
</DialogHeader>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Buscar por título o autor..." className="pl-10" /> <Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar por título o autor..."
className="pl-10"
/>
</div> </div>
<Select value={tipo} onValueChange={(v:any) => setTipo(v)}><SelectTrigger className="w-36"><SelectValue /></SelectTrigger> <Select value={tipo} onValueChange={(v: any) => setTipo(v)}>
<SelectContent><SelectItem value="BASICA">Básica</SelectItem><SelectItem value="COMPLEMENTARIA">Complem.</SelectItem></SelectContent> <SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="BASICA">Básica</SelectItem>
<SelectItem value="COMPLEMENTARIA">Complem.</SelectItem>
</SelectContent>
</Select> </Select>
</div> </div>
<div className="max-h-[300px] overflow-y-auto pr-2 space-y-2"> <div className="max-h-[300px] space-y-2 overflow-y-auto pr-2">
{filtered.map(res => ( {filtered.map((res) => (
<div key={res.id} onClick={() => onSelect(res, tipo)} className="p-3 border rounded-lg hover:bg-slate-50 cursor-pointer flex justify-between items-center group"> <div
key={res.id}
onClick={() => onSelect(res, tipo)}
className="group flex cursor-pointer items-center justify-between rounded-lg border p-3 hover:bg-slate-50"
>
<div> <div>
<p className="text-sm font-semibold text-slate-700">{res.titulo}</p> <p className="text-sm font-semibold text-slate-700">
{res.titulo}
</p>
<p className="text-xs text-slate-500">{res.autor}</p> <p className="text-xs text-slate-500">{res.autor}</p>
</div> </div>
<Plus className="w-4 h-4 text-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" /> <Plus className="h-4 w-4 text-blue-600 opacity-0 transition-opacity group-hover:opacity-100" />
</div> </div>
))} ))}
</div> </div>
</div> </div>
); )
} }

View File

@@ -1,10 +1,23 @@
import { useState } from 'react'; import { useEffect, useState } from 'react'
import { Plus, GripVertical, ChevronDown, ChevronRight, Edit3, Trash2, Clock, Save } from 'lucide-react'; import {
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; Plus,
import { Button } from '@/components/ui/button'; GripVertical,
import { Input } from '@/components/ui/input'; ChevronDown,
import { Badge } from '@/components/ui/badge'; ChevronRight,
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; Edit3,
Trash2,
Clock,
Save,
} from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -14,24 +27,22 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
//import { toast } from 'sonner'; //import { toast } from 'sonner';
export interface Tema { export interface Tema {
id: string; id: string
nombre: string; nombre: string
descripcion?: string; descripcion?: string
horasEstimadas?: number; horasEstimadas?: number
} }
export interface UnidadTematica { export interface UnidadTematica {
id: string; id: string
nombre: string; nombre: string
numero: number; numero: number
temas: Tema[]; temas: Tema[]
} }
const initialData: UnidadTematica[] = [ const initialData: UnidadTematica[] = [
@@ -42,152 +53,297 @@ const initialData: UnidadTematica[] = [
temas: [ temas: [
{ id: 't1', nombre: 'Tipos de IA y aplicaciones', horasEstimadas: 6 }, { id: 't1', nombre: 'Tipos de IA y aplicaciones', horasEstimadas: 6 },
{ id: 't2', nombre: 'Ética en IA', horasEstimadas: 3 }, { id: 't2', nombre: 'Ética en IA', horasEstimadas: 3 },
],
},
] ]
}
];
export function ContenidoTematico() { // Estructura que viene de tu JSON/API
const [unidades, setUnidades] = useState<UnidadTematica[]>(initialData); interface ContenidoApi {
const [expandedUnits, setExpandedUnits] = useState<Set<string>>(new Set(['u1'])); unidad: number
const [deleteDialog, setDeleteDialog] = useState<{ type: 'unidad' | 'tema'; id: string; parentId?: string } | null>(null); titulo: string
const [editingUnit, setEditingUnit] = useState<string | null>(null); temas: string[] | any[] // Acepta strings o objetos
const [editingTema, setEditingTema] = useState<{ unitId: string; temaId: string } | null>(null); [key: string]: any // Esta línea permite que haya más claves desconocidas
const [isSaving, setIsSaving] = useState(false); }
// Props del componente
interface ContenidoTematicoProps {
data: {
contenido_tematico: ContenidoApi[]
}
isLoading: boolean
}
export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
const [unidades, setUnidades] = useState<UnidadTematica[]>([])
const [expandedUnits, setExpandedUnits] = useState<Set<string>>(
new Set(['u1']),
)
const [deleteDialog, setDeleteDialog] = useState<{
type: 'unidad' | 'tema'
id: string
parentId?: string
} | null>(null)
const [editingUnit, setEditingUnit] = useState<string | null>(null)
const [editingTema, setEditingTema] = useState<{
unitId: string
temaId: string
} | null>(null)
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
if (data?.contenido_tematico) {
const transformed = data.contenido_tematico.map(
(u: any, idx: number) => ({
id: `u-${idx}`,
numero: u.unidad || idx + 1,
nombre: u.titulo || 'Sin título',
temas: Array.isArray(u.temas)
? u.temas.map((t: any, tidx: number) => ({
id: `t-${idx}-${tidx}`,
nombre: typeof t === 'string' ? t : t.nombre || 'Tema',
horasEstimadas: t.horasEstimadas || 0,
}))
: [],
}),
)
setUnidades(transformed)
// Expandir la primera unidad automáticamente
if (transformed.length > 0) {
setExpandedUnits(new Set([transformed[0].id]))
}
}
}, [data])
if (isLoading)
return <div className="p-10 text-center">Cargando contenido...</div>
// 3. Cálculo de horas (ahora dinámico basado en los nuevos datos)
const totalHoras = unidades.reduce(
(acc, u) =>
acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0),
0,
)
// --- Lógica de Unidades --- // --- Lógica de Unidades ---
const toggleUnit = (id: string) => { const toggleUnit = (id: string) => {
const newExpanded = new Set(expandedUnits); const newExpanded = new Set(expandedUnits)
newExpanded.has(id) ? newExpanded.delete(id) : newExpanded.add(id); newExpanded.has(id) ? newExpanded.delete(id) : newExpanded.add(id)
setExpandedUnits(newExpanded); setExpandedUnits(newExpanded)
}; }
const addUnidad = () => { const addUnidad = () => {
const newId = `u-${Date.now()}`; const newId = `u-${Date.now()}`
const newUnidad: UnidadTematica = { const newUnidad: UnidadTematica = {
id: newId, id: newId,
nombre: 'Nueva Unidad', nombre: 'Nueva Unidad',
numero: unidades.length + 1, numero: unidades.length + 1,
temas: [], temas: [],
}; }
setUnidades([...unidades, newUnidad]); setUnidades([...unidades, newUnidad])
setExpandedUnits(new Set([...expandedUnits, newId])); setExpandedUnits(new Set([...expandedUnits, newId]))
setEditingUnit(newId); setEditingUnit(newId)
}; }
const updateUnidadNombre = (id: string, nombre: string) => { const updateUnidadNombre = (id: string, nombre: string) => {
setUnidades(unidades.map(u => u.id === id ? { ...u, nombre } : u)); setUnidades(unidades.map((u) => (u.id === id ? { ...u, nombre } : u)))
}; }
// --- Lógica de Temas --- // --- Lógica de Temas ---
const addTema = (unidadId: string) => { const addTema = (unidadId: string) => {
setUnidades(unidades.map(u => { setUnidades(
unidades.map((u) => {
if (u.id === unidadId) { if (u.id === unidadId) {
const newTemaId = `t-${Date.now()}`; const newTemaId = `t-${Date.now()}`
const newTema: Tema = { id: newTemaId, nombre: 'Nuevo tema', horasEstimadas: 2 }; const newTema: Tema = {
setEditingTema({ unitId: unidadId, temaId: newTemaId }); id: newTemaId,
return { ...u, temas: [...u.temas, newTema] }; nombre: 'Nuevo tema',
horasEstimadas: 2,
}
setEditingTema({ unitId: unidadId, temaId: newTemaId })
return { ...u, temas: [...u.temas, newTema] }
}
return u
}),
)
} }
return u;
}));
};
const updateTema = (unidadId: string, temaId: string, updates: Partial<Tema>) => { const updateTema = (
setUnidades(unidades.map(u => { unidadId: string,
temaId: string,
updates: Partial<Tema>,
) => {
setUnidades(
unidades.map((u) => {
if (u.id === unidadId) { if (u.id === unidadId) {
return { ...u, temas: u.temas.map(t => t.id === temaId ? { ...t, ...updates } : t) }; return {
...u,
temas: u.temas.map((t) =>
t.id === temaId ? { ...t, ...updates } : t,
),
}
}
return u
}),
)
} }
return u;
}));
};
const handleDelete = () => { const handleDelete = () => {
if (!deleteDialog) return; if (!deleteDialog) return
if (deleteDialog.type === 'unidad') { if (deleteDialog.type === 'unidad') {
setUnidades(unidades.filter(u => u.id !== deleteDialog.id).map((u, i) => ({ ...u, numero: i + 1 }))); setUnidades(
unidades
.filter((u) => u.id !== deleteDialog.id)
.map((u, i) => ({ ...u, numero: i + 1 })),
)
} else if (deleteDialog.parentId) { } else if (deleteDialog.parentId) {
setUnidades(unidades.map(u => u.id === deleteDialog.parentId ? { ...u, temas: u.temas.filter(t => t.id !== deleteDialog.id) } : u)); setUnidades(
unidades.map((u) =>
u.id === deleteDialog.parentId
? { ...u, temas: u.temas.filter((t) => t.id !== deleteDialog.id) }
: u,
),
)
} }
setDeleteDialog(null); setDeleteDialog(null)
//toast.success("Eliminado correctamente"); //toast.success("Eliminado correctamente");
}; }
const totalHoras = unidades.reduce((acc, u) => acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0), 0);
return ( return (
<div className="max-w-5xl mx-auto py-10 space-y-6 animate-in fade-in duration-500"> <div className="animate-in fade-in mx-auto max-w-5xl space-y-6 py-10 duration-500">
<div className="flex items-center justify-between border-b pb-4"> <div className="flex items-center justify-between border-b pb-4">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight text-slate-900">Contenido Temático</h2> <h2 className="text-2xl font-bold tracking-tight text-slate-900">
<p className="text-sm text-slate-500 mt-1"> Contenido Temático
</h2>
<p className="mt-1 text-sm text-slate-500">
{unidades.length} unidades {totalHoras} horas estimadas totales {unidades.length} unidades {totalHoras} horas estimadas totales
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" onClick={addUnidad} className="gap-2"> <Button variant="outline" onClick={addUnidad} className="gap-2">
<Plus className="w-4 h-4" /> Nueva unidad <Plus className="h-4 w-4" /> Nueva unidad
</Button> </Button>
<Button onClick={() => { setIsSaving(true); setTimeout(() => { setIsSaving(false); /*toast.success("Guardado")*/; }, 1000); }} disabled={isSaving} className="bg-blue-600 hover:bg-blue-700"> <Button
<Save className="w-4 h-4 mr-2" /> {isSaving ? 'Guardando...' : 'Guardar'} onClick={() => {
setIsSaving(true)
setTimeout(() => {
setIsSaving(false) /*toast.success("Guardado")*/
}, 1000)
}}
disabled={isSaving}
className="bg-blue-600 hover:bg-blue-700"
>
<Save className="mr-2 h-4 w-4" />{' '}
{isSaving ? 'Guardando...' : 'Guardar'}
</Button> </Button>
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{unidades.map((unidad) => ( {unidades.map((unidad) => (
<Card key={unidad.id} className="overflow-hidden border-slate-200 shadow-sm"> <Card
<Collapsible open={expandedUnits.has(unidad.id)} onOpenChange={() => toggleUnit(unidad.id)}> key={unidad.id}
<CardHeader className="bg-slate-50/50 py-3 border-b border-slate-100"> className="overflow-hidden border-slate-200 shadow-sm"
>
<Collapsible
open={expandedUnits.has(unidad.id)}
onOpenChange={() => toggleUnit(unidad.id)}
>
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<GripVertical className="w-4 h-4 text-slate-300 cursor-grab" /> <GripVertical className="h-4 w-4 cursor-grab text-slate-300" />
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="p-0 h-auto"> <Button variant="ghost" size="sm" className="h-auto p-0">
{expandedUnits.has(unidad.id) ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />} {expandedUnits.has(unidad.id) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<Badge className="bg-blue-600 font-mono">Unidad {unidad.numero}</Badge> <Badge className="bg-blue-600 font-mono">
Unidad {unidad.numero}
</Badge>
{editingUnit === unidad.id ? ( {editingUnit === unidad.id ? (
<Input <Input
value={unidad.nombre} value={unidad.nombre}
onChange={(e) => updateUnidadNombre(unidad.id, e.target.value)} onChange={(e) =>
updateUnidadNombre(unidad.id, e.target.value)
}
onBlur={() => setEditingUnit(null)} onBlur={() => setEditingUnit(null)}
onKeyDown={(e) => e.key === 'Enter' && setEditingUnit(null)} onKeyDown={(e) =>
className="max-w-md h-8 bg-white" e.key === 'Enter' && setEditingUnit(null)
}
className="h-8 max-w-md bg-white"
autoFocus autoFocus
/> />
) : ( ) : (
<CardTitle className="text-base font-semibold cursor-pointer hover:text-blue-600 transition-colors" onClick={() => setEditingUnit(unidad.id)}> <CardTitle
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
onClick={() => setEditingUnit(unidad.id)}
>
{unidad.nombre} {unidad.nombre}
</CardTitle> </CardTitle>
)} )}
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
<span className="text-xs font-medium text-slate-400 flex items-center gap-1"> <span className="flex items-center gap-1 text-xs font-medium text-slate-400">
<Clock className="w-3 h-3" /> {unidad.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0)}h <Clock className="h-3 w-3" />{' '}
{unidad.temas.reduce(
(sum, t) => sum + (t.horasEstimadas || 0),
0,
)}
h
</span> </span>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-500" onClick={() => setDeleteDialog({ type: 'unidad', id: unidad.id })}> <Button
<Trash2 className="w-4 h-4" /> variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-500"
onClick={() =>
setDeleteDialog({ type: 'unidad', id: unidad.id })
}
>
<Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CollapsibleContent> <CollapsibleContent>
<CardContent className="pt-4 bg-white"> <CardContent className="bg-white pt-4">
<div className="space-y-1 ml-10 border-l-2 border-slate-50 pl-4"> <div className="ml-10 space-y-1 border-l-2 border-slate-50 pl-4">
{unidad.temas.map((tema, idx) => ( {unidad.temas.map((tema, idx) => (
<TemaRow <TemaRow
key={tema.id} key={tema.id}
tema={tema} tema={tema}
index={idx + 1} index={idx + 1}
isEditing={editingTema?.unitId === unidad.id && editingTema?.temaId === tema.id} isEditing={
onEdit={() => setEditingTema({ unitId: unidad.id, temaId: tema.id })} editingTema?.unitId === unidad.id &&
editingTema?.temaId === tema.id
}
onEdit={() =>
setEditingTema({ unitId: unidad.id, temaId: tema.id })
}
onStopEditing={() => setEditingTema(null)} onStopEditing={() => setEditingTema(null)}
onUpdate={(updates) => updateTema(unidad.id, tema.id, updates)} onUpdate={(updates) =>
onDelete={() => setDeleteDialog({ type: 'tema', id: tema.id, parentId: unidad.id })} updateTema(unidad.id, tema.id, updates)
}
onDelete={() =>
setDeleteDialog({
type: 'tema',
id: tema.id,
parentId: unidad.id,
})
}
/> />
))} ))}
<Button variant="ghost" size="sm" className="text-blue-600 hover:text-blue-700 hover:bg-blue-50 w-full justify-start mt-2" onClick={() => addTema(unidad.id)}> <Button
<Plus className="w-3 h-3 mr-2" /> Añadir subtema variant="ghost"
size="sm"
className="mt-2 w-full justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
onClick={() => addTema(unidad.id)}
>
<Plus className="mr-2 h-3 w-3" /> Añadir subtema
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -197,81 +353,137 @@ export function ContenidoTematico() {
))} ))}
</div> </div>
<DeleteConfirmDialog dialog={deleteDialog} setDialog={setDeleteDialog} onConfirm={handleDelete} /> <DeleteConfirmDialog
dialog={deleteDialog}
setDialog={setDeleteDialog}
onConfirm={handleDelete}
/>
</div> </div>
); )
} }
// --- Componentes Auxiliares --- // --- Componentes Auxiliares ---
interface TemaRowProps { interface TemaRowProps {
tema: Tema; tema: Tema
index: number; index: number
isEditing: boolean; isEditing: boolean
onEdit: () => void; onEdit: () => void
onStopEditing: () => void; onStopEditing: () => void
onUpdate: (updates: Partial<Tema>) => void; onUpdate: (updates: Partial<Tema>) => void
onDelete: () => void; onDelete: () => void
} }
function TemaRow({ tema, index, isEditing, onEdit, onStopEditing, onUpdate, onDelete }: TemaRowProps) { function TemaRow({
tema,
index,
isEditing,
onEdit,
onStopEditing,
onUpdate,
onDelete,
}: TemaRowProps) {
return ( return (
<div className={cn("flex items-center gap-3 p-2 rounded-md group transition-all", isEditing ? "bg-blue-50 ring-1 ring-blue-100" : "hover:bg-slate-50")}> <div
<span className="text-xs font-mono text-slate-400 w-4">{index}.</span> className={cn(
'group flex items-center gap-3 rounded-md p-2 transition-all',
isEditing ? 'bg-blue-50 ring-1 ring-blue-100' : 'hover:bg-slate-50',
)}
>
<span className="w-4 font-mono text-xs text-slate-400">{index}.</span>
{isEditing ? ( {isEditing ? (
<div className="flex-1 flex items-center gap-2 animate-in slide-in-from-left-2"> <div className="animate-in slide-in-from-left-2 flex flex-1 items-center gap-2">
<Input value={tema.nombre} onChange={(e) => onUpdate({ nombre: e.target.value })} className="h-8 flex-1 bg-white" placeholder="Nombre" autoFocus /> <Input
<Input type="number" value={tema.horasEstimadas} onChange={(e) => onUpdate({ horasEstimadas: parseInt(e.target.value) || 0 })} className="h-8 w-16 bg-white" /> value={tema.nombre}
<Button size="sm" className="bg-emerald-600 h-8" onClick={onStopEditing}>Listo</Button> onChange={(e) => onUpdate({ nombre: e.target.value })}
className="h-8 flex-1 bg-white"
placeholder="Nombre"
autoFocus
/>
<Input
type="number"
value={tema.horasEstimadas}
onChange={(e) =>
onUpdate({ horasEstimadas: parseInt(e.target.value) || 0 })
}
className="h-8 w-16 bg-white"
/>
<Button
size="sm"
className="h-8 bg-emerald-600"
onClick={onStopEditing}
>
Listo
</Button>
</div> </div>
) : ( ) : (
<> <>
<div className="flex-1 cursor-pointer" onClick={onEdit}> <div className="flex-1 cursor-pointer" onClick={onEdit}>
<p className="text-sm font-medium text-slate-700">{tema.nombre}</p> <p className="text-sm font-medium text-slate-700">{tema.nombre}</p>
</div> </div>
<Badge variant="secondary" className="text-[10px] opacity-60">{tema.horasEstimadas}h</Badge> <Badge variant="secondary" className="text-[10px] opacity-60">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> {tema.horasEstimadas}h
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-600" onClick={onEdit}><Edit3 className="w-3 h-3" /></Button> </Badge>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-red-500" onClick={onDelete}><Trash2 className="w-3 h-3" /></Button> <div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-blue-600"
onClick={onEdit}
>
<Edit3 className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-red-500"
onClick={onDelete}
>
<Trash2 className="h-3 w-3" />
</Button>
</div> </div>
</> </>
)} )}
</div> </div>
); )
} }
interface DeleteDialogState { interface DeleteDialogState {
type: 'unidad' | 'tema'; type: 'unidad' | 'tema'
id: string; id: string
parentId?: string; parentId?: string
} }
interface DeleteConfirmDialogProps { interface DeleteConfirmDialogProps {
dialog: DeleteDialogState | null; dialog: DeleteDialogState | null
setDialog: (value: DeleteDialogState | null) => void; setDialog: (value: DeleteDialogState | null) => void
onConfirm: () => void; onConfirm: () => void
} }
function DeleteConfirmDialog({ function DeleteConfirmDialog({
dialog, dialog,
setDialog, setDialog,
onConfirm, onConfirm,
}: DeleteConfirmDialogProps) { }: DeleteConfirmDialogProps) {
return ( return (
<AlertDialog open={!!dialog} onOpenChange={() => setDialog(null)}> <AlertDialog open={!!dialog} onOpenChange={() => setDialog(null)}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>¿Confirmar eliminación?</AlertDialogTitle> <AlertDialogTitle>¿Confirmar eliminación?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Estás a punto de borrar un {dialog?.type}. Esta acción no se puede deshacer. Estás a punto de borrar un {dialog?.type}. Esta acción no se puede
deshacer.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel> <AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm} className="bg-red-600 hover:bg-red-700 text-white">Eliminar</AlertDialogAction> <AlertDialogAction
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
); )
} }

View File

@@ -1,8 +1,16 @@
import { useState } from 'react'; import { useState } from 'react'
import { FileText, Download, RefreshCw, Calendar, FileCheck, AlertTriangle, Loader2 } from 'lucide-react'; import {
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; FileText,
import { Button } from '@/components/ui/button'; Download,
import { Badge } from '@/components/ui/badge'; RefreshCw,
Calendar,
FileCheck,
AlertTriangle,
Loader2,
} from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -13,63 +21,88 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog'
import type { DocumentoMateria, Materia, MateriaStructure } from '@/types/materia'; import type {
import { cn } from '@/lib/utils'; DocumentoMateria,
Materia,
MateriaStructure,
} from '@/types/materia'
import { cn } from '@/lib/utils'
import { useSubjectBibliografia } from '@/data/hooks/useSubjects'
//import { toast } from 'sonner'; //import { toast } from 'sonner';
//import { format } from 'date-fns'; //import { format } from 'date-fns';
//import { es } from 'date-fns/locale'; //import { es } from 'date-fns/locale';
interface DocumentoSEPTabProps { interface DocumentoSEPTabProps {
documento: DocumentoMateria | null; documento: DocumentoMateria | null
materia: Materia; materia: Materia
estructura: MateriaStructure; estructura: MateriaStructure
datosGenerales: Record<string, any>; datosGenerales: Record<string, any>
onRegenerate: () => void; onRegenerate: () => void
isRegenerating: boolean; isRegenerating: boolean
} }
export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales, onRegenerate, isRegenerating }: DocumentoSEPTabProps) { export function DocumentoSEPTab({
const [showConfirmDialog, setShowConfirmDialog] = useState(false); documento,
materia,
estructura,
datosGenerales,
onRegenerate,
isRegenerating,
}: DocumentoSEPTabProps) {
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
// Check completeness // Check completeness
const camposObligatorios = estructura.campos.filter(c => c.obligatorio); const camposObligatorios = estructura.campos.filter((c) => c.obligatorio)
const camposCompletos = camposObligatorios.filter(c => datosGenerales[c.id]?.trim()); const camposCompletos = camposObligatorios.filter((c) =>
const completeness = Math.round((camposCompletos.length / camposObligatorios.length) * 100); datosGenerales[c.id]?.trim(),
const isComplete = completeness === 100; )
const completeness = Math.round(
(camposCompletos.length / camposObligatorios.length) * 100,
)
const isComplete = completeness === 100
const handleRegenerate = () => { const handleRegenerate = () => {
setShowConfirmDialog(false); setShowConfirmDialog(false)
onRegenerate(); onRegenerate()
//toast.success('Regenerando documento...'); //toast.success('Regenerando documento...');
}; }
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="animate-fade-in space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2"> <h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
<FileCheck className="w-6 h-6 text-accent" /> <FileCheck className="text-accent h-6 w-6" />
Documento SEP Documento SEP
</h2> </h2>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1 text-sm">
Previsualización del documento oficial para la SEP Previsualización del documento oficial para la SEP
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{documento?.estado === 'listo' && ( {documento?.estado === 'listo' && (
<Button variant="outline" onClick={() => console.log("descargando") /*toast.info('Descarga iniciada')*/}> <Button
<Download className="w-4 h-4 mr-2" /> variant="outline"
onClick={
() =>
console.log('descargando') /*toast.info('Descarga iniciada')*/
}
>
<Download className="mr-2 h-4 w-4" />
Descargar Descargar
</Button> </Button>
)} )}
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> <AlertDialog
open={showConfirmDialog}
onOpenChange={setShowConfirmDialog}
>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button disabled={isRegenerating || !isComplete}> <Button disabled={isRegenerating || !isComplete}>
{isRegenerating ? ( {isRegenerating ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="mr-2 h-4 w-4" />
)} )}
{isRegenerating ? 'Generando...' : 'Regenerar documento'} {isRegenerating ? 'Generando...' : 'Regenerar documento'}
</Button> </Button>
@@ -78,8 +111,9 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>¿Regenerar documento SEP?</AlertDialogTitle> <AlertDialogTitle>¿Regenerar documento SEP?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Se creará una nueva versión del documento con los datos actuales de la materia. Se creará una nueva versión del documento con los datos
La versión anterior quedará en el historial. actuales de la materia. La versión anterior quedará en el
historial.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -93,18 +127,18 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
</div> </div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Document preview */} {/* Document preview */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<Card className="card-elevated h-[700px] overflow-hidden"> <Card className="card-elevated h-[700px] overflow-hidden">
{documento?.estado === 'listo' ? ( {documento?.estado === 'listo' ? (
<div className="h-full bg-muted/30 flex flex-col"> <div className="bg-muted/30 flex h-full flex-col">
{/* Simulated document header */} {/* Simulated document header */}
<div className="bg-card border-b p-4"> <div className="bg-card border-b p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FileText className="w-5 h-5 text-primary" /> <FileText className="text-primary h-5 w-5" />
<span className="font-medium text-foreground"> <span className="text-foreground font-medium">
Programa de Estudios - {materia.clave} Programa de Estudios - {materia.clave}
</span> </span>
</div> </div>
@@ -114,70 +148,87 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
{/* Document content simulation */} {/* Document content simulation */}
<div className="flex-1 overflow-y-auto p-8"> <div className="flex-1 overflow-y-auto p-8">
<div className="max-w-2xl mx-auto bg-card rounded-lg shadow-lg p-8 space-y-6"> <div className="bg-card mx-auto max-w-2xl space-y-6 rounded-lg p-8 shadow-lg">
{/* Header */} {/* Header */}
<div className="text-center border-b pb-6"> <div className="border-b pb-6 text-center">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2"> <p className="text-muted-foreground mb-2 text-xs tracking-wide uppercase">
Secretaría de Educación Pública Secretaría de Educación Pública
</p> </p>
<h1 className="font-display text-2xl font-bold text-primary mb-1"> <h1 className="font-display text-primary mb-1 text-2xl font-bold">
{materia.nombre} {materia.nombre}
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
Clave: {materia.clave} | Créditos: {materia.creditos || 'N/A'} Clave: {materia.clave} | Créditos:{' '}
{materia.creditos || 'N/A'}
</p> </p>
</div> </div>
{/* Datos de la institución */} {/* Datos de la institución */}
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
<p><strong>Carrera:</strong> {materia.carrera}</p> <p>
<p><strong>Facultad:</strong> {materia.facultad}</p> <strong>Carrera:</strong> {materia.carrera}
<p><strong>Plan de estudios:</strong> {materia.planNombre}</p> </p>
{materia.ciclo && <p><strong>Ciclo:</strong> {materia.ciclo}</p>} <p>
<strong>Facultad:</strong> {materia.facultad}
</p>
<p>
<strong>Plan de estudios:</strong> {materia.planNombre}
</p>
{materia.ciclo && (
<p>
<strong>Ciclo:</strong> {materia.ciclo}
</p>
)}
</div> </div>
{/* Campos del documento */} {/* Campos del documento */}
{estructura.campos.map((campo) => { {estructura.campos.map((campo) => {
const valor = datosGenerales[campo.id]; const valor = datosGenerales[campo.id]
if (!valor) return null; if (!valor) return null
return ( return (
<div key={campo.id} className="space-y-2"> <div key={campo.id} className="space-y-2">
<h3 className="font-semibold text-foreground border-b pb-1"> <h3 className="text-foreground border-b pb-1 font-semibold">
{campo.nombre} {campo.nombre}
</h3> </h3>
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed"> <p className="text-foreground text-sm leading-relaxed whitespace-pre-wrap">
{valor} {valor}
</p> </p>
</div> </div>
); )
})} })}
{/* Footer */} {/* Footer */}
<div className="border-t pt-6 mt-8 text-center text-xs text-muted-foreground"> <div className="text-muted-foreground mt-8 border-t pt-6 text-center text-xs">
<p>Documento generado el {/*format(documento.fechaGeneracion, "d 'de' MMMM 'de' yyyy", { locale: es })*/}</p> <p>
Documento generado el{' '}
{/*format(documento.fechaGeneracion, "d 'de' MMMM 'de' yyyy", { locale: es })*/}
</p>
<p className="mt-1">Universidad La Salle</p> <p className="mt-1">Universidad La Salle</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
) : documento?.estado === 'generando' ? ( ) : documento?.estado === 'generando' ? (
<div className="h-full flex items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-center"> <div className="text-center">
<Loader2 className="w-12 h-12 mx-auto text-accent animate-spin mb-4" /> <Loader2 className="text-accent mx-auto mb-4 h-12 w-12 animate-spin" />
<p className="text-muted-foreground">Generando documento...</p> <p className="text-muted-foreground">
Generando documento...
</p>
</div> </div>
</div> </div>
) : ( ) : (
<div className="h-full flex items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-center max-w-sm"> <div className="max-w-sm text-center">
<FileText className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" /> <FileText className="text-muted-foreground/50 mx-auto mb-4 h-12 w-12" />
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
No hay documento generado aún No hay documento generado aún
</p> </p>
{!isComplete && ( {!isComplete && (
<div className="p-4 bg-warning/10 rounded-lg text-sm text-warning-foreground"> <div className="bg-warning/10 text-warning-foreground rounded-lg p-4 text-sm">
<AlertTriangle className="w-4 h-4 inline mr-2" /> <AlertTriangle className="mr-2 inline h-4 w-4" />
Completa todos los campos obligatorios para generar el documento Completa todos los campos obligatorios para generar el
documento
</div> </div>
)} )}
</div> </div>
@@ -191,28 +242,41 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
{/* Status */} {/* Status */}
<Card className="card-elevated"> <Card className="card-elevated">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Estado del documento</CardTitle> <CardTitle className="text-sm font-medium">
Estado del documento
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{documento && ( {documento && (
<> <>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Versión</span> <span className="text-muted-foreground text-sm">
Versión
</span>
<Badge variant="outline">{documento.version}</Badge> <Badge variant="outline">{documento.version}</Badge>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Generado</span> <span className="text-muted-foreground text-sm">
Generado
</span>
<span className="text-sm"> <span className="text-sm">
{/*format(documento.fechaGeneracion, "d MMM yyyy, HH:mm", { locale: es })*/} {/*format(documento.fechaGeneracion, "d MMM yyyy, HH:mm", { locale: es })*/}
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Estado</span> <span className="text-muted-foreground text-sm">
<Badge className={cn( Estado
documento.estado === 'listo' && "bg-success text-success-foreground", </span>
documento.estado === 'generando' && "bg-info text-info-foreground", <Badge
documento.estado === 'error' && "bg-destructive text-destructive-foreground" className={cn(
)}> documento.estado === 'listo' &&
'bg-success text-success-foreground',
documento.estado === 'generando' &&
'bg-info text-info-foreground',
documento.estado === 'error' &&
'bg-destructive text-destructive-foreground',
)}
>
{documento.estado === 'listo' && 'Listo'} {documento.estado === 'listo' && 'Listo'}
{documento.estado === 'generando' && 'Generando'} {documento.estado === 'generando' && 'Generando'}
{documento.estado === 'error' && 'Error'} {documento.estado === 'error' && 'Error'}
@@ -226,41 +290,57 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
{/* Completeness */} {/* Completeness */}
<Card className="card-elevated"> <Card className="card-elevated">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Completitud de datos</CardTitle> <CardTitle className="text-sm font-medium">
Completitud de datos
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Campos obligatorios</span> <span className="text-muted-foreground">
<span className="font-medium">{camposCompletos.length}/{camposObligatorios.length}</span> Campos obligatorios
</span>
<span className="font-medium">
{camposCompletos.length}/{camposObligatorios.length}
</span>
</div> </div>
<div className="h-2 bg-muted rounded-full overflow-hidden"> <div className="bg-muted h-2 overflow-hidden rounded-full">
<div <div
className={cn( className={cn(
"h-full transition-all duration-500", 'h-full transition-all duration-500',
completeness === 100 ? "bg-success" : "bg-accent" completeness === 100 ? 'bg-success' : 'bg-accent',
)} )}
style={{ width: `${completeness}%` }} style={{ width: `${completeness}%` }}
/> />
</div> </div>
<p className={cn( <p
"text-xs", className={cn(
completeness === 100 ? "text-success" : "text-muted-foreground" 'text-xs',
)}> completeness === 100
? 'text-success'
: 'text-muted-foreground',
)}
>
{completeness === 100 {completeness === 100
? 'Todos los campos obligatorios están completos' ? 'Todos los campos obligatorios están completos'
: `Faltan ${camposObligatorios.length - camposCompletos.length} campos por completar` : `Faltan ${camposObligatorios.length - camposCompletos.length} campos por completar`}
}
</p> </p>
</div> </div>
{/* Missing fields */} {/* Missing fields */}
{!isComplete && ( {!isComplete && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Campos faltantes:</p> <p className="text-muted-foreground text-xs font-medium">
{camposObligatorios.filter(c => !datosGenerales[c.id]?.trim()).map((campo) => ( Campos faltantes:
<div key={campo.id} className="flex items-center gap-2 text-sm"> </p>
<AlertTriangle className="w-3 h-3 text-warning" /> {camposObligatorios
.filter((c) => !datosGenerales[c.id]?.trim())
.map((campo) => (
<div
key={campo.id}
className="flex items-center gap-2 text-sm"
>
<AlertTriangle className="text-warning h-3 w-3" />
<span className="text-foreground">{campo.nombre}</span> <span className="text-foreground">{campo.nombre}</span>
</div> </div>
))} ))}
@@ -272,36 +352,62 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
{/* Requirements */} {/* Requirements */}
<Card className="card-elevated"> <Card className="card-elevated">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Requisitos SEP</CardTitle> <CardTitle className="text-sm font-medium">
Requisitos SEP
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ul className="space-y-2 text-sm"> <ul className="space-y-2 text-sm">
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<div className={cn( <div
"w-4 h-4 rounded-full flex items-center justify-center mt-0.5", className={cn(
datosGenerales['objetivo_general'] ? "bg-success/20" : "bg-muted" 'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
)}> datosGenerales['objetivo_general']
{datosGenerales['objetivo_general'] && <Check className="w-3 h-3 text-success" />} ? 'bg-success/20'
: 'bg-muted',
)}
>
{datosGenerales['objetivo_general'] && (
<Check className="text-success h-3 w-3" />
)}
</div> </div>
<span className="text-muted-foreground">Objetivo general definido</span> <span className="text-muted-foreground">
Objetivo general definido
</span>
</li> </li>
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<div className={cn( <div
"w-4 h-4 rounded-full flex items-center justify-center mt-0.5", className={cn(
datosGenerales['competencias'] ? "bg-success/20" : "bg-muted" 'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
)}> datosGenerales['competencias']
{datosGenerales['competencias'] && <Check className="w-3 h-3 text-success" />} ? 'bg-success/20'
: 'bg-muted',
)}
>
{datosGenerales['competencias'] && (
<Check className="text-success h-3 w-3" />
)}
</div> </div>
<span className="text-muted-foreground">Competencias especificadas</span> <span className="text-muted-foreground">
Competencias especificadas
</span>
</li> </li>
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<div className={cn( <div
"w-4 h-4 rounded-full flex items-center justify-center mt-0.5", className={cn(
datosGenerales['evaluacion'] ? "bg-success/20" : "bg-muted" 'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
)}> datosGenerales['evaluacion']
{datosGenerales['evaluacion'] && <Check className="w-3 h-3 text-success" />} ? 'bg-success/20'
: 'bg-muted',
)}
>
{datosGenerales['evaluacion'] && (
<Check className="text-success h-3 w-3" />
)}
</div> </div>
<span className="text-muted-foreground">Criterios de evaluación</span> <span className="text-muted-foreground">
Criterios de evaluación
</span>
</li> </li>
</ul> </ul>
</CardContent> </CardContent>
@@ -309,13 +415,19 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
</div> </div>
</div> </div>
</div> </div>
); )
} }
function Check({ className }: { className?: string }) { function Check({ className }: { className?: string }) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"> <svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
>
<polyline points="20 6 9 17 4 12" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
); )
} }

View File

@@ -1,181 +1,216 @@
import { useState } from 'react'; import { useState, useMemo } from 'react'
import { History, FileText, List, BookMarked, Sparkles, FileCheck, User, Filter, Calendar } from 'lucide-react'; import {
import { Card, CardContent } from '@/components/ui/card'; History,
import { Button } from '@/components/ui/button'; FileText,
import { Badge } from '@/components/ui/badge'; List,
BookMarked,
Sparkles,
FileCheck,
User,
Filter,
Calendar,
Loader2,
} from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu'
import type { CambioMateria } from '@/types/materia'; import { cn } from '@/lib/utils'
import { cn } from '@/lib/utils'; import { format, formatDistanceToNow, parseISO } from 'date-fns'
import { format, formatDistanceToNow } from 'date-fns'; import { es } from 'date-fns/locale'
import { es } from 'date-fns/locale'; import { useSubjectHistorial } from '@/data/hooks/useSubjects'
interface HistorialTabProps { // Mapeo de tipos de la API a los tipos del componente
historial: CambioMateria[]; const TIPO_MAP: Record<string, string> = {
ACTUALIZACION_CAMPO: 'contenido', // O 'datos' según el campo
CREACION: 'datos',
} }
const tipoConfig: Record<string, { label: string; icon: React.ComponentType<{ className?: string }>; color: string }> = { const tipoConfig: Record<string, { label: string; icon: any; color: string }> =
{
datos: { label: 'Datos generales', icon: FileText, color: 'text-info' }, datos: { label: 'Datos generales', icon: FileText, color: 'text-info' },
contenido: { label: 'Contenido temático', icon: List, color: 'text-accent' }, contenido: {
bibliografia: { label: 'Bibliografía', icon: BookMarked, color: 'text-success' }, label: 'Contenido temático',
icon: List,
color: 'text-accent',
},
bibliografia: {
label: 'Bibliografía',
icon: BookMarked,
color: 'text-success',
},
ia: { label: 'IA', icon: Sparkles, color: 'text-amber-500' }, ia: { label: 'IA', icon: Sparkles, color: 'text-amber-500' },
documento: { label: 'Documento SEP', icon: FileCheck, color: 'text-primary' }, documento: {
}; label: 'Documento SEP',
icon: FileCheck,
color: 'text-primary',
},
}
export function HistorialTab({ historial }: HistorialTabProps) { export function HistorialTab() {
const [filtros, setFiltros] = useState<Set<string>>(new Set(['datos', 'contenido', 'bibliografia', 'ia', 'documento'])); // 1. Obtenemos los datos directamente dentro del componente
const { data: rawData, isLoading } = useSubjectHistorial(
'9d4dda6a-488f-428a-8a07-38081592a641',
)
const [filtros, setFiltros] = useState<Set<string>>(
new Set(['datos', 'contenido', 'bibliografia', 'ia', 'documento']),
)
// 2. Transformamos los datos de la API al formato que usa el componente
const historialTransformado = useMemo(() => {
if (!rawData) return []
return rawData.map((item: any) => ({
id: item.id,
// Intentamos determinar el tipo basándonos en el campo o el tipo de la API
tipo: item.campo === 'contenido_tematico' ? 'contenido' : 'datos',
descripcion: `Se actualizó el campo ${item.campo.replace('_', ' ')}`,
fecha: parseISO(item.cambiado_en),
usuario: item.fuente === 'HUMANO' ? 'Usuario Staff' : 'Sistema IA',
detalles: {
campo: item.campo,
valor_nuevo: item.valor_nuevo,
},
}))
}, [rawData])
const toggleFiltro = (tipo: string) => { const toggleFiltro = (tipo: string) => {
const newFiltros = new Set(filtros); const newFiltros = new Set(filtros)
if (newFiltros.has(tipo)) { if (newFiltros.has(tipo)) newFiltros.delete(tipo)
newFiltros.delete(tipo); else newFiltros.add(tipo)
} else { setFiltros(newFiltros)
newFiltros.add(tipo);
} }
setFiltros(newFiltros);
};
const filteredHistorial = historial.filter(cambio => filtros.has(cambio.tipo)); // 3. Aplicamos filtros y agrupamiento sobre los datos transformados
const filteredHistorial = historialTransformado.filter((cambio) =>
filtros.has(cambio.tipo),
)
// Group by date const groupedHistorial = filteredHistorial.reduce(
const groupedHistorial = filteredHistorial.reduce((groups, cambio) => { (groups, cambio) => {
const dateKey = format(cambio.fecha, 'yyyy-MM-dd'); const dateKey = format(cambio.fecha, 'yyyy-MM-dd')
if (!groups[dateKey]) { if (!groups[dateKey]) groups[dateKey] = []
groups[dateKey] = []; groups[dateKey].push(cambio)
return groups
},
{} as Record<string, any[]>,
)
const sortedDates = Object.keys(groupedHistorial).sort((a, b) =>
b.localeCompare(a),
)
if (isLoading) {
return (
<div className="flex h-48 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin" />
</div>
)
} }
groups[dateKey].push(cambio);
return groups;
}, {} as Record<string, CambioMateria[]>);
const sortedDates = Object.keys(groupedHistorial).sort((a, b) => b.localeCompare(a));
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="animate-fade-in space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2"> <h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
<History className="w-6 h-6 text-accent" /> <History className="text-accent h-6 w-6" />
Historial de cambios Historial de cambios
</h2> </h2>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1 text-sm">
{historial.length} cambios registrados {historialTransformado.length} cambios registrados
</p> </p>
</div> </div>
{/* Dropdown de Filtros (Igual al anterior) */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline"> <Button variant="outline">
<Filter className="w-4 h-4 mr-2" /> <Filter className="mr-2 h-4 w-4" />
Filtrar ({filtros.size}) Filtrar ({filtros.size})
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48"> <DropdownMenuContent align="end" className="w-48">
{Object.entries(tipoConfig).map(([tipo, config]) => { {Object.entries(tipoConfig).map(([tipo, config]) => (
const Icon = config.icon;
return (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
key={tipo} key={tipo}
checked={filtros.has(tipo)} checked={filtros.has(tipo)}
onCheckedChange={() => toggleFiltro(tipo)} onCheckedChange={() => toggleFiltro(tipo)}
> >
<Icon className={cn("w-4 h-4 mr-2", config.color)} /> <config.icon className={cn('mr-2 h-4 w-4', config.color)} />
{config.label} {config.label}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
); ))}
})}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
{filteredHistorial.length === 0 ? ( {filteredHistorial.length === 0 ? (
<Card className="card-elevated"> <Card>
<CardContent className="py-12 text-center"> <CardContent className="py-12 text-center">
<History className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" /> <History className="text-muted-foreground/50 mx-auto mb-4 h-12 w-12" />
<p className="text-muted-foreground"> <p className="text-muted-foreground">No se encontraron cambios.</p>
{historial.length === 0
? 'No hay cambios registrados aún'
: 'No hay cambios con los filtros seleccionados'
}
</p>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="space-y-8"> <div className="space-y-8">
{sortedDates.map((dateKey) => { {sortedDates.map((dateKey) => (
const cambios = groupedHistorial[dateKey];
const date = new Date(dateKey);
const isToday = format(new Date(), 'yyyy-MM-dd') === dateKey;
const isYesterday = format(new Date(Date.now() - 86400000), 'yyyy-MM-dd') === dateKey;
return (
<div key={dateKey}> <div key={dateKey}>
{/* Date header */} <div className="mb-4 flex items-center gap-3">
<div className="flex items-center gap-3 mb-4"> <Calendar className="text-muted-foreground h-4 w-4" />
<div className="p-2 rounded-lg bg-muted"> <h3 className="text-foreground font-semibold">
<Calendar className="w-4 h-4 text-muted-foreground" /> {format(parseISO(dateKey), "EEEE, d 'de' MMMM", {
</div> locale: es,
<div> })}
<h3 className="font-semibold text-foreground">
{isToday ? 'Hoy' : isYesterday ? 'Ayer' : format(date, "EEEE, d 'de' MMMM", { locale: es })}
</h3> </h3>
<p className="text-xs text-muted-foreground">
{cambios.length} {cambios.length === 1 ? 'cambio' : 'cambios'}
</p>
</div>
</div> </div>
{/* Timeline */} <div className="border-border ml-4 space-y-4 border-l-2 pl-6">
<div className="ml-4 border-l-2 border-border pl-6 space-y-4"> {groupedHistorial[dateKey].map((cambio) => {
{cambios.map((cambio) => { const config = tipoConfig[cambio.tipo] || tipoConfig.datos
const config = tipoConfig[cambio.tipo]; const Icon = config.icon
const Icon = config.icon;
return ( return (
<div key={cambio.id} className="relative"> <div key={cambio.id} className="relative">
{/* Timeline dot */} <div
<div className={cn( className={cn(
"absolute -left-[31px] w-4 h-4 rounded-full border-2 border-background", 'border-background absolute -left-[31px] h-4 w-4 rounded-full border-2',
`bg-current ${config.color}` `bg-current ${config.color}`,
)} /> )}
/>
<Card className="card-interactive"> <Card className="card-interactive">
<CardContent className="py-4"> <CardContent className="py-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className={cn( <div
"p-2 rounded-lg bg-muted flex-shrink-0", className={cn(
config.color 'bg-muted rounded-lg p-2',
)}> config.color,
<Icon className="w-4 h-4" /> )}
>
<Icon className="h-4 w-4" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1">
<div className="flex items-start justify-between gap-2"> <div className="flex justify-between">
<div> <p className="font-medium">
<p className="font-medium text-foreground">
{cambio.descripcion} {cambio.descripcion}
</p> </p>
<div className="flex items-center gap-2 mt-1"> <span className="text-muted-foreground text-xs">
<Badge variant="outline" className="text-xs">
{config.label}
</Badge>
{cambio.detalles?.campo && (
<span className="text-xs text-muted-foreground">
Campo: {cambio.detalles.campo}
</span>
)}
</div>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{format(cambio.fecha, 'HH:mm')} {format(cambio.fecha, 'HH:mm')}
</span> </span>
</div> </div>
<div className="flex items-center gap-2 mt-3 text-xs text-muted-foreground"> <div className="mt-2 flex items-center gap-2">
<User className="w-3 h-3" /> <Badge
<span>{cambio.usuario}</span> variant="outline"
<span className="text-muted-foreground/50"></span> className="text-[10px]"
<span> >
{formatDistanceToNow(cambio.fecha, { addSuffix: true, locale: es })} {config.label}
</Badge>
<span className="text-muted-foreground text-xs italic">
por {cambio.usuario}
</span> </span>
</div> </div>
</div> </div>
@@ -183,14 +218,13 @@ export function HistorialTab({ historial }: HistorialTabProps) {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); )
})} })}
</div> </div>
</div> </div>
); ))}
})}
</div> </div>
)} )}
</div> </div>
); )
} }

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react' import { useCallback, useState, useEffect } from 'react'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -39,12 +39,30 @@ export interface BibliografiaTabProps {
isSaving: boolean isSaving: boolean
} }
export interface AsignaturaDatos {
[key: string]: string
}
export interface AsignaturaResponse {
datos: AsignaturaDatos
}
export default function MateriaDetailPage() { export default function MateriaDetailPage() {
const { data: asignaturasApi, isLoading: loadingAsig } = useSubject(
'9d4dda6a-488f-428a-8a07-38081592a641',
)
// 1. Asegúrate de tener estos estados en tu componente principal // 1. Asegúrate de tener estos estados en tu componente principal
const [messages, setMessages] = useState<IAMessage[]>([]) const [messages, setMessages] = useState<IAMessage[]>([])
const [datosGenerales, setDatosGenerales] = useState({}) const [datosGenerales, setDatosGenerales] = useState({})
const [campos, setCampos] = useState<CampoEstructura[]>([]) const [campos, setCampos] = useState<CampoEstructura[]>([])
/* ---------- sincronizar API ---------- */
useEffect(() => {
if (asignaturasApi?.datos) {
setDatosGenerales(asignaturasApi.datos)
}
}, [asignaturasApi])
// 2. Funciones de manejo para la IA // 2. Funciones de manejo para la IA
const handleSendMessage = (text: string, campoId?: string) => { const handleSendMessage = (text: string, campoId?: string) => {
const newMessage: IAMessage = { const newMessage: IAMessage = {
@@ -112,17 +130,15 @@ export default function MateriaDetailPage() {
<div className="flex items-start justify-between gap-6"> <div className="flex items-start justify-between gap-6">
<div className="space-y-3"> <div className="space-y-3">
<Badge className="border border-blue-700 bg-blue-900/50"> <Badge className="border border-blue-700 bg-blue-900/50">
IA-401 {asignaturasApi?.codigo}
</Badge> </Badge>
<h1 className="text-3xl font-bold"> <h1 className="text-3xl font-bold">{asignaturasApi?.nombre}</h1>
Inteligencia Artificial Aplicada
</h1>
<div className="flex flex-wrap gap-4 text-sm text-blue-200"> <div className="flex flex-wrap gap-4 text-sm text-blue-200">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<GraduationCap className="h-4 w-4" /> <GraduationCap className="h-4 w-4" />
Ingeniería en Sistemas Computacionales {asignaturasApi?.planes_estudio?.datos?.nombre}
</span> </span>
<span>Facultad de Ingeniería</span> <span>Facultad de Ingeniería</span>
@@ -162,11 +178,14 @@ export default function MateriaDetailPage() {
{/* ================= TAB: DATOS GENERALES ================= */} {/* ================= TAB: DATOS GENERALES ================= */}
<TabsContent value="datos"> <TabsContent value="datos">
<DatosGenerales /> <DatosGenerales data={datosGenerales} isLoading={loadingAsig} />
</TabsContent> </TabsContent>
<TabsContent value="contenido"> <TabsContent value="contenido">
<ContenidoTematico></ContenidoTematico> <ContenidoTematico
data={asignaturasApi}
isLoading={loadingAsig}
></ContenidoTematico>
</TabsContent> </TabsContent>
<TabsContent value="bibliografia"> <TabsContent value="bibliografia">
@@ -215,13 +234,13 @@ export default function MateriaDetailPage() {
} }
/* ================= TAB CONTENT ================= */ /* ================= TAB CONTENT ================= */
interface DatosGeneralesProps {
function DatosGenerales() { data: AsignaturaDatos
const { data: asignaturasApi, isLoading: loadingAsig } = useSubject( isLoading: boolean
/*planId*/ '9d4dda6a-488f-428a-8a07-38081592a641', }
) function DatosGenerales({ data, isLoading }: DatosGeneralesProps) {
const formatTitle = (key: string): string =>
console.log(asignaturasApi.datos) key.replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase())
return ( return (
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500"> <div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
@@ -249,26 +268,16 @@ function DatosGenerales() {
<div className="grid grid-cols-1 gap-6 md:grid-cols-3"> <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{/* Columna Principal (Más ancha) */} {/* Columna Principal (Más ancha) */}
<div className="space-y-6 md:col-span-2"> <div className="space-y-6 md:col-span-2">
<div className="space-y-6 md:col-span-2"> {isLoading && <p>Cargando información...</p>}
<InfoCard
title="Competencias a Desarrollar"
subtitle="Competencias profesionales que se desarrollarán"
isList={true}
initialContent={`• Diseñar algoritmos de machine learning para clasificación y predicción\n• Implementar redes neuronales profundas para procesamiento de imágenes\n• Evaluar y optimizar modelos de IA considerando métricas`}
/>
{!isLoading &&
Object.entries(data).map(([key, value]) => (
<InfoCard <InfoCard
title="Objetivo General" key={key}
initialContent="Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos." title={formatTitle(key)}
initialContent={value}
/> />
</div> ))}
<div className="space-y-6">
<InfoCard
title="Justificación"
initialContent="La inteligencia artificial es una de las tecnologías más disruptivas..."
/>
</div>
</div> </div>
{/* Columna Lateral (Información Secundaria) */} {/* Columna Lateral (Información Secundaria) */}

View File

@@ -1,7 +1,17 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import {
import { qk } from "../query/keys"; keepPreviousData,
import type { PlanEstudio, UUID } from "../types/domain"; useMutation,
import type { PlanListFilters, PlanMapOperation, PlansCreateManualInput, PlansUpdateFieldsPatch } from "../api/plans.api"; useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { qk } from '../query/keys'
import type { PlanEstudio, UUID } from '../types/domain'
import type {
PlanListFilters,
PlanMapOperation,
PlansCreateManualInput,
PlansUpdateFieldsPatch,
} from '../api/plans.api'
import { import {
ai_generate_plan, ai_generate_plan,
plan_asignaturas_list, plan_asignaturas_list,
@@ -18,7 +28,7 @@ import {
plans_transition_state, plans_transition_state,
plans_update_fields, plans_update_fields,
plans_update_map, plans_update_map,
} from "../api/plans.api"; } from '../api/plans.api'
export function usePlanes(filters: PlanListFilters) { export function usePlanes(filters: PlanListFilters) {
// 🧠 Tip: memoiza "filters" (useMemo) para que queryKey sea estable. // 🧠 Tip: memoiza "filters" (useMemo) para que queryKey sea estable.
@@ -26,185 +36,188 @@ export function usePlanes(filters: PlanListFilters) {
queryKey: qk.planesList(filters), queryKey: qk.planesList(filters),
queryFn: () => plans_list(filters), queryFn: () => plans_list(filters),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); })
} }
export function usePlan(planId: UUID | null | undefined) { export function usePlan(planId: UUID | null | undefined) {
return useQuery({ return useQuery({
queryKey: planId ? qk.plan(planId) : ["planes", "detail", null], queryKey: planId ? qk.plan(planId) : ['planes', 'detail', null],
queryFn: () => plans_get(planId as UUID), queryFn: () => plans_get(planId as UUID),
enabled: Boolean(planId), enabled: Boolean(planId),
}); })
} }
export function usePlanLineas(planId: UUID | null | undefined) { export function usePlanLineas(planId: UUID | null | undefined) {
return useQuery({ return useQuery({
queryKey: planId ? qk.planLineas(planId) : ["planes", "lineas", null], queryKey: planId ? qk.planLineas(planId) : ['planes', 'lineas', null],
queryFn: () => plan_lineas_list(planId as UUID), queryFn: () => plan_lineas_list(planId as UUID),
enabled: Boolean(planId), enabled: Boolean(planId),
}); })
} }
export function usePlanAsignaturas(planId: UUID | null | undefined) { export function usePlanAsignaturas(planId: UUID | null | undefined) {
return useQuery({ return useQuery({
queryKey: planId ? qk.planAsignaturas(planId) : ["planes", "asignaturas", null], queryKey: planId
? qk.planAsignaturas(planId)
: ['planes', 'asignaturas', null],
queryFn: () => plan_asignaturas_list(planId as UUID), queryFn: () => plan_asignaturas_list(planId as UUID),
enabled: Boolean(planId), enabled: Boolean(planId),
}); })
} }
export function usePlanHistorial(planId: UUID | null | undefined) { export function usePlanHistorial(planId: UUID | null | undefined) {
return useQuery({ return useQuery({
queryKey: planId ? qk.planHistorial(planId) : ["planes", "historial", null], queryKey: planId ? qk.planHistorial(planId) : ['planes', 'historial', null],
queryFn: () => plans_history(planId as UUID), queryFn: () => plans_history(planId as UUID),
enabled: Boolean(planId), enabled: Boolean(planId),
}); })
} }
export function usePlanDocumento(planId: UUID | null | undefined) { export function usePlanDocumento(planId: UUID | null | undefined) {
return useQuery({ return useQuery({
queryKey: planId ? qk.planDocumento(planId) : ["planes", "documento", null], queryKey: planId ? qk.planDocumento(planId) : ['planes', 'documento', null],
queryFn: () => plans_get_document(planId as UUID), queryFn: () => plans_get_document(planId as UUID),
enabled: Boolean(planId), enabled: Boolean(planId),
staleTime: 30_000, staleTime: 30_000,
}); })
} }
/* ------------------ Mutations ------------------ */ /* ------------------ Mutations ------------------ */
export function useCreatePlanManual() { export function useCreatePlanManual() {
const qc = useQueryClient(); const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (input: PlansCreateManualInput) => plans_create_manual(input), mutationFn: (input: PlansCreateManualInput) => plans_create_manual(input),
onSuccess: (plan) => { onSuccess: (plan) => {
qc.invalidateQueries({ queryKey: ["planes", "list"] }); qc.invalidateQueries({ queryKey: ['planes', 'list'] })
qc.setQueryData(qk.plan(plan.id), plan); qc.setQueryData(qk.plan(plan.id), plan)
}, },
}); })
} }
export function useGeneratePlanAI() { export function useGeneratePlanAI() {
return useMutation({ return useMutation({
mutationFn: ai_generate_plan, mutationFn: ai_generate_plan,
}); })
} }
export function usePersistPlanFromAI() { export function usePersistPlanFromAI() {
const qc = useQueryClient(); const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (payload: { jsonPlan: any }) => plans_persist_from_ai(payload), mutationFn: (payload: { jsonPlan: any }) => plans_persist_from_ai(payload),
onSuccess: (plan) => { onSuccess: (plan) => {
qc.invalidateQueries({ queryKey: ["planes", "list"] }); qc.invalidateQueries({ queryKey: ['planes', 'list'] })
qc.setQueryData(qk.plan(plan.id), plan); qc.setQueryData(qk.plan(plan.id), plan)
}, },
}); })
} }
export function useClonePlan() { export function useClonePlan() {
const qc = useQueryClient(); const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: plans_clone_from_existing, mutationFn: plans_clone_from_existing,
onSuccess: (plan) => { onSuccess: (plan) => {
qc.invalidateQueries({ queryKey: ["planes", "list"] }); qc.invalidateQueries({ queryKey: ['planes', 'list'] })
qc.setQueryData(qk.plan(plan.id), plan); qc.setQueryData(qk.plan(plan.id), plan)
}, },
}); })
} }
export function useImportPlanFromFiles() { export function useImportPlanFromFiles() {
const qc = useQueryClient(); const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: plans_import_from_files, mutationFn: plans_import_from_files,
onSuccess: (plan) => { onSuccess: (plan) => {
qc.invalidateQueries({ queryKey: ["planes", "list"] }); qc.invalidateQueries({ queryKey: ['planes', 'list'] })
qc.setQueryData(qk.plan(plan.id), plan); qc.setQueryData(qk.plan(plan.id), plan)
}, },
}); })
} }
export function useUpdatePlanFields() { export function useUpdatePlanFields() {
const qc = useQueryClient(); const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (vars: { planId: UUID; patch: PlansUpdateFieldsPatch }) => mutationFn: (vars: { planId: UUID; patch: PlansUpdateFieldsPatch }) =>
plans_update_fields(vars.planId, vars.patch), plans_update_fields(vars.planId, vars.patch),
onSuccess: (updated) => { onSuccess: (updated) => {
qc.setQueryData(qk.plan(updated.id), updated); qc.setQueryData(qk.plan(updated.id), updated)
qc.invalidateQueries({ queryKey: ["planes", "list"] }); qc.invalidateQueries({ queryKey: ['planes', 'list'] })
qc.invalidateQueries({ queryKey: qk.planHistorial(updated.id) }); qc.invalidateQueries({ queryKey: qk.planHistorial(updated.id) })
}, },
}); })
} }
export function useUpdatePlanMapa() { export function useUpdatePlanMapa() {
const qc = useQueryClient(); const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (vars: { planId: UUID; ops: PlanMapOperation[] }) => plans_update_map(vars.planId, vars.ops), mutationFn: (vars: { planId: UUID; ops: PlanMapOperation[] }) =>
plans_update_map(vars.planId, vars.ops),
// ✅ Optimista (rápida) para el caso MOVE_ASIGNATURA // ✅ Optimista (rápida) para el caso MOVE_ASIGNATURA
onMutate: async (vars) => { onMutate: async (vars) => {
await qc.cancelQueries({ queryKey: qk.planAsignaturas(vars.planId) }); await qc.cancelQueries({ queryKey: qk.planAsignaturas(vars.planId) })
const prev = qc.getQueryData<any>(qk.planAsignaturas(vars.planId)); const prev = qc.getQueryData<any>(qk.planAsignaturas(vars.planId))
// solo optimizamos MOVEs simples // solo optimizamos MOVEs simples
const moves = vars.ops.filter((x) => x.op === "MOVE_ASIGNATURA") as Array< const moves = vars.ops.filter((x) => x.op === 'MOVE_ASIGNATURA') as Array<
Extract<PlanMapOperation, { op: "MOVE_ASIGNATURA" }> Extract<PlanMapOperation, { op: 'MOVE_ASIGNATURA' }>
>; >
if (prev && Array.isArray(prev) && moves.length) { if (prev && Array.isArray(prev) && moves.length) {
const next = prev.map((a: any) => { const next = prev.map((a: any) => {
const m = moves.find((x) => x.asignaturaId === a.id); const m = moves.find((x) => x.asignaturaId === a.id)
if (!m) return a; if (!m) return a
return { return {
...a, ...a,
numero_ciclo: m.numero_ciclo, numero_ciclo: m.numero_ciclo,
linea_plan_id: m.linea_plan_id, linea_plan_id: m.linea_plan_id,
orden_celda: m.orden_celda ?? a.orden_celda, orden_celda: m.orden_celda ?? a.orden_celda,
}; }
}); })
qc.setQueryData(qk.planAsignaturas(vars.planId), next); qc.setQueryData(qk.planAsignaturas(vars.planId), next)
} }
return { prev }; return { prev }
}, },
onError: (_err, vars, ctx) => { onError: (_err, vars, ctx) => {
if (ctx?.prev) qc.setQueryData(qk.planAsignaturas(vars.planId), ctx.prev); if (ctx?.prev) qc.setQueryData(qk.planAsignaturas(vars.planId), ctx.prev)
}, },
onSuccess: (_ok, vars) => { onSuccess: (_ok, vars) => {
qc.invalidateQueries({ queryKey: qk.planAsignaturas(vars.planId) }); qc.invalidateQueries({ queryKey: qk.planAsignaturas(vars.planId) })
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) }); qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) })
}, },
}); })
} }
export function useTransitionPlanEstado() { export function useTransitionPlanEstado() {
const qc = useQueryClient(); const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: plans_transition_state, mutationFn: plans_transition_state,
onSuccess: (_ok, vars) => { onSuccess: (_ok, vars) => {
qc.invalidateQueries({ queryKey: qk.plan(vars.planId) }); qc.invalidateQueries({ queryKey: qk.plan(vars.planId) })
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) }); qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) })
qc.invalidateQueries({ queryKey: ["planes", "list"] }); qc.invalidateQueries({ queryKey: ['planes', 'list'] })
}, },
}); })
} }
export function useGeneratePlanDocumento() { export function useGeneratePlanDocumento() {
const qc = useQueryClient(); const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (planId: UUID) => plans_generate_document(planId), mutationFn: (planId: UUID) => plans_generate_document(planId),
onSuccess: (_doc, planId) => { onSuccess: (_doc, planId) => {
qc.invalidateQueries({ queryKey: qk.planDocumento(planId) }); qc.invalidateQueries({ queryKey: qk.planDocumento(planId) })
qc.invalidateQueries({ queryKey: qk.planHistorial(planId) }); qc.invalidateQueries({ queryKey: qk.planHistorial(planId) })
}, },
}); })
} }

View File

@@ -1,11 +1,11 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { qk } from "../query/keys"; import { qk } from '../query/keys'
import type { UUID } from "../types/domain"; import type { UUID } from '../types/domain'
import type { import type {
BibliografiaUpsertInput, BibliografiaUpsertInput,
SubjectsCreateManualInput, SubjectsCreateManualInput,
SubjectsUpdateFieldsPatch, SubjectsUpdateFieldsPatch,
} from "../api/subjects.api"; } from '../api/subjects.api'
import { import {
ai_generate_subject, ai_generate_subject,
subjects_bibliografia_list, subjects_bibliografia_list,
@@ -20,147 +20,177 @@ import {
subjects_update_bibliografia, subjects_update_bibliografia,
subjects_update_contenido, subjects_update_contenido,
subjects_update_fields, subjects_update_fields,
} from "../api/subjects.api"; } from '../api/subjects.api'
export function useSubject(subjectId: UUID | null | undefined) { export function useSubject(subjectId: UUID | null | undefined) {
return useQuery({ return useQuery({
queryKey: subjectId ? qk.asignatura(subjectId) : ["asignaturas", "detail", null], queryKey: subjectId
? qk.asignatura(subjectId)
: ['asignaturas', 'detail', null],
queryFn: () => subjects_get(subjectId as UUID), queryFn: () => subjects_get(subjectId as UUID),
enabled: Boolean(subjectId), enabled: Boolean(subjectId),
}); })
} }
export function useSubjectBibliografia(subjectId: UUID | null | undefined) { export function useSubjectBibliografia(subjectId: UUID | null | undefined) {
return useQuery({ return useQuery({
queryKey: subjectId ? qk.asignaturaBibliografia(subjectId) : ["asignaturas", "bibliografia", null], queryKey: subjectId
? qk.asignaturaBibliografia(subjectId)
: ['asignaturas', 'bibliografia', null],
queryFn: () => subjects_bibliografia_list(subjectId as UUID), queryFn: () => subjects_bibliografia_list(subjectId as UUID),
enabled: Boolean(subjectId), enabled: Boolean(subjectId),
}); })
} }
export function useSubjectHistorial(subjectId: UUID | null | undefined) { export function useSubjectHistorial(subjectId: UUID | null | undefined) {
return useQuery({ return useQuery({
queryKey: subjectId ? qk.asignaturaHistorial(subjectId) : ["asignaturas", "historial", null], queryKey: subjectId
? qk.asignaturaHistorial(subjectId)
: ['asignaturas', 'historial', null],
queryFn: () => subjects_history(subjectId as UUID), queryFn: () => subjects_history(subjectId as UUID),
enabled: Boolean(subjectId), enabled: Boolean(subjectId),
}); })
} }
export function useSubjectDocumento(subjectId: UUID | null | undefined) { export function useSubjectDocumento(subjectId: UUID | null | undefined) {
return useQuery({ return useQuery({
queryKey: subjectId ? qk.asignaturaDocumento(subjectId) : ["asignaturas", "documento", null], queryKey: subjectId
? qk.asignaturaDocumento(subjectId)
: ['asignaturas', 'documento', null],
queryFn: () => subjects_get_document(subjectId as UUID), queryFn: () => subjects_get_document(subjectId as UUID),
enabled: Boolean(subjectId), enabled: Boolean(subjectId),
staleTime: 30_000, staleTime: 30_000,
}); })
} }
/* ------------------ Mutations ------------------ */ /* ------------------ Mutations ------------------ */
export function useCreateSubjectManual() { export function useCreateSubjectManual() {
const qc = useQueryClient(); const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (payload: SubjectsCreateManualInput) => subjects_create_manual(payload), mutationFn: (payload: SubjectsCreateManualInput) =>
subjects_create_manual(payload),
onSuccess: (subject) => { onSuccess: (subject) => {
qc.setQueryData(qk.asignatura(subject.id), subject); qc.setQueryData(qk.asignatura(subject.id), subject)
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) }); qc.invalidateQueries({
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) }); queryKey: qk.planAsignaturas(subject.plan_estudio_id),
})
qc.invalidateQueries({
queryKey: qk.planHistorial(subject.plan_estudio_id),
})
}, },
}); })
} }
export function useGenerateSubjectAI() { export function useGenerateSubjectAI() {
return useMutation({ mutationFn: ai_generate_subject }); return useMutation({ mutationFn: ai_generate_subject })
} }
export function usePersistSubjectFromAI() { export function usePersistSubjectFromAI() {
const qc = useQueryClient(); const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (payload: { planId: UUID; jsonMateria: any }) => subjects_persist_from_ai(payload), mutationFn: (payload: { planId: UUID; jsonMateria: any }) =>
subjects_persist_from_ai(payload),
onSuccess: (subject) => { onSuccess: (subject) => {
qc.setQueryData(qk.asignatura(subject.id), subject); qc.setQueryData(qk.asignatura(subject.id), subject)
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) }); qc.invalidateQueries({
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) }); queryKey: qk.planAsignaturas(subject.plan_estudio_id),
})
qc.invalidateQueries({
queryKey: qk.planHistorial(subject.plan_estudio_id),
})
}, },
}); })
} }
export function useCloneSubject() { export function useCloneSubject() {
const qc = useQueryClient(); const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: subjects_clone_from_existing, mutationFn: subjects_clone_from_existing,
onSuccess: (subject) => { onSuccess: (subject) => {
qc.setQueryData(qk.asignatura(subject.id), subject); qc.setQueryData(qk.asignatura(subject.id), subject)
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) }); qc.invalidateQueries({
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) }); queryKey: qk.planAsignaturas(subject.plan_estudio_id),
})
qc.invalidateQueries({
queryKey: qk.planHistorial(subject.plan_estudio_id),
})
}, },
}); })
} }
export function useImportSubjectFromFile() { export function useImportSubjectFromFile() {
const qc = useQueryClient(); const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: subjects_import_from_file, mutationFn: subjects_import_from_file,
onSuccess: (subject) => { onSuccess: (subject) => {
qc.setQueryData(qk.asignatura(subject.id), subject); qc.setQueryData(qk.asignatura(subject.id), subject)
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) }); qc.invalidateQueries({
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) }); queryKey: qk.planAsignaturas(subject.plan_estudio_id),
})
qc.invalidateQueries({
queryKey: qk.planHistorial(subject.plan_estudio_id),
})
}, },
}); })
} }
export function useUpdateSubjectFields() { export function useUpdateSubjectFields() {
const qc = useQueryClient(); const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (vars: { subjectId: UUID; patch: SubjectsUpdateFieldsPatch }) => mutationFn: (vars: { subjectId: UUID; patch: SubjectsUpdateFieldsPatch }) =>
subjects_update_fields(vars.subjectId, vars.patch), subjects_update_fields(vars.subjectId, vars.patch),
onSuccess: (updated) => { onSuccess: (updated) => {
qc.setQueryData(qk.asignatura(updated.id), updated); qc.setQueryData(qk.asignatura(updated.id), updated)
qc.invalidateQueries({ queryKey: qk.planAsignaturas(updated.plan_estudio_id) }); qc.invalidateQueries({
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) }); queryKey: qk.planAsignaturas(updated.plan_estudio_id),
})
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
}, },
}); })
} }
export function useUpdateSubjectContenido() { export function useUpdateSubjectContenido() {
const qc = useQueryClient(); const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (vars: { subjectId: UUID; unidades: any[] }) => mutationFn: (vars: { subjectId: UUID; unidades: any[] }) =>
subjects_update_contenido(vars.subjectId, vars.unidades), subjects_update_contenido(vars.subjectId, vars.unidades),
onSuccess: (updated) => { onSuccess: (updated) => {
qc.setQueryData(qk.asignatura(updated.id), updated); qc.setQueryData(qk.asignatura(updated.id), updated)
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) }); qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
}, },
}); })
} }
export function useUpdateSubjectBibliografia() { export function useUpdateSubjectBibliografia() {
const qc = useQueryClient(); const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (vars: { subjectId: UUID; entries: BibliografiaUpsertInput }) => mutationFn: (vars: { subjectId: UUID; entries: BibliografiaUpsertInput }) =>
subjects_update_bibliografia(vars.subjectId, vars.entries), subjects_update_bibliografia(vars.subjectId, vars.entries),
onSuccess: (_ok, vars) => { onSuccess: (_ok, vars) => {
qc.invalidateQueries({ queryKey: qk.asignaturaBibliografia(vars.subjectId) }); qc.invalidateQueries({
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(vars.subjectId) }); queryKey: qk.asignaturaBibliografia(vars.subjectId),
})
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(vars.subjectId) })
}, },
}); })
} }
export function useGenerateSubjectDocumento() { export function useGenerateSubjectDocumento() {
const qc = useQueryClient(); const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (subjectId: UUID) => subjects_generate_document(subjectId), mutationFn: (subjectId: UUID) => subjects_generate_document(subjectId),
onSuccess: (_doc, subjectId) => { onSuccess: (_doc, subjectId) => {
qc.invalidateQueries({ queryKey: qk.asignaturaDocumento(subjectId) }); qc.invalidateQueries({ queryKey: qk.asignaturaDocumento(subjectId) })
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(subjectId) }); qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(subjectId) })
}, },
}); })
} }

View File

@@ -1,49 +1,64 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { CheckCircle2, Circle, Clock } from "lucide-react" import { CheckCircle2, Circle, Clock } from 'lucide-react'
import { Badge } from "@/components/ui/badge" import { Badge } from '@/components/ui/badge'
import { Button } from "@/components/ui/button" import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Textarea } from "@/components/ui/textarea" import { Textarea } from '@/components/ui/textarea'
import { usePlanHistorial } from '@/data/hooks/usePlans'
export const Route = createFileRoute('/planes/$planId/_detalle/flujo')({ export const Route = createFileRoute('/planes/$planId/_detalle/flujo')({
component: RouteComponent, component: RouteComponent,
}) })
function RouteComponent() { function RouteComponent() {
const { data: rawData, isLoading } = usePlanHistorial(
'0e0aea4d-b8b4-4e75-8279-6224c3ac769f',
)
console.log(rawData)
return ( return (
<div className="flex flex-col gap-6 p-6"> <div className="flex flex-col gap-6 p-6">
{/* Header Informativo (Opcional, si no viene del layout padre) */} {/* Header Informativo (Opcional, si no viene del layout padre) */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold italic">Flujo de Aprobación</h1> <h1 className="text-2xl font-bold italic">Flujo de Aprobación</h1>
<p className="text-sm text-muted-foreground">Gestiona el proceso de revisión y aprobación del plan</p> <p className="text-muted-foreground text-sm">
Gestiona el proceso de revisión y aprobación del plan
</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* LADO IZQUIERDO: Timeline del Flujo */} {/* LADO IZQUIERDO: Timeline del Flujo */}
<div className="lg:col-span-2 space-y-4"> <div className="space-y-4 lg:col-span-2">
{/* Estado: Completado */} {/* Estado: Completado */}
<div className="relative flex gap-4 pb-4"> <div className="relative flex gap-4 pb-4">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="rounded-full bg-green-100 p-1 text-green-600"> <div className="rounded-full bg-green-100 p-1 text-green-600">
<CheckCircle2 className="h-6 w-6" /> <CheckCircle2 className="h-6 w-6" />
</div> </div>
<div className="w-px flex-1 bg-green-200 mt-2" /> <div className="mt-2 w-px flex-1 bg-green-200" />
</div> </div>
<Card className="flex-1"> <Card className="flex-1">
<CardHeader className="flex flex-row items-center justify-between py-3"> <CardHeader className="flex flex-row items-center justify-between py-3">
<div> <div>
<CardTitle className="text-lg">Borrador</CardTitle> <CardTitle className="text-lg">Borrador</CardTitle>
<p className="text-xs text-muted-foreground">14 de enero de 2024</p> <p className="text-muted-foreground text-xs">
14 de enero de 2024
</p>
</div> </div>
<Badge variant="secondary" className="bg-green-100 text-green-700">Completado</Badge> <Badge
variant="secondary"
className="bg-green-100 text-green-700"
>
Completado
</Badge>
</CardHeader> </CardHeader>
<CardContent className="text-sm border-t pt-3"> <CardContent className="border-t pt-3 text-sm">
<p className="font-semibold text-muted-foreground mb-2">Comentarios</p> <p className="text-muted-foreground mb-2 font-semibold">
<ul className="list-disc list-inside space-y-1 text-muted-foreground"> Comentarios
</p>
<ul className="text-muted-foreground list-inside list-disc space-y-1">
<li>Documento inicial creado</li> <li>Documento inicial creado</li>
<li>Estructura base definida</li> <li>Estructura base definida</li>
</ul> </ul>
@@ -57,19 +72,27 @@ function RouteComponent() {
<div className="rounded-full bg-blue-100 p-1 text-blue-600 ring-2 ring-blue-500 ring-offset-2"> <div className="rounded-full bg-blue-100 p-1 text-blue-600 ring-2 ring-blue-500 ring-offset-2">
<Clock className="h-6 w-6" /> <Clock className="h-6 w-6" />
</div> </div>
<div className="w-px flex-1 bg-slate-200 mt-2" /> <div className="mt-2 w-px flex-1 bg-slate-200" />
</div> </div>
<Card className="flex-1 border-blue-500 bg-blue-50/10"> <Card className="flex-1 border-blue-500 bg-blue-50/10">
<CardHeader className="flex flex-row items-center justify-between py-3"> <CardHeader className="flex flex-row items-center justify-between py-3">
<div> <div>
<CardTitle className="text-lg text-blue-700">En Revisión</CardTitle> <CardTitle className="text-lg text-blue-700">
<p className="text-xs text-muted-foreground">19 de febrero de 2024</p> En Revisión
</CardTitle>
<p className="text-muted-foreground text-xs">
19 de febrero de 2024
</p>
</div> </div>
<Badge variant="default" className="bg-blue-500">En curso</Badge> <Badge variant="default" className="bg-blue-500">
En curso
</Badge>
</CardHeader> </CardHeader>
<CardContent className="text-sm border-t border-blue-100 pt-3"> <CardContent className="border-t border-blue-100 pt-3 text-sm">
<p className="font-semibold text-muted-foreground mb-2">Comentarios</p> <p className="text-muted-foreground mb-2 font-semibold">
<ul className="list-disc list-inside space-y-1 text-muted-foreground"> Comentarios
</p>
<ul className="text-muted-foreground list-inside list-disc space-y-1">
<li>Revisión de objetivo general pendiente</li> <li>Revisión de objetivo general pendiente</li>
<li>Mapa curricular aprobado preliminarmente</li> <li>Mapa curricular aprobado preliminarmente</li>
</ul> </ul>
@@ -91,7 +114,6 @@ function RouteComponent() {
</CardHeader> </CardHeader>
</Card> </Card>
</div> </div>
</div> </div>
{/* LADO DERECHO: Formulario de Transición */} {/* LADO DERECHO: Formulario de Transición */}
@@ -101,20 +123,22 @@ function RouteComponent() {
<CardTitle className="text-lg">Transición de Estado</CardTitle> <CardTitle className="text-lg">Transición de Estado</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-lg text-sm border"> <div className="flex items-center justify-between rounded-lg border bg-slate-50 p-3 text-sm">
<div className="text-center"> <div className="text-center">
<p className="text-xs text-muted-foreground">Estado actual</p> <p className="text-muted-foreground text-xs">Estado actual</p>
<p className="font-bold">En Revisión</p> <p className="font-bold">En Revisión</p>
</div> </div>
<div className="h-px flex-1 bg-slate-300 mx-4" /> <div className="mx-4 h-px flex-1 bg-slate-300" />
<div className="text-center"> <div className="text-center">
<p className="text-xs text-muted-foreground">Siguiente</p> <p className="text-muted-foreground text-xs">Siguiente</p>
<p className="font-bold text-primary">Revisión Expertos</p> <p className="text-primary font-bold">Revisión Expertos</p>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Comentario de transición</label> <label className="text-sm font-medium">
Comentario de transición
</label>
<Textarea <Textarea
placeholder="Agrega un comentario para la transición..." placeholder="Agrega un comentario para la transición..."
className="min-h-[120px]" className="min-h-[120px]"
@@ -127,7 +151,6 @@ function RouteComponent() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </div>
</div> </div>
) )

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { import {
GitBranch, GitBranch,
@@ -5,111 +6,172 @@ import {
PlusCircle, PlusCircle,
FileText, FileText,
RefreshCw, RefreshCw,
User User,
} from "lucide-react" Loader2,
import { Badge } from "@/components/ui/badge" Clock,
import { Card, CardContent } from "@/components/ui/card" } from 'lucide-react'
import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { usePlanHistorial } from '@/data/hooks/usePlans'
import { format, formatDistanceToNow, parseISO } from 'date-fns'
import { es } from 'date-fns/locale'
export const Route = createFileRoute('/planes/$planId/_detalle/historial')({ export const Route = createFileRoute('/planes/$planId/_detalle/historial')({
component: RouteComponent, component: RouteComponent,
}) })
function RouteComponent() { // Función para determinar el icono y tipo según la respuesta de la API
const historyEvents = [ const getEventConfig = (tipo: string, campo: string) => {
{ if (tipo === 'CREACION')
id: 1, return {
type: 'Cambio de estado', label: 'Creación',
user: 'Dr. Juan Pérez',
description: 'Plan pasado de Borrador a En Revisión',
date: 'Hace 2 días',
icon: <GitBranch className="h-4 w-4" />,
details: { from: 'Borrador', to: 'En Revisión' }
},
{
id: 2,
type: 'Edición',
user: 'Lic. María García',
description: 'Actualizado perfil de egreso',
date: 'Hace 3 días',
icon: <Edit3 className="h-4 w-4" />,
},
{
id: 3,
type: 'Reorganización',
user: 'Ing. Carlos López',
description: 'Movida materia BD102 de ciclo 3 a ciclo 4',
date: 'Hace 5 días',
icon: <RefreshCw className="h-4 w-4" />,
details: { from: 'Ciclo 3', to: 'Ciclo 4' }
},
{
id: 4,
type: 'Creación',
user: 'Dr. Juan Pérez',
description: 'Añadida nueva materia: Inteligencia Artificial',
date: 'Hace 1 semana',
icon: <PlusCircle className="h-4 w-4" />, icon: <PlusCircle className="h-4 w-4" />,
}, color: 'teal',
{ }
id: 5, if (campo === 'estado')
type: 'Documento', return {
user: 'Lic. María García', label: 'Cambio de estado',
description: 'Generado documento oficial v1.0', icon: <GitBranch className="h-4 w-4" />,
date: 'Hace 1 semana', color: 'blue',
icon: <FileText className="h-4 w-4" />, }
if (campo === 'datos')
return {
label: 'Edición de Datos',
icon: <Edit3 className="h-4 w-4" />,
color: 'amber',
}
return {
label: 'Actualización',
icon: <RefreshCw className="h-4 w-4" />,
color: 'slate',
}
}
function RouteComponent() {
const { planId } = Route.useParams()
const { data: rawData, isLoading } = usePlanHistorial(
'0e0aea4d-b8b4-4e75-8279-6224c3ac769f' /*planId*/,
)
// Transformación de datos de la API al formato de la UI
const historyEvents = useMemo(() => {
if (!rawData) return []
return rawData.map((item: any) => {
const config = getEventConfig(item.tipo, item.campo)
return {
id: item.id,
type: config.label,
user:
item.cambiado_por === '11111111-1111-1111-1111-111111111111'
? 'Administrador'
: 'Usuario Staff',
description:
item.campo === 'datos'
? `Actualización general de: ${item.valor_nuevo?.nombre || 'información del plan'}`
: `Se modificó el campo ${item.campo}`,
date: parseISO(item.cambiado_en),
icon: config.icon,
details:
item.valor_anterior && item.valor_nuevo
? {
from: String(item.valor_anterior),
to: String(item.valor_nuevo),
}
: null,
}
})
}, [rawData])
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-teal-600" />
</div>
)
} }
]
return ( return (
<div className="p-6 max-w-5xl mx-auto"> <div className="mx-auto max-w-5xl p-6">
<div className="mb-8"> <div className="mb-8">
<h1 className="text-xl font-bold text-slate-800">Historial de Cambios</h1> <h1 className="flex items-center gap-2 text-xl font-bold text-slate-800">
<p className="text-sm text-muted-foreground">Registro de todas las modificaciones realizadas al plan</p> <Clock className="h-5 w-5 text-teal-600" />
Historial de Cambios del Plan
</h1>
<p className="text-muted-foreground text-sm">
Registro cronológico de modificaciones realizadas
</p>
</div> </div>
<div className="relative space-y-0"> <div className="relative space-y-0">
{/* Línea vertical de fondo */} {/* Línea vertical de fondo */}
<div className="absolute left-9 top-0 bottom-0 w-px bg-slate-200" /> <div className="absolute top-0 bottom-0 left-9 w-px bg-slate-200" />
{historyEvents.map((event) => (
<div key={event.id} className="relative flex gap-6 pb-8 group">
{historyEvents.length === 0 ? (
<div className="ml-20 py-10 text-slate-500">
No hay registros en el historial.
</div>
) : (
historyEvents.map((event) => (
<div key={event.id} className="group relative flex gap-6 pb-8">
{/* Indicador con Icono */} {/* Indicador con Icono */}
<div className="relative z-10 flex h-18 flex-col items-center"> <div className="relative z-10 flex h-18 flex-col items-center">
<div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm group-hover:bg-teal-50 group-hover:text-teal-600 transition-colors"> <div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm transition-colors group-hover:bg-teal-50 group-hover:text-teal-600">
{event.icon} {event.icon}
</div> </div>
</div> </div>
{/* Tarjeta de Contenido */} {/* Tarjeta de Contenido */}
<Card className="flex-1 shadow-none border-slate-200 hover:border-teal-200 transition-colors"> <Card className="flex-1 border-slate-200 shadow-none transition-colors hover:border-teal-200">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-2 mb-2"> <div className="mb-2 flex flex-col justify-between gap-2 md:flex-row md:items-center">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-bold text-slate-800 text-sm">{event.type}</span> <span className="text-sm font-bold text-slate-800">
<Badge variant="outline" className="text-[10px] font-normal py-0"> {event.type}
{event.date} </span>
<Badge
variant="outline"
className="py-0 text-[10px] font-normal capitalize"
>
{formatDistanceToNow(event.date, {
addSuffix: true,
locale: es,
})}
</Badge> </Badge>
</div> </div>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="text-muted-foreground flex items-center gap-2 text-xs">
<Avatar className="h-5 w-5 border"> <Avatar className="h-5 w-5 border">
<AvatarFallback className="text-[8px] bg-slate-50"><User size={10}/></AvatarFallback> <AvatarFallback className="bg-slate-50 text-[8px]">
<User size={10} />
</AvatarFallback>
</Avatar> </Avatar>
{event.user} {event.user}
</div> </div>
</div> </div>
<p className="text-sm text-slate-600 mb-3">{event.description}</p> <p className="mb-1 text-sm text-slate-600">
{event.description}
</p>
{/* Badges de transición (si existen) */} <p className="mb-3 text-[10px] text-slate-400">
{format(event.date, "PPP 'a las' HH:mm", { locale: es })}
</p>
{/* Badges de transición (Si aplica para estados) */}
{event.details && ( {event.details && (
<div className="flex items-center gap-2 mt-2"> <div className="mt-2 flex items-center gap-2">
<Badge variant="secondary" className="bg-orange-50 text-orange-700 hover:bg-orange-50 border-orange-100 text-[10px]"> <Badge
variant="secondary"
className="bg-slate-100 text-[10px]"
>
{event.details.from} {event.details.from}
</Badge> </Badge>
<span className="text-slate-400 text-xs"></span> <span className="text-xs text-slate-400"></span>
<Badge variant="secondary" className="bg-green-50 text-green-700 hover:bg-green-50 border-green-100 text-[10px]"> <Badge
variant="secondary"
className="bg-teal-50 text-[10px] text-teal-700"
>
{event.details.to} {event.details.to}
</Badge> </Badge>
</div> </div>
@@ -117,25 +179,8 @@ function RouteComponent() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
))} ))
)}
{/* Evento inicial de creación */}
<div className="relative flex gap-6 group">
<div className="relative z-10 flex items-center">
<div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-teal-600 text-white shadow-sm">
<PlusCircle className="h-4 w-4" />
</div>
</div>
<Card className="flex-1 bg-teal-50/30 border-teal-100 shadow-none">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-1">
<span className="font-bold text-teal-900 text-sm">Creación</span>
<span className="text-[10px] text-teal-600 font-medium">14 Ene 2024</span>
</div>
<p className="text-sm text-teal-800/80">Plan de estudios creado</p>
</CardContent>
</Card>
</div>
</div> </div>
</div> </div>
) )