Se termina vista de asignaturas
This commit is contained in:
@@ -17,8 +17,11 @@
|
|||||||
"ci:verify": "prettier --check . && eslint . && tsc --noEmit"
|
"ci:verify": "prettier --check . && eslint . && tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
@@ -38,6 +41,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|||||||
@@ -1,84 +1,291 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react';
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Plus, Search, BookOpen, Trash2, Library, Edit3, Save } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button'
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Pencil, BookOpen } from 'lucide-react'
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils'
|
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 { toast } from 'sonner';
|
||||||
|
//import { mockLibraryResources } from '@/data/mockMateriaData';
|
||||||
|
|
||||||
type Props = {
|
export const mockLibraryResources = [
|
||||||
value: string
|
{
|
||||||
onSave: (value: string) => void
|
id: 'lib-1',
|
||||||
|
titulo: 'Deep Learning',
|
||||||
|
autor: 'Goodfellow, I., Bengio, Y., & Courville, A.',
|
||||||
|
editorial: 'MIT Press',
|
||||||
|
anio: 2016,
|
||||||
|
isbn: '9780262035613',
|
||||||
|
disponible: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lib-2',
|
||||||
|
titulo: 'Artificial Intelligence: A Modern Approach',
|
||||||
|
autor: 'Russell, S., & Norvig, P.',
|
||||||
|
editorial: 'Pearson',
|
||||||
|
anio: 2020,
|
||||||
|
isbn: '9780134610993',
|
||||||
|
disponible: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lib-3',
|
||||||
|
titulo: 'Hands-On Machine Learning',
|
||||||
|
autor: 'Aurélien Géron',
|
||||||
|
editorial: 'O\'Reilly Media',
|
||||||
|
anio: 2019,
|
||||||
|
isbn: '9781492032649',
|
||||||
|
disponible: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Interfaces ---
|
||||||
|
export interface BibliografiaEntry {
|
||||||
|
id: string;
|
||||||
|
tipo: 'BASICA' | 'COMPLEMENTARIA';
|
||||||
|
cita: string;
|
||||||
|
fuenteBibliotecaId?: string;
|
||||||
|
fuenteBiblioteca?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BibliographyItem({ value, onSave }: Props) {
|
interface BibliografiaTabProps {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
bibliografia: BibliografiaEntry[];
|
||||||
const [draft, setDraft] = useState(value)
|
onSave: (bibliografia: BibliografiaEntry[]) => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
export function BibliographyItem({ bibliografia, onSave, isSaving }: BibliografiaTabProps) {
|
||||||
setDraft(value)
|
const [entries, setEntries] = useState<BibliografiaEntry[]>(bibliografia);
|
||||||
setIsEditing(false)
|
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');
|
||||||
|
|
||||||
function handleSave() {
|
const basicaEntries = entries.filter(e => e.tipo === 'BASICA');
|
||||||
onSave(draft)
|
const complementariaEntries = entries.filter(e => e.tipo === 'COMPLEMENTARIA');
|
||||||
setIsEditing(false)
|
|
||||||
}
|
const handleAddManual = (cita: string) => {
|
||||||
|
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 newEntry: BibliografiaEntry = {
|
||||||
|
id: `lib-ref-${Date.now()}`,
|
||||||
|
tipo,
|
||||||
|
cita,
|
||||||
|
fuenteBibliotecaId: resource.id,
|
||||||
|
fuenteBiblioteca: resource,
|
||||||
|
};
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="max-w-5xl mx-auto py-10 space-y-8 animate-in fade-in duration-500">
|
||||||
className={cn(
|
<div className="flex items-center justify-between border-b pb-4">
|
||||||
'rounded-lg border p-4 transition',
|
<div>
|
||||||
isEditing
|
<h2 className="text-2xl font-bold text-slate-900 tracking-tight">Bibliografía</h2>
|
||||||
? 'border-yellow-400 bg-yellow-50'
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
: 'border-gray-200 bg-white'
|
{basicaEntries.length} básica • {complementariaEntries.length} complementaria
|
||||||
)}
|
</p>
|
||||||
>
|
</div>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<BookOpen className="w-5 h-5 text-yellow-500 mt-1" />
|
<Dialog open={isLibraryDialogOpen} onOpenChange={setIsLibraryDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
<div className="flex-1">
|
<Button variant="outline" className="border-blue-200 text-blue-700 hover:bg-blue-50">
|
||||||
{!isEditing ? (
|
<Library className="w-4 h-4 mr-2" /> Buscar en biblioteca
|
||||||
<>
|
|
||||||
<p className="text-sm leading-relaxed">{value}</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="mt-2 text-muted-foreground"
|
|
||||||
onClick={() => setIsEditing(true)}
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4 mr-1" />
|
|
||||||
Editar
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</DialogTrigger>
|
||||||
) : (
|
<DialogContent className="max-w-2xl">
|
||||||
<>
|
<LibrarySearchDialog onSelect={handleAddFromLibrary} existingIds={entries.map(e => e.fuenteBibliotecaId || '')} />
|
||||||
<Textarea
|
</DialogContent>
|
||||||
value={draft}
|
</Dialog>
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
|
||||||
className="min-h-[90px]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 mt-3">
|
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||||
<Button
|
<DialogTrigger asChild>
|
||||||
variant="ghost"
|
<Button variant="outline"><Plus className="w-4 h-4 mr-2" /> Añadir manual</Button>
|
||||||
size="sm"
|
</DialogTrigger>
|
||||||
onClick={handleCancel}
|
<DialogContent>
|
||||||
>
|
<AddManualDialog tipo={newEntryType} onTypeChange={setNewEntryType} onAdd={handleAddManual} />
|
||||||
Cancelar
|
</DialogContent>
|
||||||
</Button>
|
</Dialog>
|
||||||
|
|
||||||
<Button
|
<Button onClick={() => onSave(entries)} disabled={isSaving} className="bg-blue-600 hover:bg-blue-700">
|
||||||
size="sm"
|
<Save className="w-4 h-4 mr-2" /> {isSaving ? 'Guardando...' : 'Guardar'}
|
||||||
className="bg-green-600 hover:bg-green-700"
|
</Button>
|
||||||
onClick={handleSave}
|
|
||||||
>
|
|
||||||
Guardar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-8">
|
||||||
|
{/* 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>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{basicaEntries.map(entry => (
|
||||||
|
<BibliografiaCard
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
isEditing={editingId === entry.id}
|
||||||
|
onEdit={() => setEditingId(entry.id)}
|
||||||
|
onStopEditing={() => setEditingId(null)}
|
||||||
|
onUpdateCita={handleUpdateCita}
|
||||||
|
onDelete={() => setDeleteId(entry.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{complementariaEntries.map(entry => (
|
||||||
|
<BibliografiaCard
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
isEditing={editingId === entry.id}
|
||||||
|
onEdit={() => setEditingId(entry.id)}
|
||||||
|
onStopEditing={() => setEditingId(null)}
|
||||||
|
onUpdateCita={handleUpdateCita}
|
||||||
|
onDelete={() => setDeleteId(entry.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>¿Eliminar referencia?</AlertDialogTitle>
|
||||||
|
<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>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Subcomponentes ---
|
||||||
|
|
||||||
|
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")}>
|
||||||
|
<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">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div onClick={onEdit} className="cursor-pointer">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddManualDialog({ tipo, onTypeChange, onAdd }: any) {
|
||||||
|
const [cita, setCita] = useState('');
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<DialogHeader><DialogTitle>Referencia Manual</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold uppercase text-slate-500">Tipo</label>
|
||||||
|
<Select value={tipo} onValueChange={onTypeChange}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="BASICA">Básica</SelectItem>
|
||||||
|
<SelectItem value="COMPLEMENTARIA">Complementaria</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</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]" />
|
||||||
|
</div>
|
||||||
|
<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())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<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" />
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,40 +1,277 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { useState } from 'react';
|
||||||
import { UnidadCard } from './contenido-tematico/UnidadCard'
|
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,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnidadTematica {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
numero: number;
|
||||||
|
temas: Tema[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialData: UnidadTematica[] = [
|
||||||
|
{
|
||||||
|
id: 'u1',
|
||||||
|
numero: 1,
|
||||||
|
nombre: 'Fundamentos de Inteligencia Artificial',
|
||||||
|
temas: [
|
||||||
|
{ id: 't1', nombre: 'Tipos de IA y aplicaciones', horasEstimadas: 6 },
|
||||||
|
{ id: 't2', nombre: 'Ética en IA', horasEstimadas: 3 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
export function ContenidoTematico() {
|
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);
|
||||||
|
|
||||||
|
// --- 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 addUnidad = () => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUnidadNombre = (id: string, nombre: string) => {
|
||||||
|
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;
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
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.type === 'unidad') {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
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 (
|
return (
|
||||||
<div className="max-w-5xl mx-auto py-10 space-y-6">
|
<div className="max-w-5xl mx-auto py-10 space-y-6 animate-in fade-in duration-500">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between border-b pb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">Contenido Temático</h2>
|
<h2 className="text-2xl font-bold tracking-tight text-slate-900">Contenido Temático</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
Unidades, temas y subtemas de la materia
|
{unidades.length} unidades • {totalHoras} horas estimadas totales
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex gap-2">
|
<Button variant="outline" onClick={addUnidad} className="gap-2">
|
||||||
<Button variant="outline">Nueva unidad</Button>
|
<Plus className="w-4 h-4" /> Nueva unidad
|
||||||
<Button>Guardar</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UnidadCard
|
<div className="space-y-4">
|
||||||
numero={1}
|
{unidades.map((unidad) => (
|
||||||
titulo="Fundamentos de Inteligencia Artificial"
|
<Card key={unidad.id} className="overflow-hidden border-slate-200 shadow-sm">
|
||||||
temas={[
|
<Collapsible open={expandedUnits.has(unidad.id)} onOpenChange={() => toggleUnit(unidad.id)}>
|
||||||
{
|
<CardHeader className="bg-slate-50/50 py-3 border-b border-slate-100">
|
||||||
id: 't1',
|
<div className="flex items-center gap-3">
|
||||||
titulo: 'Tipos de IA y aplicaciones',
|
<GripVertical className="w-4 h-4 text-slate-300 cursor-grab" />
|
||||||
horas: 6,
|
<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" />}
|
||||||
id: 't2',
|
</Button>
|
||||||
titulo: 'Ética en IA',
|
</CollapsibleTrigger>
|
||||||
horas: 3,
|
<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)}
|
||||||
|
onBlur={() => setEditingUnit(null)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && setEditingUnit(null)}
|
||||||
|
className="max-w-md h-8 bg-white"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CardTitle className="text-base font-semibold cursor-pointer hover:text-blue-600 transition-colors" 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>
|
||||||
|
<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>
|
||||||
|
</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">
|
||||||
|
{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 })}
|
||||||
|
onStopEditing={() => setEditingTema(null)}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteConfirmDialog dialog={deleteDialog} setDialog={setDeleteDialog} onConfirm={handleDelete} />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Componentes Auxiliares ---
|
||||||
|
interface TemaRowProps {
|
||||||
|
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) {
|
||||||
|
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>
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteDialogState {
|
||||||
|
type: 'unidad' | 'tema';
|
||||||
|
id: string;
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteConfirmDialogProps {
|
||||||
|
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.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={onConfirm} className="bg-red-600 hover:bg-red-700 text-white">Eliminar</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
321
src/components/asignaturas/detalle/DocumentoSEPTab.tsx
Normal file
321
src/components/asignaturas/detalle/DocumentoSEPTab.tsx
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
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,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import type { DocumentoMateria, Materia, MateriaStructure } from '@/types/materia';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
//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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 handleRegenerate = () => {
|
||||||
|
setShowConfirmDialog(false);
|
||||||
|
onRegenerate();
|
||||||
|
//toast.success('Regenerando documento...');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
<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" />
|
||||||
|
Documento SEP
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
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" />
|
||||||
|
Descargar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button disabled={isRegenerating || !isComplete}>
|
||||||
|
{isRegenerating ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<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.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleRegenerate}>
|
||||||
|
Regenerar
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* 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">
|
||||||
|
{/* 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">
|
||||||
|
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">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center border-b pb-6">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
Secretaría de Educación Pública
|
||||||
|
</p>
|
||||||
|
<h1 className="font-display text-2xl font-bold text-primary mb-1">
|
||||||
|
{materia.nombre}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
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>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Campos del documento */}
|
||||||
|
{estructura.campos.map((campo) => {
|
||||||
|
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">
|
||||||
|
{campo.nombre}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
|
||||||
|
{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>
|
||||||
|
<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="text-center">
|
||||||
|
<Loader2 className="w-12 h-12 mx-auto text-accent animate-spin mb-4" />
|
||||||
|
<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" />
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info sidebar */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Status */}
|
||||||
|
<Card className="card-elevated">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<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>
|
||||||
|
<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-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"
|
||||||
|
)}>
|
||||||
|
{documento.estado === 'listo' && 'Listo'}
|
||||||
|
{documento.estado === 'generando' && 'Generando'}
|
||||||
|
{documento.estado === 'error' && 'Error'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Completeness */}
|
||||||
|
<Card className="card-elevated">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"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
|
||||||
|
? 'Todos los campos obligatorios están completos'
|
||||||
|
: `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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Requirements */}
|
||||||
|
<Card className="card-elevated">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<span className="text-muted-foreground">Criterios de evaluación</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Check({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
src/components/asignaturas/detalle/HistorialTab.tsx
Normal file
196
src/components/asignaturas/detalle/HistorialTab.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
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 {
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface HistorialTabProps {
|
||||||
|
historial: CambioMateria[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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({ historial }: HistorialTabProps) {
|
||||||
|
const [filtros, setFiltros] = useState<Set<string>>(new Set(['datos', 'contenido', 'bibliografia', 'ia', 'documento']));
|
||||||
|
|
||||||
|
const toggleFiltro = (tipo: string) => {
|
||||||
|
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));
|
||||||
|
|
||||||
|
// 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 sortedDates = Object.keys(groupedHistorial).sort((a, b) => b.localeCompare(a));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
<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" />
|
||||||
|
Historial de cambios
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{historial.length} cambios registrados
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Filter className="w-4 h-4 mr-2" />
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredHistorial.length === 0 ? (
|
||||||
|
<Card className="card-elevated">
|
||||||
|
<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>
|
||||||
|
</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;
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
357
src/components/asignaturas/detalle/IAMateriaTab.tsx
Normal file
357
src/components/asignaturas/detalle/IAMateriaTab.tsx
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Send, Sparkles, Bot, User, Check, X, RefreshCw, Lightbulb, Wand2 } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/command';
|
||||||
|
import type { IAMessage, IASugerencia, CampoEstructura } from '@/types/materia';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
//import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface IAMateriaTabProps {
|
||||||
|
campos: CampoEstructura[];
|
||||||
|
datosGenerales: Record<string, any>;
|
||||||
|
messages: IAMessage[];
|
||||||
|
onSendMessage: (message: string, campoId?: string) => void;
|
||||||
|
onAcceptSuggestion: (sugerencia: IASugerencia) => void;
|
||||||
|
onRejectSuggestion: (messageId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quickActions = [
|
||||||
|
{ id: 'mejorar-objetivos', label: 'Mejorar objetivos', icon: Wand2, prompt: 'Mejora el :objetivo_general para que sea más específico y medible' },
|
||||||
|
{ id: 'generar-contenido', label: 'Generar contenido temático', icon: Lightbulb, prompt: 'Sugiere un contenido temático completo basado en los objetivos y competencias' },
|
||||||
|
{ id: 'alinear-perfil', label: 'Alinear con perfil de egreso', icon: RefreshCw, prompt: 'Revisa las :competencias y alinéalas con el perfil de egreso del plan' },
|
||||||
|
{ id: 'ajustar-biblio', label: 'Recomendar bibliografía', icon: Sparkles, prompt: 'Recomienda bibliografía actualizada basándote en el contenido temático' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function IAMateriaTab({ campos, datosGenerales, messages, onSendMessage, onAcceptSuggestion, onRejectSuggestion }: IAMateriaTabProps) {
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showFieldSelector, setShowFieldSelector] = useState(false);
|
||||||
|
const [fieldSelectorPosition, setFieldSelectorPosition] = useState({ top: 0, left: 0 });
|
||||||
|
const [cursorPosition, setCursorPosition] = useState(0);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
const pos = e.target.selectionStart;
|
||||||
|
setInput(value);
|
||||||
|
setCursorPosition(pos);
|
||||||
|
|
||||||
|
// Check for : character to trigger field selector
|
||||||
|
const lastChar = value.charAt(pos - 1);
|
||||||
|
if (lastChar === ':') {
|
||||||
|
const rect = textareaRef.current?.getBoundingClientRect();
|
||||||
|
if (rect) {
|
||||||
|
setFieldSelectorPosition({ top: rect.bottom + 8, left: rect.left });
|
||||||
|
setShowFieldSelector(true);
|
||||||
|
}
|
||||||
|
} else if (showFieldSelector && (lastChar === ' ' || !value.includes(':'))) {
|
||||||
|
setShowFieldSelector(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertFieldMention = (campoId: string) => {
|
||||||
|
const beforeCursor = input.slice(0, cursorPosition);
|
||||||
|
const afterCursor = input.slice(cursorPosition);
|
||||||
|
const lastColonIndex = beforeCursor.lastIndexOf(':');
|
||||||
|
const newInput = beforeCursor.slice(0, lastColonIndex) + `:${campoId}` + afterCursor;
|
||||||
|
setInput(newInput);
|
||||||
|
setShowFieldSelector(false);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!input.trim() || isLoading) return;
|
||||||
|
|
||||||
|
// Extract field mention if any
|
||||||
|
const fieldMatch = input.match(/:(\w+)/);
|
||||||
|
const campoId = fieldMatch ? fieldMatch[1] : undefined;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
onSendMessage(input, campoId);
|
||||||
|
setInput('');
|
||||||
|
|
||||||
|
// Simulate AI response delay
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickAction = (prompt: string) => {
|
||||||
|
setInput(prompt);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMessageContent = (content: string) => {
|
||||||
|
// Render field mentions as styled badges
|
||||||
|
return content.split(/(:[\w_]+)/g).map((part, i) => {
|
||||||
|
if (part.startsWith(':')) {
|
||||||
|
const campo = campos.find(c => c.id === part.slice(1));
|
||||||
|
return (
|
||||||
|
<span key={i} className="field-mention mx-0.5">
|
||||||
|
{campo?.nombre || part}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<Sparkles className="w-6 h-6 text-accent" />
|
||||||
|
IA de la materia
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Usa <kbd className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">:</kbd> para mencionar campos específicos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Chat area */}
|
||||||
|
<Card className="lg:col-span-2 card-elevated flex flex-col h-[600px]">
|
||||||
|
<CardHeader className="pb-2 border-b">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Conversación
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex flex-col p-0">
|
||||||
|
<ScrollArea className="flex-1 p-4" ref={scrollRef}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Bot className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Inicia una conversación para mejorar tu materia con IA
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messages.map((message) => (
|
||||||
|
<div key={message.id} className={cn(
|
||||||
|
"flex gap-3",
|
||||||
|
message.role === 'user' ? "justify-end" : "justify-start"
|
||||||
|
)}>
|
||||||
|
{message.role === 'assistant' && (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Bot className="w-4 h-4 text-accent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={cn(
|
||||||
|
"max-w-[80%] rounded-lg px-4 py-3",
|
||||||
|
message.role === 'user'
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted"
|
||||||
|
)}>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">
|
||||||
|
{renderMessageContent(message.content)}
|
||||||
|
</p>
|
||||||
|
{message.sugerencia && !message.sugerencia.aceptada && (
|
||||||
|
<div className="mt-3 p-3 bg-background/80 rounded-md border">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||||
|
Sugerencia para: {message.sugerencia.campoNombre}
|
||||||
|
</p>
|
||||||
|
<div className="text-sm text-foreground bg-accent/10 p-2 rounded mb-3 max-h-32 overflow-y-auto">
|
||||||
|
{message.sugerencia.valorSugerido}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onAcceptSuggestion(message.sugerencia!)}
|
||||||
|
className="bg-success hover:bg-success/90 text-success-foreground"
|
||||||
|
>
|
||||||
|
<Check className="w-3 h-3 mr-1" />
|
||||||
|
Aplicar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onRejectSuggestion(message.id)}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 mr-1" />
|
||||||
|
Rechazar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{message.sugerencia?.aceptada && (
|
||||||
|
<Badge className="mt-2 badge-library">
|
||||||
|
<Check className="w-3 h-3 mr-1" />
|
||||||
|
Sugerencia aplicada
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{message.role === 'user' && (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center flex-shrink-0">
|
||||||
|
<User className="w-4 h-4 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Bot className="w-4 h-4 text-accent animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted rounded-lg px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-2 h-2 bg-accent rounded-full animate-bounce [animation-delay:-0.3s]" />
|
||||||
|
<div className="w-2 h-2 bg-accent rounded-full animate-bounce [animation-delay:-0.15s]" />
|
||||||
|
<div className="w-2 h-2 bg-accent rounded-full animate-bounce" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Input area */}
|
||||||
|
<div className="p-4 border-t">
|
||||||
|
<div className="relative">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Escribe tu mensaje... Usa : para mencionar campos"
|
||||||
|
className="min-h-[80px] pr-12 resize-none"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!input.trim() || isLoading}
|
||||||
|
className="absolute bottom-3 right-3 h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Field selector popover */}
|
||||||
|
{showFieldSelector && (
|
||||||
|
<div className="absolute z-50 mt-1 w-64 bg-popover border rounded-lg shadow-lg">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Buscar campo..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No se encontró el campo</CommandEmpty>
|
||||||
|
<CommandGroup heading="Campos disponibles">
|
||||||
|
{campos.map((campo) => (
|
||||||
|
<CommandItem
|
||||||
|
key={campo.id}
|
||||||
|
value={campo.id}
|
||||||
|
onSelect={() => insertFieldMention(campo.id)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-xs text-accent mr-2">
|
||||||
|
:{campo.id}
|
||||||
|
</span>
|
||||||
|
<span>{campo.nombre}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Sidebar with quick actions and fields */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Quick actions */}
|
||||||
|
<Card className="card-elevated">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Acciones rápidas</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{quickActions.map((action) => {
|
||||||
|
const Icon = action.icon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={action.id}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-left h-auto py-3"
|
||||||
|
onClick={() => handleQuickAction(action.prompt)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 mr-2 text-accent flex-shrink-0" />
|
||||||
|
<span className="text-sm">{action.label}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Available fields */}
|
||||||
|
<Card className="card-elevated">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Campos de la materia</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-[280px]">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{campos.map((campo) => {
|
||||||
|
const hasValue = !!datosGenerales[campo.id];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={campo.id}
|
||||||
|
className={cn(
|
||||||
|
"p-2 rounded-md border cursor-pointer transition-colors hover:bg-muted/50",
|
||||||
|
hasValue ? "border-success/30" : "border-warning/30"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setInput(prev => prev + `:${campo.id} `);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-mono text-accent">:{campo.id}</span>
|
||||||
|
{hasValue ? (
|
||||||
|
<Badge variant="outline" className="text-xs text-success border-success/30">
|
||||||
|
Completo
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs text-warning border-warning/30">
|
||||||
|
Vacío
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-foreground mt-1">{campo.nombre}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,106 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
|
Edit2, Save,
|
||||||
|
Pencil
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { ContenidoTematico } from './ContenidoTematico'
|
import { ContenidoTematico } from './ContenidoTematico'
|
||||||
import { BibliographyItem } from './BibliographyItem'
|
import { BibliographyItem } from './BibliographyItem'
|
||||||
|
import { IAMateriaTab } from './IAMateriaTab'
|
||||||
|
import type {
|
||||||
|
CampoEstructura,
|
||||||
|
IAMessage,
|
||||||
|
IASugerencia,
|
||||||
|
UnidadTematica,
|
||||||
|
} from '@/types/materia';
|
||||||
|
import {
|
||||||
|
mockMateria,
|
||||||
|
mockEstructura,
|
||||||
|
mockDocumentoSep,
|
||||||
|
mockHistorial
|
||||||
|
} from '@/data/mockMateriaData';
|
||||||
|
import { DocumentoSEPTab } from './DocumentoSEPTab'
|
||||||
|
import { HistorialTab } from './HistorialTab'
|
||||||
|
|
||||||
|
export interface BibliografiaEntry {
|
||||||
|
id: string;
|
||||||
|
tipo: 'BASICA' | 'COMPLEMENTARIA';
|
||||||
|
cita: string;
|
||||||
|
fuenteBibliotecaId?: string;
|
||||||
|
fuenteBiblioteca?: any;
|
||||||
|
}
|
||||||
|
export interface BibliografiaTabProps {
|
||||||
|
bibliografia: BibliografiaEntry[];
|
||||||
|
onSave: (bibliografia: BibliografiaEntry[]) => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export default function MateriaDetailPage() {
|
export default function MateriaDetailPage() {
|
||||||
|
|
||||||
|
// 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[]>([]);
|
||||||
|
|
||||||
|
// 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 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 [isRegenerating, setIsRegenerating] = useState(false);
|
||||||
|
|
||||||
|
const handleRegenerateDocument = useCallback(() => {
|
||||||
|
setIsRegenerating(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsRegenerating(false);
|
||||||
|
}, 2000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -21,7 +108,7 @@ export default function MateriaDetailPage() {
|
|||||||
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
|
<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="max-w-7xl mx-auto px-6 py-10">
|
||||||
<Link
|
<Link
|
||||||
to="/materias"
|
to="/planes"
|
||||||
className="flex items-center gap-2 text-sm text-blue-200 hover:text-white mb-4"
|
className="flex items-center gap-2 text-sm text-blue-200 hover:text-white mb-4"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
@@ -90,23 +177,36 @@ export default function MateriaDetailPage() {
|
|||||||
|
|
||||||
<TabsContent value="bibliografia">
|
<TabsContent value="bibliografia">
|
||||||
<BibliographyItem
|
<BibliographyItem
|
||||||
value="Russell, S., & Norvig, P. (2021). Artificial Intelligence: A Modern Approach (4th ed.). Pearson."
|
bibliografia={bibliografia}
|
||||||
onSave={(newValue) => {
|
onSave={handleSaveBibliografia}
|
||||||
console.log('Guardar en API:', newValue)
|
isSaving={isSaving}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="ia">
|
<TabsContent value="ia">
|
||||||
<EmptyTab title="IA de la materia" />
|
<IAMateriaTab
|
||||||
|
campos={campos}
|
||||||
|
datosGenerales={datosGenerales}
|
||||||
|
messages={messages}
|
||||||
|
onSendMessage={handleSendMessage}
|
||||||
|
onAcceptSuggestion={handleAcceptSuggestion}
|
||||||
|
onRejectSuggestion={(id) => console.log("Rechazada") /*toast.error("Sugerencia rechazada")*/}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="sep">
|
<TabsContent value="sep">
|
||||||
<EmptyTab title="Documento SEP" />
|
<DocumentoSEPTab
|
||||||
|
documento={mockDocumentoSep}
|
||||||
|
materia={mockMateria}
|
||||||
|
estructura={mockEstructura}
|
||||||
|
datosGenerales={datosGenerales}
|
||||||
|
onRegenerate={handleRegenerateDocument}
|
||||||
|
isRegenerating={isRegenerating}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="historial">
|
<TabsContent value="historial">
|
||||||
<EmptyTab title="Historial" />
|
<HistorialTab historial={mockHistorial} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,105 +219,174 @@ export default function MateriaDetailPage() {
|
|||||||
|
|
||||||
function DatosGenerales() {
|
function DatosGenerales() {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto py-10 space-y-6">
|
<div className="max-w-7xl mx-auto py-8 px-4 space-y-8 animate-in fade-in duration-500">
|
||||||
<HeaderTab />
|
|
||||||
|
{/* Encabezado de la Sección */}
|
||||||
<InfoCard
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b pb-6">
|
||||||
title="Objetivo General"
|
<div>
|
||||||
content="Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos del mundo real."
|
<h2 className="text-2xl font-bold tracking-tight text-slate-900">Datos Generales</h2>
|
||||||
/>
|
<p className="text-slate-500 mt-1">
|
||||||
|
Información oficial estructurada bajo los lineamientos de la SEP.
|
||||||
<InfoCard
|
</p>
|
||||||
title="Competencias a Desarrollar"
|
</div>
|
||||||
content={
|
<div className="flex gap-3">
|
||||||
<ul className="list-disc list-inside space-y-1">
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
<li>Diseñar algoritmos de machine learning</li>
|
<Edit2 className="w-4 h-4" /> Editar borrador
|
||||||
<li>Implementar redes neuronales profundas</li>
|
</Button>
|
||||||
<li>Evaluar modelos de IA considerando métricas</li>
|
<Button size="sm" className="gap-2 bg-blue-600 hover:bg-blue-700">
|
||||||
<li>Aplicar principios éticos en sistemas inteligentes</li>
|
<Save className="w-4 h-4" /> Guardar cambios
|
||||||
</ul>
|
</Button>
|
||||||
}
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
<InfoCard
|
|
||||||
title="Justificación"
|
|
||||||
content="La inteligencia artificial es una de las tecnologías más disruptivas del siglo XXI..."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InfoCard
|
|
||||||
title="Requisitos y Seriación"
|
|
||||||
content="Programación Avanzada (PA-301), Matemáticas Discretas (MAT-201)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InfoCard
|
|
||||||
title="Estrategias Didácticas"
|
|
||||||
content={
|
|
||||||
<ul className="list-disc list-inside space-y-1">
|
|
||||||
<li>Aprendizaje basado en proyectos</li>
|
|
||||||
<li>Talleres prácticos con datasets reales</li>
|
|
||||||
<li>Estudios de caso</li>
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InfoCard
|
|
||||||
title="Sistema de Evaluación"
|
|
||||||
content={
|
|
||||||
<ul className="list-disc list-inside space-y-1">
|
|
||||||
<li>Exámenes parciales: 30%</li>
|
|
||||||
<li>Proyecto integrador: 35%</li>
|
|
||||||
<li>Prácticas de laboratorio: 20%</li>
|
|
||||||
<li>Participación: 15%</li>
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InfoCard
|
|
||||||
title="Perfil del Docente"
|
|
||||||
content="Profesional con maestría o doctorado en áreas afines a IA, con experiencia mínima de 3 años."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function HeaderTab() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold">Datos Generales</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Información basada en la plantilla SEP Licenciatura
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button size="sm">Guardar todo</Button>
|
{/* Grid de Información */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* 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 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoCard({
|
interface InfoCardProps {
|
||||||
title,
|
title: string,
|
||||||
content,
|
subtitle?: string
|
||||||
}: {
|
isList?:boolean
|
||||||
title: string
|
initialContent: any // Puede ser string o array de objetos
|
||||||
content: React.ReactNode
|
type?: 'text' | 'list' | 'requirements' | 'evaluation'
|
||||||
}) {
|
}
|
||||||
|
|
||||||
|
function InfoCard({ title, initialContent, type = 'text' }: 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
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// Aquí podrías parsear el texto de vuelta si es necesario
|
||||||
|
setData(tempText)
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="transition-all hover:border-slate-300">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-3 flex flex-row items-start justify-between space-y-0">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-sm font-bold text-slate-700">{title}</CardTitle>
|
||||||
{title}
|
{!isEditing && (
|
||||||
<Badge variant="outline">Obligatorio</Badge>
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={() => setIsEditing(true)}>
|
||||||
</CardTitle>
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="text-sm text-muted-foreground">
|
<CardContent>
|
||||||
{content}
|
{isEditing ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Textarea
|
||||||
|
value={tempText}
|
||||||
|
onChange={(e) => setTempText(e.target.value)}
|
||||||
|
className="text-xs min-h-[100px]"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm">
|
||||||
|
{type === 'requirements' && <RequirementsView items={data} />}
|
||||||
|
{type === 'evaluation' && <EvaluationView items={data} />}
|
||||||
|
{type === 'text' && <p className="text-slate-600">{data}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vista de Requisitos
|
||||||
|
function RequirementsView({ items }: { items: 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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vista de Evaluación
|
||||||
|
function EvaluationView({ items }: { items: 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">
|
||||||
|
<span className="text-slate-500">{item.label}</span>
|
||||||
|
<span className="font-bold text-blue-600">{item.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function EmptyTab({ title }: { title: string }) {
|
function EmptyTab({ title }: { title: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="py-16 text-center text-muted-foreground">
|
<div className="py-16 text-center text-muted-foreground">
|
||||||
|
|||||||
155
src/components/ui/alert-dialog.tsx
Normal file
155
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
31
src/components/ui/collapsible.tsx
Normal file
31
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
255
src/components/ui/dropdown-menu.tsx
Normal file
255
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
302
src/data/mockMateriaData.ts
Normal file
302
src/data/mockMateriaData.ts
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import type {
|
||||||
|
Materia,
|
||||||
|
MateriaStructure,
|
||||||
|
UnidadTematica,
|
||||||
|
BibliografiaEntry,
|
||||||
|
CambioMateria,
|
||||||
|
DocumentoMateria,
|
||||||
|
LibraryResource
|
||||||
|
} from '@/types/materia';
|
||||||
|
|
||||||
|
export const mockMateria: Materia = {
|
||||||
|
id: '1',
|
||||||
|
nombre: 'Inteligencia Artificial Aplicada',
|
||||||
|
clave: 'IAA-401',
|
||||||
|
creditos: 8,
|
||||||
|
lineaCurricular: 'Sistemas Inteligentes',
|
||||||
|
ciclo: '7° Semestre',
|
||||||
|
planId: 'plan-1',
|
||||||
|
planNombre: 'Licenciatura en Ingeniería en Sistemas Computacionales 2024',
|
||||||
|
carrera: 'Ingeniería en Sistemas Computacionales',
|
||||||
|
facultad: 'Facultad de Ingeniería',
|
||||||
|
estructuraId: 'estructura-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockEstructura: MateriaStructure = {
|
||||||
|
id: 'estructura-1',
|
||||||
|
nombre: 'Plantilla SEP Licenciatura',
|
||||||
|
campos: [
|
||||||
|
{
|
||||||
|
id: 'objetivo_general',
|
||||||
|
nombre: 'Objetivo General',
|
||||||
|
tipo: 'texto_largo',
|
||||||
|
obligatorio: true,
|
||||||
|
descripcion: 'Describe el propósito principal de la materia',
|
||||||
|
placeholder: 'Al finalizar el curso, el estudiante será capaz de...',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'competencias',
|
||||||
|
nombre: 'Competencias a Desarrollar',
|
||||||
|
tipo: 'texto_largo',
|
||||||
|
obligatorio: true,
|
||||||
|
descripcion: 'Competencias profesionales que se desarrollarán',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'justificacion',
|
||||||
|
nombre: 'Justificación',
|
||||||
|
tipo: 'texto_largo',
|
||||||
|
obligatorio: true,
|
||||||
|
descripcion: 'Relevancia de la materia en el plan de estudios',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'requisitos',
|
||||||
|
nombre: 'Requisitos / Seriación',
|
||||||
|
tipo: 'texto',
|
||||||
|
obligatorio: false,
|
||||||
|
descripcion: 'Materias previas requeridas',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'estrategias_didacticas',
|
||||||
|
nombre: 'Estrategias Didácticas',
|
||||||
|
tipo: 'texto_largo',
|
||||||
|
obligatorio: true,
|
||||||
|
descripcion: 'Métodos de enseñanza-aprendizaje',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'evaluacion',
|
||||||
|
nombre: 'Sistema de Evaluación',
|
||||||
|
tipo: 'texto_largo',
|
||||||
|
obligatorio: true,
|
||||||
|
descripcion: 'Criterios y porcentajes de evaluación',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'perfil_docente',
|
||||||
|
nombre: 'Perfil del Docente',
|
||||||
|
tipo: 'texto_largo',
|
||||||
|
obligatorio: false,
|
||||||
|
descripcion: 'Características requeridas del profesor',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockDatosGenerales: Record<string, any> = {
|
||||||
|
objetivo_general: 'Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos del mundo real, aplicando principios éticos y metodologías actuales en el campo.',
|
||||||
|
competencias: '• Diseñar algoritmos de machine learning para clasificación y predicción\n• Implementar redes neuronales profundas para procesamiento de imágenes y texto\n• Evaluar y optimizar modelos de IA considerando métricas de rendimiento\n• Aplicar principios éticos en el desarrollo de sistemas inteligentes',
|
||||||
|
justificacion: 'La inteligencia artificial es una de las tecnologías más disruptivas del siglo XXI. Su integración en diversos sectores demanda profesionales con sólidas bases teóricas y prácticas. Esta materia proporciona las competencias necesarias para que el egresado pueda innovar y contribuir al desarrollo tecnológico del país.',
|
||||||
|
requisitos: 'Programación Avanzada (PAV-301), Matemáticas Discretas (MAT-201)',
|
||||||
|
estrategias_didacticas: '• Aprendizaje basado en proyectos\n• Talleres prácticos con datasets reales\n• Exposiciones y discusiones grupales\n• Análisis de casos de estudio\n• Desarrollo de prototipo integrador',
|
||||||
|
evaluacion: '• Exámenes parciales: 30%\n• Proyecto integrador: 35%\n• Prácticas de laboratorio: 20%\n• Participación y tareas: 15%',
|
||||||
|
perfil_docente: 'Profesional con maestría o doctorado en áreas afines a la inteligencia artificial, con experiencia mínima de 3 años en docencia y desarrollo de proyectos de IA.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockContenidoTematico: UnidadTematica[] = [
|
||||||
|
{
|
||||||
|
id: 'unidad-1',
|
||||||
|
nombre: 'Fundamentos de Inteligencia Artificial',
|
||||||
|
numero: 1,
|
||||||
|
temas: [
|
||||||
|
{ id: 'tema-1-1', nombre: 'Historia y evolución de la IA', descripcion: 'Desde los orígenes hasta la actualidad', horasEstimadas: 2 },
|
||||||
|
{ id: 'tema-1-2', nombre: 'Tipos de IA y aplicaciones', descripcion: 'IA débil, fuerte y superinteligencia', horasEstimadas: 3 },
|
||||||
|
{ id: 'tema-1-3', nombre: 'Ética en IA', descripcion: 'Consideraciones éticas y responsabilidad', horasEstimadas: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'unidad-2',
|
||||||
|
nombre: 'Machine Learning',
|
||||||
|
numero: 2,
|
||||||
|
temas: [
|
||||||
|
{ id: 'tema-2-1', nombre: 'Aprendizaje supervisado', descripcion: 'Regresión y clasificación', horasEstimadas: 6 },
|
||||||
|
{ id: 'tema-2-2', nombre: 'Aprendizaje no supervisado', descripcion: 'Clustering y reducción de dimensionalidad', horasEstimadas: 5 },
|
||||||
|
{ id: 'tema-2-3', nombre: 'Evaluación de modelos', descripcion: 'Métricas y validación cruzada', horasEstimadas: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'unidad-3',
|
||||||
|
nombre: 'Deep Learning',
|
||||||
|
numero: 3,
|
||||||
|
temas: [
|
||||||
|
{ id: 'tema-3-1', nombre: 'Redes neuronales artificiales', descripcion: 'Perceptrón y backpropagation', horasEstimadas: 5 },
|
||||||
|
{ id: 'tema-3-2', nombre: 'Redes convolucionales (CNN)', descripcion: 'Procesamiento de imágenes', horasEstimadas: 6 },
|
||||||
|
{ id: 'tema-3-3', nombre: 'Redes recurrentes (RNN)', descripcion: 'Procesamiento de secuencias', horasEstimadas: 5 },
|
||||||
|
{ id: 'tema-3-4', nombre: 'Transformers y atención', descripcion: 'Arquitecturas modernas', horasEstimadas: 6 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'unidad-4',
|
||||||
|
nombre: 'Aplicaciones Prácticas',
|
||||||
|
numero: 4,
|
||||||
|
temas: [
|
||||||
|
{ id: 'tema-4-1', nombre: 'Procesamiento de lenguaje natural', descripcion: 'NLP y chatbots', horasEstimadas: 6 },
|
||||||
|
{ id: 'tema-4-2', nombre: 'Visión por computadora', descripcion: 'Detección y reconocimiento', horasEstimadas: 5 },
|
||||||
|
{ id: 'tema-4-3', nombre: 'Sistemas de recomendación', descripcion: 'Filtrado colaborativo y contenido', horasEstimadas: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockBibliografia: BibliografiaEntry[] = [
|
||||||
|
{
|
||||||
|
id: 'bib-1',
|
||||||
|
tipo: 'BASICA',
|
||||||
|
cita: 'Russell, S., & Norvig, P. (2021). Artificial Intelligence: A Modern Approach (4th ed.). Pearson.',
|
||||||
|
fuenteBibliotecaId: 'lib-1',
|
||||||
|
fuenteBiblioteca: {
|
||||||
|
id: 'lib-1',
|
||||||
|
titulo: 'Artificial Intelligence: A Modern Approach',
|
||||||
|
autor: 'Stuart Russell, Peter Norvig',
|
||||||
|
editorial: 'Pearson',
|
||||||
|
anio: 2021,
|
||||||
|
isbn: '978-0134610993',
|
||||||
|
tipo: 'libro',
|
||||||
|
disponible: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bib-2',
|
||||||
|
tipo: 'BASICA',
|
||||||
|
cita: 'Géron, A. (2022). Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow (3rd ed.). O\'Reilly Media.',
|
||||||
|
fuenteBibliotecaId: 'lib-2',
|
||||||
|
fuenteBiblioteca: {
|
||||||
|
id: 'lib-2',
|
||||||
|
titulo: 'Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow',
|
||||||
|
autor: 'Aurélien Géron',
|
||||||
|
editorial: 'O\'Reilly Media',
|
||||||
|
anio: 2022,
|
||||||
|
isbn: '978-1098125974',
|
||||||
|
tipo: 'libro',
|
||||||
|
disponible: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bib-3',
|
||||||
|
tipo: 'COMPLEMENTARIA',
|
||||||
|
cita: 'Goodfellow, I., Bengio, Y., & Courville, A. (2016). Deep Learning. MIT Press.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bib-4',
|
||||||
|
tipo: 'COMPLEMENTARIA',
|
||||||
|
cita: 'Chollet, F. (2021). Deep Learning with Python (2nd ed.). Manning Publications.',
|
||||||
|
fuenteBibliotecaId: 'lib-4',
|
||||||
|
fuenteBiblioteca: {
|
||||||
|
id: 'lib-4',
|
||||||
|
titulo: 'Deep Learning with Python',
|
||||||
|
autor: 'François Chollet',
|
||||||
|
editorial: 'Manning Publications',
|
||||||
|
anio: 2021,
|
||||||
|
isbn: '978-1617296864',
|
||||||
|
tipo: 'libro',
|
||||||
|
disponible: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockHistorial: CambioMateria[] = [
|
||||||
|
{
|
||||||
|
id: 'cambio-1',
|
||||||
|
tipo: 'datos',
|
||||||
|
descripcion: 'Actualización del objetivo general',
|
||||||
|
usuario: 'Dr. Carlos Méndez',
|
||||||
|
fecha: new Date('2024-12-10T14:30:00'),
|
||||||
|
detalles: { campo: 'objetivo_general' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cambio-2',
|
||||||
|
tipo: 'contenido',
|
||||||
|
descripcion: 'Agregada Unidad 4: Aplicaciones Prácticas',
|
||||||
|
usuario: 'Dr. Carlos Méndez',
|
||||||
|
fecha: new Date('2024-12-09T10:15:00'),
|
||||||
|
detalles: { unidad: 'Unidad 4' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cambio-3',
|
||||||
|
tipo: 'ia',
|
||||||
|
descripcion: 'IA mejoró las competencias a desarrollar',
|
||||||
|
usuario: 'Dra. María López',
|
||||||
|
fecha: new Date('2024-12-08T16:45:00'),
|
||||||
|
detalles: { campo: 'competencias', accion: 'mejora' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cambio-4',
|
||||||
|
tipo: 'bibliografia',
|
||||||
|
descripcion: 'Añadida referencia: Deep Learning with Python',
|
||||||
|
usuario: 'Biblioteca Central',
|
||||||
|
fecha: new Date('2024-12-07T09:00:00'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cambio-5',
|
||||||
|
tipo: 'documento',
|
||||||
|
descripcion: 'Documento SEP regenerado (versión 3)',
|
||||||
|
usuario: 'Sistema',
|
||||||
|
fecha: new Date('2024-12-06T11:30:00'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockDocumentoSep: DocumentoMateria = {
|
||||||
|
id: 'doc-1',
|
||||||
|
materiaId: '1',
|
||||||
|
version: 3,
|
||||||
|
fechaGeneracion: new Date('2024-12-06T11:30:00'),
|
||||||
|
estado: 'listo',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockLibraryResources: LibraryResource[] = [
|
||||||
|
{
|
||||||
|
id: 'lib-1',
|
||||||
|
titulo: 'Artificial Intelligence: A Modern Approach',
|
||||||
|
autor: 'Stuart Russell, Peter Norvig',
|
||||||
|
editorial: 'Pearson',
|
||||||
|
anio: 2021,
|
||||||
|
isbn: '978-0134610993',
|
||||||
|
tipo: 'libro',
|
||||||
|
disponible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lib-2',
|
||||||
|
titulo: 'Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow',
|
||||||
|
autor: 'Aurélien Géron',
|
||||||
|
editorial: 'O\'Reilly Media',
|
||||||
|
anio: 2022,
|
||||||
|
isbn: '978-1098125974',
|
||||||
|
tipo: 'libro',
|
||||||
|
disponible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lib-3',
|
||||||
|
titulo: 'Pattern Recognition and Machine Learning',
|
||||||
|
autor: 'Christopher Bishop',
|
||||||
|
editorial: 'Springer',
|
||||||
|
anio: 2006,
|
||||||
|
isbn: '978-0387310732',
|
||||||
|
tipo: 'libro',
|
||||||
|
disponible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lib-4',
|
||||||
|
titulo: 'Deep Learning with Python',
|
||||||
|
autor: 'François Chollet',
|
||||||
|
editorial: 'Manning Publications',
|
||||||
|
anio: 2021,
|
||||||
|
isbn: '978-1617296864',
|
||||||
|
tipo: 'libro',
|
||||||
|
disponible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lib-5',
|
||||||
|
titulo: 'Neural Networks and Deep Learning: A Textbook',
|
||||||
|
autor: 'Charu C. Aggarwal',
|
||||||
|
editorial: 'Springer',
|
||||||
|
anio: 2023,
|
||||||
|
isbn: '978-3031296413',
|
||||||
|
tipo: 'libro',
|
||||||
|
disponible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lib-6',
|
||||||
|
titulo: 'Machine Learning: A Probabilistic Perspective',
|
||||||
|
autor: 'Kevin Murphy',
|
||||||
|
editorial: 'MIT Press',
|
||||||
|
anio: 2012,
|
||||||
|
isbn: '978-0262018029',
|
||||||
|
tipo: 'libro',
|
||||||
|
disponible: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
119
src/types/materia.ts
Normal file
119
src/types/materia.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
export type MateriaTab =
|
||||||
|
| 'datos-generales'
|
||||||
|
| 'contenido-tematico'
|
||||||
|
| 'bibliografia'
|
||||||
|
| 'ia-materia'
|
||||||
|
| 'documento-sep'
|
||||||
|
| 'historial';
|
||||||
|
|
||||||
|
export interface Materia {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
clave: string;
|
||||||
|
creditos?: number;
|
||||||
|
lineaCurricular?: string;
|
||||||
|
ciclo?: string;
|
||||||
|
planId: string;
|
||||||
|
planNombre: string;
|
||||||
|
carrera: string;
|
||||||
|
facultad: string;
|
||||||
|
estructuraId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampoEstructura {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
tipo: 'texto' | 'texto_largo' | 'lista' | 'numero';
|
||||||
|
obligatorio: boolean;
|
||||||
|
descripcion?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MateriaStructure {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
campos: CampoEstructura[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tema {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
descripcion?: string;
|
||||||
|
horasEstimadas?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnidadTematica {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
numero: number;
|
||||||
|
temas: Tema[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BibliografiaEntry {
|
||||||
|
id: string;
|
||||||
|
tipo: 'BASICA' | 'COMPLEMENTARIA';
|
||||||
|
cita: string;
|
||||||
|
fuenteBibliotecaId?: string;
|
||||||
|
fuenteBiblioteca?: LibraryResource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibraryResource {
|
||||||
|
id: string;
|
||||||
|
titulo: string;
|
||||||
|
autor: string;
|
||||||
|
editorial?: string;
|
||||||
|
anio?: number;
|
||||||
|
isbn?: string;
|
||||||
|
tipo: 'libro' | 'articulo' | 'revista' | 'recurso_digital';
|
||||||
|
disponible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
campoAfectado?: string;
|
||||||
|
sugerencia?: IASugerencia;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IASugerencia {
|
||||||
|
campoId: string;
|
||||||
|
campoNombre: string;
|
||||||
|
valorActual: string;
|
||||||
|
valorSugerido: string;
|
||||||
|
aceptada?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CambioMateria {
|
||||||
|
id: string;
|
||||||
|
tipo: 'datos' | 'contenido' | 'bibliografia' | 'ia' | 'documento';
|
||||||
|
descripcion: string;
|
||||||
|
usuario: string;
|
||||||
|
fecha: Date;
|
||||||
|
detalles?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentoMateria {
|
||||||
|
id: string;
|
||||||
|
materiaId: string;
|
||||||
|
version: number;
|
||||||
|
fechaGeneracion: Date;
|
||||||
|
url?: string;
|
||||||
|
estado: 'generando' | 'listo' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MateriaDetailState {
|
||||||
|
materia: Materia | null;
|
||||||
|
estructura: MateriaStructure | null;
|
||||||
|
datosGenerales: Record<string, any>;
|
||||||
|
contenidoTematico: UnidadTematica[];
|
||||||
|
bibliografia: BibliografiaEntry[];
|
||||||
|
iaMessages: IAMessage[];
|
||||||
|
documentoSep: DocumentoMateria | null;
|
||||||
|
historial: CambioMateria[];
|
||||||
|
activeTab: MateriaTab;
|
||||||
|
isSaving: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
errorMessage: string | null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user