Se hidrata de informacion las tabs de asignatura
This commit is contained in:
@@ -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,14 +215,16 @@ 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}
|
||||||
isEditing={editingId === entry.id}
|
isEditing={editingId === entry.id}
|
||||||
onEdit={() => setEditingId(entry.id)}
|
onEdit={() => setEditingId(entry.id)}
|
||||||
onStopEditing={() => setEditingId(null)}
|
onStopEditing={() => setEditingId(null)}
|
||||||
@@ -154,14 +238,16 @@ 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}
|
||||||
isEditing={editingId === entry.id}
|
isEditing={editingId === entry.id}
|
||||||
onEdit={() => setEditingId(entry.id)}
|
onEdit={() => setEditingId(entry.id)}
|
||||||
onStopEditing={() => setEditingId(null)}
|
onStopEditing={() => setEditingId(null)}
|
||||||
@@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
if (u.id === unidadId) {
|
unidades.map((u) => {
|
||||||
const newTemaId = `t-${Date.now()}`;
|
if (u.id === unidadId) {
|
||||||
const newTema: Tema = { id: newTemaId, nombre: 'Nuevo tema', horasEstimadas: 2 };
|
const newTemaId = `t-${Date.now()}`
|
||||||
setEditingTema({ unitId: unidadId, temaId: newTemaId });
|
const newTema: Tema = {
|
||||||
return { ...u, temas: [...u.temas, newTema] };
|
id: newTemaId,
|
||||||
}
|
nombre: 'Nuevo tema',
|
||||||
return u;
|
horasEstimadas: 2,
|
||||||
}));
|
}
|
||||||
};
|
setEditingTema({ unitId: unidadId, temaId: newTemaId })
|
||||||
|
return { ...u, temas: [...u.temas, newTema] }
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const updateTema = (unidadId: string, temaId: string, updates: Partial<Tema>) => {
|
const updateTema = (
|
||||||
setUnidades(unidades.map(u => {
|
unidadId: string,
|
||||||
if (u.id === unidadId) {
|
temaId: string,
|
||||||
return { ...u, temas: u.temas.map(t => t.id === temaId ? { ...t, ...updates } : t) };
|
updates: Partial<Tema>,
|
||||||
}
|
) => {
|
||||||
return u;
|
setUnidades(
|
||||||
}));
|
unidades.map((u) => {
|
||||||
};
|
if (u.id === unidadId) {
|
||||||
|
return {
|
||||||
|
...u,
|
||||||
|
temas: u.temas.map((t) =>
|
||||||
|
t.id === temaId ? { ...t, ...updates } : t,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
autoFocus
|
}
|
||||||
|
className="h-8 max-w-md bg-white"
|
||||||
|
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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,91 +127,108 @@ 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>
|
||||||
<Badge variant="outline">Versión {documento.version}</Badge>
|
<Badge variant="outline">Versión {documento.version}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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,44 +290,60 @@ 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
|
||||||
{completeness === 100
|
? 'text-success'
|
||||||
|
: 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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
|
||||||
<span className="text-foreground">{campo.nombre}</span>
|
.filter((c) => !datosGenerales[c.id]?.trim())
|
||||||
</div>
|
.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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,196 +1,230 @@
|
|||||||
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' },
|
{
|
||||||
contenido: { label: 'Contenido temático', icon: List, color: 'text-accent' },
|
datos: { label: 'Datos generales', icon: FileText, color: 'text-info' },
|
||||||
bibliografia: { label: 'Bibliografía', icon: BookMarked, color: 'text-success' },
|
contenido: {
|
||||||
ia: { label: 'IA', icon: Sparkles, color: 'text-amber-500' },
|
label: 'Contenido temático',
|
||||||
documento: { label: 'Documento SEP', icon: FileCheck, color: 'text-primary' },
|
icon: List,
|
||||||
};
|
color: 'text-accent',
|
||||||
|
},
|
||||||
|
bibliografia: {
|
||||||
|
label: 'Bibliografía',
|
||||||
|
icon: BookMarked,
|
||||||
|
color: 'text-success',
|
||||||
|
},
|
||||||
|
ia: { label: 'IA', icon: Sparkles, color: 'text-amber-500' },
|
||||||
|
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
|
||||||
groups[dateKey].push(cambio);
|
},
|
||||||
return groups;
|
{} as Record<string, any[]>,
|
||||||
}, {} as Record<string, CambioMateria[]>);
|
)
|
||||||
|
|
||||||
const sortedDates = Object.keys(groupedHistorial).sort((a, b) => b.localeCompare(a));
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
<DropdownMenuCheckboxItem
|
||||||
return (
|
key={tipo}
|
||||||
<DropdownMenuCheckboxItem
|
checked={filtros.has(tipo)}
|
||||||
key={tipo}
|
onCheckedChange={() => toggleFiltro(tipo)}
|
||||||
checked={filtros.has(tipo)}
|
>
|
||||||
onCheckedChange={() => toggleFiltro(tipo)}
|
<config.icon className={cn('mr-2 h-4 w-4', config.color)} />
|
||||||
>
|
{config.label}
|
||||||
<Icon className={cn("w-4 h-4 mr-2", config.color)} />
|
</DropdownMenuCheckboxItem>
|
||||||
{config.label}
|
))}
|
||||||
</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];
|
<div key={dateKey}>
|
||||||
const date = new Date(dateKey);
|
<div className="mb-4 flex items-center gap-3">
|
||||||
const isToday = format(new Date(), 'yyyy-MM-dd') === dateKey;
|
<Calendar className="text-muted-foreground h-4 w-4" />
|
||||||
const isYesterday = format(new Date(Date.now() - 86400000), 'yyyy-MM-dd') === dateKey;
|
<h3 className="text-foreground font-semibold">
|
||||||
|
{format(parseISO(dateKey), "EEEE, d 'de' MMMM", {
|
||||||
|
locale: es,
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
<div className="border-border ml-4 space-y-4 border-l-2 pl-6">
|
||||||
<div key={dateKey}>
|
{groupedHistorial[dateKey].map((cambio) => {
|
||||||
{/* Date header */}
|
const config = tipoConfig[cambio.tipo] || tipoConfig.datos
|
||||||
<div className="flex items-center gap-3 mb-4">
|
const Icon = config.icon
|
||||||
<div className="p-2 rounded-lg bg-muted">
|
return (
|
||||||
<Calendar className="w-4 h-4 text-muted-foreground" />
|
<div key={cambio.id} className="relative">
|
||||||
</div>
|
<div
|
||||||
<div>
|
className={cn(
|
||||||
<h3 className="font-semibold text-foreground">
|
'border-background absolute -left-[31px] h-4 w-4 rounded-full border-2',
|
||||||
{isToday ? 'Hoy' : isYesterday ? 'Ayer' : format(date, "EEEE, d 'de' MMMM", { locale: es })}
|
`bg-current ${config.color}`,
|
||||||
</h3>
|
)}
|
||||||
<p className="text-xs text-muted-foreground">
|
/>
|
||||||
{cambios.length} {cambios.length === 1 ? 'cambio' : 'cambios'}
|
<Card className="card-interactive">
|
||||||
</p>
|
<CardContent className="py-4">
|
||||||
</div>
|
<div className="flex items-start gap-4">
|
||||||
</div>
|
<div
|
||||||
|
className={cn(
|
||||||
{/* Timeline */}
|
'bg-muted rounded-lg p-2',
|
||||||
<div className="ml-4 border-l-2 border-border pl-6 space-y-4">
|
config.color,
|
||||||
{cambios.map((cambio) => {
|
)}
|
||||||
const config = tipoConfig[cambio.tipo];
|
>
|
||||||
const Icon = config.icon;
|
<Icon className="h-4 w-4" />
|
||||||
return (
|
</div>
|
||||||
<div key={cambio.id} className="relative">
|
<div className="flex-1">
|
||||||
{/* Timeline dot */}
|
<div className="flex justify-between">
|
||||||
<div className={cn(
|
<p className="font-medium">
|
||||||
"absolute -left-[31px] w-4 h-4 rounded-full border-2 border-background",
|
{cambio.descripcion}
|
||||||
`bg-current ${config.color}`
|
</p>
|
||||||
)} />
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{format(cambio.fecha, 'HH:mm')}
|
||||||
<Card className="card-interactive">
|
</span>
|
||||||
<CardContent className="py-4">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className={cn(
|
|
||||||
"p-2 rounded-lg bg-muted flex-shrink-0",
|
|
||||||
config.color
|
|
||||||
)}>
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<Badge
|
||||||
<div>
|
variant="outline"
|
||||||
<p className="font-medium text-foreground">
|
className="text-[10px]"
|
||||||
{cambio.descripcion}
|
>
|
||||||
</p>
|
{config.label}
|
||||||
<div className="flex items-center gap-2 mt-1">
|
</Badge>
|
||||||
<Badge variant="outline" className="text-xs">
|
<span className="text-muted-foreground text-xs italic">
|
||||||
{config.label}
|
por {cambio.usuario}
|
||||||
</Badge>
|
</span>
|
||||||
{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')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 mt-3 text-xs text-muted-foreground">
|
|
||||||
<User className="w-3 h-3" />
|
|
||||||
<span>{cambio.usuario}</span>
|
|
||||||
<span className="text-muted-foreground/50">•</span>
|
|
||||||
<span>
|
|
||||||
{formatDistanceToNow(cambio.fecha, { addSuffix: true, locale: es })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
);
|
</div>
|
||||||
})}
|
)
|
||||||
</div>
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InfoCard
|
{!isLoading &&
|
||||||
title="Objetivo General"
|
Object.entries(data).map(([key, value]) => (
|
||||||
initialContent="Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos."
|
<InfoCard
|
||||||
/>
|
key={key}
|
||||||
</div>
|
title={formatTitle(key)}
|
||||||
|
initialContent={value}
|
||||||
<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) */}
|
||||||
|
|||||||
@@ -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) })
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) })
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,22 +123,24 @@ 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">
|
||||||
<Textarea
|
Comentario de transición
|
||||||
placeholder="Agrega un comentario para la transición..."
|
</label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Agrega un comentario para la transición..."
|
||||||
className="min-h-[120px]"
|
className="min-h-[120px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,8 +151,7 @@ function RouteComponent() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,142 +1,187 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Edit3,
|
Edit3,
|
||||||
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,
|
|
||||||
type: 'Documento',
|
|
||||||
user: 'Lic. María García',
|
|
||||||
description: 'Generado documento oficial v1.0',
|
|
||||||
date: 'Hace 1 semana',
|
|
||||||
icon: <FileText className="h-4 w-4" />,
|
|
||||||
}
|
}
|
||||||
]
|
if (campo === 'estado')
|
||||||
|
return {
|
||||||
|
label: 'Cambio de estado',
|
||||||
|
icon: <GitBranch className="h-4 w-4" />,
|
||||||
|
color: 'blue',
|
||||||
|
}
|
||||||
|
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) => (
|
{historyEvents.length === 0 ? (
|
||||||
<div key={event.id} className="relative flex gap-6 pb-8 group">
|
<div className="ml-20 py-10 text-slate-500">
|
||||||
|
No hay registros en el historial.
|
||||||
{/* Indicador con Icono */}
|
</div>
|
||||||
<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">
|
historyEvents.map((event) => (
|
||||||
{event.icon}
|
<div key={event.id} className="group relative flex gap-6 pb-8">
|
||||||
</div>
|
{/* Indicador con Icono */}
|
||||||
</div>
|
<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 transition-colors group-hover:bg-teal-50 group-hover:text-teal-600">
|
||||||
{/* Tarjeta de Contenido */}
|
{event.icon}
|
||||||
<Card className="flex-1 shadow-none border-slate-200 hover:border-teal-200 transition-colors">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-2 mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-bold text-slate-800 text-sm">{event.type}</span>
|
|
||||||
<Badge variant="outline" className="text-[10px] font-normal py-0">
|
|
||||||
{event.date}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Avatar className="h-5 w-5 border">
|
|
||||||
<AvatarFallback className="text-[8px] bg-slate-50"><User size={10}/></AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
{event.user}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-slate-600 mb-3">{event.description}</p>
|
{/* Tarjeta de Contenido */}
|
||||||
|
<Card className="flex-1 border-slate-200 shadow-none transition-colors hover:border-teal-200">
|
||||||
{/* Badges de transición (si existen) */}
|
<CardContent className="p-4">
|
||||||
{event.details && (
|
<div className="mb-2 flex flex-col justify-between gap-2 md:flex-row md:items-center">
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="secondary" className="bg-orange-50 text-orange-700 hover:bg-orange-50 border-orange-100 text-[10px]">
|
<span className="text-sm font-bold text-slate-800">
|
||||||
{event.details.from}
|
{event.type}
|
||||||
</Badge>
|
</span>
|
||||||
<span className="text-slate-400 text-xs">→</span>
|
<Badge
|
||||||
<Badge variant="secondary" className="bg-green-50 text-green-700 hover:bg-green-50 border-green-100 text-[10px]">
|
variant="outline"
|
||||||
{event.details.to}
|
className="py-0 text-[10px] font-normal capitalize"
|
||||||
</Badge>
|
>
|
||||||
|
{formatDistanceToNow(event.date, {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: es,
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||||
|
<Avatar className="h-5 w-5 border">
|
||||||
|
<AvatarFallback className="bg-slate-50 text-[8px]">
|
||||||
|
<User size={10} />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
{event.user}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Evento inicial de creación */}
|
<p className="mb-1 text-sm text-slate-600">
|
||||||
<div className="relative flex gap-6 group">
|
{event.description}
|
||||||
<div className="relative z-10 flex items-center">
|
</p>
|
||||||
<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" />
|
<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 && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-slate-100 text-[10px]"
|
||||||
|
>
|
||||||
|
{event.details.from}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-slate-400">→</span>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-teal-50 text-[10px] text-teal-700"
|
||||||
|
>
|
||||||
|
{event.details.to}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user