Merge branch 'fix/merge' into feat/ai-generate-plan
This commit is contained in:
@@ -1,14 +1,44 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Search, BookOpen, Trash2, Library, Edit3, Save } from 'lucide-react';
|
||||
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 { useEffect, useState } from 'react'
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
BookOpen,
|
||||
Trash2,
|
||||
Library,
|
||||
Edit3,
|
||||
Save,
|
||||
} from 'lucide-react'
|
||||
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 { mockLibraryResources } from '@/data/mockMateriaData';
|
||||
|
||||
@@ -20,7 +50,7 @@ export const mockLibraryResources = [
|
||||
editorial: 'MIT Press',
|
||||
anio: 2016,
|
||||
isbn: '9780262035613',
|
||||
disponible: true
|
||||
disponible: true,
|
||||
},
|
||||
{
|
||||
id: 'lib-2',
|
||||
@@ -29,102 +59,154 @@ export const mockLibraryResources = [
|
||||
editorial: 'Pearson',
|
||||
anio: 2020,
|
||||
isbn: '9780134610993',
|
||||
disponible: true
|
||||
disponible: true,
|
||||
},
|
||||
{
|
||||
id: 'lib-3',
|
||||
titulo: 'Hands-On Machine Learning',
|
||||
autor: 'Aurélien Géron',
|
||||
editorial: 'O\'Reilly Media',
|
||||
editorial: "O'Reilly Media",
|
||||
anio: 2019,
|
||||
isbn: '9781492032649',
|
||||
disponible: false
|
||||
}
|
||||
];
|
||||
disponible: false,
|
||||
},
|
||||
]
|
||||
|
||||
// --- Interfaces ---
|
||||
export interface BibliografiaEntry {
|
||||
id: string;
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA';
|
||||
cita: string;
|
||||
fuenteBibliotecaId?: string;
|
||||
fuenteBiblioteca?: any;
|
||||
id: string
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA'
|
||||
cita: string
|
||||
tipo_fuente?: 'MANUAL' | 'BIBLIOTECA'
|
||||
biblioteca_item_id?: string | null
|
||||
fuenteBibliotecaId?: string
|
||||
fuenteBiblioteca?: any
|
||||
}
|
||||
|
||||
interface BibliografiaTabProps {
|
||||
bibliografia: BibliografiaEntry[];
|
||||
onSave: (bibliografia: BibliografiaEntry[]) => void;
|
||||
isSaving: boolean;
|
||||
bibliografia: BibliografiaEntry[]
|
||||
onSave: (bibliografia: BibliografiaEntry[]) => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export function BibliographyItem({ bibliografia, onSave, isSaving }: BibliografiaTabProps) {
|
||||
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');
|
||||
export function BibliographyItem({
|
||||
bibliografia,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: BibliografiaTabProps) {
|
||||
const { data: bibliografia2, isLoading: loadinmateria } =
|
||||
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');
|
||||
const complementariaEntries = entries.filter(e => e.tipo === 'COMPLEMENTARIA');
|
||||
useEffect(() => {
|
||||
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 newEntry: BibliografiaEntry = { id: `manual-${Date.now()}`, tipo: newEntryType, cita };
|
||||
setEntries([...entries, newEntry]);
|
||||
setIsAddDialogOpen(false);
|
||||
const newEntry: BibliografiaEntry = {
|
||||
id: `manual-${Date.now()}`,
|
||||
tipo: newEntryType,
|
||||
cita,
|
||||
}
|
||||
setEntries([...entries, newEntry])
|
||||
setIsAddDialogOpen(false)
|
||||
//toast.success('Referencia manual añadida');
|
||||
};
|
||||
}
|
||||
|
||||
const handleAddFromLibrary = (resource: any, tipo: 'BASICA' | 'COMPLEMENTARIA') => {
|
||||
const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.`;
|
||||
const handleAddFromLibrary = (
|
||||
resource: any,
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA',
|
||||
) => {
|
||||
const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.`
|
||||
const newEntry: BibliografiaEntry = {
|
||||
id: `lib-ref-${Date.now()}`,
|
||||
tipo,
|
||||
cita,
|
||||
fuenteBibliotecaId: resource.id,
|
||||
fuenteBiblioteca: resource,
|
||||
};
|
||||
setEntries([...entries, newEntry]);
|
||||
setIsLibraryDialogOpen(false);
|
||||
}
|
||||
setEntries([...entries, newEntry])
|
||||
setIsLibraryDialogOpen(false)
|
||||
//toast.success('Añadido desde biblioteca');
|
||||
};
|
||||
}
|
||||
|
||||
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 (
|
||||
<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>
|
||||
<h2 className="text-2xl font-bold text-slate-900 tracking-tight">Bibliografía</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
{basicaEntries.length} básica • {complementariaEntries.length} complementaria
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||
Bibliografía
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{basicaEntries.length} básica • {complementariaEntries.length}{' '}
|
||||
complementaria
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dialog open={isLibraryDialogOpen} onOpenChange={setIsLibraryDialogOpen}>
|
||||
<Dialog
|
||||
open={isLibraryDialogOpen}
|
||||
onOpenChange={setIsLibraryDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="border-blue-200 text-blue-700 hover:bg-blue-50">
|
||||
<Library className="w-4 h-4 mr-2" /> Buscar en biblioteca
|
||||
<Button
|
||||
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>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<LibrarySearchDialog onSelect={handleAddFromLibrary} existingIds={entries.map(e => e.fuenteBibliotecaId || '')} />
|
||||
<LibrarySearchDialog
|
||||
onSelect={handleAddFromLibrary}
|
||||
existingIds={entries.map((e) => e.fuenteBibliotecaId || '')}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<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>
|
||||
<DialogContent>
|
||||
<AddManualDialog tipo={newEntryType} onTypeChange={setNewEntryType} onAdd={handleAddManual} />
|
||||
<AddManualDialog
|
||||
tipo={newEntryType}
|
||||
onTypeChange={setNewEntryType}
|
||||
onAdd={handleAddManual}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Button onClick={() => onSave(entries)} disabled={isSaving} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Save className="w-4 h-4 mr-2" /> {isSaving ? 'Guardando...' : 'Guardar'}
|
||||
<Button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,14 +215,16 @@ export function BibliographyItem({ bibliografia, onSave, isSaving }: Bibliografi
|
||||
{/* BASICA */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-1 bg-blue-600 rounded-full" />
|
||||
<h3 className="font-semibold text-slate-800">Bibliografía Básica</h3>
|
||||
<div className="h-4 w-1 rounded-full bg-blue-600" />
|
||||
<h3 className="font-semibold text-slate-800">
|
||||
Bibliografía Básica
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{basicaEntries.map(entry => (
|
||||
<BibliografiaCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
{basicaEntries.map((entry) => (
|
||||
<BibliografiaCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isEditing={editingId === entry.id}
|
||||
onEdit={() => setEditingId(entry.id)}
|
||||
onStopEditing={() => setEditingId(null)}
|
||||
@@ -154,14 +238,16 @@ export function BibliographyItem({ bibliografia, onSave, isSaving }: Bibliografi
|
||||
{/* COMPLEMENTARIA */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-1 bg-slate-400 rounded-full" />
|
||||
<h3 className="font-semibold text-slate-800">Bibliografía Complementaria</h3>
|
||||
<div className="h-4 w-1 rounded-full bg-slate-400" />
|
||||
<h3 className="font-semibold text-slate-800">
|
||||
Bibliografía Complementaria
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{complementariaEntries.map(entry => (
|
||||
<BibliografiaCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
{complementariaEntries.map((entry) => (
|
||||
<BibliografiaCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isEditing={editingId === entry.id}
|
||||
onEdit={() => setEditingId(entry.id)}
|
||||
onStopEditing={() => setEditingId(null)}
|
||||
@@ -177,70 +263,143 @@ export function BibliographyItem({ bibliografia, onSave, isSaving }: Bibliografi
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<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>
|
||||
<AlertDialogFooter>
|
||||
<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>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// --- Subcomponentes ---
|
||||
|
||||
function BibliografiaCard({ entry, isEditing, onEdit, onStopEditing, onUpdateCita, onDelete }: any) {
|
||||
const [localCita, setLocalCita] = useState(entry.cita);
|
||||
function BibliografiaCard({
|
||||
entry,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onStopEditing,
|
||||
onUpdateCita,
|
||||
onDelete,
|
||||
}: any) {
|
||||
const [localCita, setLocalCita] = useState(entry.cita)
|
||||
|
||||
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">
|
||||
<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")} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<BookOpen
|
||||
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 ? (
|
||||
<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">
|
||||
<Button variant="ghost" size="sm" onClick={onStopEditing}>Cancelar</Button>
|
||||
<Button size="sm" className="bg-emerald-600" onClick={() => { onUpdateCita(entry.id, localCita); onStopEditing(); }}>Guardar</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onStopEditing}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-emerald-600"
|
||||
onClick={() => {
|
||||
onUpdateCita(entry.id, localCita)
|
||||
onStopEditing()
|
||||
}}
|
||||
>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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 && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Badge variant="secondary" className="text-[10px] bg-slate-100 text-slate-600">Biblioteca</Badge>
|
||||
{entry.fuenteBiblioteca.disponible && <Badge className="text-[10px] bg-emerald-50 text-emerald-700 border-emerald-100">Disponible</Badge>}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<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>
|
||||
{!isEditing && (
|
||||
<div className="flex opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<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 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>
|
||||
<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="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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function AddManualDialog({ tipo, onTypeChange, onAdd }: any) {
|
||||
const [cita, setCita] = useState('');
|
||||
const [cita, setCita] = useState('')
|
||||
return (
|
||||
<div className="space-y-4 py-4">
|
||||
<DialogHeader><DialogTitle>Referencia Manual</DialogTitle></DialogHeader>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Referencia Manual</DialogTitle>
|
||||
</DialogHeader>
|
||||
<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}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="BASICA">Básica</SelectItem>
|
||||
<SelectItem value="COMPLEMENTARIA">Complementaria</SelectItem>
|
||||
@@ -248,44 +407,78 @@ function AddManualDialog({ tipo, onTypeChange, onAdd }: any) {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold uppercase text-slate-500">Cita APA</label>
|
||||
<Textarea value={cita} onChange={(e) => setCita(e.target.value)} placeholder="Autor, A. (Año). Título..." className="min-h-[120px]" />
|
||||
<label className="text-xs font-bold text-slate-500 uppercase">
|
||||
Cita APA
|
||||
</label>
|
||||
<Textarea
|
||||
value={cita}
|
||||
onChange={(e) => setCita(e.target.value)}
|
||||
placeholder="Autor, A. (Año). Título..."
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function LibrarySearchDialog({ onSelect, existingIds }: any) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA');
|
||||
const filtered = mockLibraryResources.filter(r =>
|
||||
!existingIds.includes(r.id) && r.titulo.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
const [search, setSearch] = useState('')
|
||||
const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA')
|
||||
const filtered = mockLibraryResources.filter(
|
||||
(r) =>
|
||||
!existingIds.includes(r.id) &&
|
||||
r.titulo.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<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="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Buscar por título o autor..." className="pl-10" />
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
<Select value={tipo} onValueChange={(v:any) => setTipo(v)}><SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="BASICA">Básica</SelectItem><SelectItem value="COMPLEMENTARIA">Complem.</SelectItem></SelectContent>
|
||||
<Select value={tipo} onValueChange={(v: any) => setTipo(v)}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="BASICA">Básica</SelectItem>
|
||||
<SelectItem value="COMPLEMENTARIA">Complem.</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto pr-2 space-y-2">
|
||||
{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 className="max-h-[300px] space-y-2 overflow-y-auto pr-2">
|
||||
{filtered.map((res) => (
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, GripVertical, ChevronDown, ChevronRight, 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 { useEffect, useState } from 'react'
|
||||
import {
|
||||
Plus,
|
||||
GripVertical,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -14,24 +27,22 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
//import { toast } from 'sonner';
|
||||
|
||||
|
||||
|
||||
export interface Tema {
|
||||
id: string;
|
||||
nombre: string;
|
||||
descripcion?: string;
|
||||
horasEstimadas?: number;
|
||||
id: string
|
||||
nombre: string
|
||||
descripcion?: string
|
||||
horasEstimadas?: number
|
||||
}
|
||||
|
||||
export interface UnidadTematica {
|
||||
id: string;
|
||||
nombre: string;
|
||||
numero: number;
|
||||
temas: Tema[];
|
||||
id: string
|
||||
nombre: string
|
||||
numero: number
|
||||
temas: Tema[]
|
||||
}
|
||||
|
||||
const initialData: UnidadTematica[] = [
|
||||
@@ -42,152 +53,297 @@ const initialData: UnidadTematica[] = [
|
||||
temas: [
|
||||
{ id: 't1', nombre: 'Tipos de IA y aplicaciones', horasEstimadas: 6 },
|
||||
{ id: 't2', nombre: 'Ética en IA', horasEstimadas: 3 },
|
||||
]
|
||||
}
|
||||
];
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function ContenidoTematico() {
|
||||
const [unidades, setUnidades] = useState<UnidadTematica[]>(initialData);
|
||||
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);
|
||||
// Estructura que viene de tu JSON/API
|
||||
interface ContenidoApi {
|
||||
unidad: number
|
||||
titulo: string
|
||||
temas: string[] | any[] // Acepta strings o objetos
|
||||
[key: string]: any // Esta línea permite que haya más claves desconocidas
|
||||
}
|
||||
|
||||
// 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 ---
|
||||
const toggleUnit = (id: string) => {
|
||||
const newExpanded = new Set(expandedUnits);
|
||||
newExpanded.has(id) ? newExpanded.delete(id) : newExpanded.add(id);
|
||||
setExpandedUnits(newExpanded);
|
||||
};
|
||||
const newExpanded = new Set(expandedUnits)
|
||||
newExpanded.has(id) ? newExpanded.delete(id) : newExpanded.add(id)
|
||||
setExpandedUnits(newExpanded)
|
||||
}
|
||||
|
||||
const addUnidad = () => {
|
||||
const newId = `u-${Date.now()}`;
|
||||
const newId = `u-${Date.now()}`
|
||||
const newUnidad: UnidadTematica = {
|
||||
id: newId,
|
||||
nombre: 'Nueva Unidad',
|
||||
numero: unidades.length + 1,
|
||||
temas: [],
|
||||
};
|
||||
setUnidades([...unidades, newUnidad]);
|
||||
setExpandedUnits(new Set([...expandedUnits, newId]));
|
||||
setEditingUnit(newId);
|
||||
};
|
||||
}
|
||||
setUnidades([...unidades, newUnidad])
|
||||
setExpandedUnits(new Set([...expandedUnits, newId]))
|
||||
setEditingUnit(newId)
|
||||
}
|
||||
|
||||
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 ---
|
||||
const addTema = (unidadId: string) => {
|
||||
setUnidades(unidades.map(u => {
|
||||
if (u.id === unidadId) {
|
||||
const newTemaId = `t-${Date.now()}`;
|
||||
const newTema: Tema = { id: newTemaId, nombre: 'Nuevo tema', horasEstimadas: 2 };
|
||||
setEditingTema({ unitId: unidadId, temaId: newTemaId });
|
||||
return { ...u, temas: [...u.temas, newTema] };
|
||||
}
|
||||
return u;
|
||||
}));
|
||||
};
|
||||
setUnidades(
|
||||
unidades.map((u) => {
|
||||
if (u.id === unidadId) {
|
||||
const newTemaId = `t-${Date.now()}`
|
||||
const newTema: Tema = {
|
||||
id: newTemaId,
|
||||
nombre: 'Nuevo tema',
|
||||
horasEstimadas: 2,
|
||||
}
|
||||
setEditingTema({ unitId: unidadId, temaId: newTemaId })
|
||||
return { ...u, temas: [...u.temas, newTema] }
|
||||
}
|
||||
return u
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const updateTema = (unidadId: string, temaId: string, updates: Partial<Tema>) => {
|
||||
setUnidades(unidades.map(u => {
|
||||
if (u.id === unidadId) {
|
||||
return { ...u, temas: u.temas.map(t => t.id === temaId ? { ...t, ...updates } : t) };
|
||||
}
|
||||
return u;
|
||||
}));
|
||||
};
|
||||
const updateTema = (
|
||||
unidadId: string,
|
||||
temaId: string,
|
||||
updates: Partial<Tema>,
|
||||
) => {
|
||||
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 = () => {
|
||||
if (!deleteDialog) return;
|
||||
if (!deleteDialog) return
|
||||
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) {
|
||||
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");
|
||||
};
|
||||
|
||||
const totalHoras = unidades.reduce((acc, u) => acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0), 0);
|
||||
}
|
||||
|
||||
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>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">Contenido Temático</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||
Contenido Temático
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{unidades.length} unidades • {totalHoras} horas estimadas totales
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center 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 onClick={() => { setIsSaving(true); setTimeout(() => { setIsSaving(false); /*toast.success("Guardado")*/; }, 1000); }} disabled={isSaving} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Save className="w-4 h-4 mr-2" /> {isSaving ? 'Guardando...' : 'Guardar'}
|
||||
<Button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{unidades.map((unidad) => (
|
||||
<Card key={unidad.id} className="overflow-hidden border-slate-200 shadow-sm">
|
||||
<Collapsible open={expandedUnits.has(unidad.id)} onOpenChange={() => toggleUnit(unidad.id)}>
|
||||
<CardHeader className="bg-slate-50/50 py-3 border-b border-slate-100">
|
||||
<Card
|
||||
key={unidad.id}
|
||||
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">
|
||||
<GripVertical className="w-4 h-4 text-slate-300 cursor-grab" />
|
||||
<GripVertical className="h-4 w-4 cursor-grab text-slate-300" />
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="p-0 h-auto">
|
||||
{expandedUnits.has(unidad.id) ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
<Button variant="ghost" size="sm" className="h-auto p-0">
|
||||
{expandedUnits.has(unidad.id) ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</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 ? (
|
||||
<Input
|
||||
value={unidad.nombre}
|
||||
onChange={(e) => updateUnidadNombre(unidad.id, e.target.value)}
|
||||
<Input
|
||||
value={unidad.nombre}
|
||||
onChange={(e) =>
|
||||
updateUnidadNombre(unidad.id, e.target.value)
|
||||
}
|
||||
onBlur={() => setEditingUnit(null)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setEditingUnit(null)}
|
||||
className="max-w-md h-8 bg-white"
|
||||
autoFocus
|
||||
onKeyDown={(e) =>
|
||||
e.key === 'Enter' && setEditingUnit(null)
|
||||
}
|
||||
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}
|
||||
</CardTitle>
|
||||
)}
|
||||
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<span className="text-xs font-medium text-slate-400 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" /> {unidad.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0)}h
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-slate-400">
|
||||
<Clock className="h-3 w-3" />{' '}
|
||||
{unidad.temas.reduce(
|
||||
(sum, t) => sum + (t.horasEstimadas || 0),
|
||||
0,
|
||||
)}
|
||||
h
|
||||
</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 })}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<Button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="pt-4 bg-white">
|
||||
<div className="space-y-1 ml-10 border-l-2 border-slate-50 pl-4">
|
||||
<CardContent className="bg-white pt-4">
|
||||
<div className="ml-10 space-y-1 border-l-2 border-slate-50 pl-4">
|
||||
{unidad.temas.map((tema, idx) => (
|
||||
<TemaRow
|
||||
key={tema.id}
|
||||
tema={tema}
|
||||
index={idx + 1}
|
||||
isEditing={editingTema?.unitId === unidad.id && editingTema?.temaId === tema.id}
|
||||
onEdit={() => setEditingTema({ unitId: unidad.id, temaId: tema.id })}
|
||||
<TemaRow
|
||||
key={tema.id}
|
||||
tema={tema}
|
||||
index={idx + 1}
|
||||
isEditing={
|
||||
editingTema?.unitId === unidad.id &&
|
||||
editingTema?.temaId === tema.id
|
||||
}
|
||||
onEdit={() =>
|
||||
setEditingTema({ unitId: unidad.id, temaId: tema.id })
|
||||
}
|
||||
onStopEditing={() => setEditingTema(null)}
|
||||
onUpdate={(updates) => updateTema(unidad.id, tema.id, updates)}
|
||||
onDelete={() => setDeleteDialog({ type: 'tema', id: tema.id, parentId: unidad.id })}
|
||||
onUpdate={(updates) =>
|
||||
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)}>
|
||||
<Plus className="w-3 h-3 mr-2" /> Añadir subtema
|
||||
<Button
|
||||
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>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -197,81 +353,137 @@ export function ContenidoTematico() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DeleteConfirmDialog dialog={deleteDialog} setDialog={setDeleteDialog} onConfirm={handleDelete} />
|
||||
<DeleteConfirmDialog
|
||||
dialog={deleteDialog}
|
||||
setDialog={setDeleteDialog}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// --- Componentes Auxiliares ---
|
||||
interface TemaRowProps {
|
||||
tema: Tema;
|
||||
index: number;
|
||||
isEditing: boolean;
|
||||
onEdit: () => void;
|
||||
onStopEditing: () => void;
|
||||
onUpdate: (updates: Partial<Tema>) => void;
|
||||
onDelete: () => void;
|
||||
tema: Tema
|
||||
index: number
|
||||
isEditing: boolean
|
||||
onEdit: () => void
|
||||
onStopEditing: () => void
|
||||
onUpdate: (updates: Partial<Tema>) => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
function TemaRow({ tema, index, isEditing, onEdit, onStopEditing, onUpdate, onDelete }: TemaRowProps) {
|
||||
function TemaRow({
|
||||
tema,
|
||||
index,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onStopEditing,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: TemaRowProps) {
|
||||
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")}>
|
||||
<span className="text-xs font-mono text-slate-400 w-4">{index}.</span>
|
||||
<div
|
||||
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 ? (
|
||||
<div className="flex-1 flex items-center gap-2 animate-in slide-in-from-left-2">
|
||||
<Input value={tema.nombre} 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="bg-emerald-600 h-8" onClick={onStopEditing}>Listo</Button>
|
||||
<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
|
||||
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 className="flex-1 cursor-pointer" onClick={onEdit}>
|
||||
<p className="text-sm font-medium text-slate-700">{tema.nombre}</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-[10px] opacity-60">{tema.horasEstimadas}h</Badge>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<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>
|
||||
<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>
|
||||
<Badge variant="secondary" className="text-[10px] opacity-60">
|
||||
{tema.horasEstimadas}h
|
||||
</Badge>
|
||||
<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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
interface DeleteDialogState {
|
||||
type: 'unidad' | 'tema';
|
||||
id: string;
|
||||
parentId?: string;
|
||||
type: 'unidad' | 'tema'
|
||||
id: string
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
interface DeleteConfirmDialogProps {
|
||||
dialog: DeleteDialogState | null;
|
||||
setDialog: (value: DeleteDialogState | null) => void;
|
||||
onConfirm: () => void;
|
||||
dialog: DeleteDialogState | null
|
||||
setDialog: (value: DeleteDialogState | null) => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
|
||||
function DeleteConfirmDialog({
|
||||
dialog,
|
||||
setDialog,
|
||||
onConfirm,
|
||||
}: DeleteConfirmDialogProps) {
|
||||
|
||||
return (
|
||||
<AlertDialog open={!!dialog} onOpenChange={() => setDialog(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>¿Confirmar eliminación?</AlertDialogTitle>
|
||||
<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>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<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>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { FileText, Download, 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 { useState } from 'react'
|
||||
import {
|
||||
FileText,
|
||||
Download,
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -13,63 +21,88 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import type { DocumentoMateria, Materia, MateriaStructure } from '@/types/materia';
|
||||
import { cn } from '@/lib/utils';
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import type {
|
||||
DocumentoMateria,
|
||||
Materia,
|
||||
MateriaStructure,
|
||||
} from '@/types/materia'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSubjectBibliografia } from '@/data/hooks/useSubjects'
|
||||
//import { toast } from 'sonner';
|
||||
//import { format } from 'date-fns';
|
||||
//import { es } from 'date-fns/locale';
|
||||
|
||||
interface DocumentoSEPTabProps {
|
||||
documento: DocumentoMateria | null;
|
||||
materia: Materia;
|
||||
estructura: MateriaStructure;
|
||||
datosGenerales: Record<string, any>;
|
||||
onRegenerate: () => void;
|
||||
isRegenerating: boolean;
|
||||
documento: DocumentoMateria | null
|
||||
materia: Materia
|
||||
estructura: MateriaStructure
|
||||
datosGenerales: Record<string, any>
|
||||
onRegenerate: () => void
|
||||
isRegenerating: boolean
|
||||
}
|
||||
|
||||
export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales, onRegenerate, isRegenerating }: DocumentoSEPTabProps) {
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
export function DocumentoSEPTab({
|
||||
documento,
|
||||
materia,
|
||||
estructura,
|
||||
datosGenerales,
|
||||
onRegenerate,
|
||||
isRegenerating,
|
||||
}: DocumentoSEPTabProps) {
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
|
||||
|
||||
// Check completeness
|
||||
const camposObligatorios = estructura.campos.filter(c => c.obligatorio);
|
||||
const camposCompletos = camposObligatorios.filter(c => datosGenerales[c.id]?.trim());
|
||||
const completeness = Math.round((camposCompletos.length / camposObligatorios.length) * 100);
|
||||
const isComplete = completeness === 100;
|
||||
const camposObligatorios = estructura.campos.filter((c) => c.obligatorio)
|
||||
const camposCompletos = camposObligatorios.filter((c) =>
|
||||
datosGenerales[c.id]?.trim(),
|
||||
)
|
||||
const completeness = Math.round(
|
||||
(camposCompletos.length / camposObligatorios.length) * 100,
|
||||
)
|
||||
const isComplete = completeness === 100
|
||||
|
||||
const handleRegenerate = () => {
|
||||
setShowConfirmDialog(false);
|
||||
onRegenerate();
|
||||
setShowConfirmDialog(false)
|
||||
onRegenerate()
|
||||
//toast.success('Regenerando documento...');
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<FileCheck className="w-6 h-6 text-accent" />
|
||||
<h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
|
||||
<FileCheck className="text-accent h-6 w-6" />
|
||||
Documento SEP
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{documento?.estado === 'listo' && (
|
||||
<Button variant="outline" onClick={() => console.log("descargando") /*toast.info('Descarga iniciada')*/}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={
|
||||
() =>
|
||||
console.log('descargando') /*toast.info('Descarga iniciada')*/
|
||||
}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Descargar
|
||||
</Button>
|
||||
)}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialog
|
||||
open={showConfirmDialog}
|
||||
onOpenChange={setShowConfirmDialog}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button disabled={isRegenerating || !isComplete}>
|
||||
{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'}
|
||||
</Button>
|
||||
@@ -78,8 +111,9 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>¿Regenerar documento SEP?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Se creará una nueva versión del documento con los datos actuales de la materia.
|
||||
La versión anterior quedará en el historial.
|
||||
Se creará una nueva versión del documento con los datos
|
||||
actuales de la materia. La versión anterior quedará en el
|
||||
historial.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -93,91 +127,108 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
|
||||
</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 */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="card-elevated h-[700px] overflow-hidden">
|
||||
{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 */}
|
||||
<div className="bg-card border-b p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
<span className="font-medium text-foreground">
|
||||
<FileText className="text-primary h-5 w-5" />
|
||||
<span className="text-foreground font-medium">
|
||||
Programa de Estudios - {materia.clave}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline">Versión {documento.version}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Document content simulation */}
|
||||
<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 */}
|
||||
<div className="text-center border-b pb-6">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">
|
||||
<div className="border-b pb-6 text-center">
|
||||
<p className="text-muted-foreground mb-2 text-xs tracking-wide uppercase">
|
||||
Secretaría de Educación Pública
|
||||
</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}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Clave: {materia.clave} | Créditos: {materia.creditos || 'N/A'}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Clave: {materia.clave} | Créditos:{' '}
|
||||
{materia.creditos || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Datos de la institución */}
|
||||
<div className="space-y-1 text-sm">
|
||||
<p><strong>Carrera:</strong> {materia.carrera}</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>}
|
||||
<p>
|
||||
<strong>Carrera:</strong> {materia.carrera}
|
||||
</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>
|
||||
|
||||
{/* Campos del documento */}
|
||||
{estructura.campos.map((campo) => {
|
||||
const valor = datosGenerales[campo.id];
|
||||
if (!valor) return null;
|
||||
const valor = datosGenerales[campo.id]
|
||||
if (!valor) return null
|
||||
return (
|
||||
<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}
|
||||
</h3>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
|
||||
<p className="text-foreground text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{valor}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t pt-6 mt-8 text-center text-xs text-muted-foreground">
|
||||
<p>Documento generado el {/*format(documento.fechaGeneracion, "d 'de' MMMM 'de' yyyy", { locale: es })*/}</p>
|
||||
<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 className="mt-1">Universidad La Salle</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : 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">
|
||||
<Loader2 className="w-12 h-12 mx-auto text-accent animate-spin mb-4" />
|
||||
<p className="text-muted-foreground">Generando documento...</p>
|
||||
<Loader2 className="text-accent mx-auto mb-4 h-12 w-12 animate-spin" />
|
||||
<p className="text-muted-foreground">
|
||||
Generando documento...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center max-w-sm">
|
||||
<FileText className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="max-w-sm text-center">
|
||||
<FileText className="text-muted-foreground/50 mx-auto mb-4 h-12 w-12" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No hay documento generado aún
|
||||
</p>
|
||||
{!isComplete && (
|
||||
<div className="p-4 bg-warning/10 rounded-lg text-sm text-warning-foreground">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-2" />
|
||||
Completa todos los campos obligatorios para generar el documento
|
||||
<div className="bg-warning/10 text-warning-foreground rounded-lg p-4 text-sm">
|
||||
<AlertTriangle className="mr-2 inline h-4 w-4" />
|
||||
Completa todos los campos obligatorios para generar el
|
||||
documento
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -191,28 +242,41 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
|
||||
{/* Status */}
|
||||
<Card className="card-elevated">
|
||||
<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>
|
||||
<CardContent className="space-y-4">
|
||||
{documento && (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
{/*format(documento.fechaGeneracion, "d MMM yyyy, HH:mm", { locale: es })*/}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Estado</span>
|
||||
<Badge 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"
|
||||
)}>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Estado
|
||||
</span>
|
||||
<Badge
|
||||
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 === 'generando' && 'Generando'}
|
||||
{documento.estado === 'error' && 'Error'}
|
||||
@@ -226,44 +290,60 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
|
||||
{/* Completeness */}
|
||||
<Card className="card-elevated">
|
||||
<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>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Campos obligatorios</span>
|
||||
<span className="font-medium">{camposCompletos.length}/{camposObligatorios.length}</span>
|
||||
<span className="text-muted-foreground">
|
||||
Campos obligatorios
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{camposCompletos.length}/{camposObligatorios.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
<div className="bg-muted h-2 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full transition-all duration-500",
|
||||
completeness === 100 ? "bg-success" : "bg-accent"
|
||||
'h-full transition-all duration-500',
|
||||
completeness === 100 ? 'bg-success' : 'bg-accent',
|
||||
)}
|
||||
style={{ width: `${completeness}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className={cn(
|
||||
"text-xs",
|
||||
completeness === 100 ? "text-success" : "text-muted-foreground"
|
||||
)}>
|
||||
{completeness === 100
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs',
|
||||
completeness === 100
|
||||
? 'text-success'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{completeness === 100
|
||||
? 'Todos los campos obligatorios están completos'
|
||||
: `Faltan ${camposObligatorios.length - camposCompletos.length} campos por completar`
|
||||
}
|
||||
: `Faltan ${camposObligatorios.length - camposCompletos.length} campos por completar`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Missing fields */}
|
||||
{!isComplete && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Campos faltantes:</p>
|
||||
{camposObligatorios.filter(c => !datosGenerales[c.id]?.trim()).map((campo) => (
|
||||
<div key={campo.id} className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="w-3 h-3 text-warning" />
|
||||
<span className="text-foreground">{campo.nombre}</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
Campos faltantes:
|
||||
</p>
|
||||
{camposObligatorios
|
||||
.filter((c) => !datosGenerales[c.id]?.trim())
|
||||
.map((campo) => (
|
||||
<div
|
||||
key={campo.id}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<AlertTriangle className="text-warning h-3 w-3" />
|
||||
<span className="text-foreground">{campo.nombre}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -272,36 +352,62 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
|
||||
{/* Requirements */}
|
||||
<Card className="card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Requisitos SEP</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Requisitos SEP
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-start gap-2">
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded-full flex items-center justify-center mt-0.5",
|
||||
datosGenerales['objetivo_general'] ? "bg-success/20" : "bg-muted"
|
||||
)}>
|
||||
{datosGenerales['objetivo_general'] && <Check className="w-3 h-3 text-success" />}
|
||||
<div
|
||||
className={cn(
|
||||
'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
|
||||
datosGenerales['objetivo_general']
|
||||
? 'bg-success/20'
|
||||
: 'bg-muted',
|
||||
)}
|
||||
>
|
||||
{datosGenerales['objetivo_general'] && (
|
||||
<Check className="text-success h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">Objetivo general definido</span>
|
||||
<span className="text-muted-foreground">
|
||||
Objetivo general definido
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded-full flex items-center justify-center mt-0.5",
|
||||
datosGenerales['competencias'] ? "bg-success/20" : "bg-muted"
|
||||
)}>
|
||||
{datosGenerales['competencias'] && <Check className="w-3 h-3 text-success" />}
|
||||
<div
|
||||
className={cn(
|
||||
'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
|
||||
datosGenerales['competencias']
|
||||
? 'bg-success/20'
|
||||
: 'bg-muted',
|
||||
)}
|
||||
>
|
||||
{datosGenerales['competencias'] && (
|
||||
<Check className="text-success h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">Competencias especificadas</span>
|
||||
<span className="text-muted-foreground">
|
||||
Competencias especificadas
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded-full flex items-center justify-center mt-0.5",
|
||||
datosGenerales['evaluacion'] ? "bg-success/20" : "bg-muted"
|
||||
)}>
|
||||
{datosGenerales['evaluacion'] && <Check className="w-3 h-3 text-success" />}
|
||||
<div
|
||||
className={cn(
|
||||
'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
|
||||
datosGenerales['evaluacion']
|
||||
? 'bg-success/20'
|
||||
: 'bg-muted',
|
||||
)}
|
||||
>
|
||||
{datosGenerales['evaluacion'] && (
|
||||
<Check className="text-success h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">Criterios de evaluación</span>
|
||||
<span className="text-muted-foreground">
|
||||
Criterios de evaluación
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
@@ -309,13 +415,19 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function Check({ className }: { className?: string }) {
|
||||
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" />
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,196 +1,354 @@
|
||||
import { useState } from 'react';
|
||||
import { History, FileText, List, BookMarked, Sparkles, FileCheck, User, Filter, Calendar } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
History,
|
||||
FileText,
|
||||
List,
|
||||
BookMarked,
|
||||
Sparkles,
|
||||
FileCheck,
|
||||
User,
|
||||
Filter,
|
||||
Calendar,
|
||||
Loader2,
|
||||
Eye,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { CambioMateria } from '@/types/materia';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import { useSubjectHistorial } from '@/data/hooks/useSubjects'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
interface HistorialTabProps {
|
||||
historial: CambioMateria[];
|
||||
}
|
||||
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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
}
|
||||
|
||||
const tipoConfig: Record<string, { label: string; icon: React.ComponentType<{ className?: string }>; color: string }> = {
|
||||
datos: { label: 'Datos generales', icon: FileText, color: 'text-info' },
|
||||
contenido: { label: 'Contenido temático', icon: List, color: 'text-accent' },
|
||||
bibliografia: { label: 'Bibliografía', icon: BookMarked, color: 'text-success' },
|
||||
ia: { label: 'IA', icon: Sparkles, color: 'text-amber-500' },
|
||||
documento: { label: 'Documento SEP', icon: FileCheck, color: 'text-primary' },
|
||||
};
|
||||
export function HistorialTab() {
|
||||
// 1. Obtenemos los datos directamente dentro del componente
|
||||
const { data: rawData, isLoading } = useSubjectHistorial(
|
||||
'9d4dda6a-488f-428a-8a07-38081592a641',
|
||||
)
|
||||
|
||||
export function HistorialTab({ historial }: HistorialTabProps) {
|
||||
const [filtros, setFiltros] = useState<Set<string>>(new Set(['datos', 'contenido', 'bibliografia', 'ia', 'documento']));
|
||||
const [filtros, setFiltros] = useState<Set<string>>(
|
||||
new Set(['datos', 'contenido', 'bibliografia', 'ia', 'documento']),
|
||||
)
|
||||
|
||||
// ESTADOS PARA EL MODAL
|
||||
const [selectedChange, setSelectedChange] = useState<any>(null)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const RenderValue = ({ value }: { value: any }) => {
|
||||
// 1. Caso: Nulo o vacío
|
||||
if (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
value === 'Sin información previa'
|
||||
) {
|
||||
return (
|
||||
<span className="text-muted-foreground italic">Sin información</span>
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Caso: Es un ARRAY (como tu lista de unidades)
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{value.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-white/50 p-3 shadow-sm"
|
||||
>
|
||||
<RenderValue value={item} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Caso: Es un OBJETO (como cada unidad con titulo, temas, etc.)
|
||||
if (typeof value === 'object') {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{Object.entries(value).map(([key, val]) => (
|
||||
<div key={key} className="flex flex-col">
|
||||
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<div className="text-sm text-slate-700">
|
||||
{/* Llamada recursiva para manejar lo que haya dentro del valor */}
|
||||
{typeof val === 'object' ? (
|
||||
<div className="mt-1 border-l-2 border-slate-100 pl-2">
|
||||
<RenderValue value={val} />
|
||||
</div>
|
||||
) : (
|
||||
String(val)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Caso: Texto o número simple
|
||||
return <span className="text-sm leading-relaxed">{String(value)}</span>
|
||||
}
|
||||
|
||||
const historialTransformado = useMemo(() => {
|
||||
if (!rawData) return []
|
||||
return rawData.map((item: any) => ({
|
||||
id: item.id,
|
||||
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_anterior: item.valor_anterior || 'Sin datos previos', // Asumiendo que existe en tu API
|
||||
valor_nuevo: item.valor_nuevo,
|
||||
},
|
||||
}))
|
||||
}, [rawData])
|
||||
|
||||
const openCompareModal = (cambio: any) => {
|
||||
setSelectedChange(cambio)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const toggleFiltro = (tipo: string) => {
|
||||
const newFiltros = new Set(filtros);
|
||||
if (newFiltros.has(tipo)) {
|
||||
newFiltros.delete(tipo);
|
||||
} else {
|
||||
newFiltros.add(tipo);
|
||||
}
|
||||
setFiltros(newFiltros);
|
||||
};
|
||||
const newFiltros = new Set(filtros)
|
||||
if (newFiltros.has(tipo)) newFiltros.delete(tipo)
|
||||
else 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((groups, cambio) => {
|
||||
const dateKey = format(cambio.fecha, 'yyyy-MM-dd');
|
||||
if (!groups[dateKey]) {
|
||||
groups[dateKey] = [];
|
||||
}
|
||||
groups[dateKey].push(cambio);
|
||||
return groups;
|
||||
}, {} as Record<string, CambioMateria[]>);
|
||||
const groupedHistorial = filteredHistorial.reduce(
|
||||
(groups, cambio) => {
|
||||
const dateKey = format(cambio.fecha, 'yyyy-MM-dd')
|
||||
if (!groups[dateKey]) groups[dateKey] = []
|
||||
groups[dateKey].push(cambio)
|
||||
return groups
|
||||
},
|
||||
{} as Record<string, any[]>,
|
||||
)
|
||||
|
||||
const sortedDates = Object.keys(groupedHistorial).sort((a, b) => b.localeCompare(a));
|
||||
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 (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<History className="w-6 h-6 text-accent" />
|
||||
<h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
|
||||
<History className="text-accent h-6 w-6" />
|
||||
Historial de cambios
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{historial.length} cambios registrados
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{historialTransformado.length} cambios registrados
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dropdown de Filtros (Igual al anterior) */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Filtrar ({filtros.size})
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{Object.entries(tipoConfig).map(([tipo, config]) => {
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={tipo}
|
||||
checked={filtros.has(tipo)}
|
||||
onCheckedChange={() => toggleFiltro(tipo)}
|
||||
>
|
||||
<Icon className={cn("w-4 h-4 mr-2", config.color)} />
|
||||
{config.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
{Object.entries(tipoConfig).map(([tipo, config]) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={tipo}
|
||||
checked={filtros.has(tipo)}
|
||||
onCheckedChange={() => toggleFiltro(tipo)}
|
||||
>
|
||||
<config.icon className={cn('mr-2 h-4 w-4', config.color)} />
|
||||
{config.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{filteredHistorial.length === 0 ? (
|
||||
<Card className="card-elevated">
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<History className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{historial.length === 0
|
||||
? 'No hay cambios registrados aún'
|
||||
: 'No hay cambios con los filtros seleccionados'
|
||||
}
|
||||
</p>
|
||||
<History className="text-muted-foreground/50 mx-auto mb-4 h-12 w-12" />
|
||||
<p className="text-muted-foreground">No se encontraron cambios.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{sortedDates.map((dateKey) => {
|
||||
const cambios = groupedHistorial[dateKey];
|
||||
const date = new Date(dateKey);
|
||||
const isToday = format(new Date(), 'yyyy-MM-dd') === dateKey;
|
||||
const isYesterday = format(new Date(Date.now() - 86400000), 'yyyy-MM-dd') === dateKey;
|
||||
{sortedDates.map((dateKey) => (
|
||||
<div key={dateKey}>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<Calendar className="text-muted-foreground h-4 w-4" />
|
||||
<h3 className="text-foreground font-semibold">
|
||||
{format(parseISO(dateKey), "EEEE, d 'de' MMMM", {
|
||||
locale: es,
|
||||
})}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div key={dateKey}>
|
||||
{/* Date header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-muted">
|
||||
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{isToday ? 'Hoy' : isYesterday ? 'Ayer' : format(date, "EEEE, d 'de' MMMM", { locale: es })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{cambios.length} {cambios.length === 1 ? 'cambio' : 'cambios'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="ml-4 border-l-2 border-border pl-6 space-y-4">
|
||||
{cambios.map((cambio) => {
|
||||
const config = tipoConfig[cambio.tipo];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<div key={cambio.id} className="relative">
|
||||
{/* Timeline dot */}
|
||||
<div className={cn(
|
||||
"absolute -left-[31px] w-4 h-4 rounded-full border-2 border-background",
|
||||
`bg-current ${config.color}`
|
||||
)} />
|
||||
|
||||
<Card className="card-interactive">
|
||||
<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 className="border-border ml-4 space-y-4 border-l-2 pl-6">
|
||||
{groupedHistorial[dateKey].map((cambio) => {
|
||||
const config = tipoConfig[cambio.tipo] || tipoConfig.datos
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<div key={cambio.id} className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
'border-background absolute -left-[31px] h-4 w-4 rounded-full border-2',
|
||||
`bg-current ${config.color}`,
|
||||
)}
|
||||
/>
|
||||
<Card className="card-interactive">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
'bg-muted rounded-lg p-2',
|
||||
config.color,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between">
|
||||
<p className="font-medium">
|
||||
{cambio.descripcion}
|
||||
</p>
|
||||
{/* BOTÓN PARA VER CAMBIOS */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2 text-blue-600 hover:bg-blue-50 hover:text-blue-700"
|
||||
onClick={() => openCompareModal(cambio)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Ver cambios
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{format(cambio.fecha, 'HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{cambio.descripcion}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.label}
|
||||
</Badge>
|
||||
{cambio.detalles?.campo && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Campo: {cambio.detalles.campo}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{format(cambio.fecha, 'HH:mm')}
|
||||
</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 className="mt-2 flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs italic">
|
||||
por {cambio.usuario}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* MODAL DE COMPARACIÓN */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col overflow-hidden">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||
<History className="h-5 w-5 text-blue-500" />
|
||||
Comparación de cambios
|
||||
</DialogTitle>
|
||||
{/* ... info de usuario y fecha */}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="custom-scrollbar mt-4 flex-1 overflow-y-auto pr-2">
|
||||
<div className="grid h-full grid-cols-2 gap-6">
|
||||
{/* Lado Antes */}
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white pb-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-400" />
|
||||
<span className="text-xs font-bold text-slate-500 uppercase">
|
||||
Versión Anterior
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 rounded-xl border border-red-100 bg-red-50/30 p-4">
|
||||
<RenderValue
|
||||
value={selectedChange?.detalles.valor_anterior}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lado Después */}
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white pb-2">
|
||||
<div className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
<span className="text-xs font-bold text-slate-500 uppercase">
|
||||
Nueva Versión
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 rounded-xl border border-emerald-100 bg-emerald-50/40 p-4">
|
||||
<RenderValue value={selectedChange?.detalles.valor_nuevo} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-shrink-0 items-center justify-center gap-2 rounded-lg border border-slate-100 bg-slate-50 p-3 text-xs text-slate-500">
|
||||
Campo modificado:{' '}
|
||||
<Badge variant="secondary">{selectedChange?.detalles.campo}</Badge>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,161 +1,260 @@
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { ArrowLeft, GraduationCap, Pencil, Sparkles } from 'lucide-react'
|
||||
import { useCallback, useState, useEffect } from 'react'
|
||||
|
||||
import { BibliographyItem } from './BibliographyItem'
|
||||
import { ContenidoTematico } from './ContenidoTematico'
|
||||
import { DocumentoSEPTab } from './DocumentoSEPTab'
|
||||
import { HistorialTab } from './HistorialTab'
|
||||
import { IAMateriaTab } from './IAMateriaTab'
|
||||
|
||||
import type { CampoEstructura, IAMessage, IASugerencia } from '@/types/materia'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
ArrowLeft,
|
||||
GraduationCap,
|
||||
Edit2, Save,
|
||||
Pencil
|
||||
} from 'lucide-react'
|
||||
import { ContenidoTematico } from './ContenidoTematico'
|
||||
import { BibliographyItem } from './BibliographyItem'
|
||||
import { IAMateriaTab } from './IAMateriaTab'
|
||||
import type {
|
||||
CampoEstructura,
|
||||
IAMessage,
|
||||
IASugerencia,
|
||||
UnidadTematica,
|
||||
} from '@/types/materia';
|
||||
import { useSubject } from '@/data/hooks/useSubjects'
|
||||
import {
|
||||
mockMateria,
|
||||
mockEstructura,
|
||||
mockDocumentoSep,
|
||||
mockHistorial
|
||||
} from '@/data/mockMateriaData';
|
||||
import { DocumentoSEPTab } from './DocumentoSEPTab'
|
||||
import { HistorialTab } from './HistorialTab'
|
||||
} from '@/data/mockMateriaData'
|
||||
|
||||
export interface BibliografiaEntry {
|
||||
id: string;
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA';
|
||||
cita: string;
|
||||
fuenteBibliotecaId?: string;
|
||||
fuenteBiblioteca?: any;
|
||||
id: string
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA'
|
||||
cita: string
|
||||
fuenteBibliotecaId?: string
|
||||
fuenteBiblioteca?: any
|
||||
}
|
||||
export interface BibliografiaTabProps {
|
||||
bibliografia: BibliografiaEntry[];
|
||||
onSave: (bibliografia: BibliografiaEntry[]) => void;
|
||||
isSaving: boolean;
|
||||
bibliografia: Array<BibliografiaEntry>
|
||||
onSave: (bibliografia: Array<BibliografiaEntry>) => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export interface AsignaturaDatos {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
export interface AsignaturaResponse {
|
||||
datos: AsignaturaDatos
|
||||
}
|
||||
|
||||
function EditableHeaderField({
|
||||
value,
|
||||
onSave,
|
||||
className,
|
||||
}: {
|
||||
value: string | number
|
||||
onSave: (val: string) => void
|
||||
className?: string
|
||||
}) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
;(e.currentTarget as HTMLElement).blur() // Quita el foco
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLElement>) => {
|
||||
const newValue = e.currentTarget.textContent || ''
|
||||
if (newValue !== value.toString()) {
|
||||
onSave(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className={`cursor-text rounded px-1 transition-all outline-none focus:ring-2 focus:ring-blue-400 ${className}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
export default function MateriaDetailPage() {
|
||||
|
||||
const routerState = useRouterState()
|
||||
const state = routerState.location.state as any
|
||||
const { data: asignaturasApi, isLoading: loadingAsig } = useSubject(
|
||||
state?.realId,
|
||||
)
|
||||
// 1. Asegúrate de tener estos estados en tu componente principal
|
||||
const [messages, setMessages] = useState<IAMessage[]>([]);
|
||||
const [datosGenerales, setDatosGenerales] = useState({});
|
||||
const [campos, setCampos] = useState<CampoEstructura[]>([]);
|
||||
const [messages, setMessages] = useState<Array<IAMessage>>([])
|
||||
const [datosGenerales, setDatosGenerales] = useState({})
|
||||
const [campos, setCampos] = useState<Array<CampoEstructura>>([])
|
||||
|
||||
// 2. Funciones de manejo para la IA
|
||||
const handleSendMessage = (text: string, campoId?: string) => {
|
||||
const newMessage: IAMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date(),
|
||||
campoAfectado: campoId
|
||||
};
|
||||
setMessages([...messages, newMessage]);
|
||||
|
||||
// Aquí llamarías a tu API de OpenAI/Claude
|
||||
//toast.info("Enviando consulta a la IA...");
|
||||
};
|
||||
// Dentro de MateriaDetailPage
|
||||
const [headerData, setHeaderData] = useState({
|
||||
codigo: '',
|
||||
nombre: '',
|
||||
creditos: 0,
|
||||
ciclo: 0,
|
||||
})
|
||||
|
||||
const handleAcceptSuggestion = (sugerencia: IASugerencia) => {
|
||||
// Lógica para actualizar el valor del campo en tu estado de datosGenerales
|
||||
//toast.success(`Sugerencia aplicada a ${sugerencia.campoNombre}`);
|
||||
};
|
||||
// Sincronizar cuando llegue la API
|
||||
useEffect(() => {
|
||||
if (asignaturasApi) {
|
||||
setHeaderData({
|
||||
codigo: asignaturasApi?.codigo ?? '',
|
||||
nombre: asignaturasApi?.nombre ?? '',
|
||||
creditos: asignaturasApi?.creditos ?? '',
|
||||
ciclo: asignaturasApi?.numero_ciclo ?? 0,
|
||||
})
|
||||
}
|
||||
}, [asignaturasApi])
|
||||
|
||||
const handleUpdateHeader = (key: string, value: string | number) => {
|
||||
const newData = { ...headerData, [key]: value }
|
||||
setHeaderData(newData)
|
||||
console.log('💾 Guardando en estado y base de datos:', key, value)
|
||||
}
|
||||
/* ---------- sincronizar API ---------- */
|
||||
useEffect(() => {
|
||||
if (asignaturasApi?.datos) {
|
||||
setDatosGenerales(asignaturasApi.datos)
|
||||
}
|
||||
}, [asignaturasApi])
|
||||
|
||||
// 2. Funciones de manejo para la IA
|
||||
const handleSendMessage = (text: string, campoId?: string) => {
|
||||
const newMessage: IAMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date(),
|
||||
campoAfectado: campoId,
|
||||
}
|
||||
setMessages([...messages, newMessage])
|
||||
|
||||
// Aquí llamarías a tu API de OpenAI/Claude
|
||||
// toast.info("Enviando consulta a la IA...");
|
||||
}
|
||||
|
||||
const handleAcceptSuggestion = (sugerencia: IASugerencia) => {
|
||||
// Lógica para actualizar el valor del campo en tu estado de datosGenerales
|
||||
// toast.success(`Sugerencia aplicada a ${sugerencia.campoNombre}`);
|
||||
}
|
||||
|
||||
// Dentro de tu componente principal (donde están los Tabs)
|
||||
const [bibliografia, setBibliografia] = useState<BibliografiaEntry[]>([
|
||||
{
|
||||
id: '1',
|
||||
tipo: 'BASICA',
|
||||
cita: 'Russell, S., & Norvig, P. (2020). Artificial Intelligence: A Modern Approach. Pearson.'
|
||||
}
|
||||
]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [bibliografia, setBibliografia] = useState<Array<BibliografiaEntry>>([
|
||||
{
|
||||
id: '1',
|
||||
tipo: 'BASICA',
|
||||
cita: 'Russell, S., & Norvig, P. (2020). Artificial Intelligence: A Modern Approach. Pearson.',
|
||||
},
|
||||
])
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const handleSaveBibliografia = (data: BibliografiaEntry[]) => {
|
||||
setIsSaving(true);
|
||||
// Aquí iría tu llamada a la API
|
||||
setBibliografia(data);
|
||||
|
||||
// Simulamos un guardado
|
||||
setTimeout(() => {
|
||||
setIsSaving(false);
|
||||
//toast.success("Cambios guardados");
|
||||
}, 1000);
|
||||
};
|
||||
const handleSaveBibliografia = (data: Array<BibliografiaEntry>) => {
|
||||
setIsSaving(true)
|
||||
// Aquí iría tu llamada a la API
|
||||
setBibliografia(data)
|
||||
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
|
||||
const handleRegenerateDocument = useCallback(() => {
|
||||
setIsRegenerating(true);
|
||||
// Simulamos un guardado
|
||||
setTimeout(() => {
|
||||
setIsRegenerating(false);
|
||||
}, 2000);
|
||||
}, []);
|
||||
setIsSaving(false)
|
||||
// toast.success("Cambios guardados");
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const [isRegenerating, setIsRegenerating] = useState(false)
|
||||
|
||||
const handleRegenerateDocument = useCallback(() => {
|
||||
setIsRegenerating(true)
|
||||
setTimeout(() => {
|
||||
setIsRegenerating(false)
|
||||
}, 2000)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* ================= HEADER ================= */}
|
||||
{/* ================= HEADER ACTUALIZADO ================= */}
|
||||
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
|
||||
<div className="max-w-7xl mx-auto px-6 py-10">
|
||||
<div className="mx-auto max-w-7xl px-6 py-10">
|
||||
<Link
|
||||
to="/planes"
|
||||
className="flex items-center gap-2 text-sm text-blue-200 hover:text-white mb-4"
|
||||
className="mb-4 flex items-center gap-2 text-sm text-blue-200 hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Volver al plan
|
||||
<ArrowLeft className="h-4 w-4" /> Volver al plan
|
||||
</Link>
|
||||
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="space-y-3">
|
||||
<Badge className="bg-blue-900/50 border border-blue-700">
|
||||
IA-401
|
||||
{/* CÓDIGO EDITABLE */}
|
||||
<Badge className="border border-blue-700 bg-blue-900/50">
|
||||
<EditableHeaderField
|
||||
value={headerData.codigo}
|
||||
onSave={(val) => handleUpdateHeader('codigo', val)}
|
||||
/>
|
||||
</Badge>
|
||||
|
||||
{/* NOMBRE EDITABLE */}
|
||||
<h1 className="text-3xl font-bold">
|
||||
Inteligencia Artificial Aplicada
|
||||
<EditableHeaderField
|
||||
value={headerData.nombre}
|
||||
onSave={(val) => handleUpdateHeader('nombre', val)}
|
||||
/>
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
|
||||
<span className="flex items-center gap-1">
|
||||
<GraduationCap className="w-4 h-4" />
|
||||
Ingeniería en Sistemas Computacionales
|
||||
<GraduationCap className="h-4 w-4" />
|
||||
{asignaturasApi?.planes_estudio?.datos?.nombre}
|
||||
</span>
|
||||
<span>
|
||||
{asignaturasApi?.planes_estudio?.carreras?.facultades?.nombre}
|
||||
</span>
|
||||
|
||||
<span>Facultad de Ingeniería</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-blue-300">
|
||||
Pertenece al plan:{' '}
|
||||
<span className="underline cursor-pointer">
|
||||
Licenciatura en Ingeniería en Sistemas Computacionales 2024
|
||||
<span className="cursor-pointer underline">
|
||||
{asignaturasApi?.planes_estudio?.nombre}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 items-end">
|
||||
<Badge variant="secondary">8 créditos</Badge>
|
||||
<Badge variant="secondary">7° semestre</Badge>
|
||||
<Badge variant="secondary">Sistemas Inteligentes</Badge>
|
||||
<div className="flex flex-col items-end gap-2 text-right">
|
||||
{/* CRÉDITOS EDITABLES */}
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<EditableHeaderField
|
||||
value={headerData.creditos}
|
||||
onSave={(val) =>
|
||||
handleUpdateHeader('creditos', parseInt(val) || 0)
|
||||
}
|
||||
/>
|
||||
<span>créditos</span>
|
||||
</Badge>
|
||||
|
||||
{/* SEMESTRE EDITABLE */}
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<EditableHeaderField
|
||||
value={headerData.ciclo}
|
||||
onSave={(val) =>
|
||||
handleUpdateHeader('ciclo', parseInt(val) || 0)
|
||||
}
|
||||
/>
|
||||
<span>° ciclo</span>
|
||||
</Badge>
|
||||
|
||||
<Badge variant="secondary">{asignaturasApi?.tipo}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ================= TABS ================= */}
|
||||
<section className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<section className="border-b bg-white">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<Tabs defaultValue="datos">
|
||||
<TabsList className="h-auto bg-transparent p-0 gap-6">
|
||||
<TabsList className="h-auto gap-6 bg-transparent p-0">
|
||||
<TabsTrigger value="datos">Datos generales</TabsTrigger>
|
||||
<TabsTrigger value="contenido">Contenido temático</TabsTrigger>
|
||||
<TabsTrigger value="bibliografia">Bibliografía</TabsTrigger>
|
||||
@@ -168,30 +267,38 @@ const handleRegenerateDocument = useCallback(() => {
|
||||
|
||||
{/* ================= TAB: DATOS GENERALES ================= */}
|
||||
<TabsContent value="datos">
|
||||
<DatosGenerales />
|
||||
<DatosGenerales data={datosGenerales} isLoading={loadingAsig} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="contenido">
|
||||
<ContenidoTematico></ContenidoTematico>
|
||||
<ContenidoTematico
|
||||
data={asignaturasApi}
|
||||
isLoading={loadingAsig}
|
||||
></ContenidoTematico>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bibliografia">
|
||||
<BibliographyItem
|
||||
bibliografia={bibliografia}
|
||||
onSave={handleSaveBibliografia}
|
||||
isSaving={isSaving}
|
||||
<BibliographyItem
|
||||
bibliografia={bibliografia}
|
||||
onSave={handleSaveBibliografia}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ia">
|
||||
<IAMateriaTab
|
||||
campos={campos}
|
||||
datosGenerales={datosGenerales}
|
||||
messages={messages}
|
||||
onSendMessage={handleSendMessage}
|
||||
onAcceptSuggestion={handleAcceptSuggestion}
|
||||
onRejectSuggestion={(id) => console.log("Rechazada") /*toast.error("Sugerencia rechazada")*/}
|
||||
/>
|
||||
<IAMateriaTab
|
||||
campos={campos}
|
||||
datosGenerales={datosGenerales}
|
||||
messages={messages}
|
||||
onSendMessage={handleSendMessage}
|
||||
onAcceptSuggestion={handleAcceptSuggestion}
|
||||
onRejectSuggestion={
|
||||
(id) =>
|
||||
console.log(
|
||||
'Rechazada',
|
||||
) /* toast.error("Sugerencia rechazada")*/
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sep">
|
||||
@@ -206,7 +313,7 @@ const handleRegenerateDocument = useCallback(() => {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="historial">
|
||||
<HistorialTab historial={mockHistorial} />
|
||||
<HistorialTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -216,81 +323,81 @@ const handleRegenerateDocument = useCallback(() => {
|
||||
}
|
||||
|
||||
/* ================= TAB CONTENT ================= */
|
||||
interface DatosGeneralesProps {
|
||||
data: AsignaturaDatos
|
||||
isLoading: boolean
|
||||
}
|
||||
function DatosGenerales({ data, isLoading }: DatosGeneralesProps) {
|
||||
const formatTitle = (key: string): string =>
|
||||
key.replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase())
|
||||
|
||||
function DatosGenerales() {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-8 px-4 space-y-8 animate-in fade-in duration-500">
|
||||
|
||||
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
|
||||
{/* Encabezado de la Sección */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b pb-6">
|
||||
<div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">Datos Generales</h2>
|
||||
<p className="text-slate-500 mt-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||
Datos Generales
|
||||
</h2>
|
||||
<p className="mt-1 text-slate-500">
|
||||
Información oficial estructurada bajo los lineamientos de la SEP.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Edit2 className="w-4 h-4" /> Editar borrador
|
||||
</Button>
|
||||
<Button size="sm" className="gap-2 bg-blue-600 hover:bg-blue-700">
|
||||
<Save className="w-4 h-4" /> Guardar cambios
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid de Información */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{/* Columna Principal (Más ancha) */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<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
|
||||
title="Objetivo General"
|
||||
initialContent="Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<InfoCard
|
||||
title="Justificación"
|
||||
initialContent="La inteligencia artificial es una de las tecnologías más disruptivas..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
{isLoading && <p>Cargando información...</p>}
|
||||
|
||||
{!isLoading &&
|
||||
Object.entries(data).map(([key, value]) => (
|
||||
<InfoCard
|
||||
key={key}
|
||||
title={formatTitle(key)}
|
||||
initialContent={value}
|
||||
onEnhanceAI={(contenido) => {
|
||||
console.log('Llevar a IA:', contenido)
|
||||
// Aquí tu lógica: setPestañaActiva('mejorar-con-ia');
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Columna Lateral (Información Secundaria) */}
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
{/* Tarjeta de Requisitos */}
|
||||
<InfoCard
|
||||
title="Requisitos y Seriación"
|
||||
type="requirements"
|
||||
initialContent={[
|
||||
{ type: "Pre-requisito", code: "PA-301", name: "Programación Avanzada" },
|
||||
{ type: "Co-requisito", code: "MAT-201", name: "Matemáticas Discretas" }
|
||||
]}
|
||||
/>
|
||||
{/* Tarjeta de Requisitos */}
|
||||
<InfoCard
|
||||
title="Requisitos y Seriación"
|
||||
type="requirements"
|
||||
initialContent={[
|
||||
{
|
||||
type: 'Pre-requisito',
|
||||
code: 'PA-301',
|
||||
name: 'Programación Avanzada',
|
||||
},
|
||||
{
|
||||
type: 'Co-requisito',
|
||||
code: 'MAT-201',
|
||||
name: 'Matemáticas Discretas',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Tarjeta de Evaluación */}
|
||||
<InfoCard
|
||||
title="Sistema de Evaluación"
|
||||
type="evaluation"
|
||||
initialContent={[
|
||||
{ label: "Exámenes parciales", value: "30%" },
|
||||
{ label: "Proyecto integrador", value: "35%" },
|
||||
{ label: "Prácticas de laboratorio", value: "20%" },
|
||||
{ label: "Participación", value: "15%" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{/* Tarjeta de Evaluación */}
|
||||
<InfoCard
|
||||
title="Sistema de Evaluación"
|
||||
type="evaluation"
|
||||
initialContent={[
|
||||
{ label: 'Exámenes parciales', value: '30%' },
|
||||
{ label: 'Proyecto integrador', value: '35%' },
|
||||
{ label: 'Prácticas de laboratorio', value: '20%' },
|
||||
{ label: 'Participación', value: '15%' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,51 +405,85 @@ function DatosGenerales() {
|
||||
}
|
||||
|
||||
interface InfoCardProps {
|
||||
title: string,
|
||||
subtitle?: string
|
||||
isList?:boolean
|
||||
initialContent: any // Puede ser string o array de objetos
|
||||
type?: 'text' | 'list' | 'requirements' | 'evaluation'
|
||||
title: string
|
||||
initialContent: any
|
||||
type?: 'text' | 'requirements' | 'evaluation'
|
||||
onEnhanceAI?: (content: any) => void // Nueva prop para la acción de IA
|
||||
}
|
||||
|
||||
function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) {
|
||||
function InfoCard({
|
||||
title,
|
||||
initialContent,
|
||||
type = 'text',
|
||||
onEnhanceAI,
|
||||
}: InfoCardProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [data, setData] = useState(initialContent)
|
||||
// Estado temporal para el área de texto (siempre editamos como texto por simplicidad)
|
||||
const [tempText, setTempText] = useState(
|
||||
type === 'text' || type === 'list'
|
||||
? initialContent
|
||||
: JSON.stringify(initialContent, null, 2) // O un formato legible
|
||||
type === 'text' ? initialContent : JSON.stringify(initialContent, null, 2),
|
||||
)
|
||||
|
||||
const handleSave = () => {
|
||||
// Aquí podrías parsear el texto de vuelta si es necesario
|
||||
setData(tempText)
|
||||
setData(tempText)
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="transition-all hover:border-slate-300">
|
||||
<CardHeader className="pb-3 flex flex-row items-start justify-between space-y-0">
|
||||
<CardTitle className="text-sm font-bold text-slate-700">{title}</CardTitle>
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-bold text-slate-700">
|
||||
{title}
|
||||
</CardTitle>
|
||||
|
||||
{!isEditing && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={() => setIsEditing(true)}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<div className="flex gap-1">
|
||||
{/* NUEVO: Botón de Mejorar con IA */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-blue-500 hover:bg-blue-50 hover:text-blue-600"
|
||||
onClick={() => onEnhanceAI?.(data)} // Enviamos la data actual a la IA
|
||||
title="Mejorar con IA"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Botón de Editar original */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={tempText}
|
||||
<Textarea
|
||||
value={tempText}
|
||||
onChange={(e) => setTempText(e.target.value)}
|
||||
className="text-xs min-h-[100px]"
|
||||
className="min-h-[100px] text-xs"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)}>Cancelar</Button>
|
||||
<Button size="sm" className="bg-[#00a878]" onClick={handleSave}>Guardar</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#00a878] hover:bg-[#008f66]"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -358,13 +499,20 @@ function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) {
|
||||
}
|
||||
|
||||
// Vista de Requisitos
|
||||
function RequirementsView({ items }: { items: any[] }) {
|
||||
function RequirementsView({ items }: { items: Array<any> }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map((req, i) => (
|
||||
<div key={i} className="p-3 bg-slate-50 rounded-lg border border-slate-100">
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-tight">{req.type}</p>
|
||||
<p className="text-sm font-medium text-slate-700">{req.code} {req.name}</p>
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border border-slate-100 bg-slate-50 p-3"
|
||||
>
|
||||
<p className="text-[10px] font-bold tracking-tight text-slate-400 uppercase">
|
||||
{req.type}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
{req.code} {req.name}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -372,11 +520,14 @@ function RequirementsView({ items }: { items: any[] }) {
|
||||
}
|
||||
|
||||
// Vista de Evaluación
|
||||
function EvaluationView({ items }: { items: any[] }) {
|
||||
function EvaluationView({ items }: { items: Array<any> }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex justify-between text-sm border-b border-slate-50 pb-1.5 italic">
|
||||
<div
|
||||
key={i}
|
||||
className="flex justify-between border-b border-slate-50 pb-1.5 text-sm italic"
|
||||
>
|
||||
<span className="text-slate-500">{item.label}</span>
|
||||
<span className="font-bold text-blue-600">{item.value}</span>
|
||||
</div>
|
||||
@@ -384,13 +535,3 @@ function EvaluationView({ items }: { items: any[] }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
function EmptyTab({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
{title} (pendiente)
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user