Merge branch 'fix/merge' into feat/ai-generate-plan
This commit is contained in:
@@ -1,14 +1,44 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Search, BookOpen, Trash2, Library, Edit3, Save } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
BookOpen,
|
||||
Trash2,
|
||||
Library,
|
||||
Edit3,
|
||||
Save,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSubjectBibliografia } from '@/data/hooks/useSubjects'
|
||||
//import { toast } from 'sonner';
|
||||
//import { mockLibraryResources } from '@/data/mockMateriaData';
|
||||
|
||||
@@ -20,7 +50,7 @@ export const mockLibraryResources = [
|
||||
editorial: 'MIT Press',
|
||||
anio: 2016,
|
||||
isbn: '9780262035613',
|
||||
disponible: true
|
||||
disponible: true,
|
||||
},
|
||||
{
|
||||
id: 'lib-2',
|
||||
@@ -29,102 +59,154 @@ export const mockLibraryResources = [
|
||||
editorial: 'Pearson',
|
||||
anio: 2020,
|
||||
isbn: '9780134610993',
|
||||
disponible: true
|
||||
disponible: true,
|
||||
},
|
||||
{
|
||||
id: 'lib-3',
|
||||
titulo: 'Hands-On Machine Learning',
|
||||
autor: 'Aurélien Géron',
|
||||
editorial: 'O\'Reilly Media',
|
||||
editorial: "O'Reilly Media",
|
||||
anio: 2019,
|
||||
isbn: '9781492032649',
|
||||
disponible: false
|
||||
}
|
||||
];
|
||||
disponible: false,
|
||||
},
|
||||
]
|
||||
|
||||
// --- Interfaces ---
|
||||
export interface BibliografiaEntry {
|
||||
id: string;
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA';
|
||||
cita: string;
|
||||
fuenteBibliotecaId?: string;
|
||||
fuenteBiblioteca?: any;
|
||||
id: string
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA'
|
||||
cita: string
|
||||
tipo_fuente?: 'MANUAL' | 'BIBLIOTECA'
|
||||
biblioteca_item_id?: string | null
|
||||
fuenteBibliotecaId?: string
|
||||
fuenteBiblioteca?: any
|
||||
}
|
||||
|
||||
interface BibliografiaTabProps {
|
||||
bibliografia: BibliografiaEntry[];
|
||||
onSave: (bibliografia: BibliografiaEntry[]) => void;
|
||||
isSaving: boolean;
|
||||
bibliografia: BibliografiaEntry[]
|
||||
onSave: (bibliografia: BibliografiaEntry[]) => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export function BibliographyItem({ bibliografia, onSave, isSaving }: BibliografiaTabProps) {
|
||||
const [entries, setEntries] = useState<BibliografiaEntry[]>(bibliografia);
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [newEntryType, setNewEntryType] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA');
|
||||
export function BibliographyItem({
|
||||
bibliografia,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: BibliografiaTabProps) {
|
||||
const { data: bibliografia2, isLoading: loadinmateria } =
|
||||
useSubjectBibliografia('9d4dda6a-488f-428a-8a07-38081592a641')
|
||||
const [entries, setEntries] = useState<BibliografiaEntry[]>(bibliografia)
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [newEntryType, setNewEntryType] = useState<'BASICA' | 'COMPLEMENTARIA'>(
|
||||
'BASICA',
|
||||
)
|
||||
|
||||
const basicaEntries = entries.filter(e => e.tipo === 'BASICA');
|
||||
const complementariaEntries = entries.filter(e => e.tipo === 'COMPLEMENTARIA');
|
||||
useEffect(() => {
|
||||
if (bibliografia2 && Array.isArray(bibliografia2)) {
|
||||
setEntries(bibliografia2)
|
||||
} else if (bibliografia) {
|
||||
// Fallback a la prop inicial si la API no devuelve nada
|
||||
setEntries(bibliografia)
|
||||
}
|
||||
}, [bibliografia2, bibliografia])
|
||||
|
||||
const basicaEntries = entries.filter((e) => e.tipo === 'BASICA')
|
||||
const complementariaEntries = entries.filter(
|
||||
(e) => e.tipo === 'COMPLEMENTARIA',
|
||||
)
|
||||
console.log(bibliografia2)
|
||||
|
||||
const handleAddManual = (cita: string) => {
|
||||
const newEntry: BibliografiaEntry = { id: `manual-${Date.now()}`, tipo: newEntryType, cita };
|
||||
setEntries([...entries, newEntry]);
|
||||
setIsAddDialogOpen(false);
|
||||
const newEntry: BibliografiaEntry = {
|
||||
id: `manual-${Date.now()}`,
|
||||
tipo: newEntryType,
|
||||
cita,
|
||||
}
|
||||
setEntries([...entries, newEntry])
|
||||
setIsAddDialogOpen(false)
|
||||
//toast.success('Referencia manual añadida');
|
||||
};
|
||||
}
|
||||
|
||||
const handleAddFromLibrary = (resource: any, tipo: 'BASICA' | 'COMPLEMENTARIA') => {
|
||||
const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.`;
|
||||
const handleAddFromLibrary = (
|
||||
resource: any,
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA',
|
||||
) => {
|
||||
const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.`
|
||||
const newEntry: BibliografiaEntry = {
|
||||
id: `lib-ref-${Date.now()}`,
|
||||
tipo,
|
||||
cita,
|
||||
fuenteBibliotecaId: resource.id,
|
||||
fuenteBiblioteca: resource,
|
||||
};
|
||||
setEntries([...entries, newEntry]);
|
||||
setIsLibraryDialogOpen(false);
|
||||
}
|
||||
setEntries([...entries, newEntry])
|
||||
setIsLibraryDialogOpen(false)
|
||||
//toast.success('Añadido desde biblioteca');
|
||||
};
|
||||
}
|
||||
|
||||
const handleUpdateCita = (id: string, cita: string) => {
|
||||
setEntries(entries.map(e => e.id === id ? { ...e, cita } : e));
|
||||
};
|
||||
setEntries(entries.map((e) => (e.id === id ? { ...e, cita } : e)))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-10 space-y-8 animate-in fade-in duration-500">
|
||||
<div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500">
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 tracking-tight">Bibliografía</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
{basicaEntries.length} básica • {complementariaEntries.length} complementaria
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||
Bibliografía
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{basicaEntries.length} básica • {complementariaEntries.length}{' '}
|
||||
complementaria
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dialog open={isLibraryDialogOpen} onOpenChange={setIsLibraryDialogOpen}>
|
||||
<Dialog
|
||||
open={isLibraryDialogOpen}
|
||||
onOpenChange={setIsLibraryDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="border-blue-200 text-blue-700 hover:bg-blue-50">
|
||||
<Library className="w-4 h-4 mr-2" /> Buscar en biblioteca
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-blue-200 text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
<Library className="mr-2 h-4 w-4" /> Buscar en biblioteca
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<LibrarySearchDialog onSelect={handleAddFromLibrary} existingIds={entries.map(e => e.fuenteBibliotecaId || '')} />
|
||||
<LibrarySearchDialog
|
||||
onSelect={handleAddFromLibrary}
|
||||
existingIds={entries.map((e) => e.fuenteBibliotecaId || '')}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline"><Plus className="w-4 h-4 mr-2" /> Añadir manual</Button>
|
||||
<Button variant="outline">
|
||||
<Plus className="mr-2 h-4 w-4" /> Añadir manual
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<AddManualDialog tipo={newEntryType} onTypeChange={setNewEntryType} onAdd={handleAddManual} />
|
||||
<AddManualDialog
|
||||
tipo={newEntryType}
|
||||
onTypeChange={setNewEntryType}
|
||||
onAdd={handleAddManual}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Button onClick={() => onSave(entries)} disabled={isSaving} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Save className="w-4 h-4 mr-2" /> {isSaving ? 'Guardando...' : 'Guardar'}
|
||||
<Button
|
||||
onClick={() => onSave(entries)}
|
||||
disabled={isSaving}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />{' '}
|
||||
{isSaving ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,14 +215,16 @@ export function BibliographyItem({ bibliografia, onSave, isSaving }: Bibliografi
|
||||
{/* BASICA */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-1 bg-blue-600 rounded-full" />
|
||||
<h3 className="font-semibold text-slate-800">Bibliografía Básica</h3>
|
||||
<div className="h-4 w-1 rounded-full bg-blue-600" />
|
||||
<h3 className="font-semibold text-slate-800">
|
||||
Bibliografía Básica
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{basicaEntries.map(entry => (
|
||||
<BibliografiaCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
{basicaEntries.map((entry) => (
|
||||
<BibliografiaCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isEditing={editingId === entry.id}
|
||||
onEdit={() => setEditingId(entry.id)}
|
||||
onStopEditing={() => setEditingId(null)}
|
||||
@@ -154,14 +238,16 @@ export function BibliographyItem({ bibliografia, onSave, isSaving }: Bibliografi
|
||||
{/* COMPLEMENTARIA */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-1 bg-slate-400 rounded-full" />
|
||||
<h3 className="font-semibold text-slate-800">Bibliografía Complementaria</h3>
|
||||
<div className="h-4 w-1 rounded-full bg-slate-400" />
|
||||
<h3 className="font-semibold text-slate-800">
|
||||
Bibliografía Complementaria
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{complementariaEntries.map(entry => (
|
||||
<BibliografiaCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
{complementariaEntries.map((entry) => (
|
||||
<BibliografiaCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isEditing={editingId === entry.id}
|
||||
onEdit={() => setEditingId(entry.id)}
|
||||
onStopEditing={() => setEditingId(null)}
|
||||
@@ -177,70 +263,143 @@ export function BibliographyItem({ bibliografia, onSave, isSaving }: Bibliografi
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>¿Eliminar referencia?</AlertDialogTitle>
|
||||
<AlertDialogDescription>La referencia será quitada del plan de estudios.</AlertDialogDescription>
|
||||
<AlertDialogDescription>
|
||||
La referencia será quitada del plan de estudios.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => { setEntries(entries.filter(e => e.id !== deleteId)); setDeleteId(null); }} className="bg-red-600">Eliminar</AlertDialogAction>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setEntries(entries.filter((e) => e.id !== deleteId))
|
||||
setDeleteId(null)
|
||||
}}
|
||||
className="bg-red-600"
|
||||
>
|
||||
Eliminar
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// --- Subcomponentes ---
|
||||
|
||||
function BibliografiaCard({ entry, isEditing, onEdit, onStopEditing, onUpdateCita, onDelete }: any) {
|
||||
const [localCita, setLocalCita] = useState(entry.cita);
|
||||
function BibliografiaCard({
|
||||
entry,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onStopEditing,
|
||||
onUpdateCita,
|
||||
onDelete,
|
||||
}: any) {
|
||||
const [localCita, setLocalCita] = useState(entry.cita)
|
||||
|
||||
return (
|
||||
<Card className={cn("group transition-all hover:shadow-md", isEditing && "ring-2 ring-blue-500")}>
|
||||
<Card
|
||||
className={cn(
|
||||
'group transition-all hover:shadow-md',
|
||||
isEditing && 'ring-2 ring-blue-500',
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<BookOpen className={cn("w-5 h-5 mt-1", entry.tipo === 'BASICA' ? "text-blue-600" : "text-slate-400")} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<BookOpen
|
||||
className={cn(
|
||||
'mt-1 h-5 w-5',
|
||||
entry.tipo === 'BASICA' ? 'text-blue-600' : 'text-slate-400',
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea value={localCita} onChange={(e) => setLocalCita(e.target.value)} className="min-h-[80px]" />
|
||||
<Textarea
|
||||
value={localCita}
|
||||
onChange={(e) => setLocalCita(e.target.value)}
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onStopEditing}>Cancelar</Button>
|
||||
<Button size="sm" className="bg-emerald-600" onClick={() => { onUpdateCita(entry.id, localCita); onStopEditing(); }}>Guardar</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onStopEditing}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-emerald-600"
|
||||
onClick={() => {
|
||||
onUpdateCita(entry.id, localCita)
|
||||
onStopEditing()
|
||||
}}
|
||||
>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div onClick={onEdit} className="cursor-pointer">
|
||||
<p className="text-sm leading-relaxed text-slate-700">{entry.cita}</p>
|
||||
<p className="text-sm leading-relaxed text-slate-700">
|
||||
{entry.cita}
|
||||
</p>
|
||||
{entry.fuenteBiblioteca && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Badge variant="secondary" className="text-[10px] bg-slate-100 text-slate-600">Biblioteca</Badge>
|
||||
{entry.fuenteBiblioteca.disponible && <Badge className="text-[10px] bg-emerald-50 text-emerald-700 border-emerald-100">Disponible</Badge>}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-slate-100 text-[10px] text-slate-600"
|
||||
>
|
||||
Biblioteca
|
||||
</Badge>
|
||||
{entry.fuenteBiblioteca.disponible && (
|
||||
<Badge className="border-emerald-100 bg-emerald-50 text-[10px] text-emerald-700">
|
||||
Disponible
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<div className="flex opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-600" onClick={onEdit}><Edit3 className="w-4 h-4" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-500" onClick={onDelete}><Trash2 className="w-4 h-4" /></Button>
|
||||
<div className="flex opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-600"
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-500"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function AddManualDialog({ tipo, onTypeChange, onAdd }: any) {
|
||||
const [cita, setCita] = useState('');
|
||||
const [cita, setCita] = useState('')
|
||||
return (
|
||||
<div className="space-y-4 py-4">
|
||||
<DialogHeader><DialogTitle>Referencia Manual</DialogTitle></DialogHeader>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Referencia Manual</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold uppercase text-slate-500">Tipo</label>
|
||||
<label className="text-xs font-bold text-slate-500 uppercase">
|
||||
Tipo
|
||||
</label>
|
||||
<Select value={tipo} onValueChange={onTypeChange}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="BASICA">Básica</SelectItem>
|
||||
<SelectItem value="COMPLEMENTARIA">Complementaria</SelectItem>
|
||||
@@ -248,44 +407,78 @@ function AddManualDialog({ tipo, onTypeChange, onAdd }: any) {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold uppercase text-slate-500">Cita APA</label>
|
||||
<Textarea value={cita} onChange={(e) => setCita(e.target.value)} placeholder="Autor, A. (Año). Título..." className="min-h-[120px]" />
|
||||
<label className="text-xs font-bold text-slate-500 uppercase">
|
||||
Cita APA
|
||||
</label>
|
||||
<Textarea
|
||||
value={cita}
|
||||
onChange={(e) => setCita(e.target.value)}
|
||||
placeholder="Autor, A. (Año). Título..."
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => onAdd(cita)} disabled={!cita.trim()} className="w-full bg-blue-600">Añadir a la lista</Button>
|
||||
<Button
|
||||
onClick={() => onAdd(cita)}
|
||||
disabled={!cita.trim()}
|
||||
className="w-full bg-blue-600"
|
||||
>
|
||||
Añadir a la lista
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function LibrarySearchDialog({ onSelect, existingIds }: any) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA');
|
||||
const filtered = mockLibraryResources.filter(r =>
|
||||
!existingIds.includes(r.id) && r.titulo.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
const [search, setSearch] = useState('')
|
||||
const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA')
|
||||
const filtered = mockLibraryResources.filter(
|
||||
(r) =>
|
||||
!existingIds.includes(r.id) &&
|
||||
r.titulo.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-2">
|
||||
<DialogHeader><DialogTitle>Catálogo de Biblioteca</DialogTitle></DialogHeader>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Catálogo de Biblioteca</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Buscar por título o autor..." className="pl-10" />
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar por título o autor..."
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={tipo} onValueChange={(v:any) => setTipo(v)}><SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="BASICA">Básica</SelectItem><SelectItem value="COMPLEMENTARIA">Complem.</SelectItem></SelectContent>
|
||||
<Select value={tipo} onValueChange={(v: any) => setTipo(v)}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="BASICA">Básica</SelectItem>
|
||||
<SelectItem value="COMPLEMENTARIA">Complem.</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto pr-2 space-y-2">
|
||||
{filtered.map(res => (
|
||||
<div key={res.id} onClick={() => onSelect(res, tipo)} className="p-3 border rounded-lg hover:bg-slate-50 cursor-pointer flex justify-between items-center group">
|
||||
<div className="max-h-[300px] space-y-2 overflow-y-auto pr-2">
|
||||
{filtered.map((res) => (
|
||||
<div
|
||||
key={res.id}
|
||||
onClick={() => onSelect(res, tipo)}
|
||||
className="group flex cursor-pointer items-center justify-between rounded-lg border p-3 hover:bg-slate-50"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-700">{res.titulo}</p>
|
||||
<p className="text-sm font-semibold text-slate-700">
|
||||
{res.titulo}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">{res.autor}</p>
|
||||
</div>
|
||||
<Plus className="w-4 h-4 text-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<Plus className="h-4 w-4 text-blue-600 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, GripVertical, ChevronDown, ChevronRight, Edit3, Trash2, Clock, Save } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Plus,
|
||||
GripVertical,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Edit3,
|
||||
Trash2,
|
||||
Clock,
|
||||
Save,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -14,24 +27,22 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
//import { toast } from 'sonner';
|
||||
|
||||
|
||||
|
||||
export interface Tema {
|
||||
id: string;
|
||||
nombre: string;
|
||||
descripcion?: string;
|
||||
horasEstimadas?: number;
|
||||
id: string
|
||||
nombre: string
|
||||
descripcion?: string
|
||||
horasEstimadas?: number
|
||||
}
|
||||
|
||||
export interface UnidadTematica {
|
||||
id: string;
|
||||
nombre: string;
|
||||
numero: number;
|
||||
temas: Tema[];
|
||||
id: string
|
||||
nombre: string
|
||||
numero: number
|
||||
temas: Tema[]
|
||||
}
|
||||
|
||||
const initialData: UnidadTematica[] = [
|
||||
@@ -42,152 +53,297 @@ const initialData: UnidadTematica[] = [
|
||||
temas: [
|
||||
{ id: 't1', nombre: 'Tipos de IA y aplicaciones', horasEstimadas: 6 },
|
||||
{ id: 't2', nombre: 'Ética en IA', horasEstimadas: 3 },
|
||||
]
|
||||
}
|
||||
];
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function ContenidoTematico() {
|
||||
const [unidades, setUnidades] = useState<UnidadTematica[]>(initialData);
|
||||
const [expandedUnits, setExpandedUnits] = useState<Set<string>>(new Set(['u1']));
|
||||
const [deleteDialog, setDeleteDialog] = useState<{ type: 'unidad' | 'tema'; id: string; parentId?: string } | null>(null);
|
||||
const [editingUnit, setEditingUnit] = useState<string | null>(null);
|
||||
const [editingTema, setEditingTema] = useState<{ unitId: string; temaId: string } | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
// Estructura que viene de tu JSON/API
|
||||
interface ContenidoApi {
|
||||
unidad: number
|
||||
titulo: string
|
||||
temas: string[] | any[] // Acepta strings o objetos
|
||||
[key: string]: any // Esta línea permite que haya más claves desconocidas
|
||||
}
|
||||
|
||||
// Props del componente
|
||||
interface ContenidoTematicoProps {
|
||||
data: {
|
||||
contenido_tematico: ContenidoApi[]
|
||||
}
|
||||
isLoading: boolean
|
||||
}
|
||||
export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
|
||||
const [unidades, setUnidades] = useState<UnidadTematica[]>([])
|
||||
const [expandedUnits, setExpandedUnits] = useState<Set<string>>(
|
||||
new Set(['u1']),
|
||||
)
|
||||
const [deleteDialog, setDeleteDialog] = useState<{
|
||||
type: 'unidad' | 'tema'
|
||||
id: string
|
||||
parentId?: string
|
||||
} | null>(null)
|
||||
const [editingUnit, setEditingUnit] = useState<string | null>(null)
|
||||
const [editingTema, setEditingTema] = useState<{
|
||||
unitId: string
|
||||
temaId: string
|
||||
} | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.contenido_tematico) {
|
||||
const transformed = data.contenido_tematico.map(
|
||||
(u: any, idx: number) => ({
|
||||
id: `u-${idx}`,
|
||||
numero: u.unidad || idx + 1,
|
||||
nombre: u.titulo || 'Sin título',
|
||||
temas: Array.isArray(u.temas)
|
||||
? u.temas.map((t: any, tidx: number) => ({
|
||||
id: `t-${idx}-${tidx}`,
|
||||
nombre: typeof t === 'string' ? t : t.nombre || 'Tema',
|
||||
horasEstimadas: t.horasEstimadas || 0,
|
||||
}))
|
||||
: [],
|
||||
}),
|
||||
)
|
||||
setUnidades(transformed)
|
||||
|
||||
// Expandir la primera unidad automáticamente
|
||||
if (transformed.length > 0) {
|
||||
setExpandedUnits(new Set([transformed[0].id]))
|
||||
}
|
||||
}
|
||||
}, [data])
|
||||
|
||||
if (isLoading)
|
||||
return <div className="p-10 text-center">Cargando contenido...</div>
|
||||
|
||||
// 3. Cálculo de horas (ahora dinámico basado en los nuevos datos)
|
||||
const totalHoras = unidades.reduce(
|
||||
(acc, u) =>
|
||||
acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0),
|
||||
0,
|
||||
)
|
||||
|
||||
// --- Lógica de Unidades ---
|
||||
const toggleUnit = (id: string) => {
|
||||
const newExpanded = new Set(expandedUnits);
|
||||
newExpanded.has(id) ? newExpanded.delete(id) : newExpanded.add(id);
|
||||
setExpandedUnits(newExpanded);
|
||||
};
|
||||
const newExpanded = new Set(expandedUnits)
|
||||
newExpanded.has(id) ? newExpanded.delete(id) : newExpanded.add(id)
|
||||
setExpandedUnits(newExpanded)
|
||||
}
|
||||
|
||||
const addUnidad = () => {
|
||||
const newId = `u-${Date.now()}`;
|
||||
const newId = `u-${Date.now()}`
|
||||
const newUnidad: UnidadTematica = {
|
||||
id: newId,
|
||||
nombre: 'Nueva Unidad',
|
||||
numero: unidades.length + 1,
|
||||
temas: [],
|
||||
};
|
||||
setUnidades([...unidades, newUnidad]);
|
||||
setExpandedUnits(new Set([...expandedUnits, newId]));
|
||||
setEditingUnit(newId);
|
||||
};
|
||||
}
|
||||
setUnidades([...unidades, newUnidad])
|
||||
setExpandedUnits(new Set([...expandedUnits, newId]))
|
||||
setEditingUnit(newId)
|
||||
}
|
||||
|
||||
const updateUnidadNombre = (id: string, nombre: string) => {
|
||||
setUnidades(unidades.map(u => u.id === id ? { ...u, nombre } : u));
|
||||
};
|
||||
setUnidades(unidades.map((u) => (u.id === id ? { ...u, nombre } : u)))
|
||||
}
|
||||
|
||||
// --- Lógica de Temas ---
|
||||
const addTema = (unidadId: string) => {
|
||||
setUnidades(unidades.map(u => {
|
||||
if (u.id === unidadId) {
|
||||
const newTemaId = `t-${Date.now()}`;
|
||||
const newTema: Tema = { id: newTemaId, nombre: 'Nuevo tema', horasEstimadas: 2 };
|
||||
setEditingTema({ unitId: unidadId, temaId: newTemaId });
|
||||
return { ...u, temas: [...u.temas, newTema] };
|
||||
}
|
||||
return u;
|
||||
}));
|
||||
};
|
||||
setUnidades(
|
||||
unidades.map((u) => {
|
||||
if (u.id === unidadId) {
|
||||
const newTemaId = `t-${Date.now()}`
|
||||
const newTema: Tema = {
|
||||
id: newTemaId,
|
||||
nombre: 'Nuevo tema',
|
||||
horasEstimadas: 2,
|
||||
}
|
||||
setEditingTema({ unitId: unidadId, temaId: newTemaId })
|
||||
return { ...u, temas: [...u.temas, newTema] }
|
||||
}
|
||||
return u
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const updateTema = (unidadId: string, temaId: string, updates: Partial<Tema>) => {
|
||||
setUnidades(unidades.map(u => {
|
||||
if (u.id === unidadId) {
|
||||
return { ...u, temas: u.temas.map(t => t.id === temaId ? { ...t, ...updates } : t) };
|
||||
}
|
||||
return u;
|
||||
}));
|
||||
};
|
||||
const updateTema = (
|
||||
unidadId: string,
|
||||
temaId: string,
|
||||
updates: Partial<Tema>,
|
||||
) => {
|
||||
setUnidades(
|
||||
unidades.map((u) => {
|
||||
if (u.id === unidadId) {
|
||||
return {
|
||||
...u,
|
||||
temas: u.temas.map((t) =>
|
||||
t.id === temaId ? { ...t, ...updates } : t,
|
||||
),
|
||||
}
|
||||
}
|
||||
return u
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteDialog) return;
|
||||
if (!deleteDialog) return
|
||||
if (deleteDialog.type === 'unidad') {
|
||||
setUnidades(unidades.filter(u => u.id !== deleteDialog.id).map((u, i) => ({ ...u, numero: i + 1 })));
|
||||
setUnidades(
|
||||
unidades
|
||||
.filter((u) => u.id !== deleteDialog.id)
|
||||
.map((u, i) => ({ ...u, numero: i + 1 })),
|
||||
)
|
||||
} else if (deleteDialog.parentId) {
|
||||
setUnidades(unidades.map(u => u.id === deleteDialog.parentId ? { ...u, temas: u.temas.filter(t => t.id !== deleteDialog.id) } : u));
|
||||
setUnidades(
|
||||
unidades.map((u) =>
|
||||
u.id === deleteDialog.parentId
|
||||
? { ...u, temas: u.temas.filter((t) => t.id !== deleteDialog.id) }
|
||||
: u,
|
||||
),
|
||||
)
|
||||
}
|
||||
setDeleteDialog(null);
|
||||
setDeleteDialog(null)
|
||||
//toast.success("Eliminado correctamente");
|
||||
};
|
||||
|
||||
const totalHoras = unidades.reduce((acc, u) => acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0), 0);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-10 space-y-6 animate-in fade-in duration-500">
|
||||
<div className="animate-in fade-in mx-auto max-w-5xl space-y-6 py-10 duration-500">
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">Contenido Temático</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||
Contenido Temático
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{unidades.length} unidades • {totalHoras} horas estimadas totales
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={addUnidad} className="gap-2">
|
||||
<Plus className="w-4 h-4" /> Nueva unidad
|
||||
<Plus className="h-4 w-4" /> Nueva unidad
|
||||
</Button>
|
||||
<Button onClick={() => { setIsSaving(true); setTimeout(() => { setIsSaving(false); /*toast.success("Guardado")*/; }, 1000); }} disabled={isSaving} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Save className="w-4 h-4 mr-2" /> {isSaving ? 'Guardando...' : 'Guardar'}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsSaving(true)
|
||||
setTimeout(() => {
|
||||
setIsSaving(false) /*toast.success("Guardado")*/
|
||||
}, 1000)
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />{' '}
|
||||
{isSaving ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{unidades.map((unidad) => (
|
||||
<Card key={unidad.id} className="overflow-hidden border-slate-200 shadow-sm">
|
||||
<Collapsible open={expandedUnits.has(unidad.id)} onOpenChange={() => toggleUnit(unidad.id)}>
|
||||
<CardHeader className="bg-slate-50/50 py-3 border-b border-slate-100">
|
||||
<Card
|
||||
key={unidad.id}
|
||||
className="overflow-hidden border-slate-200 shadow-sm"
|
||||
>
|
||||
<Collapsible
|
||||
open={expandedUnits.has(unidad.id)}
|
||||
onOpenChange={() => toggleUnit(unidad.id)}
|
||||
>
|
||||
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical className="w-4 h-4 text-slate-300 cursor-grab" />
|
||||
<GripVertical className="h-4 w-4 cursor-grab text-slate-300" />
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="p-0 h-auto">
|
||||
{expandedUnits.has(unidad.id) ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
<Button variant="ghost" size="sm" className="h-auto p-0">
|
||||
{expandedUnits.has(unidad.id) ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<Badge className="bg-blue-600 font-mono">Unidad {unidad.numero}</Badge>
|
||||
|
||||
<Badge className="bg-blue-600 font-mono">
|
||||
Unidad {unidad.numero}
|
||||
</Badge>
|
||||
|
||||
{editingUnit === unidad.id ? (
|
||||
<Input
|
||||
value={unidad.nombre}
|
||||
onChange={(e) => updateUnidadNombre(unidad.id, e.target.value)}
|
||||
<Input
|
||||
value={unidad.nombre}
|
||||
onChange={(e) =>
|
||||
updateUnidadNombre(unidad.id, e.target.value)
|
||||
}
|
||||
onBlur={() => setEditingUnit(null)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setEditingUnit(null)}
|
||||
className="max-w-md h-8 bg-white"
|
||||
autoFocus
|
||||
onKeyDown={(e) =>
|
||||
e.key === 'Enter' && setEditingUnit(null)
|
||||
}
|
||||
className="h-8 max-w-md bg-white"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<CardTitle className="text-base font-semibold cursor-pointer hover:text-blue-600 transition-colors" onClick={() => setEditingUnit(unidad.id)}>
|
||||
<CardTitle
|
||||
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
|
||||
onClick={() => setEditingUnit(unidad.id)}
|
||||
>
|
||||
{unidad.nombre}
|
||||
</CardTitle>
|
||||
)}
|
||||
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<span className="text-xs font-medium text-slate-400 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" /> {unidad.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0)}h
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-slate-400">
|
||||
<Clock className="h-3 w-3" />{' '}
|
||||
{unidad.temas.reduce(
|
||||
(sum, t) => sum + (t.horasEstimadas || 0),
|
||||
0,
|
||||
)}
|
||||
h
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-500" onClick={() => setDeleteDialog({ type: 'unidad', id: unidad.id })}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-500"
|
||||
onClick={() =>
|
||||
setDeleteDialog({ type: 'unidad', id: unidad.id })
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="pt-4 bg-white">
|
||||
<div className="space-y-1 ml-10 border-l-2 border-slate-50 pl-4">
|
||||
<CardContent className="bg-white pt-4">
|
||||
<div className="ml-10 space-y-1 border-l-2 border-slate-50 pl-4">
|
||||
{unidad.temas.map((tema, idx) => (
|
||||
<TemaRow
|
||||
key={tema.id}
|
||||
tema={tema}
|
||||
index={idx + 1}
|
||||
isEditing={editingTema?.unitId === unidad.id && editingTema?.temaId === tema.id}
|
||||
onEdit={() => setEditingTema({ unitId: unidad.id, temaId: tema.id })}
|
||||
<TemaRow
|
||||
key={tema.id}
|
||||
tema={tema}
|
||||
index={idx + 1}
|
||||
isEditing={
|
||||
editingTema?.unitId === unidad.id &&
|
||||
editingTema?.temaId === tema.id
|
||||
}
|
||||
onEdit={() =>
|
||||
setEditingTema({ unitId: unidad.id, temaId: tema.id })
|
||||
}
|
||||
onStopEditing={() => setEditingTema(null)}
|
||||
onUpdate={(updates) => updateTema(unidad.id, tema.id, updates)}
|
||||
onDelete={() => setDeleteDialog({ type: 'tema', id: tema.id, parentId: unidad.id })}
|
||||
onUpdate={(updates) =>
|
||||
updateTema(unidad.id, tema.id, updates)
|
||||
}
|
||||
onDelete={() =>
|
||||
setDeleteDialog({
|
||||
type: 'tema',
|
||||
id: tema.id,
|
||||
parentId: unidad.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Button variant="ghost" size="sm" className="text-blue-600 hover:text-blue-700 hover:bg-blue-50 w-full justify-start mt-2" onClick={() => addTema(unidad.id)}>
|
||||
<Plus className="w-3 h-3 mr-2" /> Añadir subtema
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 w-full justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
|
||||
onClick={() => addTema(unidad.id)}
|
||||
>
|
||||
<Plus className="mr-2 h-3 w-3" /> Añadir subtema
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -197,81 +353,137 @@ export function ContenidoTematico() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DeleteConfirmDialog dialog={deleteDialog} setDialog={setDeleteDialog} onConfirm={handleDelete} />
|
||||
<DeleteConfirmDialog
|
||||
dialog={deleteDialog}
|
||||
setDialog={setDeleteDialog}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// --- Componentes Auxiliares ---
|
||||
interface TemaRowProps {
|
||||
tema: Tema;
|
||||
index: number;
|
||||
isEditing: boolean;
|
||||
onEdit: () => void;
|
||||
onStopEditing: () => void;
|
||||
onUpdate: (updates: Partial<Tema>) => void;
|
||||
onDelete: () => void;
|
||||
tema: Tema
|
||||
index: number
|
||||
isEditing: boolean
|
||||
onEdit: () => void
|
||||
onStopEditing: () => void
|
||||
onUpdate: (updates: Partial<Tema>) => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
function TemaRow({ tema, index, isEditing, onEdit, onStopEditing, onUpdate, onDelete }: TemaRowProps) {
|
||||
function TemaRow({
|
||||
tema,
|
||||
index,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onStopEditing,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: TemaRowProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3 p-2 rounded-md group transition-all", isEditing ? "bg-blue-50 ring-1 ring-blue-100" : "hover:bg-slate-50")}>
|
||||
<span className="text-xs font-mono text-slate-400 w-4">{index}.</span>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-3 rounded-md p-2 transition-all',
|
||||
isEditing ? 'bg-blue-50 ring-1 ring-blue-100' : 'hover:bg-slate-50',
|
||||
)}
|
||||
>
|
||||
<span className="w-4 font-mono text-xs text-slate-400">{index}.</span>
|
||||
{isEditing ? (
|
||||
<div className="flex-1 flex items-center gap-2 animate-in slide-in-from-left-2">
|
||||
<Input value={tema.nombre} onChange={(e) => onUpdate({ nombre: e.target.value })} className="h-8 flex-1 bg-white" placeholder="Nombre" autoFocus />
|
||||
<Input type="number" value={tema.horasEstimadas} onChange={(e) => onUpdate({ horasEstimadas: parseInt(e.target.value) || 0 })} className="h-8 w-16 bg-white" />
|
||||
<Button size="sm" className="bg-emerald-600 h-8" onClick={onStopEditing}>Listo</Button>
|
||||
<div className="animate-in slide-in-from-left-2 flex flex-1 items-center gap-2">
|
||||
<Input
|
||||
value={tema.nombre}
|
||||
onChange={(e) => onUpdate({ nombre: e.target.value })}
|
||||
className="h-8 flex-1 bg-white"
|
||||
placeholder="Nombre"
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={tema.horasEstimadas}
|
||||
onChange={(e) =>
|
||||
onUpdate({ horasEstimadas: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
className="h-8 w-16 bg-white"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 bg-emerald-600"
|
||||
onClick={onStopEditing}
|
||||
>
|
||||
Listo
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 cursor-pointer" onClick={onEdit}>
|
||||
<p className="text-sm font-medium text-slate-700">{tema.nombre}</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-[10px] opacity-60">{tema.horasEstimadas}h</Badge>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-600" onClick={onEdit}><Edit3 className="w-3 h-3" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-red-500" onClick={onDelete}><Trash2 className="w-3 h-3" /></Button>
|
||||
<Badge variant="secondary" className="text-[10px] opacity-60">
|
||||
{tema.horasEstimadas}h
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-blue-600"
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-red-500"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
interface DeleteDialogState {
|
||||
type: 'unidad' | 'tema';
|
||||
id: string;
|
||||
parentId?: string;
|
||||
type: 'unidad' | 'tema'
|
||||
id: string
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
interface DeleteConfirmDialogProps {
|
||||
dialog: DeleteDialogState | null;
|
||||
setDialog: (value: DeleteDialogState | null) => void;
|
||||
onConfirm: () => void;
|
||||
dialog: DeleteDialogState | null
|
||||
setDialog: (value: DeleteDialogState | null) => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
|
||||
function DeleteConfirmDialog({
|
||||
dialog,
|
||||
setDialog,
|
||||
onConfirm,
|
||||
}: DeleteConfirmDialogProps) {
|
||||
|
||||
return (
|
||||
<AlertDialog open={!!dialog} onOpenChange={() => setDialog(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>¿Confirmar eliminación?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Estás a punto de borrar un {dialog?.type}. Esta acción no se puede deshacer.
|
||||
Estás a punto de borrar un {dialog?.type}. Esta acción no se puede
|
||||
deshacer.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm} className="bg-red-600 hover:bg-red-700 text-white">Eliminar</AlertDialogAction>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
Eliminar
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { FileText, Download, RefreshCw, Calendar, FileCheck, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
FileText,
|
||||
Download,
|
||||
RefreshCw,
|
||||
Calendar,
|
||||
FileCheck,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -13,63 +21,88 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import type { DocumentoMateria, Materia, MateriaStructure } from '@/types/materia';
|
||||
import { cn } from '@/lib/utils';
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import type {
|
||||
DocumentoMateria,
|
||||
Materia,
|
||||
MateriaStructure,
|
||||
} from '@/types/materia'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSubjectBibliografia } from '@/data/hooks/useSubjects'
|
||||
//import { toast } from 'sonner';
|
||||
//import { format } from 'date-fns';
|
||||
//import { es } from 'date-fns/locale';
|
||||
|
||||
interface DocumentoSEPTabProps {
|
||||
documento: DocumentoMateria | null;
|
||||
materia: Materia;
|
||||
estructura: MateriaStructure;
|
||||
datosGenerales: Record<string, any>;
|
||||
onRegenerate: () => void;
|
||||
isRegenerating: boolean;
|
||||
documento: DocumentoMateria | null
|
||||
materia: Materia
|
||||
estructura: MateriaStructure
|
||||
datosGenerales: Record<string, any>
|
||||
onRegenerate: () => void
|
||||
isRegenerating: boolean
|
||||
}
|
||||
|
||||
export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales, onRegenerate, isRegenerating }: DocumentoSEPTabProps) {
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
export function DocumentoSEPTab({
|
||||
documento,
|
||||
materia,
|
||||
estructura,
|
||||
datosGenerales,
|
||||
onRegenerate,
|
||||
isRegenerating,
|
||||
}: DocumentoSEPTabProps) {
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
|
||||
|
||||
// Check completeness
|
||||
const camposObligatorios = estructura.campos.filter(c => c.obligatorio);
|
||||
const camposCompletos = camposObligatorios.filter(c => datosGenerales[c.id]?.trim());
|
||||
const completeness = Math.round((camposCompletos.length / camposObligatorios.length) * 100);
|
||||
const isComplete = completeness === 100;
|
||||
const camposObligatorios = estructura.campos.filter((c) => c.obligatorio)
|
||||
const camposCompletos = camposObligatorios.filter((c) =>
|
||||
datosGenerales[c.id]?.trim(),
|
||||
)
|
||||
const completeness = Math.round(
|
||||
(camposCompletos.length / camposObligatorios.length) * 100,
|
||||
)
|
||||
const isComplete = completeness === 100
|
||||
|
||||
const handleRegenerate = () => {
|
||||
setShowConfirmDialog(false);
|
||||
onRegenerate();
|
||||
setShowConfirmDialog(false)
|
||||
onRegenerate()
|
||||
//toast.success('Regenerando documento...');
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<FileCheck className="w-6 h-6 text-accent" />
|
||||
<h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
|
||||
<FileCheck className="text-accent h-6 w-6" />
|
||||
Documento SEP
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Previsualización del documento oficial para la SEP
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{documento?.estado === 'listo' && (
|
||||
<Button variant="outline" onClick={() => console.log("descargando") /*toast.info('Descarga iniciada')*/}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={
|
||||
() =>
|
||||
console.log('descargando') /*toast.info('Descarga iniciada')*/
|
||||
}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Descargar
|
||||
</Button>
|
||||
)}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialog
|
||||
open={showConfirmDialog}
|
||||
onOpenChange={setShowConfirmDialog}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button disabled={isRegenerating || !isComplete}>
|
||||
{isRegenerating ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
|
||||
</Button>
|
||||
@@ -78,8 +111,9 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>¿Regenerar documento SEP?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Se creará una nueva versión del documento con los datos actuales de la materia.
|
||||
La versión anterior quedará en el historial.
|
||||
Se creará una nueva versión del documento con los datos
|
||||
actuales de la materia. La versión anterior quedará en el
|
||||
historial.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -93,91 +127,108 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Document preview */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="card-elevated h-[700px] overflow-hidden">
|
||||
{documento?.estado === 'listo' ? (
|
||||
<div className="h-full bg-muted/30 flex flex-col">
|
||||
<div className="bg-muted/30 flex h-full flex-col">
|
||||
{/* Simulated document header */}
|
||||
<div className="bg-card border-b p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
<span className="font-medium text-foreground">
|
||||
<FileText className="text-primary h-5 w-5" />
|
||||
<span className="text-foreground font-medium">
|
||||
Programa de Estudios - {materia.clave}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline">Versión {documento.version}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Document content simulation */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="max-w-2xl mx-auto bg-card rounded-lg shadow-lg p-8 space-y-6">
|
||||
<div className="bg-card mx-auto max-w-2xl space-y-6 rounded-lg p-8 shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="text-center border-b pb-6">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">
|
||||
<div className="border-b pb-6 text-center">
|
||||
<p className="text-muted-foreground mb-2 text-xs tracking-wide uppercase">
|
||||
Secretaría de Educación Pública
|
||||
</p>
|
||||
<h1 className="font-display text-2xl font-bold text-primary mb-1">
|
||||
<h1 className="font-display text-primary mb-1 text-2xl font-bold">
|
||||
{materia.nombre}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Clave: {materia.clave} | Créditos: {materia.creditos || 'N/A'}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Clave: {materia.clave} | Créditos:{' '}
|
||||
{materia.creditos || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Datos de la institución */}
|
||||
<div className="space-y-1 text-sm">
|
||||
<p><strong>Carrera:</strong> {materia.carrera}</p>
|
||||
<p><strong>Facultad:</strong> {materia.facultad}</p>
|
||||
<p><strong>Plan de estudios:</strong> {materia.planNombre}</p>
|
||||
{materia.ciclo && <p><strong>Ciclo:</strong> {materia.ciclo}</p>}
|
||||
<p>
|
||||
<strong>Carrera:</strong> {materia.carrera}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Facultad:</strong> {materia.facultad}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Plan de estudios:</strong> {materia.planNombre}
|
||||
</p>
|
||||
{materia.ciclo && (
|
||||
<p>
|
||||
<strong>Ciclo:</strong> {materia.ciclo}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Campos del documento */}
|
||||
{estructura.campos.map((campo) => {
|
||||
const valor = datosGenerales[campo.id];
|
||||
if (!valor) return null;
|
||||
const valor = datosGenerales[campo.id]
|
||||
if (!valor) return null
|
||||
return (
|
||||
<div key={campo.id} className="space-y-2">
|
||||
<h3 className="font-semibold text-foreground border-b pb-1">
|
||||
<h3 className="text-foreground border-b pb-1 font-semibold">
|
||||
{campo.nombre}
|
||||
</h3>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
|
||||
<p className="text-foreground text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{valor}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t pt-6 mt-8 text-center text-xs text-muted-foreground">
|
||||
<p>Documento generado el {/*format(documento.fechaGeneracion, "d 'de' MMMM 'de' yyyy", { locale: es })*/}</p>
|
||||
<div className="text-muted-foreground mt-8 border-t pt-6 text-center text-xs">
|
||||
<p>
|
||||
Documento generado el{' '}
|
||||
{/*format(documento.fechaGeneracion, "d 'de' MMMM 'de' yyyy", { locale: es })*/}
|
||||
</p>
|
||||
<p className="mt-1">Universidad La Salle</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : documento?.estado === 'generando' ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 mx-auto text-accent animate-spin mb-4" />
|
||||
<p className="text-muted-foreground">Generando documento...</p>
|
||||
<Loader2 className="text-accent mx-auto mb-4 h-12 w-12 animate-spin" />
|
||||
<p className="text-muted-foreground">
|
||||
Generando documento...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center max-w-sm">
|
||||
<FileText className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="max-w-sm text-center">
|
||||
<FileText className="text-muted-foreground/50 mx-auto mb-4 h-12 w-12" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No hay documento generado aún
|
||||
</p>
|
||||
{!isComplete && (
|
||||
<div className="p-4 bg-warning/10 rounded-lg text-sm text-warning-foreground">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-2" />
|
||||
Completa todos los campos obligatorios para generar el documento
|
||||
<div className="bg-warning/10 text-warning-foreground rounded-lg p-4 text-sm">
|
||||
<AlertTriangle className="mr-2 inline h-4 w-4" />
|
||||
Completa todos los campos obligatorios para generar el
|
||||
documento
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -191,28 +242,41 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
|
||||
{/* Status */}
|
||||
<Card className="card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Estado del documento</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Estado del documento
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{documento && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Versión</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Versión
|
||||
</span>
|
||||
<Badge variant="outline">{documento.version}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Generado</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Generado
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
{/*format(documento.fechaGeneracion, "d MMM yyyy, HH:mm", { locale: es })*/}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Estado</span>
|
||||
<Badge className={cn(
|
||||
documento.estado === 'listo' && "bg-success text-success-foreground",
|
||||
documento.estado === 'generando' && "bg-info text-info-foreground",
|
||||
documento.estado === 'error' && "bg-destructive text-destructive-foreground"
|
||||
)}>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Estado
|
||||
</span>
|
||||
<Badge
|
||||
className={cn(
|
||||
documento.estado === 'listo' &&
|
||||
'bg-success text-success-foreground',
|
||||
documento.estado === 'generando' &&
|
||||
'bg-info text-info-foreground',
|
||||
documento.estado === 'error' &&
|
||||
'bg-destructive text-destructive-foreground',
|
||||
)}
|
||||
>
|
||||
{documento.estado === 'listo' && 'Listo'}
|
||||
{documento.estado === 'generando' && 'Generando'}
|
||||
{documento.estado === 'error' && 'Error'}
|
||||
@@ -226,44 +290,60 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
|
||||
{/* Completeness */}
|
||||
<Card className="card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Completitud de datos</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Completitud de datos
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Campos obligatorios</span>
|
||||
<span className="font-medium">{camposCompletos.length}/{camposObligatorios.length}</span>
|
||||
<span className="text-muted-foreground">
|
||||
Campos obligatorios
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{camposCompletos.length}/{camposObligatorios.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
<div className="bg-muted h-2 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full transition-all duration-500",
|
||||
completeness === 100 ? "bg-success" : "bg-accent"
|
||||
'h-full transition-all duration-500',
|
||||
completeness === 100 ? 'bg-success' : 'bg-accent',
|
||||
)}
|
||||
style={{ width: `${completeness}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className={cn(
|
||||
"text-xs",
|
||||
completeness === 100 ? "text-success" : "text-muted-foreground"
|
||||
)}>
|
||||
{completeness === 100
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs',
|
||||
completeness === 100
|
||||
? 'text-success'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{completeness === 100
|
||||
? 'Todos los campos obligatorios están completos'
|
||||
: `Faltan ${camposObligatorios.length - camposCompletos.length} campos por completar`
|
||||
}
|
||||
: `Faltan ${camposObligatorios.length - camposCompletos.length} campos por completar`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Missing fields */}
|
||||
{!isComplete && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Campos faltantes:</p>
|
||||
{camposObligatorios.filter(c => !datosGenerales[c.id]?.trim()).map((campo) => (
|
||||
<div key={campo.id} className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="w-3 h-3 text-warning" />
|
||||
<span className="text-foreground">{campo.nombre}</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
Campos faltantes:
|
||||
</p>
|
||||
{camposObligatorios
|
||||
.filter((c) => !datosGenerales[c.id]?.trim())
|
||||
.map((campo) => (
|
||||
<div
|
||||
key={campo.id}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<AlertTriangle className="text-warning h-3 w-3" />
|
||||
<span className="text-foreground">{campo.nombre}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -272,36 +352,62 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
|
||||
{/* Requirements */}
|
||||
<Card className="card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Requisitos SEP</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Requisitos SEP
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-start gap-2">
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded-full flex items-center justify-center mt-0.5",
|
||||
datosGenerales['objetivo_general'] ? "bg-success/20" : "bg-muted"
|
||||
)}>
|
||||
{datosGenerales['objetivo_general'] && <Check className="w-3 h-3 text-success" />}
|
||||
<div
|
||||
className={cn(
|
||||
'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
|
||||
datosGenerales['objetivo_general']
|
||||
? 'bg-success/20'
|
||||
: 'bg-muted',
|
||||
)}
|
||||
>
|
||||
{datosGenerales['objetivo_general'] && (
|
||||
<Check className="text-success h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">Objetivo general definido</span>
|
||||
<span className="text-muted-foreground">
|
||||
Objetivo general definido
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded-full flex items-center justify-center mt-0.5",
|
||||
datosGenerales['competencias'] ? "bg-success/20" : "bg-muted"
|
||||
)}>
|
||||
{datosGenerales['competencias'] && <Check className="w-3 h-3 text-success" />}
|
||||
<div
|
||||
className={cn(
|
||||
'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
|
||||
datosGenerales['competencias']
|
||||
? 'bg-success/20'
|
||||
: 'bg-muted',
|
||||
)}
|
||||
>
|
||||
{datosGenerales['competencias'] && (
|
||||
<Check className="text-success h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">Competencias especificadas</span>
|
||||
<span className="text-muted-foreground">
|
||||
Competencias especificadas
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded-full flex items-center justify-center mt-0.5",
|
||||
datosGenerales['evaluacion'] ? "bg-success/20" : "bg-muted"
|
||||
)}>
|
||||
{datosGenerales['evaluacion'] && <Check className="w-3 h-3 text-success" />}
|
||||
<div
|
||||
className={cn(
|
||||
'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
|
||||
datosGenerales['evaluacion']
|
||||
? 'bg-success/20'
|
||||
: 'bg-muted',
|
||||
)}
|
||||
>
|
||||
{datosGenerales['evaluacion'] && (
|
||||
<Check className="text-success h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">Criterios de evaluación</span>
|
||||
<span className="text-muted-foreground">
|
||||
Criterios de evaluación
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
@@ -309,13 +415,19 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function Check({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,196 +1,354 @@
|
||||
import { useState } from 'react';
|
||||
import { History, FileText, List, BookMarked, Sparkles, FileCheck, User, Filter, Calendar } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
History,
|
||||
FileText,
|
||||
List,
|
||||
BookMarked,
|
||||
Sparkles,
|
||||
FileCheck,
|
||||
User,
|
||||
Filter,
|
||||
Calendar,
|
||||
Loader2,
|
||||
Eye,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { CambioMateria } from '@/types/materia';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import { useSubjectHistorial } from '@/data/hooks/useSubjects'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
interface HistorialTabProps {
|
||||
historial: CambioMateria[];
|
||||
}
|
||||
const tipoConfig: Record<string, { label: string; icon: any; color: string }> =
|
||||
{
|
||||
datos: { label: 'Datos generales', icon: FileText, color: 'text-info' },
|
||||
contenido: {
|
||||
label: 'Contenido temático',
|
||||
icon: List,
|
||||
color: 'text-accent',
|
||||
},
|
||||
bibliografia: {
|
||||
label: 'Bibliografía',
|
||||
icon: BookMarked,
|
||||
color: 'text-success',
|
||||
},
|
||||
ia: { label: 'IA', icon: Sparkles, color: 'text-amber-500' },
|
||||
documento: {
|
||||
label: 'Documento SEP',
|
||||
icon: FileCheck,
|
||||
color: 'text-primary',
|
||||
},
|
||||
}
|
||||
|
||||
const tipoConfig: Record<string, { label: string; icon: React.ComponentType<{ className?: string }>; color: string }> = {
|
||||
datos: { label: 'Datos generales', icon: FileText, color: 'text-info' },
|
||||
contenido: { label: 'Contenido temático', icon: List, color: 'text-accent' },
|
||||
bibliografia: { label: 'Bibliografía', icon: BookMarked, color: 'text-success' },
|
||||
ia: { label: 'IA', icon: Sparkles, color: 'text-amber-500' },
|
||||
documento: { label: 'Documento SEP', icon: FileCheck, color: 'text-primary' },
|
||||
};
|
||||
export function HistorialTab() {
|
||||
// 1. Obtenemos los datos directamente dentro del componente
|
||||
const { data: rawData, isLoading } = useSubjectHistorial(
|
||||
'9d4dda6a-488f-428a-8a07-38081592a641',
|
||||
)
|
||||
|
||||
export function HistorialTab({ historial }: HistorialTabProps) {
|
||||
const [filtros, setFiltros] = useState<Set<string>>(new Set(['datos', 'contenido', 'bibliografia', 'ia', 'documento']));
|
||||
const [filtros, setFiltros] = useState<Set<string>>(
|
||||
new Set(['datos', 'contenido', 'bibliografia', 'ia', 'documento']),
|
||||
)
|
||||
|
||||
// ESTADOS PARA EL MODAL
|
||||
const [selectedChange, setSelectedChange] = useState<any>(null)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const RenderValue = ({ value }: { value: any }) => {
|
||||
// 1. Caso: Nulo o vacío
|
||||
if (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
value === 'Sin información previa'
|
||||
) {
|
||||
return (
|
||||
<span className="text-muted-foreground italic">Sin información</span>
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Caso: Es un ARRAY (como tu lista de unidades)
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{value.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-white/50 p-3 shadow-sm"
|
||||
>
|
||||
<RenderValue value={item} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Caso: Es un OBJETO (como cada unidad con titulo, temas, etc.)
|
||||
if (typeof value === 'object') {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{Object.entries(value).map(([key, val]) => (
|
||||
<div key={key} className="flex flex-col">
|
||||
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<div className="text-sm text-slate-700">
|
||||
{/* Llamada recursiva para manejar lo que haya dentro del valor */}
|
||||
{typeof val === 'object' ? (
|
||||
<div className="mt-1 border-l-2 border-slate-100 pl-2">
|
||||
<RenderValue value={val} />
|
||||
</div>
|
||||
) : (
|
||||
String(val)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Caso: Texto o número simple
|
||||
return <span className="text-sm leading-relaxed">{String(value)}</span>
|
||||
}
|
||||
|
||||
const historialTransformado = useMemo(() => {
|
||||
if (!rawData) return []
|
||||
return rawData.map((item: any) => ({
|
||||
id: item.id,
|
||||
tipo: item.campo === 'contenido_tematico' ? 'contenido' : 'datos',
|
||||
descripcion: `Se actualizó el campo ${item.campo.replace('_', ' ')}`,
|
||||
fecha: parseISO(item.cambiado_en),
|
||||
usuario: item.fuente === 'HUMANO' ? 'Usuario Staff' : 'Sistema IA',
|
||||
detalles: {
|
||||
campo: item.campo,
|
||||
valor_anterior: item.valor_anterior || 'Sin datos previos', // Asumiendo que existe en tu API
|
||||
valor_nuevo: item.valor_nuevo,
|
||||
},
|
||||
}))
|
||||
}, [rawData])
|
||||
|
||||
const openCompareModal = (cambio: any) => {
|
||||
setSelectedChange(cambio)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const toggleFiltro = (tipo: string) => {
|
||||
const newFiltros = new Set(filtros);
|
||||
if (newFiltros.has(tipo)) {
|
||||
newFiltros.delete(tipo);
|
||||
} else {
|
||||
newFiltros.add(tipo);
|
||||
}
|
||||
setFiltros(newFiltros);
|
||||
};
|
||||
const newFiltros = new Set(filtros)
|
||||
if (newFiltros.has(tipo)) newFiltros.delete(tipo)
|
||||
else newFiltros.add(tipo)
|
||||
setFiltros(newFiltros)
|
||||
}
|
||||
|
||||
const filteredHistorial = historial.filter(cambio => filtros.has(cambio.tipo));
|
||||
// 3. Aplicamos filtros y agrupamiento sobre los datos transformados
|
||||
const filteredHistorial = historialTransformado.filter((cambio) =>
|
||||
filtros.has(cambio.tipo),
|
||||
)
|
||||
|
||||
// Group by date
|
||||
const groupedHistorial = filteredHistorial.reduce((groups, cambio) => {
|
||||
const dateKey = format(cambio.fecha, 'yyyy-MM-dd');
|
||||
if (!groups[dateKey]) {
|
||||
groups[dateKey] = [];
|
||||
}
|
||||
groups[dateKey].push(cambio);
|
||||
return groups;
|
||||
}, {} as Record<string, CambioMateria[]>);
|
||||
const groupedHistorial = filteredHistorial.reduce(
|
||||
(groups, cambio) => {
|
||||
const dateKey = format(cambio.fecha, 'yyyy-MM-dd')
|
||||
if (!groups[dateKey]) groups[dateKey] = []
|
||||
groups[dateKey].push(cambio)
|
||||
return groups
|
||||
},
|
||||
{} as Record<string, any[]>,
|
||||
)
|
||||
|
||||
const sortedDates = Object.keys(groupedHistorial).sort((a, b) => b.localeCompare(a));
|
||||
const sortedDates = Object.keys(groupedHistorial).sort((a, b) =>
|
||||
b.localeCompare(a),
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<History className="w-6 h-6 text-accent" />
|
||||
<h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
|
||||
<History className="text-accent h-6 w-6" />
|
||||
Historial de cambios
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{historial.length} cambios registrados
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{historialTransformado.length} cambios registrados
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dropdown de Filtros (Igual al anterior) */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Filtrar ({filtros.size})
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{Object.entries(tipoConfig).map(([tipo, config]) => {
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={tipo}
|
||||
checked={filtros.has(tipo)}
|
||||
onCheckedChange={() => toggleFiltro(tipo)}
|
||||
>
|
||||
<Icon className={cn("w-4 h-4 mr-2", config.color)} />
|
||||
{config.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
{Object.entries(tipoConfig).map(([tipo, config]) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={tipo}
|
||||
checked={filtros.has(tipo)}
|
||||
onCheckedChange={() => toggleFiltro(tipo)}
|
||||
>
|
||||
<config.icon className={cn('mr-2 h-4 w-4', config.color)} />
|
||||
{config.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{filteredHistorial.length === 0 ? (
|
||||
<Card className="card-elevated">
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<History className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{historial.length === 0
|
||||
? 'No hay cambios registrados aún'
|
||||
: 'No hay cambios con los filtros seleccionados'
|
||||
}
|
||||
</p>
|
||||
<History className="text-muted-foreground/50 mx-auto mb-4 h-12 w-12" />
|
||||
<p className="text-muted-foreground">No se encontraron cambios.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{sortedDates.map((dateKey) => {
|
||||
const cambios = groupedHistorial[dateKey];
|
||||
const date = new Date(dateKey);
|
||||
const isToday = format(new Date(), 'yyyy-MM-dd') === dateKey;
|
||||
const isYesterday = format(new Date(Date.now() - 86400000), 'yyyy-MM-dd') === dateKey;
|
||||
{sortedDates.map((dateKey) => (
|
||||
<div key={dateKey}>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<Calendar className="text-muted-foreground h-4 w-4" />
|
||||
<h3 className="text-foreground font-semibold">
|
||||
{format(parseISO(dateKey), "EEEE, d 'de' MMMM", {
|
||||
locale: es,
|
||||
})}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div key={dateKey}>
|
||||
{/* Date header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-muted">
|
||||
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{isToday ? 'Hoy' : isYesterday ? 'Ayer' : format(date, "EEEE, d 'de' MMMM", { locale: es })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{cambios.length} {cambios.length === 1 ? 'cambio' : 'cambios'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="ml-4 border-l-2 border-border pl-6 space-y-4">
|
||||
{cambios.map((cambio) => {
|
||||
const config = tipoConfig[cambio.tipo];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<div key={cambio.id} className="relative">
|
||||
{/* Timeline dot */}
|
||||
<div className={cn(
|
||||
"absolute -left-[31px] w-4 h-4 rounded-full border-2 border-background",
|
||||
`bg-current ${config.color}`
|
||||
)} />
|
||||
|
||||
<Card className="card-interactive">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={cn(
|
||||
"p-2 rounded-lg bg-muted flex-shrink-0",
|
||||
config.color
|
||||
)}>
|
||||
<Icon className="w-4 h-4" />
|
||||
<div className="border-border ml-4 space-y-4 border-l-2 pl-6">
|
||||
{groupedHistorial[dateKey].map((cambio) => {
|
||||
const config = tipoConfig[cambio.tipo] || tipoConfig.datos
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<div key={cambio.id} className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
'border-background absolute -left-[31px] h-4 w-4 rounded-full border-2',
|
||||
`bg-current ${config.color}`,
|
||||
)}
|
||||
/>
|
||||
<Card className="card-interactive">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
'bg-muted rounded-lg p-2',
|
||||
config.color,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between">
|
||||
<p className="font-medium">
|
||||
{cambio.descripcion}
|
||||
</p>
|
||||
{/* BOTÓN PARA VER CAMBIOS */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2 text-blue-600 hover:bg-blue-50 hover:text-blue-700"
|
||||
onClick={() => openCompareModal(cambio)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Ver cambios
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{format(cambio.fecha, 'HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{cambio.descripcion}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.label}
|
||||
</Badge>
|
||||
{cambio.detalles?.campo && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Campo: {cambio.detalles.campo}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{format(cambio.fecha, 'HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-3 text-xs text-muted-foreground">
|
||||
<User className="w-3 h-3" />
|
||||
<span>{cambio.usuario}</span>
|
||||
<span className="text-muted-foreground/50">•</span>
|
||||
<span>
|
||||
{formatDistanceToNow(cambio.fecha, { addSuffix: true, locale: es })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs italic">
|
||||
por {cambio.usuario}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* MODAL DE COMPARACIÓN */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col overflow-hidden">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||
<History className="h-5 w-5 text-blue-500" />
|
||||
Comparación de cambios
|
||||
</DialogTitle>
|
||||
{/* ... info de usuario y fecha */}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="custom-scrollbar mt-4 flex-1 overflow-y-auto pr-2">
|
||||
<div className="grid h-full grid-cols-2 gap-6">
|
||||
{/* Lado Antes */}
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white pb-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-400" />
|
||||
<span className="text-xs font-bold text-slate-500 uppercase">
|
||||
Versión Anterior
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 rounded-xl border border-red-100 bg-red-50/30 p-4">
|
||||
<RenderValue
|
||||
value={selectedChange?.detalles.valor_anterior}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lado Después */}
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white pb-2">
|
||||
<div className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
<span className="text-xs font-bold text-slate-500 uppercase">
|
||||
Nueva Versión
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 rounded-xl border border-emerald-100 bg-emerald-50/40 p-4">
|
||||
<RenderValue value={selectedChange?.detalles.valor_nuevo} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-shrink-0 items-center justify-center gap-2 rounded-lg border border-slate-100 bg-slate-50 p-3 text-xs text-slate-500">
|
||||
Campo modificado:{' '}
|
||||
<Badge variant="secondary">{selectedChange?.detalles.campo}</Badge>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,161 +1,260 @@
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { ArrowLeft, GraduationCap, Pencil, Sparkles } from 'lucide-react'
|
||||
import { useCallback, useState, useEffect } from 'react'
|
||||
|
||||
import { BibliographyItem } from './BibliographyItem'
|
||||
import { ContenidoTematico } from './ContenidoTematico'
|
||||
import { DocumentoSEPTab } from './DocumentoSEPTab'
|
||||
import { HistorialTab } from './HistorialTab'
|
||||
import { IAMateriaTab } from './IAMateriaTab'
|
||||
|
||||
import type { CampoEstructura, IAMessage, IASugerencia } from '@/types/materia'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
ArrowLeft,
|
||||
GraduationCap,
|
||||
Edit2, Save,
|
||||
Pencil
|
||||
} from 'lucide-react'
|
||||
import { ContenidoTematico } from './ContenidoTematico'
|
||||
import { BibliographyItem } from './BibliographyItem'
|
||||
import { IAMateriaTab } from './IAMateriaTab'
|
||||
import type {
|
||||
CampoEstructura,
|
||||
IAMessage,
|
||||
IASugerencia,
|
||||
UnidadTematica,
|
||||
} from '@/types/materia';
|
||||
import { useSubject } from '@/data/hooks/useSubjects'
|
||||
import {
|
||||
mockMateria,
|
||||
mockEstructura,
|
||||
mockDocumentoSep,
|
||||
mockHistorial
|
||||
} from '@/data/mockMateriaData';
|
||||
import { DocumentoSEPTab } from './DocumentoSEPTab'
|
||||
import { HistorialTab } from './HistorialTab'
|
||||
} from '@/data/mockMateriaData'
|
||||
|
||||
export interface BibliografiaEntry {
|
||||
id: string;
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA';
|
||||
cita: string;
|
||||
fuenteBibliotecaId?: string;
|
||||
fuenteBiblioteca?: any;
|
||||
id: string
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA'
|
||||
cita: string
|
||||
fuenteBibliotecaId?: string
|
||||
fuenteBiblioteca?: any
|
||||
}
|
||||
export interface BibliografiaTabProps {
|
||||
bibliografia: BibliografiaEntry[];
|
||||
onSave: (bibliografia: BibliografiaEntry[]) => void;
|
||||
isSaving: boolean;
|
||||
bibliografia: Array<BibliografiaEntry>
|
||||
onSave: (bibliografia: Array<BibliografiaEntry>) => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export interface AsignaturaDatos {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
export interface AsignaturaResponse {
|
||||
datos: AsignaturaDatos
|
||||
}
|
||||
|
||||
function EditableHeaderField({
|
||||
value,
|
||||
onSave,
|
||||
className,
|
||||
}: {
|
||||
value: string | number
|
||||
onSave: (val: string) => void
|
||||
className?: string
|
||||
}) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
;(e.currentTarget as HTMLElement).blur() // Quita el foco
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLElement>) => {
|
||||
const newValue = e.currentTarget.textContent || ''
|
||||
if (newValue !== value.toString()) {
|
||||
onSave(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className={`cursor-text rounded px-1 transition-all outline-none focus:ring-2 focus:ring-blue-400 ${className}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
export default function MateriaDetailPage() {
|
||||
|
||||
const routerState = useRouterState()
|
||||
const state = routerState.location.state as any
|
||||
const { data: asignaturasApi, isLoading: loadingAsig } = useSubject(
|
||||
state?.realId,
|
||||
)
|
||||
// 1. Asegúrate de tener estos estados en tu componente principal
|
||||
const [messages, setMessages] = useState<IAMessage[]>([]);
|
||||
const [datosGenerales, setDatosGenerales] = useState({});
|
||||
const [campos, setCampos] = useState<CampoEstructura[]>([]);
|
||||
const [messages, setMessages] = useState<Array<IAMessage>>([])
|
||||
const [datosGenerales, setDatosGenerales] = useState({})
|
||||
const [campos, setCampos] = useState<Array<CampoEstructura>>([])
|
||||
|
||||
// 2. Funciones de manejo para la IA
|
||||
const handleSendMessage = (text: string, campoId?: string) => {
|
||||
const newMessage: IAMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date(),
|
||||
campoAfectado: campoId
|
||||
};
|
||||
setMessages([...messages, newMessage]);
|
||||
|
||||
// Aquí llamarías a tu API de OpenAI/Claude
|
||||
//toast.info("Enviando consulta a la IA...");
|
||||
};
|
||||
// Dentro de MateriaDetailPage
|
||||
const [headerData, setHeaderData] = useState({
|
||||
codigo: '',
|
||||
nombre: '',
|
||||
creditos: 0,
|
||||
ciclo: 0,
|
||||
})
|
||||
|
||||
const handleAcceptSuggestion = (sugerencia: IASugerencia) => {
|
||||
// Lógica para actualizar el valor del campo en tu estado de datosGenerales
|
||||
//toast.success(`Sugerencia aplicada a ${sugerencia.campoNombre}`);
|
||||
};
|
||||
// Sincronizar cuando llegue la API
|
||||
useEffect(() => {
|
||||
if (asignaturasApi) {
|
||||
setHeaderData({
|
||||
codigo: asignaturasApi?.codigo ?? '',
|
||||
nombre: asignaturasApi?.nombre ?? '',
|
||||
creditos: asignaturasApi?.creditos ?? '',
|
||||
ciclo: asignaturasApi?.numero_ciclo ?? 0,
|
||||
})
|
||||
}
|
||||
}, [asignaturasApi])
|
||||
|
||||
const handleUpdateHeader = (key: string, value: string | number) => {
|
||||
const newData = { ...headerData, [key]: value }
|
||||
setHeaderData(newData)
|
||||
console.log('💾 Guardando en estado y base de datos:', key, value)
|
||||
}
|
||||
/* ---------- sincronizar API ---------- */
|
||||
useEffect(() => {
|
||||
if (asignaturasApi?.datos) {
|
||||
setDatosGenerales(asignaturasApi.datos)
|
||||
}
|
||||
}, [asignaturasApi])
|
||||
|
||||
// 2. Funciones de manejo para la IA
|
||||
const handleSendMessage = (text: string, campoId?: string) => {
|
||||
const newMessage: IAMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date(),
|
||||
campoAfectado: campoId,
|
||||
}
|
||||
setMessages([...messages, newMessage])
|
||||
|
||||
// Aquí llamarías a tu API de OpenAI/Claude
|
||||
// toast.info("Enviando consulta a la IA...");
|
||||
}
|
||||
|
||||
const handleAcceptSuggestion = (sugerencia: IASugerencia) => {
|
||||
// Lógica para actualizar el valor del campo en tu estado de datosGenerales
|
||||
// toast.success(`Sugerencia aplicada a ${sugerencia.campoNombre}`);
|
||||
}
|
||||
|
||||
// Dentro de tu componente principal (donde están los Tabs)
|
||||
const [bibliografia, setBibliografia] = useState<BibliografiaEntry[]>([
|
||||
{
|
||||
id: '1',
|
||||
tipo: 'BASICA',
|
||||
cita: 'Russell, S., & Norvig, P. (2020). Artificial Intelligence: A Modern Approach. Pearson.'
|
||||
}
|
||||
]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [bibliografia, setBibliografia] = useState<Array<BibliografiaEntry>>([
|
||||
{
|
||||
id: '1',
|
||||
tipo: 'BASICA',
|
||||
cita: 'Russell, S., & Norvig, P. (2020). Artificial Intelligence: A Modern Approach. Pearson.',
|
||||
},
|
||||
])
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const handleSaveBibliografia = (data: BibliografiaEntry[]) => {
|
||||
setIsSaving(true);
|
||||
// Aquí iría tu llamada a la API
|
||||
setBibliografia(data);
|
||||
|
||||
// Simulamos un guardado
|
||||
setTimeout(() => {
|
||||
setIsSaving(false);
|
||||
//toast.success("Cambios guardados");
|
||||
}, 1000);
|
||||
};
|
||||
const handleSaveBibliografia = (data: Array<BibliografiaEntry>) => {
|
||||
setIsSaving(true)
|
||||
// Aquí iría tu llamada a la API
|
||||
setBibliografia(data)
|
||||
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
|
||||
const handleRegenerateDocument = useCallback(() => {
|
||||
setIsRegenerating(true);
|
||||
// Simulamos un guardado
|
||||
setTimeout(() => {
|
||||
setIsRegenerating(false);
|
||||
}, 2000);
|
||||
}, []);
|
||||
setIsSaving(false)
|
||||
// toast.success("Cambios guardados");
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const [isRegenerating, setIsRegenerating] = useState(false)
|
||||
|
||||
const handleRegenerateDocument = useCallback(() => {
|
||||
setIsRegenerating(true)
|
||||
setTimeout(() => {
|
||||
setIsRegenerating(false)
|
||||
}, 2000)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* ================= HEADER ================= */}
|
||||
{/* ================= HEADER ACTUALIZADO ================= */}
|
||||
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
|
||||
<div className="max-w-7xl mx-auto px-6 py-10">
|
||||
<div className="mx-auto max-w-7xl px-6 py-10">
|
||||
<Link
|
||||
to="/planes"
|
||||
className="flex items-center gap-2 text-sm text-blue-200 hover:text-white mb-4"
|
||||
className="mb-4 flex items-center gap-2 text-sm text-blue-200 hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Volver al plan
|
||||
<ArrowLeft className="h-4 w-4" /> Volver al plan
|
||||
</Link>
|
||||
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="space-y-3">
|
||||
<Badge className="bg-blue-900/50 border border-blue-700">
|
||||
IA-401
|
||||
{/* CÓDIGO EDITABLE */}
|
||||
<Badge className="border border-blue-700 bg-blue-900/50">
|
||||
<EditableHeaderField
|
||||
value={headerData.codigo}
|
||||
onSave={(val) => handleUpdateHeader('codigo', val)}
|
||||
/>
|
||||
</Badge>
|
||||
|
||||
{/* NOMBRE EDITABLE */}
|
||||
<h1 className="text-3xl font-bold">
|
||||
Inteligencia Artificial Aplicada
|
||||
<EditableHeaderField
|
||||
value={headerData.nombre}
|
||||
onSave={(val) => handleUpdateHeader('nombre', val)}
|
||||
/>
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
|
||||
<span className="flex items-center gap-1">
|
||||
<GraduationCap className="w-4 h-4" />
|
||||
Ingeniería en Sistemas Computacionales
|
||||
<GraduationCap className="h-4 w-4" />
|
||||
{asignaturasApi?.planes_estudio?.datos?.nombre}
|
||||
</span>
|
||||
<span>
|
||||
{asignaturasApi?.planes_estudio?.carreras?.facultades?.nombre}
|
||||
</span>
|
||||
|
||||
<span>Facultad de Ingeniería</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-blue-300">
|
||||
Pertenece al plan:{' '}
|
||||
<span className="underline cursor-pointer">
|
||||
Licenciatura en Ingeniería en Sistemas Computacionales 2024
|
||||
<span className="cursor-pointer underline">
|
||||
{asignaturasApi?.planes_estudio?.nombre}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 items-end">
|
||||
<Badge variant="secondary">8 créditos</Badge>
|
||||
<Badge variant="secondary">7° semestre</Badge>
|
||||
<Badge variant="secondary">Sistemas Inteligentes</Badge>
|
||||
<div className="flex flex-col items-end gap-2 text-right">
|
||||
{/* CRÉDITOS EDITABLES */}
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<EditableHeaderField
|
||||
value={headerData.creditos}
|
||||
onSave={(val) =>
|
||||
handleUpdateHeader('creditos', parseInt(val) || 0)
|
||||
}
|
||||
/>
|
||||
<span>créditos</span>
|
||||
</Badge>
|
||||
|
||||
{/* SEMESTRE EDITABLE */}
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<EditableHeaderField
|
||||
value={headerData.ciclo}
|
||||
onSave={(val) =>
|
||||
handleUpdateHeader('ciclo', parseInt(val) || 0)
|
||||
}
|
||||
/>
|
||||
<span>° ciclo</span>
|
||||
</Badge>
|
||||
|
||||
<Badge variant="secondary">{asignaturasApi?.tipo}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ================= TABS ================= */}
|
||||
<section className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<section className="border-b bg-white">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<Tabs defaultValue="datos">
|
||||
<TabsList className="h-auto bg-transparent p-0 gap-6">
|
||||
<TabsList className="h-auto gap-6 bg-transparent p-0">
|
||||
<TabsTrigger value="datos">Datos generales</TabsTrigger>
|
||||
<TabsTrigger value="contenido">Contenido temático</TabsTrigger>
|
||||
<TabsTrigger value="bibliografia">Bibliografía</TabsTrigger>
|
||||
@@ -168,30 +267,38 @@ const handleRegenerateDocument = useCallback(() => {
|
||||
|
||||
{/* ================= TAB: DATOS GENERALES ================= */}
|
||||
<TabsContent value="datos">
|
||||
<DatosGenerales />
|
||||
<DatosGenerales data={datosGenerales} isLoading={loadingAsig} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="contenido">
|
||||
<ContenidoTematico></ContenidoTematico>
|
||||
<ContenidoTematico
|
||||
data={asignaturasApi}
|
||||
isLoading={loadingAsig}
|
||||
></ContenidoTematico>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bibliografia">
|
||||
<BibliographyItem
|
||||
bibliografia={bibliografia}
|
||||
onSave={handleSaveBibliografia}
|
||||
isSaving={isSaving}
|
||||
<BibliographyItem
|
||||
bibliografia={bibliografia}
|
||||
onSave={handleSaveBibliografia}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ia">
|
||||
<IAMateriaTab
|
||||
campos={campos}
|
||||
datosGenerales={datosGenerales}
|
||||
messages={messages}
|
||||
onSendMessage={handleSendMessage}
|
||||
onAcceptSuggestion={handleAcceptSuggestion}
|
||||
onRejectSuggestion={(id) => console.log("Rechazada") /*toast.error("Sugerencia rechazada")*/}
|
||||
/>
|
||||
<IAMateriaTab
|
||||
campos={campos}
|
||||
datosGenerales={datosGenerales}
|
||||
messages={messages}
|
||||
onSendMessage={handleSendMessage}
|
||||
onAcceptSuggestion={handleAcceptSuggestion}
|
||||
onRejectSuggestion={
|
||||
(id) =>
|
||||
console.log(
|
||||
'Rechazada',
|
||||
) /* toast.error("Sugerencia rechazada")*/
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sep">
|
||||
@@ -206,7 +313,7 @@ const handleRegenerateDocument = useCallback(() => {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="historial">
|
||||
<HistorialTab historial={mockHistorial} />
|
||||
<HistorialTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -216,81 +323,81 @@ const handleRegenerateDocument = useCallback(() => {
|
||||
}
|
||||
|
||||
/* ================= TAB CONTENT ================= */
|
||||
interface DatosGeneralesProps {
|
||||
data: AsignaturaDatos
|
||||
isLoading: boolean
|
||||
}
|
||||
function DatosGenerales({ data, isLoading }: DatosGeneralesProps) {
|
||||
const formatTitle = (key: string): string =>
|
||||
key.replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase())
|
||||
|
||||
function DatosGenerales() {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-8 px-4 space-y-8 animate-in fade-in duration-500">
|
||||
|
||||
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
|
||||
{/* Encabezado de la Sección */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b pb-6">
|
||||
<div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">Datos Generales</h2>
|
||||
<p className="text-slate-500 mt-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||
Datos Generales
|
||||
</h2>
|
||||
<p className="mt-1 text-slate-500">
|
||||
Información oficial estructurada bajo los lineamientos de la SEP.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Edit2 className="w-4 h-4" /> Editar borrador
|
||||
</Button>
|
||||
<Button size="sm" className="gap-2 bg-blue-600 hover:bg-blue-700">
|
||||
<Save className="w-4 h-4" /> Guardar cambios
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid de Información */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{/* Columna Principal (Más ancha) */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<InfoCard
|
||||
title="Competencias a Desarrollar"
|
||||
subtitle="Competencias profesionales que se desarrollarán"
|
||||
isList={true}
|
||||
initialContent={`• Diseñar algoritmos de machine learning para clasificación y predicción\n• Implementar redes neuronales profundas para procesamiento de imágenes\n• Evaluar y optimizar modelos de IA considerando métricas`}
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
title="Objetivo General"
|
||||
initialContent="Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<InfoCard
|
||||
title="Justificación"
|
||||
initialContent="La inteligencia artificial es una de las tecnologías más disruptivas..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
{isLoading && <p>Cargando información...</p>}
|
||||
|
||||
{!isLoading &&
|
||||
Object.entries(data).map(([key, value]) => (
|
||||
<InfoCard
|
||||
key={key}
|
||||
title={formatTitle(key)}
|
||||
initialContent={value}
|
||||
onEnhanceAI={(contenido) => {
|
||||
console.log('Llevar a IA:', contenido)
|
||||
// Aquí tu lógica: setPestañaActiva('mejorar-con-ia');
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Columna Lateral (Información Secundaria) */}
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
{/* Tarjeta de Requisitos */}
|
||||
<InfoCard
|
||||
title="Requisitos y Seriación"
|
||||
type="requirements"
|
||||
initialContent={[
|
||||
{ type: "Pre-requisito", code: "PA-301", name: "Programación Avanzada" },
|
||||
{ type: "Co-requisito", code: "MAT-201", name: "Matemáticas Discretas" }
|
||||
]}
|
||||
/>
|
||||
{/* Tarjeta de Requisitos */}
|
||||
<InfoCard
|
||||
title="Requisitos y Seriación"
|
||||
type="requirements"
|
||||
initialContent={[
|
||||
{
|
||||
type: 'Pre-requisito',
|
||||
code: 'PA-301',
|
||||
name: 'Programación Avanzada',
|
||||
},
|
||||
{
|
||||
type: 'Co-requisito',
|
||||
code: 'MAT-201',
|
||||
name: 'Matemáticas Discretas',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Tarjeta de Evaluación */}
|
||||
<InfoCard
|
||||
title="Sistema de Evaluación"
|
||||
type="evaluation"
|
||||
initialContent={[
|
||||
{ label: "Exámenes parciales", value: "30%" },
|
||||
{ label: "Proyecto integrador", value: "35%" },
|
||||
{ label: "Prácticas de laboratorio", value: "20%" },
|
||||
{ label: "Participación", value: "15%" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{/* Tarjeta de Evaluación */}
|
||||
<InfoCard
|
||||
title="Sistema de Evaluación"
|
||||
type="evaluation"
|
||||
initialContent={[
|
||||
{ label: 'Exámenes parciales', value: '30%' },
|
||||
{ label: 'Proyecto integrador', value: '35%' },
|
||||
{ label: 'Prácticas de laboratorio', value: '20%' },
|
||||
{ label: 'Participación', value: '15%' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,51 +405,85 @@ function DatosGenerales() {
|
||||
}
|
||||
|
||||
interface InfoCardProps {
|
||||
title: string,
|
||||
subtitle?: string
|
||||
isList?:boolean
|
||||
initialContent: any // Puede ser string o array de objetos
|
||||
type?: 'text' | 'list' | 'requirements' | 'evaluation'
|
||||
title: string
|
||||
initialContent: any
|
||||
type?: 'text' | 'requirements' | 'evaluation'
|
||||
onEnhanceAI?: (content: any) => void // Nueva prop para la acción de IA
|
||||
}
|
||||
|
||||
function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) {
|
||||
function InfoCard({
|
||||
title,
|
||||
initialContent,
|
||||
type = 'text',
|
||||
onEnhanceAI,
|
||||
}: InfoCardProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [data, setData] = useState(initialContent)
|
||||
// Estado temporal para el área de texto (siempre editamos como texto por simplicidad)
|
||||
const [tempText, setTempText] = useState(
|
||||
type === 'text' || type === 'list'
|
||||
? initialContent
|
||||
: JSON.stringify(initialContent, null, 2) // O un formato legible
|
||||
type === 'text' ? initialContent : JSON.stringify(initialContent, null, 2),
|
||||
)
|
||||
|
||||
const handleSave = () => {
|
||||
// Aquí podrías parsear el texto de vuelta si es necesario
|
||||
setData(tempText)
|
||||
setData(tempText)
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="transition-all hover:border-slate-300">
|
||||
<CardHeader className="pb-3 flex flex-row items-start justify-between space-y-0">
|
||||
<CardTitle className="text-sm font-bold text-slate-700">{title}</CardTitle>
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-bold text-slate-700">
|
||||
{title}
|
||||
</CardTitle>
|
||||
|
||||
{!isEditing && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={() => setIsEditing(true)}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<div className="flex gap-1">
|
||||
{/* NUEVO: Botón de Mejorar con IA */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-blue-500 hover:bg-blue-50 hover:text-blue-600"
|
||||
onClick={() => onEnhanceAI?.(data)} // Enviamos la data actual a la IA
|
||||
title="Mejorar con IA"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Botón de Editar original */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={tempText}
|
||||
<Textarea
|
||||
value={tempText}
|
||||
onChange={(e) => setTempText(e.target.value)}
|
||||
className="text-xs min-h-[100px]"
|
||||
className="min-h-[100px] text-xs"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)}>Cancelar</Button>
|
||||
<Button size="sm" className="bg-[#00a878]" onClick={handleSave}>Guardar</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#00a878] hover:bg-[#008f66]"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -358,13 +499,20 @@ function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) {
|
||||
}
|
||||
|
||||
// Vista de Requisitos
|
||||
function RequirementsView({ items }: { items: any[] }) {
|
||||
function RequirementsView({ items }: { items: Array<any> }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map((req, i) => (
|
||||
<div key={i} className="p-3 bg-slate-50 rounded-lg border border-slate-100">
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-tight">{req.type}</p>
|
||||
<p className="text-sm font-medium text-slate-700">{req.code} {req.name}</p>
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border border-slate-100 bg-slate-50 p-3"
|
||||
>
|
||||
<p className="text-[10px] font-bold tracking-tight text-slate-400 uppercase">
|
||||
{req.type}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
{req.code} {req.name}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -372,11 +520,14 @@ function RequirementsView({ items }: { items: any[] }) {
|
||||
}
|
||||
|
||||
// Vista de Evaluación
|
||||
function EvaluationView({ items }: { items: any[] }) {
|
||||
function EvaluationView({ items }: { items: Array<any> }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex justify-between text-sm border-b border-slate-50 pb-1.5 italic">
|
||||
<div
|
||||
key={i}
|
||||
className="flex justify-between border-b border-slate-50 pb-1.5 text-sm italic"
|
||||
>
|
||||
<span className="text-slate-500">{item.label}</span>
|
||||
<span className="font-bold text-blue-600">{item.value}</span>
|
||||
</div>
|
||||
@@ -384,13 +535,3 @@ function EvaluationView({ items }: { items: any[] }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
function EmptyTab({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
{title} (pendiente)
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { invokeEdge } from "../supabase/invokeEdge";
|
||||
import { supabaseBrowser } from '../supabase/client'
|
||||
import { invokeEdge } from '../supabase/invokeEdge'
|
||||
|
||||
import { buildRange, requireData, throwIfError } from "./_helpers";
|
||||
import { buildRange, requireData, throwIfError } from './_helpers'
|
||||
|
||||
import type {
|
||||
Asignatura,
|
||||
@@ -13,60 +13,59 @@ import type {
|
||||
PlanEstudio,
|
||||
TipoCiclo,
|
||||
UUID,
|
||||
} from "../types/domain";
|
||||
import type { UploadedFile } from "@/components/planes/wizard/PasoDetallesPanel/FileDropZone";
|
||||
} from '../types/domain'
|
||||
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
||||
|
||||
const EDGE = {
|
||||
plans_create_manual: "plans_create_manual",
|
||||
ai_generate_plan: "ai-generate-plan",
|
||||
plans_persist_from_ai: "plans_persist_from_ai",
|
||||
plans_clone_from_existing: "plans_clone_from_existing",
|
||||
plans_create_manual: 'plans_create_manual',
|
||||
ai_generate_plan: 'ai-generate-plan',
|
||||
plans_persist_from_ai: 'plans_persist_from_ai',
|
||||
plans_clone_from_existing: 'plans_clone_from_existing',
|
||||
|
||||
plans_import_from_files: "plans_import_from_files",
|
||||
plans_import_from_files: 'plans_import_from_files',
|
||||
|
||||
plans_update_fields: "plans_update_fields",
|
||||
plans_update_map: "plans_update_map",
|
||||
plans_transition_state: "plans_transition_state",
|
||||
plans_update_fields: 'plans_update_fields',
|
||||
plans_update_map: 'plans_update_map',
|
||||
plans_transition_state: 'plans_transition_state',
|
||||
|
||||
plans_generate_document: "plans_generate_document",
|
||||
plans_get_document: "plans_get_document",
|
||||
} as const;
|
||||
plans_generate_document: 'plans_generate_document',
|
||||
plans_get_document: 'plans_get_document',
|
||||
} as const
|
||||
|
||||
export type PlanListFilters = {
|
||||
search?: string;
|
||||
carreraId?: UUID;
|
||||
facultadId?: UUID; // filtra por carreras.facultad_id
|
||||
estadoId?: UUID;
|
||||
activo?: boolean;
|
||||
search?: string
|
||||
carreraId?: UUID
|
||||
facultadId?: UUID // filtra por carreras.facultad_id
|
||||
estadoId?: UUID
|
||||
activo?: boolean
|
||||
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
// Helper para limpiar texto (lo movemos fuera para reutilizar o lo dejas en un utils)
|
||||
const cleanText = (text: string) => {
|
||||
return text
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase();
|
||||
};
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export async function plans_list(
|
||||
filters: PlanListFilters = {},
|
||||
): Promise<Paged<PlanEstudio>> {
|
||||
const supabase = supabaseBrowser();
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
// 1. Construimos la query base
|
||||
// NOTA IMPORTANTE: Para filtrar planes basados en facultad (que está en carreras),
|
||||
// necesitamos hacer un INNER JOIN. En Supabase se usa "!inner".
|
||||
// Si filters.facultadId existe, forzamos el inner join, si no, lo dejamos normal.
|
||||
|
||||
const carreraModifier = filters.facultadId && filters.facultadId !== "todas"
|
||||
? "!inner"
|
||||
: "";
|
||||
const carreraModifier =
|
||||
filters.facultadId && filters.facultadId !== 'todas' ? '!inner' : ''
|
||||
|
||||
let q = supabase
|
||||
.from("planes_estudio")
|
||||
.from('planes_estudio')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
@@ -77,56 +76,56 @@ export async function plans_list(
|
||||
estructuras_plan (*),
|
||||
estados_plan (*)
|
||||
`,
|
||||
{ count: "exact" },
|
||||
{ count: 'exact' },
|
||||
)
|
||||
.order("actualizado_en", { ascending: false });
|
||||
.order('actualizado_en', { ascending: false })
|
||||
|
||||
// 2. Aplicamos filtros dinámicos
|
||||
|
||||
// SOLUCIÓN SEARCH: Limpiamos el input y buscamos en la columna generada
|
||||
if (filters.search?.trim()) {
|
||||
const cleanTerm = cleanText(filters.search.trim());
|
||||
const cleanTerm = cleanText(filters.search.trim())
|
||||
// Usamos la columna nueva creada en el Paso 1
|
||||
q = q.ilike("nombre_search", `%${cleanTerm}%`);
|
||||
q = q.ilike('nombre_search', `%${cleanTerm}%`)
|
||||
}
|
||||
|
||||
if (filters.carreraId && filters.carreraId !== "todas") {
|
||||
q = q.eq("carrera_id", filters.carreraId);
|
||||
if (filters.carreraId && filters.carreraId !== 'todas') {
|
||||
q = q.eq('carrera_id', filters.carreraId)
|
||||
}
|
||||
|
||||
if (filters.estadoId && filters.estadoId !== "todos") {
|
||||
q = q.eq("estado_actual_id", filters.estadoId);
|
||||
if (filters.estadoId && filters.estadoId !== 'todos') {
|
||||
q = q.eq('estado_actual_id', filters.estadoId)
|
||||
}
|
||||
|
||||
if (typeof filters.activo === "boolean") {
|
||||
q = q.eq("activo", filters.activo);
|
||||
if (typeof filters.activo === 'boolean') {
|
||||
q = q.eq('activo', filters.activo)
|
||||
}
|
||||
|
||||
// Filtro por facultad (gracias al !inner arriba, esto filtrará los planes)
|
||||
if (filters.facultadId && filters.facultadId !== "todas") {
|
||||
q = q.eq("carreras.facultad_id", filters.facultadId);
|
||||
if (filters.facultadId && filters.facultadId !== 'todas') {
|
||||
q = q.eq('carreras.facultad_id', filters.facultadId)
|
||||
}
|
||||
|
||||
// 3. Paginación
|
||||
const { from, to } = buildRange(filters.limit, filters.offset);
|
||||
if (from !== undefined && to !== undefined) q = q.range(from, to);
|
||||
const { from, to } = buildRange(filters.limit, filters.offset)
|
||||
if (from !== undefined && to !== undefined) q = q.range(from, to)
|
||||
|
||||
const { data, error, count } = await q;
|
||||
throwIfError(error);
|
||||
const { data, error, count } = await q
|
||||
throwIfError(error)
|
||||
|
||||
return {
|
||||
// 1. Si data es null, usa [].
|
||||
// 2. Luego dile a TS que el resultado es tu Array tipado.
|
||||
data: (data ?? []) as unknown as Array<PlanEstudio>,
|
||||
count: count ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function plans_get(planId: UUID): Promise<PlanEstudio> {
|
||||
const supabase = supabaseBrowser();
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("planes_estudio")
|
||||
.from('planes_estudio')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
@@ -135,219 +134,217 @@ export async function plans_get(planId: UUID): Promise<PlanEstudio> {
|
||||
estados_plan (*)
|
||||
`,
|
||||
)
|
||||
.eq("id", planId)
|
||||
.single();
|
||||
.eq('id', planId)
|
||||
.single()
|
||||
|
||||
throwIfError(error);
|
||||
return requireData(data, "Plan no encontrado.");
|
||||
throwIfError(error)
|
||||
return requireData(data, 'Plan no encontrado.')
|
||||
}
|
||||
|
||||
export async function plan_lineas_list(
|
||||
planId: UUID,
|
||||
): Promise<Array<LineaPlan>> {
|
||||
const supabase = supabaseBrowser();
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase
|
||||
.from("lineas_plan")
|
||||
.select("id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en")
|
||||
.eq("plan_estudio_id", planId)
|
||||
.order("orden", { ascending: true });
|
||||
.from('lineas_plan')
|
||||
.select('id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en')
|
||||
.eq('plan_estudio_id', planId)
|
||||
.order('orden', { ascending: true })
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
throwIfError(error)
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
export async function plan_asignaturas_list(
|
||||
planId: UUID,
|
||||
): Promise<Array<Asignatura>> {
|
||||
const supabase = supabaseBrowser();
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.from('asignaturas')
|
||||
.select(
|
||||
"id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en",
|
||||
'id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en',
|
||||
)
|
||||
.eq("plan_estudio_id", planId)
|
||||
.order("numero_ciclo", { ascending: true, nullsFirst: false })
|
||||
.order("orden_celda", { ascending: true, nullsFirst: false })
|
||||
.order("nombre", { ascending: true });
|
||||
.eq('plan_estudio_id', planId)
|
||||
.order('numero_ciclo', { ascending: true, nullsFirst: false })
|
||||
.order('orden_celda', { ascending: true, nullsFirst: false })
|
||||
.order('nombre', { ascending: true })
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
throwIfError(error)
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
export async function plans_history(planId: UUID): Promise<Array<CambioPlan>> {
|
||||
const supabase = supabaseBrowser();
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase
|
||||
.from("cambios_plan")
|
||||
.from('cambios_plan')
|
||||
.select(
|
||||
"id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,interaccion_ia_id",
|
||||
'id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,interaccion_ia_id',
|
||||
)
|
||||
.eq("plan_estudio_id", planId)
|
||||
.order("cambiado_en", { ascending: false });
|
||||
.eq('plan_estudio_id', planId)
|
||||
.order('cambiado_en', { ascending: false })
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
throwIfError(error)
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
/** Wizard: crear plan manual (Edge Function) */
|
||||
export type PlansCreateManualInput = {
|
||||
carreraId: UUID;
|
||||
estructuraId: UUID;
|
||||
nombre: string;
|
||||
nivel: NivelPlanEstudio;
|
||||
tipoCiclo: TipoCiclo;
|
||||
numCiclos: number;
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>;
|
||||
};
|
||||
carreraId: UUID
|
||||
estructuraId: UUID
|
||||
nombre: string
|
||||
nivel: NivelPlanEstudio
|
||||
tipoCiclo: TipoCiclo
|
||||
numCiclos: number
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>
|
||||
}
|
||||
|
||||
export async function plans_create_manual(
|
||||
input: PlansCreateManualInput,
|
||||
): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_create_manual, input);
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_create_manual, input)
|
||||
}
|
||||
|
||||
/** Wizard: IA genera preview JSON (Edge Function) */
|
||||
export type AIGeneratePlanInput = {
|
||||
datosBasicos: {
|
||||
nombrePlan: string;
|
||||
carreraId: UUID;
|
||||
facultadId?: UUID;
|
||||
nivel: string;
|
||||
tipoCiclo: TipoCiclo;
|
||||
numCiclos: number;
|
||||
estructuraPlanId: UUID;
|
||||
};
|
||||
nombrePlan: string
|
||||
carreraId: UUID
|
||||
facultadId?: UUID
|
||||
nivel: string
|
||||
tipoCiclo: TipoCiclo
|
||||
numCiclos: number
|
||||
estructuraPlanId: UUID
|
||||
}
|
||||
iaConfig: {
|
||||
descripcionEnfoque: string;
|
||||
notasAdicionales?: string;
|
||||
archivosReferencia?: Array<UUID>;
|
||||
repositoriosIds?: Array<UUID>;
|
||||
archivosAdjuntos: Array<UploadedFile>;
|
||||
usarMCP?: boolean;
|
||||
};
|
||||
};
|
||||
descripcionEnfoque: string
|
||||
notasAdicionales?: string
|
||||
archivosReferencia?: Array<UUID>
|
||||
repositoriosIds?: Array<UUID>
|
||||
archivosAdjuntos: Array<UploadedFile>
|
||||
usarMCP?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export async function ai_generate_plan(
|
||||
input: AIGeneratePlanInput,
|
||||
): Promise<any> {
|
||||
console.log("input ai generate", input);
|
||||
console.log('input ai generate', input)
|
||||
|
||||
const edgeFunctionBody = new FormData();
|
||||
edgeFunctionBody.append("datosBasicos", JSON.stringify(input.datosBasicos));
|
||||
const edgeFunctionBody = new FormData()
|
||||
edgeFunctionBody.append('datosBasicos', JSON.stringify(input.datosBasicos))
|
||||
edgeFunctionBody.append(
|
||||
"iaConfig",
|
||||
'iaConfig',
|
||||
JSON.stringify({
|
||||
...input.iaConfig,
|
||||
archivosAdjuntos: undefined, // los manejamos aparte
|
||||
}),
|
||||
);
|
||||
)
|
||||
input.iaConfig.archivosAdjuntos.forEach((file, index) => {
|
||||
edgeFunctionBody.append(`archivosAdjuntos`, file.file);
|
||||
});
|
||||
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
|
||||
})
|
||||
|
||||
return invokeEdge<any>(
|
||||
EDGE.ai_generate_plan,
|
||||
edgeFunctionBody,
|
||||
undefined,
|
||||
supabaseBrowser(),
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export async function plans_persist_from_ai(
|
||||
payload: { jsonPlan: any },
|
||||
): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_persist_from_ai, payload);
|
||||
export async function plans_persist_from_ai(payload: {
|
||||
jsonPlan: any
|
||||
}): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_persist_from_ai, payload)
|
||||
}
|
||||
|
||||
export async function plans_clone_from_existing(payload: {
|
||||
planOrigenId: UUID;
|
||||
overrides:
|
||||
& Partial<
|
||||
Pick<PlanEstudio, "nombre" | "nivel" | "tipo_ciclo" | "numero_ciclos">
|
||||
>
|
||||
& {
|
||||
carrera_id?: UUID;
|
||||
estructura_id?: UUID;
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>;
|
||||
};
|
||||
planOrigenId: UUID
|
||||
overrides: Partial<
|
||||
Pick<PlanEstudio, 'nombre' | 'nivel' | 'tipo_ciclo' | 'numero_ciclos'>
|
||||
> & {
|
||||
carrera_id?: UUID
|
||||
estructura_id?: UUID
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>
|
||||
}
|
||||
}): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_clone_from_existing, payload);
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_clone_from_existing, payload)
|
||||
}
|
||||
|
||||
export async function plans_import_from_files(payload: {
|
||||
datosBasicos: {
|
||||
nombrePlan: string;
|
||||
carreraId: UUID;
|
||||
estructuraId: UUID;
|
||||
nivel: string;
|
||||
tipoCiclo: TipoCiclo;
|
||||
numCiclos: number;
|
||||
};
|
||||
archivoWordPlanId: UUID;
|
||||
archivoMapaExcelId?: UUID | null;
|
||||
archivoMateriasExcelId?: UUID | null;
|
||||
nombrePlan: string
|
||||
carreraId: UUID
|
||||
estructuraId: UUID
|
||||
nivel: string
|
||||
tipoCiclo: TipoCiclo
|
||||
numCiclos: number
|
||||
}
|
||||
archivoWordPlanId: UUID
|
||||
archivoMapaExcelId?: UUID | null
|
||||
archivoMateriasExcelId?: UUID | null
|
||||
}): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_import_from_files, payload);
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_import_from_files, payload)
|
||||
}
|
||||
|
||||
/** Update de tarjetas/fields del plan (Edge Function: merge server-side) */
|
||||
export type PlansUpdateFieldsPatch = {
|
||||
nombre?: string;
|
||||
nivel?: NivelPlanEstudio;
|
||||
tipo_ciclo?: TipoCiclo;
|
||||
numero_ciclos?: number;
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>;
|
||||
};
|
||||
nombre?: string
|
||||
nivel?: NivelPlanEstudio
|
||||
tipo_ciclo?: TipoCiclo
|
||||
numero_ciclos?: number
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>
|
||||
}
|
||||
|
||||
export async function plans_update_fields(
|
||||
planId: UUID,
|
||||
patch: PlansUpdateFieldsPatch,
|
||||
): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_update_fields, { planId, patch });
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_update_fields, { planId, patch })
|
||||
}
|
||||
|
||||
/** Operaciones del mapa curricular (mover/reordenar) */
|
||||
export type PlanMapOperation =
|
||||
| {
|
||||
op: "MOVE_ASIGNATURA";
|
||||
asignaturaId: UUID;
|
||||
numero_ciclo: number | null;
|
||||
linea_plan_id: UUID | null;
|
||||
orden_celda?: number | null;
|
||||
}
|
||||
op: 'MOVE_ASIGNATURA'
|
||||
asignaturaId: UUID
|
||||
numero_ciclo: number | null
|
||||
linea_plan_id: UUID | null
|
||||
orden_celda?: number | null
|
||||
}
|
||||
| {
|
||||
op: "REORDER_CELDA";
|
||||
linea_plan_id: UUID;
|
||||
numero_ciclo: number;
|
||||
asignaturaIdsOrdenados: Array<UUID>;
|
||||
};
|
||||
op: 'REORDER_CELDA'
|
||||
linea_plan_id: UUID
|
||||
numero_ciclo: number
|
||||
asignaturaIdsOrdenados: Array<UUID>
|
||||
}
|
||||
|
||||
export async function plans_update_map(
|
||||
planId: UUID,
|
||||
ops: Array<PlanMapOperation>,
|
||||
): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops });
|
||||
return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops })
|
||||
}
|
||||
|
||||
export async function plans_transition_state(payload: {
|
||||
planId: UUID;
|
||||
haciaEstadoId: UUID;
|
||||
comentario?: string;
|
||||
planId: UUID
|
||||
haciaEstadoId: UUID
|
||||
comentario?: string
|
||||
}): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.plans_transition_state, payload);
|
||||
return invokeEdge<{ ok: true }>(EDGE.plans_transition_state, payload)
|
||||
}
|
||||
|
||||
/** Documento (Edge Function: genera y devuelve URL firmada o metadata) */
|
||||
export type DocumentoResult = {
|
||||
archivoId: UUID;
|
||||
signedUrl: string;
|
||||
mimeType?: string;
|
||||
nombre?: string;
|
||||
};
|
||||
archivoId: UUID
|
||||
signedUrl: string
|
||||
mimeType?: string
|
||||
nombre?: string
|
||||
}
|
||||
|
||||
export async function plans_generate_document(
|
||||
planId: UUID,
|
||||
): Promise<DocumentoResult> {
|
||||
return invokeEdge<DocumentoResult>(EDGE.plans_generate_document, { planId });
|
||||
return invokeEdge<DocumentoResult>(EDGE.plans_generate_document, { planId })
|
||||
}
|
||||
|
||||
export async function plans_get_document(
|
||||
@@ -355,26 +352,26 @@ export async function plans_get_document(
|
||||
): Promise<DocumentoResult | null> {
|
||||
return invokeEdge<DocumentoResult | null>(EDGE.plans_get_document, {
|
||||
planId,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCatalogos() {
|
||||
const supabase = supabaseBrowser();
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const [facultadesRes, carrerasRes, estadosRes, estructurasPlanRes] =
|
||||
await Promise.all([
|
||||
supabase.from("facultades").select("*").order("nombre"),
|
||||
supabase.from("carreras").select("*").order("nombre"),
|
||||
supabase.from("estados_plan").select("*").order("orden"),
|
||||
supabase.from("estructuras_plan").select("*").order("creado_en", {
|
||||
supabase.from('facultades').select('*').order('nombre'),
|
||||
supabase.from('carreras').select('*').order('nombre'),
|
||||
supabase.from('estados_plan').select('*').order('orden'),
|
||||
supabase.from('estructuras_plan').select('*').order('creado_en', {
|
||||
ascending: true,
|
||||
}),
|
||||
]);
|
||||
])
|
||||
|
||||
return {
|
||||
facultades: facultadesRes.data ?? [],
|
||||
carreras: carrerasRes.data ?? [],
|
||||
estados: estadosRes.data ?? [],
|
||||
estructurasPlan: estructurasPlanRes.data ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
ai_generate_plan,
|
||||
@@ -22,16 +22,16 @@ import {
|
||||
plans_transition_state,
|
||||
plans_update_fields,
|
||||
plans_update_map,
|
||||
} from "../api/plans.api";
|
||||
import { qk } from "../query/keys";
|
||||
} from '../api/plans.api'
|
||||
import { qk } from '../query/keys'
|
||||
|
||||
import type {
|
||||
PlanListFilters,
|
||||
PlanMapOperation,
|
||||
PlansCreateManualInput,
|
||||
PlansUpdateFieldsPatch,
|
||||
} from "../api/plans.api";
|
||||
import type { UUID } from "../types/domain";
|
||||
} from '../api/plans.api'
|
||||
import type { UUID } from '../types/domain'
|
||||
|
||||
export function usePlanes(filters: PlanListFilters) {
|
||||
// 🧠 Tip: memoiza "filters" (useMemo) para que queryKey sea estable.
|
||||
@@ -47,146 +47,146 @@ export function usePlanes(filters: PlanListFilters) {
|
||||
|
||||
// Opcional: Tiempo que la data se considera fresca
|
||||
staleTime: 1000 * 60 * 5, // 5 minutos
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlan(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.plan(planId) : ["planes", "detail", null],
|
||||
queryKey: planId ? qk.plan(planId) : ['planes', 'detail', null],
|
||||
queryFn: () => plans_get(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlanLineas(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.planLineas(planId) : ["planes", "lineas", null],
|
||||
queryKey: planId ? qk.planLineas(planId) : ['planes', 'lineas', null],
|
||||
queryFn: () => plan_lineas_list(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlanAsignaturas(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId
|
||||
? qk.planAsignaturas(planId)
|
||||
: ["planes", "asignaturas", null],
|
||||
: ['planes', 'asignaturas', null],
|
||||
queryFn: () => plan_asignaturas_list(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlanHistorial(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.planHistorial(planId) : ["planes", "historial", null],
|
||||
queryKey: planId ? qk.planHistorial(planId) : ['planes', 'historial', null],
|
||||
queryFn: () => plans_history(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlanDocumento(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.planDocumento(planId) : ["planes", "documento", null],
|
||||
queryKey: planId ? qk.planDocumento(planId) : ['planes', 'documento', null],
|
||||
queryFn: () => plans_get_document(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useCatalogosPlanes() {
|
||||
return useQuery({
|
||||
queryKey: ["catalogos_planes"],
|
||||
queryKey: ['catalogos_planes'],
|
||||
queryFn: getCatalogos,
|
||||
staleTime: 1000 * 60 * 60, // 1 hora de caché (estos datos casi no cambian)
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/* ------------------ Mutations ------------------ */
|
||||
|
||||
export function useCreatePlanManual() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: PlansCreateManualInput) => plans_create_manual(input),
|
||||
onSuccess: (plan) => {
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.setQueryData(qk.plan(plan.id), plan);
|
||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
||||
qc.setQueryData(qk.plan(plan.id), plan)
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useGeneratePlanAI() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ai_generate_plan,
|
||||
onSuccess: (data) => {
|
||||
// Asumiendo que la Edge Function devuelve { ok: true, plan: { id: ... } }
|
||||
const newPlan = data.plan;
|
||||
const newPlan = data.plan
|
||||
|
||||
if (newPlan) {
|
||||
// 1. Invalidar la lista para que aparezca el nuevo plan
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
||||
|
||||
// 2. (Opcional) Pre-cargar el dato individual para que la navegación sea instantánea
|
||||
// qc.setQueryData(["planes", "detail", newPlan.id], newPlan);
|
||||
}
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// Funcion obsoleta porque ahora el plan se persiste directamente en useGeneratePlanAI
|
||||
export function usePersistPlanFromAI() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: { jsonPlan: any }) => plans_persist_from_ai(payload),
|
||||
onSuccess: (plan) => {
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.setQueryData(qk.plan(plan.id), plan);
|
||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
||||
qc.setQueryData(qk.plan(plan.id), plan)
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useClonePlan() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: plans_clone_from_existing,
|
||||
onSuccess: (plan) => {
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.setQueryData(qk.plan(plan.id), plan);
|
||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
||||
qc.setQueryData(qk.plan(plan.id), plan)
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useImportPlanFromFiles() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: plans_import_from_files,
|
||||
onSuccess: (plan) => {
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.setQueryData(qk.plan(plan.id), plan);
|
||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
||||
qc.setQueryData(qk.plan(plan.id), plan)
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdatePlanFields() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { planId: UUID; patch: PlansUpdateFieldsPatch }) =>
|
||||
plans_update_fields(vars.planId, vars.patch),
|
||||
onSuccess: (updated) => {
|
||||
qc.setQueryData(qk.plan(updated.id), updated);
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(updated.id) });
|
||||
qc.setQueryData(qk.plan(updated.id), updated)
|
||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(updated.id) })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdatePlanMapa() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { planId: UUID; ops: Array<PlanMapOperation> }) =>
|
||||
@@ -194,61 +194,61 @@ export function useUpdatePlanMapa() {
|
||||
|
||||
// ✅ Optimista (rápida) para el caso MOVE_ASIGNATURA
|
||||
onMutate: async (vars) => {
|
||||
await qc.cancelQueries({ queryKey: qk.planAsignaturas(vars.planId) });
|
||||
const prev = qc.getQueryData<any>(qk.planAsignaturas(vars.planId));
|
||||
await qc.cancelQueries({ queryKey: qk.planAsignaturas(vars.planId) })
|
||||
const prev = qc.getQueryData<any>(qk.planAsignaturas(vars.planId))
|
||||
|
||||
// solo optimizamos MOVEs simples
|
||||
const moves = vars.ops.filter((x) => x.op === "MOVE_ASIGNATURA");
|
||||
const moves = vars.ops.filter((x) => x.op === 'MOVE_ASIGNATURA')
|
||||
|
||||
if (prev && Array.isArray(prev) && moves.length) {
|
||||
const next = prev.map((a: any) => {
|
||||
const m = moves.find((x) => x.asignaturaId === a.id);
|
||||
if (!m) return a;
|
||||
const m = moves.find((x) => x.asignaturaId === a.id)
|
||||
if (!m) return a
|
||||
return {
|
||||
...a,
|
||||
numero_ciclo: m.numero_ciclo,
|
||||
linea_plan_id: m.linea_plan_id,
|
||||
orden_celda: m.orden_celda ?? a.orden_celda,
|
||||
};
|
||||
});
|
||||
qc.setQueryData(qk.planAsignaturas(vars.planId), next);
|
||||
}
|
||||
})
|
||||
qc.setQueryData(qk.planAsignaturas(vars.planId), next)
|
||||
}
|
||||
|
||||
return { prev };
|
||||
return { prev }
|
||||
},
|
||||
|
||||
onError: (_err, vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(qk.planAsignaturas(vars.planId), ctx.prev);
|
||||
if (ctx?.prev) qc.setQueryData(qk.planAsignaturas(vars.planId), ctx.prev)
|
||||
},
|
||||
|
||||
onSuccess: (_ok, vars) => {
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(vars.planId) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) });
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(vars.planId) })
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useTransitionPlanEstado() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: plans_transition_state,
|
||||
onSuccess: (_ok, vars) => {
|
||||
qc.invalidateQueries({ queryKey: qk.plan(vars.planId) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) });
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.invalidateQueries({ queryKey: qk.plan(vars.planId) })
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) })
|
||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useGeneratePlanDocumento() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (planId: UUID) => plans_generate_document(planId),
|
||||
onSuccess: (_doc, planId) => {
|
||||
qc.invalidateQueries({ queryKey: qk.planDocumento(planId) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(planId) });
|
||||
qc.invalidateQueries({ queryKey: qk.planDocumento(planId) })
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(planId) })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../query/keys";
|
||||
import type { UUID } from "../types/domain";
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { qk } from '../query/keys'
|
||||
import type { UUID } from '../types/domain'
|
||||
import type {
|
||||
BibliografiaUpsertInput,
|
||||
SubjectsCreateManualInput,
|
||||
SubjectsUpdateFieldsPatch,
|
||||
} from "../api/subjects.api";
|
||||
} from '../api/subjects.api'
|
||||
import {
|
||||
ai_generate_subject,
|
||||
subjects_bibliografia_list,
|
||||
@@ -20,147 +20,177 @@ import {
|
||||
subjects_update_bibliografia,
|
||||
subjects_update_contenido,
|
||||
subjects_update_fields,
|
||||
} from "../api/subjects.api";
|
||||
} from '../api/subjects.api'
|
||||
|
||||
export function useSubject(subjectId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: subjectId ? qk.asignatura(subjectId) : ["asignaturas", "detail", null],
|
||||
queryKey: subjectId
|
||||
? qk.asignatura(subjectId)
|
||||
: ['asignaturas', 'detail', null],
|
||||
queryFn: () => subjects_get(subjectId as UUID),
|
||||
enabled: Boolean(subjectId),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useSubjectBibliografia(subjectId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: subjectId ? qk.asignaturaBibliografia(subjectId) : ["asignaturas", "bibliografia", null],
|
||||
queryKey: subjectId
|
||||
? qk.asignaturaBibliografia(subjectId)
|
||||
: ['asignaturas', 'bibliografia', null],
|
||||
queryFn: () => subjects_bibliografia_list(subjectId as UUID),
|
||||
enabled: Boolean(subjectId),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useSubjectHistorial(subjectId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: subjectId ? qk.asignaturaHistorial(subjectId) : ["asignaturas", "historial", null],
|
||||
queryKey: subjectId
|
||||
? qk.asignaturaHistorial(subjectId)
|
||||
: ['asignaturas', 'historial', null],
|
||||
queryFn: () => subjects_history(subjectId as UUID),
|
||||
enabled: Boolean(subjectId),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useSubjectDocumento(subjectId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: subjectId ? qk.asignaturaDocumento(subjectId) : ["asignaturas", "documento", null],
|
||||
queryKey: subjectId
|
||||
? qk.asignaturaDocumento(subjectId)
|
||||
: ['asignaturas', 'documento', null],
|
||||
queryFn: () => subjects_get_document(subjectId as UUID),
|
||||
enabled: Boolean(subjectId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/* ------------------ Mutations ------------------ */
|
||||
|
||||
export function useCreateSubjectManual() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: SubjectsCreateManualInput) => subjects_create_manual(payload),
|
||||
mutationFn: (payload: SubjectsCreateManualInput) =>
|
||||
subjects_create_manual(payload),
|
||||
onSuccess: (subject) => {
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject)
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planAsignaturas(subject.plan_estudio_id),
|
||||
})
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planHistorial(subject.plan_estudio_id),
|
||||
})
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useGenerateSubjectAI() {
|
||||
return useMutation({ mutationFn: ai_generate_subject });
|
||||
return useMutation({ mutationFn: ai_generate_subject })
|
||||
}
|
||||
|
||||
export function usePersistSubjectFromAI() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: { planId: UUID; jsonMateria: any }) => subjects_persist_from_ai(payload),
|
||||
mutationFn: (payload: { planId: UUID; jsonMateria: any }) =>
|
||||
subjects_persist_from_ai(payload),
|
||||
onSuccess: (subject) => {
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject)
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planAsignaturas(subject.plan_estudio_id),
|
||||
})
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planHistorial(subject.plan_estudio_id),
|
||||
})
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useCloneSubject() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: subjects_clone_from_existing,
|
||||
onSuccess: (subject) => {
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject)
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planAsignaturas(subject.plan_estudio_id),
|
||||
})
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planHistorial(subject.plan_estudio_id),
|
||||
})
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useImportSubjectFromFile() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: subjects_import_from_file,
|
||||
onSuccess: (subject) => {
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject)
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planAsignaturas(subject.plan_estudio_id),
|
||||
})
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planHistorial(subject.plan_estudio_id),
|
||||
})
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateSubjectFields() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { subjectId: UUID; patch: SubjectsUpdateFieldsPatch }) =>
|
||||
subjects_update_fields(vars.subjectId, vars.patch),
|
||||
onSuccess: (updated) => {
|
||||
qc.setQueryData(qk.asignatura(updated.id), updated);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(updated.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) });
|
||||
qc.setQueryData(qk.asignatura(updated.id), updated)
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planAsignaturas(updated.plan_estudio_id),
|
||||
})
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateSubjectContenido() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { subjectId: UUID; unidades: any[] }) =>
|
||||
subjects_update_contenido(vars.subjectId, vars.unidades),
|
||||
onSuccess: (updated) => {
|
||||
qc.setQueryData(qk.asignatura(updated.id), updated);
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) });
|
||||
qc.setQueryData(qk.asignatura(updated.id), updated)
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateSubjectBibliografia() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { subjectId: UUID; entries: BibliografiaUpsertInput }) =>
|
||||
subjects_update_bibliografia(vars.subjectId, vars.entries),
|
||||
onSuccess: (_ok, vars) => {
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaBibliografia(vars.subjectId) });
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(vars.subjectId) });
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.asignaturaBibliografia(vars.subjectId),
|
||||
})
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(vars.subjectId) })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useGenerateSubjectDocumento() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (subjectId: UUID) => subjects_generate_document(subjectId),
|
||||
onSuccess: (_doc, subjectId) => {
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaDocumento(subjectId) });
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(subjectId) });
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaDocumento(subjectId) })
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(subjectId) })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,40 +1,53 @@
|
||||
import { usePlan } from '@/data';
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { Pencil, Check, X, Sparkles, AlertCircle } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
import type { DatosGeneralesField } from '@/types/plan'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Pencil,
|
||||
Check,
|
||||
X,
|
||||
Sparkles,
|
||||
AlertCircle
|
||||
} from 'lucide-react'
|
||||
//import { toast } from 'sonner' // Asegúrate de tener sonner instalado o quita la línea
|
||||
|
||||
import { usePlan } from '@/data'
|
||||
// import { toast } from 'sonner' // Asegúrate de tener sonner instalado o quita la línea
|
||||
export const Route = createFileRoute('/planes/$planId/_detalle/datos')({
|
||||
component: DatosGeneralesPage,
|
||||
})
|
||||
|
||||
function DatosGeneralesPage() {
|
||||
const {data, isFetching} = usePlan('0e0aea4d-b8b4-4e75-8279-6224c3ac769f');
|
||||
if(!isFetching && !data) {
|
||||
return <div>No se encontró el plan de estudios.</div>
|
||||
}
|
||||
console.log(data);
|
||||
|
||||
// 1. Definimos los DATOS iniciales (Lo que antes venía por props)
|
||||
const [campos, setCampos] = useState<DatosGeneralesField[]>([
|
||||
{ id: '1', label: 'Objetivo General', value: 'Formar profesionales...', requerido: true, tipo: 'texto' },
|
||||
{ id: '2', label: 'Perfil de Ingreso', value: 'Interés por la tecnología...', requerido: true, tipo: 'lista' },
|
||||
{ id: '3', label: 'Perfil de Egreso', value: '', requerido: true, tipo: 'texto' },
|
||||
])
|
||||
const formatLabel = (key: string) => {
|
||||
const result = key.replace(/_/g, ' ')
|
||||
return result.charAt(0).toUpperCase() + result.slice(1)
|
||||
}
|
||||
|
||||
// 2. Estados de edición
|
||||
function DatosGeneralesPage() {
|
||||
const { planId } = Route.useParams()
|
||||
const { data } = usePlan(planId)
|
||||
const navigate = useNavigate()
|
||||
// Inicializamos campos como un arreglo vacío
|
||||
const [campos, setCampos] = useState<Array<DatosGeneralesField>>([])
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
|
||||
// Efecto para transformar data?.datos en el arreglo de campos
|
||||
useEffect(() => {
|
||||
// 2. Validación de seguridad para sourceData
|
||||
const sourceData = data?.datos
|
||||
|
||||
if (sourceData && typeof sourceData === 'object') {
|
||||
const datosTransformados: Array<DatosGeneralesField> = Object.entries(
|
||||
sourceData,
|
||||
).map(([key, value], index) => ({
|
||||
id: (index + 1).toString(),
|
||||
label: formatLabel(key),
|
||||
// Forzamos el valor a string de forma segura
|
||||
value: typeof value === 'string' ? value : value?.toString() || '',
|
||||
requerido: true,
|
||||
tipo: 'texto',
|
||||
}))
|
||||
|
||||
setCampos(datosTransformados)
|
||||
}
|
||||
console.log(data)
|
||||
}, [data])
|
||||
|
||||
// 3. Manejadores de acciones (Ahora como funciones locales)
|
||||
const handleEdit = (campo: DatosGeneralesField) => {
|
||||
setEditingId(campo.id)
|
||||
@@ -48,54 +61,76 @@ function DatosGeneralesPage() {
|
||||
|
||||
const handleSave = (id: string) => {
|
||||
// Actualizamos el estado local de la lista
|
||||
setCampos(prev => prev.map(c =>
|
||||
c.id === id ? { ...c, value: editValue } : c
|
||||
))
|
||||
setCampos((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, value: editValue } : c)),
|
||||
)
|
||||
setEditingId(null)
|
||||
setEditValue('')
|
||||
//toast.success('Cambios guardados localmente')
|
||||
// toast.success('Cambios guardados localmente')
|
||||
}
|
||||
|
||||
const handleIARequest = (id: string) => {
|
||||
//toast.info('La IA está analizando el campo ' + id)
|
||||
// Aquí conectarías con tu endpoint de IA
|
||||
const handleIARequest = (descripcion: string) => {
|
||||
navigate({
|
||||
to: '/planes/$planId/iaplan',
|
||||
params: {
|
||||
planId: '1', // o dinámico
|
||||
},
|
||||
state: {
|
||||
prefill: descripcion,
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 animate-in fade-in duration-500">
|
||||
<div className="animate-in fade-in container mx-auto px-6 py-6 duration-500">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
Datos Generales del Plan
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Información estructural y descriptiva del plan de estudios
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{campos.map((campo) => {
|
||||
const isEditing = editingId === campo.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={campo.id}
|
||||
className={`border rounded-xl transition-all ${
|
||||
isEditing ? 'border-teal-500 ring-2 ring-teal-50 shadow-lg' : 'bg-white hover:shadow-md'
|
||||
className={`rounded-xl border transition-all ${
|
||||
isEditing
|
||||
? 'border-teal-500 shadow-lg ring-2 ring-teal-50'
|
||||
: 'bg-white hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
{/* Header de la Card */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b bg-slate-50/50">
|
||||
<div className="flex items-center justify-between border-b bg-slate-50/50 px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm text-slate-700">{campo.label}</h3>
|
||||
{campo.requerido && <span className="text-red-500 text-xs">*</span>}
|
||||
<h3 className="text-sm font-medium text-slate-700">
|
||||
{campo.label}
|
||||
</h3>
|
||||
{campo.requerido && (
|
||||
<span className="text-xs text-red-500">*</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-teal-600" onClick={() => handleIARequest(campo.id)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-teal-600"
|
||||
onClick={() => handleIARequest(campo.value)}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleEdit(campo)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleEdit(campo)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -112,10 +147,18 @@ function DatosGeneralesPage() {
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleCancel}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<X size={14} className="mr-1" /> Cancelar
|
||||
</Button>
|
||||
<Button size="sm" className="bg-teal-600 hover:bg-teal-700" onClick={() => handleSave(campo.id)}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-teal-600 hover:bg-teal-700"
|
||||
onClick={() => handleSave(campo.id)}
|
||||
>
|
||||
<Check size={14} className="mr-1" /> Guardar
|
||||
</Button>
|
||||
</div>
|
||||
@@ -123,12 +166,12 @@ function DatosGeneralesPage() {
|
||||
) : (
|
||||
<div className="min-h-[100px]">
|
||||
{campo.value ? (
|
||||
<div className="text-sm text-slate-600 leading-relaxed">
|
||||
<div className="text-sm leading-relaxed text-slate-600">
|
||||
{campo.tipo === 'lista' ? (
|
||||
<ul className="space-y-1">
|
||||
{campo.value.split('\n').map((item, i) => (
|
||||
<li key={i} className="flex gap-2">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-teal-500 shrink-0" />
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-teal-500" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
@@ -138,7 +181,7 @@ function DatosGeneralesPage() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<AlertCircle size={14} />
|
||||
<span>Sin contenido.</span>
|
||||
</div>
|
||||
@@ -152,4 +195,4 @@ function DatosGeneralesPage() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,64 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { CheckCircle2, Circle, Clock } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { CheckCircle2, Circle, Clock } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { usePlanHistorial } from '@/data/hooks/usePlans'
|
||||
|
||||
export const Route = createFileRoute('/planes/$planId/_detalle/flujo')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const { data: rawData, isLoading } = usePlanHistorial(
|
||||
'0e0aea4d-b8b4-4e75-8279-6224c3ac769f',
|
||||
)
|
||||
console.log(rawData)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
{/* Header Informativo (Opcional, si no viene del layout padre) */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold italic">Flujo de Aprobación</h1>
|
||||
<p className="text-sm text-muted-foreground">Gestiona el proceso de revisión y aprobación del plan</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Gestiona el proceso de revisión y aprobación del plan
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
{/* LADO IZQUIERDO: Timeline del Flujo */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
{/* Estado: Completado */}
|
||||
<div className="relative flex gap-4 pb-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="rounded-full bg-green-100 p-1 text-green-600">
|
||||
<CheckCircle2 className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="w-px flex-1 bg-green-200 mt-2" />
|
||||
<div className="mt-2 w-px flex-1 bg-green-200" />
|
||||
</div>
|
||||
<Card className="flex-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||
<div>
|
||||
<CardTitle className="text-lg">Borrador</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">14 de enero de 2024</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
14 de enero de 2024
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-700">Completado</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-green-100 text-green-700"
|
||||
>
|
||||
Completado
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm border-t pt-3">
|
||||
<p className="font-semibold text-muted-foreground mb-2">Comentarios</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<CardContent className="border-t pt-3 text-sm">
|
||||
<p className="text-muted-foreground mb-2 font-semibold">
|
||||
Comentarios
|
||||
</p>
|
||||
<ul className="text-muted-foreground list-inside list-disc space-y-1">
|
||||
<li>Documento inicial creado</li>
|
||||
<li>Estructura base definida</li>
|
||||
</ul>
|
||||
@@ -57,19 +72,27 @@ function RouteComponent() {
|
||||
<div className="rounded-full bg-blue-100 p-1 text-blue-600 ring-2 ring-blue-500 ring-offset-2">
|
||||
<Clock className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="w-px flex-1 bg-slate-200 mt-2" />
|
||||
<div className="mt-2 w-px flex-1 bg-slate-200" />
|
||||
</div>
|
||||
<Card className="flex-1 border-blue-500 bg-blue-50/10">
|
||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||
<div>
|
||||
<CardTitle className="text-lg text-blue-700">En Revisión</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">19 de febrero de 2024</p>
|
||||
<CardTitle className="text-lg text-blue-700">
|
||||
En Revisión
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
19 de febrero de 2024
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="default" className="bg-blue-500">En curso</Badge>
|
||||
<Badge variant="default" className="bg-blue-500">
|
||||
En curso
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm border-t border-blue-100 pt-3">
|
||||
<p className="font-semibold text-muted-foreground mb-2">Comentarios</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<CardContent className="border-t border-blue-100 pt-3 text-sm">
|
||||
<p className="text-muted-foreground mb-2 font-semibold">
|
||||
Comentarios
|
||||
</p>
|
||||
<ul className="text-muted-foreground list-inside list-disc space-y-1">
|
||||
<li>Revisión de objetivo general pendiente</li>
|
||||
<li>Mapa curricular aprobado preliminarmente</li>
|
||||
</ul>
|
||||
@@ -91,7 +114,6 @@ function RouteComponent() {
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* LADO DERECHO: Formulario de Transición */}
|
||||
@@ -101,22 +123,24 @@ function RouteComponent() {
|
||||
<CardTitle className="text-lg">Transición de Estado</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-lg text-sm border">
|
||||
<div className="flex items-center justify-between rounded-lg border bg-slate-50 p-3 text-sm">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">Estado actual</p>
|
||||
<p className="text-muted-foreground text-xs">Estado actual</p>
|
||||
<p className="font-bold">En Revisión</p>
|
||||
</div>
|
||||
<div className="h-px flex-1 bg-slate-300 mx-4" />
|
||||
<div className="mx-4 h-px flex-1 bg-slate-300" />
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">Siguiente</p>
|
||||
<p className="font-bold text-primary">Revisión Expertos</p>
|
||||
<p className="text-muted-foreground text-xs">Siguiente</p>
|
||||
<p className="text-primary font-bold">Revisión Expertos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Comentario de transición</label>
|
||||
<Textarea
|
||||
placeholder="Agrega un comentario para la transición..."
|
||||
<label className="text-sm font-medium">
|
||||
Comentario de transición
|
||||
</label>
|
||||
<Textarea
|
||||
placeholder="Agrega un comentario para la transición..."
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
</div>
|
||||
@@ -127,8 +151,7 @@ function RouteComponent() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,142 +1,284 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import {
|
||||
GitBranch,
|
||||
Edit3,
|
||||
PlusCircle,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
User
|
||||
} from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import {
|
||||
GitBranch,
|
||||
Edit3,
|
||||
PlusCircle,
|
||||
RefreshCw,
|
||||
User,
|
||||
Loader2,
|
||||
Clock,
|
||||
Eye,
|
||||
History,
|
||||
Calendar,
|
||||
} from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { usePlanHistorial } from '@/data/hooks/usePlans'
|
||||
import { format, formatDistanceToNow, parseISO } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
|
||||
export const Route = createFileRoute('/planes/$planId/_detalle/historial')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const historyEvents = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'Cambio de estado',
|
||||
user: 'Dr. Juan Pérez',
|
||||
description: 'Plan pasado de Borrador a En Revisión',
|
||||
date: 'Hace 2 días',
|
||||
icon: <GitBranch className="h-4 w-4" />,
|
||||
details: { from: 'Borrador', to: 'En Revisión' }
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'Edición',
|
||||
user: 'Lic. María García',
|
||||
description: 'Actualizado perfil de egreso',
|
||||
date: 'Hace 3 días',
|
||||
icon: <Edit3 className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'Reorganización',
|
||||
user: 'Ing. Carlos López',
|
||||
description: 'Movida materia BD102 de ciclo 3 a ciclo 4',
|
||||
date: 'Hace 5 días',
|
||||
icon: <RefreshCw className="h-4 w-4" />,
|
||||
details: { from: 'Ciclo 3', to: 'Ciclo 4' }
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'Creación',
|
||||
user: 'Dr. Juan Pérez',
|
||||
description: 'Añadida nueva materia: Inteligencia Artificial',
|
||||
date: 'Hace 1 semana',
|
||||
const getEventConfig = (tipo: string, campo: string) => {
|
||||
if (tipo === 'CREACION')
|
||||
return {
|
||||
label: 'Creación',
|
||||
icon: <PlusCircle className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: 'Documento',
|
||||
user: 'Lic. María García',
|
||||
description: 'Generado documento oficial v1.0',
|
||||
date: 'Hace 1 semana',
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
color: 'teal',
|
||||
}
|
||||
]
|
||||
if (campo === 'estado')
|
||||
return {
|
||||
label: 'Cambio de estado',
|
||||
icon: <GitBranch className="h-4 w-4" />,
|
||||
color: 'blue',
|
||||
}
|
||||
if (campo === 'datos')
|
||||
return {
|
||||
label: 'Edición de Datos',
|
||||
icon: <Edit3 className="h-4 w-4" />,
|
||||
color: 'amber',
|
||||
}
|
||||
return {
|
||||
label: 'Actualización',
|
||||
icon: <RefreshCw className="h-4 w-4" />,
|
||||
color: 'slate',
|
||||
}
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const { planId } = Route.useParams()
|
||||
const { data: rawData, isLoading } = usePlanHistorial(
|
||||
'0e0aea4d-b8b4-4e75-8279-6224c3ac769f',
|
||||
)
|
||||
|
||||
// ESTADOS PARA EL MODAL
|
||||
const [selectedEvent, setSelectedEvent] = useState<any>(null)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const historyEvents = useMemo(() => {
|
||||
if (!rawData) return []
|
||||
return rawData.map((item: any) => {
|
||||
const config = getEventConfig(item.tipo, item.campo)
|
||||
return {
|
||||
id: item.id,
|
||||
type: config.label,
|
||||
user:
|
||||
item.cambiado_por === '11111111-1111-1111-1111-111111111111'
|
||||
? 'Administrador'
|
||||
: 'Usuario Staff',
|
||||
description:
|
||||
item.campo === 'datos'
|
||||
? `Actualización general de: ${item.valor_nuevo?.nombre || 'información del plan'}`
|
||||
: `Se modificó el campo ${item.campo}`,
|
||||
date: parseISO(item.cambiado_en),
|
||||
icon: config.icon,
|
||||
campo: item.campo,
|
||||
details: {
|
||||
from: item.valor_anterior,
|
||||
to: item.valor_nuevo,
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [rawData])
|
||||
|
||||
const openCompareModal = (event: any) => {
|
||||
setSelectedEvent(event)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const renderValue = (val: any) => {
|
||||
if (!val) return 'Sin información'
|
||||
if (typeof val === 'object') return JSON.stringify(val, null, 2)
|
||||
return String(val)
|
||||
}
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-teal-600" />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-xl font-bold text-slate-800">Historial de Cambios</h1>
|
||||
<p className="text-sm text-muted-foreground">Registro de todas las modificaciones realizadas al plan</p>
|
||||
<div className="mx-auto max-w-5xl p-6">
|
||||
<div className="mb-8 flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-xl font-bold text-slate-800">
|
||||
<Clock className="h-5 w-5 text-teal-600" /> Historial de Cambios del
|
||||
Plan
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Registro cronológico de modificaciones realizadas
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-0">
|
||||
{/* Línea vertical de fondo */}
|
||||
<div className="absolute left-9 top-0 bottom-0 w-px bg-slate-200" />
|
||||
<div className="absolute top-0 bottom-0 left-9 w-px bg-slate-200" />
|
||||
{historyEvents.length === 0 ? (
|
||||
<div className="ml-20 py-10 text-slate-500">No hay registros.</div>
|
||||
) : (
|
||||
historyEvents.map((event) => (
|
||||
<div key={event.id} className="group relative flex gap-6 pb-8">
|
||||
<div className="relative z-10 flex h-18 flex-col items-center">
|
||||
<div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm transition-colors group-hover:bg-teal-50 group-hover:text-teal-600">
|
||||
{event.icon}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{historyEvents.map((event) => (
|
||||
<div key={event.id} className="relative flex gap-6 pb-8 group">
|
||||
|
||||
{/* Indicador con Icono */}
|
||||
<div className="relative z-10 flex h-18 flex-col items-center">
|
||||
<div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm group-hover:bg-teal-50 group-hover:text-teal-600 transition-colors">
|
||||
{event.icon}
|
||||
<Card className="flex-1 border-slate-200 shadow-none transition-colors hover:border-teal-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-slate-800">
|
||||
{event.type}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 py-0 text-[10px] font-normal"
|
||||
>
|
||||
{formatDistanceToNow(event.date, {
|
||||
addSuffix: true,
|
||||
locale: es,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Grupo de elementos alineados a la derecha */}
|
||||
<div className="flex items-center gap-4 text-slate-500">
|
||||
{/* Usuario e Icono */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
<span className="text-muted-foreground">
|
||||
{event.user}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Botón Ver Cambios */}
|
||||
<button
|
||||
onClick={() => openCompareModal(event)}
|
||||
className="group/btn flex items-center gap-1.5 text-xs transition-colors hover:text-teal-600"
|
||||
>
|
||||
<Eye className="h-4 w-4 text-slate-400 group-hover/btn:text-teal-600" />
|
||||
<span>Ver cambios</span>
|
||||
</button>
|
||||
|
||||
{/* Fecha exacta (Solo visible en desktop para no amontonar) */}
|
||||
<span className="hidden text-[11px] text-slate-400 md:block">
|
||||
{format(event.date, 'yyyy-MM-dd HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LÍNEA INFERIOR: Descripción */}
|
||||
<div className="mt-1">
|
||||
<p className="text-sm text-slate-600">
|
||||
{event.description}
|
||||
</p>
|
||||
|
||||
{/* Badges de transición opcionales (de estado) */}
|
||||
{event.details &&
|
||||
typeof event.details.from === 'string' &&
|
||||
event.campo === 'estado' && (
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-red-50 px-1.5 text-[9px] text-red-700"
|
||||
>
|
||||
{event.details.from}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
→
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-emerald-50 px-1.5 text-[9px] text-emerald-700"
|
||||
>
|
||||
{event.details.to}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b bg-slate-50/50 p-6">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5 text-teal-600" /> Comparación de
|
||||
Versiones
|
||||
</DialogTitle>
|
||||
<div className="text-muted-foreground flex items-center gap-4 pt-2 text-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" /> {selectedEvent?.user}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />{' '}
|
||||
{selectedEvent &&
|
||||
format(selectedEvent.date, "d 'de' MMMM, HH:mm", {
|
||||
locale: es,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid h-full grid-cols-2 gap-6">
|
||||
{/* Lado Antes */}
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1">
|
||||
<div className="h-2 w-2 rounded-full bg-red-400" />
|
||||
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
|
||||
Versión Anterior
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-red-100 bg-red-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700">
|
||||
{renderValue(selectedEvent?.details.from)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lado Después */}
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1">
|
||||
<div className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
|
||||
Nueva Versión
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-emerald-100 bg-emerald-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700">
|
||||
{renderValue(selectedEvent?.details.to)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tarjeta de Contenido */}
|
||||
<Card className="flex-1 shadow-none border-slate-200 hover:border-teal-200 transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-slate-800 text-sm">{event.type}</span>
|
||||
<Badge variant="outline" className="text-[10px] font-normal py-0">
|
||||
{event.date}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Avatar className="h-5 w-5 border">
|
||||
<AvatarFallback className="text-[8px] bg-slate-50"><User size={10}/></AvatarFallback>
|
||||
</Avatar>
|
||||
{event.user}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-600 mb-3">{event.description}</p>
|
||||
|
||||
{/* Badges de transición (si existen) */}
|
||||
{event.details && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant="secondary" className="bg-orange-50 text-orange-700 hover:bg-orange-50 border-orange-100 text-[10px]">
|
||||
{event.details.from}
|
||||
</Badge>
|
||||
<span className="text-slate-400 text-xs">→</span>
|
||||
<Badge variant="secondary" className="bg-green-50 text-green-700 hover:bg-green-50 border-green-100 text-[10px]">
|
||||
{event.details.to}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Evento inicial de creación */}
|
||||
<div className="relative flex gap-6 group">
|
||||
<div className="relative z-10 flex items-center">
|
||||
<div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-teal-600 text-white shadow-sm">
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex justify-center border-t bg-slate-50 p-4">
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
Campo: {selectedEvent?.campo}
|
||||
</Badge>
|
||||
</div>
|
||||
<Card className="flex-1 bg-teal-50/30 border-teal-100 shadow-none">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-bold text-teal-900 text-sm">Creación</span>
|
||||
<span className="text-[10px] text-teal-600 font-medium">14 Ene 2024</span>
|
||||
</div>
|
||||
<p className="text-sm text-teal-800/80">Plan de estudios creado</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,118 +1,371 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Sparkles, Send, Paperclip, Target, UserCheck, Lightbulb, FileText } from "lucide-react"
|
||||
import { useState } from 'react' // Importamos useState
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { createFileRoute, useRouterState } from '@tanstack/react-router'
|
||||
import {
|
||||
Sparkles,
|
||||
Send,
|
||||
Target,
|
||||
UserCheck,
|
||||
Lightbulb,
|
||||
FileText,
|
||||
GraduationCap,
|
||||
BookOpen,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { usePlan } from '@/data/hooks/usePlans'
|
||||
|
||||
const PRESETS = [
|
||||
{
|
||||
id: 'objetivo',
|
||||
label: 'Mejorar objetivo general',
|
||||
icon: Target,
|
||||
prompt: 'Mejora la redacción del objetivo general...',
|
||||
},
|
||||
{
|
||||
id: 'perfil-egreso',
|
||||
label: 'Redactar perfil de egreso',
|
||||
icon: GraduationCap,
|
||||
prompt: 'Genera un perfil de egreso detallado...',
|
||||
},
|
||||
{
|
||||
id: 'competencias',
|
||||
label: 'Sugerir competencias',
|
||||
icon: BookOpen,
|
||||
prompt: 'Genera una lista de competencias...',
|
||||
},
|
||||
{
|
||||
id: 'pertinencia',
|
||||
label: 'Justificar pertinencia',
|
||||
icon: FileText,
|
||||
prompt: 'Redacta una justificación de pertinencia...',
|
||||
},
|
||||
]
|
||||
|
||||
// --- Tipado y Helpers ---
|
||||
interface SelectedField {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const formatLabel = (key: string) => {
|
||||
const result = key.replace(/_/g, ' ')
|
||||
return result.charAt(0).toUpperCase() + result.slice(1)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
// 1. Estado para el texto del input
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
// 2. Estado para la lista de mensajes (iniciamos con los de la imagen)
|
||||
const [messages, setMessages] = useState([
|
||||
{ id: 1, role: 'ai', text: 'Hola, soy tu asistente de IA para el diseño del plan de estudios...' },
|
||||
{ id: 2, role: 'user', text: 'jkasakj' },
|
||||
{ id: 3, role: 'ai', text: 'Entendido. Estoy procesando tu solicitud.' },
|
||||
const { planId } = Route.useParams()
|
||||
// Usamos el ID dinámico del plan o el hardcoded según tu necesidad
|
||||
const { data } = usePlan('0e0aea4d-b8b4-4e75-8279-6224c3ac769f')
|
||||
const routerState = useRouterState()
|
||||
|
||||
// ESTADOS PRINCIPALES
|
||||
const [messages, setMessages] = useState<Array<any>>([
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content:
|
||||
'¡Hola! Soy tu asistente de IA. ¿Qué campos deseas mejorar? Puedes escribir ":" para seleccionar uno.',
|
||||
},
|
||||
])
|
||||
const [input, setInput] = useState('')
|
||||
const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([])
|
||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
|
||||
|
||||
// 3. Función para enviar el mensaje
|
||||
const handleSend = () => {
|
||||
if (!inputValue.trim()) return
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Agregamos el mensaje del usuario
|
||||
const newMessage = {
|
||||
id: Date.now(),
|
||||
role: 'user',
|
||||
text: inputValue
|
||||
// 1. Transformar datos de la API para el menú de selección
|
||||
const availableFields = useMemo(() => {
|
||||
if (!data?.datos) return []
|
||||
return Object.entries(data.datos).map(([key, value]) => ({
|
||||
key,
|
||||
label: formatLabel(key),
|
||||
value: String(value || ''),
|
||||
}))
|
||||
}, [data])
|
||||
|
||||
// 2. Manejar el estado inicial si viene de "Datos Generales"
|
||||
useEffect(() => {
|
||||
const state = routerState.location.state as any
|
||||
if (state?.prefill && availableFields.length > 0) {
|
||||
// Intentamos encontrar qué campo es por su valor o si mandaste el fieldKey
|
||||
const field = availableFields.find(
|
||||
(f) => f.value === state.prefill || f.key === state.fieldKey,
|
||||
)
|
||||
if (field && !selectedFields.find((sf) => sf.key === field.key)) {
|
||||
setSelectedFields([field])
|
||||
}
|
||||
setInput(`Mejora este campo: `)
|
||||
}
|
||||
}, [availableFields])
|
||||
|
||||
setMessages([...messages, newMessage])
|
||||
setInputValue('') // Limpiamos el input
|
||||
// 3. Lógica para el disparador ":"
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = e.target.value
|
||||
setInput(val)
|
||||
if (val.endsWith(':')) {
|
||||
setShowSuggestions(true)
|
||||
} else {
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleField = (field: SelectedField) => {
|
||||
setSelectedFields((prev) =>
|
||||
prev.find((f) => f.key === field.key)
|
||||
? prev.filter((f) => f.key !== field.key)
|
||||
: [...prev, field],
|
||||
)
|
||||
if (input.endsWith(':')) setInput(input.slice(0, -1))
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
|
||||
const handleSend = async (promptOverride?: string) => {
|
||||
const textToSend = promptOverride || input
|
||||
if (!textToSend.trim() && selectedFields.length === 0) return
|
||||
|
||||
const userMsg = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: textToSend,
|
||||
}
|
||||
setMessages((prev) => [...prev, userMsg])
|
||||
setInput('')
|
||||
setIsLoading(true)
|
||||
|
||||
// Aquí simularías la llamada a la API enviando 'selectedFields' como contexto
|
||||
setTimeout(() => {
|
||||
const mockText =
|
||||
'Sugerencia generada basada en los campos seleccionados...'
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
role: 'assistant',
|
||||
content: `He analizado ${selectedFields.length > 0 ? selectedFields.map((f) => f.label).join(', ') : 'tu solicitud'}. Aquí tienes una propuesta:\n\n${mockText}`,
|
||||
},
|
||||
])
|
||||
setPendingSuggestion({ text: mockText })
|
||||
setIsLoading(false)
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-200px)] gap-6 p-4">
|
||||
<div className="flex flex-col flex-1 bg-slate-50/50 rounded-xl border relative overflow-hidden">
|
||||
|
||||
<ScrollArea className="flex-1 p-6">
|
||||
<div className="space-y-6 max-w-3xl mx-auto">
|
||||
{/* 4. Mapeamos los mensajes dinámicamente */}
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} gap-3`}>
|
||||
{msg.role === 'ai' && (
|
||||
<Avatar className="h-8 w-8 border bg-teal-50">
|
||||
<AvatarFallback className="text-teal-600"><Sparkles size={16}/></AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<div className={msg.role === 'ai' ? 'space-y-2' : ''}>
|
||||
{msg.role === 'ai' && <p className="text-xs font-bold text-teal-700 uppercase tracking-wider">Asistente IA</p>}
|
||||
<div className={`p-4 rounded-2xl text-sm shadow-sm ${
|
||||
msg.role === 'user'
|
||||
? 'bg-teal-600 text-white rounded-tr-none'
|
||||
: 'bg-white border text-slate-700 rounded-tl-none'
|
||||
}`}>
|
||||
{msg.text}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
|
||||
{/* PANEL DE CHAT PRINCIPAL */}
|
||||
<div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm">
|
||||
{/* NUEVO: Barra superior de campos seleccionados */}
|
||||
<div className="shrink-0 border-b bg-white p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase">
|
||||
Campos a mejorar:
|
||||
</span>
|
||||
{selectedFields.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="animate-in zoom-in-95 flex items-center gap-1.5 rounded-lg border border-teal-100 bg-teal-50 px-2 py-1 text-xs font-medium text-teal-700"
|
||||
>
|
||||
{field.label}
|
||||
<button
|
||||
onClick={() => toggleField(field)}
|
||||
className="hover:text-red-500"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{selectedFields.length === 0 && (
|
||||
<span className="text-xs text-slate-400 italic">
|
||||
Escribe ":" para añadir campos
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* 5. Input vinculado al estado */}
|
||||
<div className="p-4 bg-white border-t">
|
||||
<div className="max-w-4xl mx-auto flex gap-2 items-center bg-slate-50 border rounded-lg px-3 py-1 shadow-sm focus-within:ring-1 focus-within:ring-teal-500 transition-all">
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()} // Enviar con Enter
|
||||
className="border-none bg-transparent focus-visible:ring-0 text-sm"
|
||||
placeholder='Escribe tu solicitud... Usa ":" para mencionar campos'
|
||||
/>
|
||||
<Button variant="ghost" size="icon" className="text-slate-400">
|
||||
<Paperclip size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
size="icon"
|
||||
className="bg-teal-600 hover:bg-teal-700 h-8 w-8"
|
||||
>
|
||||
<Send size={16} />
|
||||
</Button>
|
||||
{/* CONTENIDO DEL CHAT */}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<ScrollArea ref={scrollRef} className="h-full w-full">
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`}
|
||||
>
|
||||
<Avatar
|
||||
className={`h-8 w-8 shrink-0 border ${msg.role === 'assistant' ? 'bg-teal-50' : 'bg-slate-200'}`}
|
||||
>
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{msg.role === 'assistant' ? (
|
||||
<Sparkles size={14} className="text-teal-600" />
|
||||
) : (
|
||||
<UserCheck size={14} />
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div
|
||||
className={`flex max-w-[85%] flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm ${
|
||||
msg.role === 'user'
|
||||
? 'rounded-tr-none bg-teal-600 text-white'
|
||||
: 'rounded-tl-none border bg-white text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex gap-2 p-4">
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.2s]" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.4s]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Botones flotantes de aplicación */}
|
||||
{pendingSuggestion && !isLoading && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 gap-2 rounded-full border bg-white p-1.5 shadow-2xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPendingSuggestion(null)}
|
||||
className="h-8 rounded-full text-xs"
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" /> Descartar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 rounded-full bg-teal-600 text-xs text-white hover:bg-teal-700"
|
||||
>
|
||||
<Check className="mr-1 h-3 w-3" /> Aplicar cambios
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* INPUT FIJO AL FONDO CON SUGERENCIAS : */}
|
||||
{/* INPUT FIJO AL FONDO CON SUGERENCIAS : */}
|
||||
<div className="shrink-0 border-t bg-white p-4">
|
||||
<div className="relative mx-auto max-w-4xl">
|
||||
{/* MENÚ DE SUGERENCIAS FLOTANTE (Se mantiene igual) */}
|
||||
{showSuggestions && (
|
||||
<div className="animate-in slide-in-from-bottom-2 absolute bottom-full z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl">
|
||||
<div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold tracking-wider text-slate-500 uppercase">
|
||||
Seleccionar campo para IA
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto p-1">
|
||||
{availableFields.map((field) => (
|
||||
<button
|
||||
key={field.key}
|
||||
onClick={() => toggleField(field)}
|
||||
className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50"
|
||||
>
|
||||
<span className="text-slate-700 group-hover:text-teal-700">
|
||||
{field.label}
|
||||
</span>
|
||||
{selectedFields.find((f) => f.key === field.key) && (
|
||||
<Check size={14} className="text-teal-600" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CONTENEDOR DEL INPUT TRANSFORMADO */}
|
||||
<div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500">
|
||||
{/* 1. Visualización de campos dentro del input (Tags) */}
|
||||
{selectedFields.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-2 pt-1">
|
||||
{selectedFields.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="animate-in zoom-in-95 flex items-center gap-1 rounded-md border border-teal-200 bg-teal-100 px-2 py-0.5 text-[11px] font-semibold text-teal-800"
|
||||
>
|
||||
<span className="opacity-70">Campo:</span> {field.label}
|
||||
<button
|
||||
onClick={() => toggleField(field)}
|
||||
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-teal-200"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. Área de escritura */}
|
||||
<div className="flex items-end gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
selectedFields.length > 0
|
||||
? 'Escribe instrucciones adicionales...'
|
||||
: 'Escribe tu solicitud o ":" para campos...'
|
||||
}
|
||||
className="max-h-[120px] min-h-[40px] flex-1 resize-none border-none bg-transparent py-2 text-sm shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
disabled={
|
||||
(!input.trim() && selectedFields.length === 0) || isLoading
|
||||
}
|
||||
size="icon"
|
||||
className="mb-1 h-9 w-9 shrink-0 bg-teal-600 hover:bg-teal-700"
|
||||
>
|
||||
<Send size={16} className="text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Panel lateral (se mantiene igual) */}
|
||||
<div className="w-72 space-y-4">
|
||||
<div className="flex items-center gap-2 text-orange-500 font-semibold text-sm mb-4">
|
||||
<Lightbulb size={18} />
|
||||
Acciones rápidas
|
||||
</div>
|
||||
{/* PANEL LATERAL (PRESETS) - SE MANTIENE COMO LO TENÍAS */}
|
||||
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
|
||||
<h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800">
|
||||
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<ActionButton icon={<Target className="text-teal-500" size={18} />} text="Mejorar objetivo general" />
|
||||
<ActionButton icon={<UserCheck className="text-slate-500" size={18} />} text="Redactar perfil de egreso" />
|
||||
<ActionButton icon={<Lightbulb className="text-blue-500" size={18} />} text="Sugerir competencias" />
|
||||
<ActionButton icon={<FileText className="text-teal-500" size={18} />} text="Justificar pertinencia" />
|
||||
{PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => handleSend(preset.prompt)}
|
||||
className="group flex w-full items-center gap-3 rounded-xl border bg-white p-3 text-left text-sm shadow-sm transition-all hover:border-teal-500 hover:bg-teal-50"
|
||||
>
|
||||
<div className="rounded-lg bg-slate-100 p-2 text-slate-500 group-hover:bg-teal-100 group-hover:text-teal-600">
|
||||
<preset.icon size={16} />
|
||||
</div>
|
||||
<span className="leading-tight font-medium text-slate-700">
|
||||
{preset.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionButton({ icon, text }: { icon: React.ReactNode, text: string }) {
|
||||
return (
|
||||
<Button variant="outline" className="w-full justify-start gap-3 h-auto py-3 px-4 text-sm font-normal hover:bg-slate-50 border-slate-200 shadow-sm text-slate-700">
|
||||
{icon}
|
||||
{text}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,302 +1,725 @@
|
||||
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Plus,
|
||||
ChevronDown,
|
||||
AlertTriangle,
|
||||
import {
|
||||
Plus,
|
||||
ChevronDown,
|
||||
AlertTriangle,
|
||||
GripVertical,
|
||||
Trash2
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { useMemo, useState, useEffect } from 'react'
|
||||
|
||||
import type { Materia, LineaCurricular } from '@/types/plan'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { usePlanAsignaturas, usePlanLineas } from '@/data'
|
||||
|
||||
export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({
|
||||
component: MapaCurricularPage,
|
||||
})
|
||||
// --- Mapeadores (Fuera del componente para mayor limpieza) ---
|
||||
const mapLineasToLineaCurricular = (
|
||||
lineasApi: Array<any> = [],
|
||||
): Array<LineaCurricular> => {
|
||||
return lineasApi.map((linea) => ({
|
||||
id: linea.id,
|
||||
nombre: linea.nombre,
|
||||
orden: linea.orden ?? 0,
|
||||
color: '#1976d2',
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Constantes de Estilo y Datos ---
|
||||
const INITIAL_LINEAS: LineaCurricular[] = [
|
||||
{ id: 'l1', nombre: 'Formación Básica', orden: 1 },
|
||||
{ id: 'l2', nombre: 'Ciencias de la Computación', orden: 2 },
|
||||
];
|
||||
|
||||
const INITIAL_MATERIAS: Materia[] = [
|
||||
{ id: "1", clave: 'MAT101', nombre: 'Cálculo Diferencial', creditos: 8, hd: 4, hi: 4, ciclo: 1, lineaCurricularId: 'l1', tipo: 'obligatoria', estado: 'aprobada' },
|
||||
{ id: "2", clave: 'FIS101', nombre: 'Física Mecánica', creditos: 6, hd: 3, hi: 3, ciclo: 1, lineaCurricularId: 'l1', tipo: 'obligatoria', estado: 'aprobada' },
|
||||
{ id: "3", clave: 'PRO101', nombre: 'Fundamentos de Programación', creditos: 8, hd: 4, hi: 4, ciclo: null, lineaCurricularId: null, tipo: 'obligatoria', estado: 'borrador' },
|
||||
];
|
||||
const mapAsignaturasToMaterias = (asigApi: Array<any> = []): Array<Materia> => {
|
||||
return asigApi.map((asig) => ({
|
||||
id: asig.id,
|
||||
clave: asig.codigo,
|
||||
nombre: asig.nombre,
|
||||
creditos: asig.creditos ?? 0,
|
||||
ciclo: asig.numero_ciclo ?? null,
|
||||
lineaCurricularId: asig.linea_plan_id ?? null,
|
||||
tipo: asig.tipo === 'OBLIGATORIA' ? 'obligatoria' : 'optativa',
|
||||
estado: 'borrador',
|
||||
orden: asig.orden_celda ?? 0,
|
||||
hd: Math.floor((asig.horas_semana ?? 0) / 2),
|
||||
hi: Math.ceil((asig.horas_semana ?? 0) / 2),
|
||||
prerrequisitos: [],
|
||||
}))
|
||||
}
|
||||
|
||||
const lineColors = [
|
||||
'bg-blue-50 border-blue-200 text-blue-700',
|
||||
'bg-purple-50 border-purple-200 text-purple-700',
|
||||
'bg-orange-50 border-orange-200 text-orange-700',
|
||||
'bg-emerald-50 border-emerald-200 text-emerald-700',
|
||||
];
|
||||
]
|
||||
|
||||
const statusBadge: Record<string, string> = {
|
||||
borrador: 'bg-slate-100 text-slate-600',
|
||||
revisada: 'bg-amber-100 text-amber-700',
|
||||
aprobada: 'bg-emerald-100 text-emerald-700',
|
||||
};
|
||||
}
|
||||
|
||||
// --- Subcomponentes ---
|
||||
function StatItem({ label, value, total }: { label: string, value: number, total?: number }) {
|
||||
function StatItem({
|
||||
label,
|
||||
value,
|
||||
total,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
total?: number
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">{label}:</span>
|
||||
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
|
||||
{label}:
|
||||
</span>
|
||||
<span className="text-sm font-bold text-slate-700">
|
||||
{value}{total ? <span className="text-slate-400 font-normal">/{total}</span> : ''}
|
||||
{value}
|
||||
{total ? (
|
||||
<span className="font-normal text-slate-400">/{total}</span>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MateriaCardItem({ materia, onDragStart, isDragging, onClick }: {
|
||||
materia: Materia,
|
||||
onDragStart: (e: React.DragEvent, id: string) => void,
|
||||
isDragging: boolean,
|
||||
function MateriaCardItem({
|
||||
materia,
|
||||
onDragStart,
|
||||
isDragging,
|
||||
onClick,
|
||||
}: {
|
||||
materia: Materia
|
||||
onDragStart: (e: React.DragEvent, id: string) => void
|
||||
isDragging: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, materia.id)}
|
||||
onClick={onClick}
|
||||
className={`group p-3 rounded-lg border bg-white shadow-sm cursor-grab active:cursor-grabbing transition-all ${
|
||||
isDragging ? 'opacity-40 scale-95' : 'hover:border-teal-400 hover:shadow-md'
|
||||
className={`group cursor-grab rounded-lg border bg-white p-3 shadow-sm transition-all active:cursor-grabbing ${
|
||||
isDragging
|
||||
? 'scale-95 opacity-40'
|
||||
: 'hover:border-teal-400 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<span className="text-[10px] font-mono font-bold text-slate-400">{materia.clave}</span>
|
||||
<Badge variant="outline" className={`text-[9px] px-1 py-0 uppercase ${statusBadge[materia.estado] || ''}`}>
|
||||
<div className="mb-1 flex items-start justify-between">
|
||||
<span className="font-mono text-[10px] font-bold text-slate-400">
|
||||
{materia.clave}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`px-1 py-0 text-[9px] uppercase ${statusBadge[materia.estado] || ''}`}
|
||||
>
|
||||
{materia.estado}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs font-bold text-slate-700 leading-tight mb-1">{materia.nombre}</p>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-[10px] text-slate-500">{materia.creditos} CR • HD:{materia.hd} • HI:{materia.hi}</span>
|
||||
<GripVertical size={12} className="text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<p className="mb-1 text-xs leading-tight font-bold text-slate-700">
|
||||
{materia.nombre}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-[10px] text-slate-500">
|
||||
{materia.creditos} CR • HD:{materia.hd} • HI:{materia.hi}
|
||||
</span>
|
||||
<GripVertical
|
||||
size={12}
|
||||
className="text-slate-300 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Componente Principal ---
|
||||
export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({
|
||||
component: MapaCurricularPage,
|
||||
})
|
||||
|
||||
function MapaCurricularPage() {
|
||||
const [materias, setMaterias] = useState<Materia[]>(INITIAL_MATERIAS);
|
||||
const [lineas, setLineas] = useState<LineaCurricular[]>(INITIAL_LINEAS);
|
||||
const [draggedMateria, setDraggedMateria] = useState<string | null>(null);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [selectedMateria, setSelectedMateria] = useState<Materia | null>(null);
|
||||
|
||||
const ciclosTotales = 9;
|
||||
const ciclosArray = Array.from({ length: ciclosTotales }, (_, i) => i + 1);
|
||||
const { planId } = Route.useParams() // Idealmente usa el ID de la ruta
|
||||
|
||||
// 1. Fetch de Datos
|
||||
const { data: asignaturasApi, isLoading: loadingAsig } =
|
||||
usePlanAsignaturas(planId)
|
||||
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
|
||||
|
||||
// 2. Estado Local (Para interactividad)
|
||||
const [materias, setMaterias] = useState<Array<Materia>>([])
|
||||
const [lineas, setLineas] = useState<Array<LineaCurricular>>([])
|
||||
const [draggedMateria, setDraggedMateria] = useState<string | null>(null)
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||
const [selectedMateria, setSelectedMateria] = useState<Materia | null>(null)
|
||||
const [hasAreaComun, setHasAreaComun] = useState(false)
|
||||
const [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado
|
||||
|
||||
const manejarAgregarLinea = (nombre: string) => {
|
||||
const nombreNormalizado = nombre.trim()
|
||||
|
||||
// Validar si es Área Común (insensible a mayúsculas/minúsculas)
|
||||
const esAreaComun =
|
||||
nombreNormalizado.toLowerCase() === 'área común' ||
|
||||
nombreNormalizado.toLowerCase() === 'area comun'
|
||||
|
||||
if (esAreaComun && hasAreaComun) {
|
||||
alert('El Área Común ya ha sido agregada.')
|
||||
return
|
||||
}
|
||||
|
||||
const nueva = {
|
||||
id: crypto.randomUUID(),
|
||||
nombre: nombreNormalizado,
|
||||
orden: lineas.length + 1,
|
||||
}
|
||||
|
||||
setLineas([...lineas, nueva])
|
||||
|
||||
if (esAreaComun) {
|
||||
setHasAreaComun(true)
|
||||
}
|
||||
setNombreNuevaLinea('') // Limpiar input
|
||||
}
|
||||
|
||||
const tieneAreaComun = useMemo(() => {
|
||||
return lineas.some(
|
||||
(l) =>
|
||||
l.nombre.toLowerCase() === 'área común' ||
|
||||
l.nombre.toLowerCase() === 'area comun',
|
||||
)
|
||||
}, [lineas])
|
||||
|
||||
// 3. Sincronizar API -> Estado Local
|
||||
useEffect(() => {
|
||||
if (asignaturasApi) setMaterias(mapAsignaturasToMaterias(asignaturasApi))
|
||||
}, [asignaturasApi])
|
||||
|
||||
useEffect(() => {
|
||||
if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi))
|
||||
}, [lineasApi])
|
||||
|
||||
const ciclosTotales = 9
|
||||
const ciclosArray = Array.from({ length: ciclosTotales }, (_, i) => i + 1)
|
||||
|
||||
// Nuevo estado para controlar los datos temporales del modal de edición
|
||||
const [editingData, setEditingData] = useState<Materia | null>(null)
|
||||
|
||||
// 1. FUNCION DE GUARDAR MODAL
|
||||
const handleSaveChanges = () => {
|
||||
if (!editingData) return
|
||||
console.log(materias)
|
||||
|
||||
setMaterias((prev) =>
|
||||
prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)),
|
||||
)
|
||||
setIsEditModalOpen(false)
|
||||
}
|
||||
|
||||
// 2. MODIFICACIÓN: Zona de soltado siempre visible
|
||||
// Cambiamos la condición: Mostramos la sección si hay materias sin asignar
|
||||
// O si simplemente queremos tener el "depósito" disponible.
|
||||
const unassignedMaterias = materias.filter((m) => m.ciclo === null)
|
||||
|
||||
// --- Lógica de Gestión ---
|
||||
const agregarLinea = (nombre: string) => {
|
||||
const nueva = { id: crypto.randomUUID(), nombre, orden: lineas.length + 1 };
|
||||
setLineas([...lineas, nueva]);
|
||||
};
|
||||
const nueva = { id: crypto.randomUUID(), nombre, orden: lineas.length + 1 }
|
||||
setLineas([...lineas, nueva])
|
||||
}
|
||||
|
||||
const borrarLinea = (id: string) => {
|
||||
setMaterias(prev => prev.map(m => m.lineaCurricularId === id ? { ...m, ciclo: null, lineaCurricularId: null } : m));
|
||||
setLineas(prev => prev.filter(l => l.id !== id));
|
||||
};
|
||||
setMaterias((prev) =>
|
||||
prev.map((m) =>
|
||||
m.lineaCurricularId === id
|
||||
? { ...m, ciclo: null, lineaCurricularId: null }
|
||||
: m,
|
||||
),
|
||||
)
|
||||
setLineas((prev) => prev.filter((l) => l.id !== id))
|
||||
}
|
||||
|
||||
// --- Selectores/Cálculos ---
|
||||
const getTotalesCiclo = (ciclo: number) => {
|
||||
return materias.filter(m => m.ciclo === ciclo).reduce((acc, m) => ({
|
||||
cr: acc.cr + (m.creditos || 0), hd: acc.hd + (m.hd || 0), hi: acc.hi + (m.hi || 0)
|
||||
}), { cr: 0, hd: 0, hi: 0 });
|
||||
};
|
||||
return materias
|
||||
.filter((m) => m.ciclo === ciclo)
|
||||
.reduce(
|
||||
(acc, m) => ({
|
||||
cr: acc.cr + (m.creditos || 0),
|
||||
hd: acc.hd + (m.hd || 0),
|
||||
hi: acc.hi + (m.hi || 0),
|
||||
}),
|
||||
{ cr: 0, hd: 0, hi: 0 },
|
||||
)
|
||||
}
|
||||
|
||||
const getSubtotalLinea = (lineaId: string) => {
|
||||
return materias.filter(m => m.lineaCurricularId === lineaId && m.ciclo !== null).reduce((acc, m) => ({
|
||||
cr: acc.cr + (m.creditos || 0), hd: acc.hd + (m.hd || 0), hi: acc.hi + (m.hi || 0)
|
||||
}), { cr: 0, hd: 0, hi: 0 });
|
||||
};
|
||||
return materias
|
||||
.filter((m) => m.lineaCurricularId === lineaId && m.ciclo !== null)
|
||||
.reduce(
|
||||
(acc, m) => ({
|
||||
cr: acc.cr + (m.creditos || 0),
|
||||
hd: acc.hd + (m.hd || 0),
|
||||
hi: acc.hi + (m.hi || 0),
|
||||
}),
|
||||
{ cr: 0, hd: 0, hi: 0 },
|
||||
)
|
||||
}
|
||||
|
||||
// --- Handlers Drag & Drop ---
|
||||
const handleDragStart = (e: React.DragEvent, id: string) => { setDraggedMateria(id); e.dataTransfer.effectAllowed = 'move'; };
|
||||
const handleDragOver = (e: React.DragEvent) => e.preventDefault();
|
||||
const handleDrop = (e: React.DragEvent, ciclo: number | null, lineaId: string | null) => {
|
||||
e.preventDefault();
|
||||
const handleDragStart = (e: React.DragEvent, id: string) => {
|
||||
setDraggedMateria(id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
const handleDragOver = (e: React.DragEvent) => e.preventDefault()
|
||||
const handleDrop = (
|
||||
e: React.DragEvent,
|
||||
ciclo: number | null,
|
||||
lineaId: string | null,
|
||||
) => {
|
||||
e.preventDefault()
|
||||
if (draggedMateria) {
|
||||
setMaterias(prev => prev.map(m => m.id === draggedMateria ? { ...m, ciclo, lineaCurricularId: lineaId } : m));
|
||||
setDraggedMateria(null);
|
||||
setMaterias((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === draggedMateria
|
||||
? { ...m, ciclo, lineaCurricularId: lineaId }
|
||||
: m,
|
||||
),
|
||||
)
|
||||
setDraggedMateria(null)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// --- Estadísticas Generales ---
|
||||
const stats = materias.reduce((acc, m) => {
|
||||
if (m.ciclo !== null) {
|
||||
acc.cr += m.creditos || 0; acc.hd += m.hd || 0; acc.hi += m.hi || 0;
|
||||
}
|
||||
return acc;
|
||||
}, { cr: 0, hd: 0, hi: 0 });
|
||||
const stats = useMemo(
|
||||
() =>
|
||||
materias.reduce(
|
||||
(acc, m) => {
|
||||
if (m.ciclo !== null) {
|
||||
acc.cr += m.creditos || 0
|
||||
acc.hd += m.hd || 0
|
||||
acc.hi += m.hi || 0
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ cr: 0, hd: 0, hi: 0 },
|
||||
),
|
||||
[materias],
|
||||
)
|
||||
|
||||
if (loadingAsig || loadingLineas)
|
||||
return <div className="p-10 text-center">Cargando mapa curricular...</div>
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-2 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">Mapa Curricular</h2>
|
||||
<p className="text-sm text-slate-500">Organiza las materias por línea curricular y ciclo</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
Organiza las materias de la petición por línea y ciclo
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{materias.filter(m => !m.ciclo).length > 0 && (
|
||||
<Badge className="bg-amber-50 text-amber-600 border-amber-100 hover:bg-amber-50">
|
||||
<AlertTriangle size={14} className="mr-1" /> {materias.filter(m => !m.ciclo).length} materias sin asignar
|
||||
{materias.filter((m) => !m.ciclo).length > 0 && (
|
||||
<Badge className="border-amber-100 bg-amber-50 text-amber-600 hover:bg-amber-50">
|
||||
<AlertTriangle size={14} className="mr-1" />{' '}
|
||||
{materias.filter((m) => !m.ciclo).length} sin asignar
|
||||
</Badge>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="bg-teal-700 hover:bg-teal-800 text-white">
|
||||
<Plus size={16} className="mr-2" /> Agregar <ChevronDown size={14} className="ml-2" />
|
||||
<Button className="bg-teal-700 text-white hover:bg-teal-800">
|
||||
<Plus size={16} className="mr-2" /> Agregar{' '}
|
||||
<ChevronDown size={14} className="ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => agregarLinea("Nueva Línea")}>Nueva Línea Curricular</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => agregarLinea("Área Común")}>Agregar Área Común</DropdownMenuItem>
|
||||
{!tieneAreaComun && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => manejarAgregarLinea('Área Común')}
|
||||
className="font-bold text-teal-700"
|
||||
>
|
||||
+ Agregar Área Común
|
||||
</DropdownMenuItem>
|
||||
<div className="my-1 border-t border-slate-100" />
|
||||
</>
|
||||
)}
|
||||
{/* Input para nombre personalizado */}
|
||||
<div className="p-2">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase">
|
||||
Nombre de Línea
|
||||
</label>
|
||||
<div className="mt-1 flex gap-1">
|
||||
<Input
|
||||
value={nombreNuevaLinea}
|
||||
onChange={(e) => setNombreNuevaLinea(e.target.value)}
|
||||
placeholder="Ej: Optativas"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
onClick={() => manejarAgregarLinea(nombreNuevaLinea)}
|
||||
disabled={!nombreNuevaLinea.trim()}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barra Totales */}
|
||||
<div className="bg-slate-50/80 border border-slate-200 rounded-xl p-4 mb-8 flex gap-10">
|
||||
<div className="mb-8 flex gap-10 rounded-xl border border-slate-200 bg-slate-50/80 p-4">
|
||||
<StatItem label="Total Créditos" value={stats.cr} total={320} />
|
||||
<StatItem label="Total HD" value={stats.hd} />
|
||||
<StatItem label="Total HI" value={stats.hi} />
|
||||
<StatItem label="Total Horas" value={stats.hd + stats.hi} />
|
||||
</div>
|
||||
|
||||
{/* Grid Principal */}
|
||||
<div className="overflow-x-auto pb-6">
|
||||
<div className="min-w-[1500px]">
|
||||
{/* Header Ciclos */}
|
||||
<div className="grid gap-3 mb-4" style={{ gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px` }}>
|
||||
<div className="text-xs font-bold text-slate-400 self-end px-2">LÍNEA CURRICULAR</div>
|
||||
{ciclosArray.map(n => <div key={n} className="bg-slate-100 rounded-lg p-2 text-center text-sm font-bold text-slate-600">Ciclo {n}</div>)}
|
||||
<div className="text-xs font-bold text-slate-400 self-end text-center">SUBTOTAL</div>
|
||||
<div
|
||||
className="mb-4 grid gap-3"
|
||||
style={{
|
||||
gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
|
||||
}}
|
||||
>
|
||||
<div className="self-end px-2 text-xs font-bold text-slate-400">
|
||||
LÍNEA CURRICULAR
|
||||
</div>
|
||||
{ciclosArray.map((n) => (
|
||||
<div
|
||||
key={n}
|
||||
className="rounded-lg bg-slate-100 p-2 text-center text-sm font-bold text-slate-600"
|
||||
>
|
||||
Ciclo {n}
|
||||
</div>
|
||||
))}
|
||||
<div className="self-end text-center text-xs font-bold text-slate-400">
|
||||
SUBTOTAL
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filas por Línea */}
|
||||
{lineas.map((linea, idx) => {
|
||||
const sub = getSubtotalLinea(linea.id);
|
||||
const sub = getSubtotalLinea(linea.id)
|
||||
return (
|
||||
<div key={linea.id} className="grid gap-3 mb-3" style={{ gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px` }}>
|
||||
<div className={`p-4 rounded-xl border-l-4 flex justify-between items-center ${lineColors[idx % lineColors.length]}`}>
|
||||
<div
|
||||
key={linea.id}
|
||||
className="mb-3 grid gap-3"
|
||||
style={{
|
||||
gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-between rounded-xl border-l-4 p-4 ${lineColors[idx % lineColors.length]}`}
|
||||
>
|
||||
<span className="text-xs font-bold">{linea.nombre}</span>
|
||||
<Trash2 size={14} className="text-slate-400 hover:text-red-500 cursor-pointer" onClick={() => borrarLinea(linea.id)} />
|
||||
<Trash2
|
||||
size={14}
|
||||
className="cursor-pointer text-slate-400 hover:text-red-500"
|
||||
onClick={() => borrarLinea(linea.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ciclosArray.map(ciclo => (
|
||||
{ciclosArray.map((ciclo) => (
|
||||
<div
|
||||
key={ciclo}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, ciclo, linea.id)}
|
||||
className="min-h-[140px] p-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 space-y-2"
|
||||
className="min-h-[140px] space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 p-2"
|
||||
>
|
||||
{materias.filter(m => m.ciclo === ciclo && m.lineaCurricularId === linea.id).map(m => (
|
||||
<MateriaCardItem key={m.id} materia={m} isDragging={draggedMateria === m.id} onDragStart={handleDragStart} onClick={() => { setSelectedMateria(m); setIsEditModalOpen(true); }} />
|
||||
))}
|
||||
{materias
|
||||
.filter(
|
||||
(m) =>
|
||||
m.ciclo === ciclo && m.lineaCurricularId === linea.id,
|
||||
)
|
||||
.map((m) => (
|
||||
<MateriaCardItem
|
||||
key={m.id}
|
||||
materia={m}
|
||||
isDragging={draggedMateria === m.id}
|
||||
onDragStart={handleDragStart}
|
||||
onClick={() => {
|
||||
setEditingData(m)
|
||||
setIsEditModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-xl flex flex-col justify-center text-[10px] text-slate-500 font-medium border border-slate-100">
|
||||
<div>Cr: {sub.cr}</div><div>HD: {sub.hd}</div><div>HI: {sub.hi}</div>
|
||||
<div className="flex flex-col justify-center rounded-xl border border-slate-100 bg-slate-50 p-4 text-[10px] font-medium text-slate-500">
|
||||
<div>Cr: {sub.cr}</div>
|
||||
<div>HD: {sub.hd}</div>
|
||||
<div>HI: {sub.hi}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Fila Totales Ciclo */}
|
||||
<div className="grid gap-3 mt-6 border-t pt-4" style={{ gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px` }}>
|
||||
<div className="p-2 font-bold text-slate-600">Totales por Ciclo</div>
|
||||
{ciclosArray.map(ciclo => {
|
||||
const t = getTotalesCiclo(ciclo);
|
||||
<div
|
||||
className="mt-6 grid gap-3 border-t pt-4"
|
||||
style={{
|
||||
gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
|
||||
}}
|
||||
>
|
||||
<div className="p-2 font-bold text-slate-600">
|
||||
Totales por Ciclo
|
||||
</div>
|
||||
{ciclosArray.map((ciclo) => {
|
||||
const t = getTotalesCiclo(ciclo)
|
||||
return (
|
||||
<div key={ciclo} className="text-[10px] text-center p-2 bg-slate-50 rounded-lg">
|
||||
<div
|
||||
key={ciclo}
|
||||
className="rounded-lg bg-slate-50 p-2 text-center text-[10px]"
|
||||
>
|
||||
<div className="font-bold text-slate-700">Cr: {t.cr}</div>
|
||||
<div>HD: {t.hd} • HI: {t.hi}</div>
|
||||
<div>
|
||||
HD: {t.hd} • HI: {t.hi}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="bg-teal-50 rounded-lg p-2 text-center text-teal-800 font-bold text-xs flex flex-col justify-center">
|
||||
<div>{stats.cr} Cr</div><div>{stats.hd + stats.hi} Hrs</div>
|
||||
<div className="flex flex-col justify-center rounded-lg bg-teal-50 p-2 text-center text-xs font-bold text-teal-800">
|
||||
<div>{stats.cr} Cr</div>
|
||||
<div>{stats.hd + stats.hi} Hrs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de Edición */}
|
||||
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader><DialogTitle>Editar Materia</DialogTitle></DialogHeader>
|
||||
{selectedMateria && (
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="space-y-2"><label className="text-xs font-bold uppercase">Clave</label><Input defaultValue={selectedMateria.clave} /></div>
|
||||
<div className="space-y-2"><label className="text-xs font-bold uppercase">Nombre</label><Input defaultValue={selectedMateria.nombre} /></div>
|
||||
<div className="space-y-2"><label className="text-xs font-bold uppercase">Créditos</label><Input type="number" defaultValue={selectedMateria.creditos} /></div>
|
||||
<div className="flex gap-2">
|
||||
<div className="space-y-2"><label className="text-xs font-bold uppercase">HD</label><Input type="number" defaultValue={selectedMateria.hd} /></div>
|
||||
<div className="space-y-2"><label className="text-xs font-bold uppercase">HI</label><Input type="number" defaultValue={selectedMateria.hi} /></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<Button variant="outline" onClick={() => setIsEditModalOpen(false)}>Cancelar</Button>
|
||||
<Button className="bg-teal-700 text-white">Guardar Cambios</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 4. Materias Pendientes (Sin Asignar) */}
|
||||
{materias.filter(m => m.ciclo === null).length > 0 && (
|
||||
<div className="mt-10 p-6 bg-slate-50 rounded-2xl border border-slate-200 shadow-sm animate-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex items-center gap-2 mb-4 text-amber-600">
|
||||
<AlertTriangle size={20} />
|
||||
<h3 className="font-bold text-sm uppercase tracking-tight">
|
||||
Materias pendientes de asignar ({materias.filter(m => m.ciclo === null).length})
|
||||
{/* Materias Sin Asignar */}
|
||||
{/* SECCIÓN DE MATERIAS SIN ASIGNAR (Mejorada para estar siempre disponible) */}
|
||||
<div className="mt-10 rounded-2xl border border-slate-200 bg-slate-50 p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-slate-600">
|
||||
<h3 className="text-sm font-bold tracking-wider uppercase">
|
||||
Bandeja de Entrada / Materias sin asignar
|
||||
</h3>
|
||||
<Badge variant="secondary">{unassignedMaterias.length}</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex flex-wrap gap-4 min-h-[100px] p-4 rounded-xl border-2 border-dashed transition-all ${
|
||||
draggedMateria ? 'border-amber-200 bg-amber-50/50' : 'border-slate-200 bg-white/50'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, null, null)} // null devuelve la materia al estado "sin asignar"
|
||||
>
|
||||
{materias
|
||||
.filter(m => m.ciclo === null)
|
||||
.map(m => (
|
||||
<div key={m.id} className="w-[200px]">
|
||||
<MateriaCardItem
|
||||
materia={m}
|
||||
isDragging={draggedMateria === m.id}
|
||||
onDragStart={handleDragStart}
|
||||
onClick={() => { setSelectedMateria(m); setIsEditModalOpen(true); }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-[11px] text-slate-400 italic text-center">
|
||||
Arrastra las materias desde aquí hacia cualquier ciclo y línea del mapa curricular.
|
||||
<p className="text-xs text-slate-400">
|
||||
Arrastra una materia aquí para quitarla del mapa
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex min-h-[120px] flex-wrap gap-4 rounded-xl border-2 border-dashed p-4 transition-colors ${
|
||||
draggedMateria
|
||||
? 'border-teal-300 bg-teal-50/50'
|
||||
: 'border-slate-200 bg-white/50'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, null, null)} // Limpia ciclo y línea
|
||||
>
|
||||
{unassignedMaterias.map((m) => (
|
||||
<div key={m.id} className="w-[200px]">
|
||||
<MateriaCardItem
|
||||
materia={m}
|
||||
isDragging={draggedMateria === m.id}
|
||||
onDragStart={handleDragStart}
|
||||
onClick={() => {
|
||||
setEditingData(m) // Cargamos los datos en el estado de edición
|
||||
setIsEditModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{unassignedMaterias.length === 0 && (
|
||||
<div className="flex w-full items-center justify-center text-sm text-slate-400">
|
||||
No hay materias pendientes. Arrastra una materia aquí para
|
||||
desasignarla.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de Edición */}
|
||||
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
||||
<DialogContent className="sm:max-w-[550px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-bold text-slate-700">
|
||||
Editar Materia
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Verificación de seguridad: solo renderiza si hay datos */}
|
||||
{editingData ? (
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* Fila 1: Clave y Nombre */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase">
|
||||
Clave
|
||||
</label>
|
||||
<Input
|
||||
value={editingData.clave}
|
||||
onChange={(e) =>
|
||||
setEditingData({ ...editingData, clave: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase">
|
||||
Nombre
|
||||
</label>
|
||||
<Input
|
||||
value={editingData.nombre}
|
||||
onChange={(e) =>
|
||||
setEditingData({ ...editingData, nombre: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fila 2: Créditos y Horas */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase">
|
||||
Créditos
|
||||
</label>
|
||||
<Input type="number" value={editingData.creditos} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase">
|
||||
HD (Horas Docente)
|
||||
</label>
|
||||
<Input type="number" value={editingData.hd} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase">
|
||||
HI (Horas Indep.)
|
||||
</label>
|
||||
<Input type="number" value={editingData.hi} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fila 3: Ciclo y Línea */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase">
|
||||
Ciclo
|
||||
</label>
|
||||
<Select value={editingData.ciclo?.toString() || 'null'}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ciclosArray.map((n) => (
|
||||
<SelectItem key={n} value={n.toString()}>
|
||||
Ciclo {n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase">
|
||||
Línea Curricular
|
||||
</label>
|
||||
<Select value={editingData.lineaCurricularId || 'null'}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lineas.map((l) => (
|
||||
<SelectItem key={l.id} value={l.id}>
|
||||
{l.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fila 4: Seriación (Igual a tu imagen) */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase">
|
||||
Seriación (Prerrequisitos)
|
||||
</label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Seleccionar materia..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{materias.map((m) => (
|
||||
<SelectItem key={m.id} value={m.clave}>
|
||||
{m.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="mt-2 flex gap-2">
|
||||
{/* Aquí usamos el array vacío que inicializamos en el mapeador */}
|
||||
{editingData.prerrequisitos.map((pre) => (
|
||||
<Badge
|
||||
key={pre}
|
||||
variant="secondary"
|
||||
className="bg-slate-100 text-slate-600"
|
||||
>
|
||||
{pre} <span className="ml-1 cursor-pointer">×</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fila 5: Tipo */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase">
|
||||
Tipo
|
||||
</label>
|
||||
<Select value={editingData.tipo}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="obligatoria">Obligatoria</SelectItem>
|
||||
<SelectItem value="optativa">Optativa</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsEditModalOpen(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-teal-700 text-white"
|
||||
onClick={handleSaveChanges}
|
||||
>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-20 text-center">No hay datos seleccionados</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Plus,
|
||||
Copy,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronRight,
|
||||
BookOpen,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
import type { Materia } from '@/types/plan'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -12,204 +30,257 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { usePlanAsignaturas, usePlanLineas } from '@/data'
|
||||
|
||||
export const Route = createFileRoute('/planes/$planId/_detalle/materias')({
|
||||
component: Materias,
|
||||
})
|
||||
|
||||
type Materia = {
|
||||
id: string;
|
||||
clave: string
|
||||
nombre: string
|
||||
creditos: number
|
||||
hd: number
|
||||
hi: number
|
||||
ciclo: string
|
||||
linea: string
|
||||
tipo: 'Obligatoria' | 'Optativa' | 'Troncal'
|
||||
estado: 'Aprobada' | 'Revisada' | 'Borrador'
|
||||
// --- Configuración de Estilos ---
|
||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||
borrador: { label: 'Borrador', className: 'bg-slate-100 text-slate-600' },
|
||||
revisada: { label: 'Revisada', className: 'bg-amber-100 text-amber-700' },
|
||||
aprobada: { label: 'Aprobada', className: 'bg-emerald-100 text-emerald-700' },
|
||||
}
|
||||
|
||||
const MATERIAS: Materia[] = [
|
||||
{
|
||||
id: "1",
|
||||
clave: 'MAT101',
|
||||
nombre: 'Cálculo Diferencial',
|
||||
creditos: 8,
|
||||
hd: 4,
|
||||
hi: 4,
|
||||
ciclo: 'Ciclo 1',
|
||||
linea: 'Formación Básica',
|
||||
tipo: 'Obligatoria',
|
||||
estado: 'Aprobada',
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
clave: 'FIS101',
|
||||
nombre: 'Física Mecánica',
|
||||
creditos: 6,
|
||||
hd: 3,
|
||||
hi: 3,
|
||||
ciclo: 'Ciclo 1',
|
||||
linea: 'Formación Básica',
|
||||
tipo: 'Obligatoria',
|
||||
estado: 'Aprobada',
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
clave: 'PRO101',
|
||||
nombre: 'Fundamentos de Programación',
|
||||
creditos: 8,
|
||||
hd: 4,
|
||||
hi: 4,
|
||||
ciclo: 'Ciclo 1',
|
||||
linea: 'Ciencias de la Computación',
|
||||
tipo: 'Obligatoria',
|
||||
estado: 'Revisada',
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
clave: 'EST101',
|
||||
nombre: 'Estructura de Datos',
|
||||
creditos: 6,
|
||||
hd: 3,
|
||||
hi: 3,
|
||||
ciclo: 'Ciclo 2',
|
||||
linea: 'Ciencias de la Computación',
|
||||
tipo: 'Obligatoria',
|
||||
estado: 'Borrador',
|
||||
},
|
||||
]
|
||||
const tipoConfig: Record<string, { label: string; className: string }> = {
|
||||
obligatoria: { label: 'Obligatoria', className: 'bg-blue-100 text-blue-700' },
|
||||
optativa: { label: 'Optativa', className: 'bg-purple-100 text-purple-700' },
|
||||
troncal: { label: 'Troncal', className: 'bg-slate-100 text-slate-700' },
|
||||
}
|
||||
|
||||
function Materias() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [filtro, setFiltro] = useState<'Todas' | Materia['tipo']>('Todas')
|
||||
// --- Mapeadores de API ---
|
||||
const mapAsignaturas = (asigApi: Array<any> = []): Array<Materia> => {
|
||||
return asigApi.map((asig) => ({
|
||||
id: asig.id,
|
||||
clave: asig.codigo,
|
||||
nombre: asig.nombre,
|
||||
creditos: asig.creditos ?? 0,
|
||||
ciclo: asig.numero_ciclo ?? null,
|
||||
lineaCurricularId: asig.linea_plan_id ?? null,
|
||||
tipo:
|
||||
asig.tipo?.toLowerCase() === 'obligatoria' ? 'obligatoria' : 'optativa',
|
||||
estado: 'borrador', // O el campo que venga de tu API
|
||||
hd: Math.floor((asig.horas_semana ?? 0) / 2),
|
||||
hi: Math.ceil((asig.horas_semana ?? 0) / 2),
|
||||
}))
|
||||
}
|
||||
|
||||
const materiasFiltradas = MATERIAS.filter((m) => {
|
||||
const okFiltro = filtro === 'Todas' || m.tipo === filtro
|
||||
const okSearch =
|
||||
m.nombre.toLowerCase().includes(search.toLowerCase()) ||
|
||||
m.clave.toLowerCase().includes(search.toLowerCase())
|
||||
export const Route = createFileRoute('/planes/$planId/_detalle/materias')({
|
||||
component: MateriasPage,
|
||||
})
|
||||
|
||||
return okFiltro && okSearch
|
||||
function MateriasPage() {
|
||||
const { planId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 1. Fetch de datos reales
|
||||
const { data: asignaturasApi, isLoading: loadingAsig } =
|
||||
usePlanAsignaturas(planId)
|
||||
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
|
||||
|
||||
// 2. Estados de filtrado
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [filterTipo, setFilterTipo] = useState<string>('all')
|
||||
const [filterEstado, setFilterEstado] = useState<string>('all')
|
||||
const [filterLinea, setFilterLinea] = useState<string>('all')
|
||||
|
||||
// 3. Procesamiento de datos
|
||||
const materias = useMemo(
|
||||
() => mapAsignaturas(asignaturasApi),
|
||||
[asignaturasApi],
|
||||
)
|
||||
const lineas = useMemo(() => lineasApi || [], [lineasApi])
|
||||
|
||||
const filteredMaterias = materias.filter((m) => {
|
||||
const matchesSearch =
|
||||
m.nombre.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
m.clave.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesTipo = filterTipo === 'all' || m.tipo === filterTipo
|
||||
const matchesEstado = filterEstado === 'all' || m.estado === filterEstado
|
||||
const matchesLinea =
|
||||
filterLinea === 'all' || m.lineaCurricularId === filterLinea
|
||||
|
||||
return matchesSearch && matchesTipo && matchesEstado && matchesLinea
|
||||
})
|
||||
|
||||
const totalCreditos = materiasFiltradas.reduce(
|
||||
(acc, m) => acc + m.creditos,
|
||||
0
|
||||
)
|
||||
const getLineaNombre = (lineaId: string | null) => {
|
||||
if (!lineaId) return 'Sin asignar'
|
||||
return lineas.find((l: any) => l.id === lineaId)?.nombre || 'Desconocida'
|
||||
}
|
||||
|
||||
if (loadingAsig || loadingLineas) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="container mx-auto space-y-6 px-6 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Materias del Plan</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{materiasFiltradas.length} materias · {totalCreditos} créditos
|
||||
<h2 className="text-foreground text-xl font-bold">
|
||||
Materias del Plan
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{materias.length} materias en total • {filteredMaterias.length}{' '}
|
||||
filtradas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline">Clonar de mi Facultad</Button>
|
||||
<Button variant="outline">Clonar de otra Facultad</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Copy className="mr-2 h-4 w-4" /> Clonar
|
||||
</Button>
|
||||
<Button className="bg-emerald-700 hover:bg-emerald-800">
|
||||
+ Nueva Materia
|
||||
<Plus className="mr-2 h-4 w-4" /> Nueva Materia
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buscador y filtros */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Input
|
||||
placeholder="Buscar por nombre o clave..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
{/* Barra de Filtros Avanzada */}
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border bg-slate-50 p-4">
|
||||
<div className="relative min-w-[240px] flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="Buscar por nombre o clave..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="bg-white pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{['Todas', 'Obligatoria', 'Optativa', 'Troncal'].map((t) => (
|
||||
<Button
|
||||
key={t}
|
||||
variant={filtro === t ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setFiltro(t as any)}
|
||||
>
|
||||
{t === 'Obligatoria' ? 'Obligatorias' : t}
|
||||
</Button>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Filter className="text-muted-foreground mr-1 h-4 w-4" />
|
||||
|
||||
<Select value={filterTipo} onValueChange={setFilterTipo}>
|
||||
<SelectTrigger className="w-[140px] bg-white">
|
||||
<SelectValue placeholder="Tipo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos los tipos</SelectItem>
|
||||
<SelectItem value="obligatoria">Obligatoria</SelectItem>
|
||||
<SelectItem value="optativa">Optativa</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterEstado} onValueChange={setFilterEstado}>
|
||||
<SelectTrigger className="w-[140px] bg-white">
|
||||
<SelectValue placeholder="Estado" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos los estados</SelectItem>
|
||||
<SelectItem value="borrador">Borrador</SelectItem>
|
||||
<SelectItem value="revisada">Revisada</SelectItem>
|
||||
<SelectItem value="aprobada">Aprobada</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterLinea} onValueChange={setFilterLinea}>
|
||||
<SelectTrigger className="w-[180px] bg-white">
|
||||
<SelectValue placeholder="Línea" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas las líneas</SelectItem>
|
||||
{lineas.map((linea: any) => (
|
||||
<SelectItem key={linea.id} value={linea.id}>
|
||||
{linea.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabla */}
|
||||
<div className="rounded-md border">
|
||||
{/* Tabla Pro */}
|
||||
<div className="overflow-hidden rounded-xl border bg-white shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Clave</TableHead>
|
||||
<TableRow className="bg-slate-50/50">
|
||||
<TableHead className="w-[120px]">Clave</TableHead>
|
||||
<TableHead>Nombre</TableHead>
|
||||
<TableHead className="text-center">Créditos</TableHead>
|
||||
<TableHead className="text-center">HD</TableHead>
|
||||
<TableHead className="text-center">HI</TableHead>
|
||||
<TableHead>Ciclo</TableHead>
|
||||
<TableHead>Línea</TableHead>
|
||||
<TableHead className="text-center">Ciclo</TableHead>
|
||||
<TableHead>Línea Curricular</TableHead>
|
||||
<TableHead>Tipo</TableHead>
|
||||
<TableHead>Estado</TableHead>
|
||||
<TableHead className="text-center">Acciones</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{materiasFiltradas.map((m) => (
|
||||
<TableRow key={m.clave}>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{m.clave}
|
||||
{filteredMaterias.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-40 text-center">
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center">
|
||||
<BookOpen className="mb-2 h-10 w-10 opacity-20" />
|
||||
<p className="font-medium">No se encontraron materias</p>
|
||||
<p className="text-xs">
|
||||
Intenta cambiar los filtros de búsqueda
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{m.nombre}</TableCell>
|
||||
<TableCell className="text-center">{m.creditos}</TableCell>
|
||||
<TableCell className="text-center">{m.hd}</TableCell>
|
||||
<TableCell className="text-center">{m.hi}</TableCell>
|
||||
<TableCell>{m.ciclo}</TableCell>
|
||||
<TableCell>{m.linea}</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{m.tipo}</Badge>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
m.estado === 'Aprobada'
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: m.estado === 'Revisada'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredMaterias.map((materia) => (
|
||||
<TableRow
|
||||
key={materia.id}
|
||||
className="group cursor-pointer transition-colors hover:bg-slate-50/80"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
params: {
|
||||
planId,
|
||||
asignaturaId: materia.id, // 👈 puede ser índice, consecutivo o slug
|
||||
},
|
||||
state: {
|
||||
realId: materia.id, // 👈 ID largo oculto
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
>
|
||||
{m.estado}
|
||||
</Badge>
|
||||
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-center">
|
||||
<Button variant="ghost" size="icon">
|
||||
✏️
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{materiasFiltradas.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={10}
|
||||
className="text-center py-6 text-muted-foreground"
|
||||
>
|
||||
No se encontraron materias
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableCell className="font-mono text-xs font-bold text-slate-400">
|
||||
{materia.clave}
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold text-slate-700">
|
||||
{materia.nombre}
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-medium">
|
||||
{materia.creditos}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{materia.ciclo ? (
|
||||
<Badge variant="outline" className="font-normal">
|
||||
Ciclo {materia.ciclo}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-slate-300">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-slate-600">
|
||||
{getLineaNombre(materia.lineaCurricularId)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`capitalize shadow-sm ${tipoConfig[materia.tipo]?.className}`}
|
||||
>
|
||||
{tipoConfig[materia.tipo]?.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`capitalize shadow-sm ${statusConfig[materia.estado]?.className}`}
|
||||
>
|
||||
{statusConfig[materia.estado]?.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<ChevronRight className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { createFileRoute, Outlet, Link } from '@tanstack/react-router'
|
||||
import { ChevronLeft, GraduationCap, Clock, Hash, CalendarDays, Rocket, BookOpen, CheckCircle2 } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
ChevronLeft,
|
||||
GraduationCap,
|
||||
Clock,
|
||||
Hash,
|
||||
CalendarDays,
|
||||
Rocket,
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
export const Route = createFileRoute('/planes/$planId/_detalle')({
|
||||
component: RouteComponent,
|
||||
@@ -12,11 +22,11 @@ function RouteComponent() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* 1. Header Superior con Sombra (Volver a planes) */}
|
||||
<div className="border-b bg-white/50 backdrop-blur-sm sticky top-0 z-20 shadow-sm">
|
||||
<div className="sticky top-0 z-20 border-b bg-white/50 shadow-sm backdrop-blur-sm">
|
||||
<div className="px-6 py-2">
|
||||
<Link
|
||||
to="/planes"
|
||||
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-800 transition-colors w-fit"
|
||||
<Link
|
||||
to="/planes"
|
||||
className="flex w-fit items-center gap-1 text-xs text-gray-500 transition-colors hover:text-gray-800"
|
||||
>
|
||||
<ChevronLeft size={14} /> Volver a planes
|
||||
</Link>
|
||||
@@ -24,54 +34,91 @@ function RouteComponent() {
|
||||
</div>
|
||||
|
||||
{/* 2. Contenido Principal con Padding */}
|
||||
<div className="p-8 max-w-[1600px] mx-auto space-y-8">
|
||||
|
||||
<div className="mx-auto max-w-[1600px] space-y-8 p-8">
|
||||
{/* Header del Plan y Badges */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start gap-4">
|
||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">Plan de Estudios 2024</h1>
|
||||
<p className="text-lg text-slate-500 font-medium mt-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||
Plan de Estudios 2024
|
||||
</h1>
|
||||
<p className="mt-1 text-lg font-medium text-slate-500">
|
||||
Ingeniería en Sistemas Computacionales
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Badges de la derecha */}
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="secondary" className="bg-blue-50 text-blue-700 border-blue-100 gap-1 px-3">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 border-blue-100 bg-blue-50 px-3 text-blue-700"
|
||||
>
|
||||
<Rocket size={12} /> Ingeniería
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="bg-orange-50 text-orange-700 border-orange-100 gap-1 px-3">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 border-orange-100 bg-orange-50 px-3 text-orange-700"
|
||||
>
|
||||
<BookOpen size={12} /> Licenciatura
|
||||
</Badge>
|
||||
<Badge className="bg-teal-50 text-teal-700 border-teal-200 gap-1 px-3 hover:bg-teal-100">
|
||||
<Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100">
|
||||
<CheckCircle2 size={12} /> En Revisión
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Cards de Información (Nivel, Duración, etc.) */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<InfoCard icon={<GraduationCap className="text-slate-400" />} label="Nivel" value="Superior" />
|
||||
<InfoCard icon={<Clock className="text-slate-400" />} label="Duración" value="9 Semestres" />
|
||||
<InfoCard icon={<Hash className="text-slate-400" />} label="Créditos" value="320" />
|
||||
<InfoCard icon={<CalendarDays className="text-slate-400" />} label="Creación" value="14 ene 2024" />
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<InfoCard
|
||||
icon={<GraduationCap className="text-slate-400" />}
|
||||
label="Nivel"
|
||||
value="Superior"
|
||||
/>
|
||||
<InfoCard
|
||||
icon={<Clock className="text-slate-400" />}
|
||||
label="Duración"
|
||||
value="9 Semestres"
|
||||
/>
|
||||
<InfoCard
|
||||
icon={<Hash className="text-slate-400" />}
|
||||
label="Créditos"
|
||||
value="320"
|
||||
/>
|
||||
<InfoCard
|
||||
icon={<CalendarDays className="text-slate-400" />}
|
||||
label="Creación"
|
||||
value="14 ene 2024"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 4. Navegación de Tabs */}
|
||||
<div className="border-b overflow-x-auto scrollbar-hide">
|
||||
<nav className="flex gap-8 min-w-max">
|
||||
<Tab to="/planes/$planId/datos" params={{ planId }}>Datos Generales</Tab>
|
||||
<Tab to="/planes/$planId/mapa" params={{ planId }}>Mapa Curricular</Tab>
|
||||
<Tab to="/planes/$planId/materias" params={{ planId }}>Materias</Tab>
|
||||
<Tab to="/planes/$planId/flujo" params={{ planId }}>Flujo y Estados</Tab>
|
||||
<Tab to="/planes/$planId/iaplan" params={{ planId }}>IA del Plan</Tab>
|
||||
<Tab to="/planes/$planId/documento" params={{ planId }}>Documento</Tab>
|
||||
<Tab to="/planes/$planId/historial" params={{ planId }}>Historial</Tab>
|
||||
<div className="scrollbar-hide overflow-x-auto border-b">
|
||||
<nav className="flex min-w-max gap-8">
|
||||
<Tab to="/planes/$planId/datos" params={{ planId }}>
|
||||
Datos Generales
|
||||
</Tab>
|
||||
<Tab to="/planes/$planId/mapa" params={{ planId }}>
|
||||
Mapa Curricular
|
||||
</Tab>
|
||||
<Tab to="/planes/$planId/materias" params={{ planId }}>
|
||||
Materias
|
||||
</Tab>
|
||||
<Tab to="/planes/$planId/flujo" params={{ planId }}>
|
||||
Flujo y Estados
|
||||
</Tab>
|
||||
<Tab to="/planes/$planId/iaplan" params={{ planId }}>
|
||||
IA del Plan
|
||||
</Tab>
|
||||
<Tab to="/planes/$planId/documento" params={{ planId }}>
|
||||
Documento
|
||||
</Tab>
|
||||
<Tab to="/planes/$planId/historial" params={{ planId }}>
|
||||
Historial
|
||||
</Tab>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 5. Contenido del Tab */}
|
||||
<main className="pt-2 animate-in fade-in duration-500">
|
||||
<main className="animate-in fade-in pt-2 duration-500">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
@@ -79,36 +126,43 @@ function RouteComponent() {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Sub-componente para las tarjetas de información
|
||||
function InfoCard({ icon, label, value }: { icon: React.ReactNode, label: string, value: string }) {
|
||||
function InfoCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 bg-slate-50/50 border border-slate-200/60 p-4 rounded-xl shadow-sm">
|
||||
<div className="p-2 bg-white rounded-lg border shadow-sm">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 rounded-xl border border-slate-200/60 bg-slate-50/50 p-4 shadow-sm">
|
||||
<div className="rounded-lg border bg-white p-2 shadow-sm">{icon}</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase font-bold text-slate-400 tracking-wider leading-none mb-1">{label}</p>
|
||||
<p className="mb-1 text-[10px] leading-none font-bold tracking-wider text-slate-400 uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-slate-700">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Tab({
|
||||
to,
|
||||
params,
|
||||
children
|
||||
}: {
|
||||
to: string;
|
||||
params?: any;
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
function Tab({
|
||||
to,
|
||||
params,
|
||||
children,
|
||||
}: {
|
||||
to: string
|
||||
params?: any
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
params={params}
|
||||
className="pb-3 text-sm font-medium text-slate-500 border-b-2 border-transparent hover:text-slate-800 transition-all"
|
||||
className="border-b-2 border-transparent pb-3 text-sm font-medium text-slate-500 transition-all hover:text-slate-800"
|
||||
activeProps={{
|
||||
className: 'border-teal-600 text-teal-700 font-bold',
|
||||
}}
|
||||
@@ -116,4 +170,4 @@ function Tab({
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,146 +1,10 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
BookOpen,
|
||||
Sparkles,
|
||||
FileText,
|
||||
Library,
|
||||
LayoutTemplate,
|
||||
History,
|
||||
ArrowRight,
|
||||
GraduationCap,
|
||||
} from 'lucide-react'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/planes/$planId/asignaturas/'
|
||||
)({
|
||||
component: MateriasLandingPage,
|
||||
export const Route = createFileRoute('/planes/$planId/asignaturas/')({
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: '/planes/$planId/materias',
|
||||
params,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export default function MateriasLandingPage() {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* ================= HERO ================= */}
|
||||
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
|
||||
<div className="max-w-7xl mx-auto px-6 py-28">
|
||||
<div className="flex items-center gap-2 mb-6 text-sm text-blue-200">
|
||||
<GraduationCap className="w-5 h-5 text-yellow-400" />
|
||||
<span>SISTEMA DE GESTIÓN CURRICULAR</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl font-bold mb-6">
|
||||
Universidad La Salle
|
||||
</h1>
|
||||
|
||||
<p className="max-w-xl text-lg text-blue-100 mb-10">
|
||||
Diseña, documenta y mejora programas de estudio con herramientas
|
||||
de inteligencia artificial integradas y cumplimiento normativo SEP.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-yellow-400 text-black hover:bg-yellow-300 font-semibold"
|
||||
>
|
||||
Ver materia de ejemplo
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ================= FEATURES ================= */}
|
||||
<section className="bg-white py-24">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<h2 className="text-center text-2xl font-semibold mb-14">
|
||||
Características principales
|
||||
</h2>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
<FeatureCard
|
||||
icon={<BookOpen />}
|
||||
title="Gestión de Materias"
|
||||
description="Edita datos generales, contenido temático y bibliografía con una interfaz intuitiva."
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
icon={<Sparkles />}
|
||||
title="IA Integrada"
|
||||
description="Usa inteligencia artificial para mejorar objetivos, competencias y alinear con perfiles de egreso."
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
icon={<FileText />}
|
||||
title="Documentos SEP"
|
||||
description="Genera automáticamente documentos oficiales para la Secretaría de Educación Pública."
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
icon={<Library />}
|
||||
title="Biblioteca Digital"
|
||||
description="Busca y vincula recursos del repositorio de Biblioteca La Salle directamente."
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
icon={<LayoutTemplate />}
|
||||
title="Plantillas Flexibles"
|
||||
description="Adapta la estructura de materias según plantillas SEP o institucionales."
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
icon={<History />}
|
||||
title="Historial Completo"
|
||||
description="Rastrea todos los cambios con historial detallado por usuario y fecha."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ================= CTA ================= */}
|
||||
<section className="bg-gray-50 py-20">
|
||||
<div className="max-w-3xl mx-auto text-center px-6">
|
||||
<h3 className="text-xl font-semibold mb-4">
|
||||
Explora la vista de detalle de materia
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Navega por las diferentes pestañas para ver cómo funciona el sistema
|
||||
de gestión curricular.
|
||||
</p>
|
||||
|
||||
<Button size="lg" className="bg-[#0e2a5c] hover:bg-[#0b1d3a]">
|
||||
Ir a Inteligencia Artificial Aplicada
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ================= FEATURE CARD ================= */
|
||||
|
||||
function FeatureCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<Card className="border border-gray-200 shadow-sm">
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="w-10 h-10 rounded-md bg-yellow-100 text-yellow-600 flex items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<h4 className="font-semibold">{title}</h4>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
10
src/routes/planes/$planId/index.tsx
Normal file
10
src/routes/planes/$planId/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/planes/$planId/')({
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: '/planes/$planId/datos',
|
||||
params,
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -223,7 +223,17 @@ function RouteComponent() {
|
||||
estado={estado?.etiqueta ?? 'Desconocido'}
|
||||
claseColorEstado={estadoColor}
|
||||
colorFacultad={facultad?.color ?? '#000000'}
|
||||
onClick={() => console.log('Ver plan', plan.id)}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: '/planes/$planId',
|
||||
params: {
|
||||
planId: plan.id,
|
||||
},
|
||||
state: {
|
||||
realId: plan.id, // 👈 ID largo oculto
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,102 +1,107 @@
|
||||
export type PlanStatus =
|
||||
| 'borrador'
|
||||
| 'revision'
|
||||
| 'expertos'
|
||||
| 'consejo'
|
||||
| 'aprobado'
|
||||
| 'rechazado';
|
||||
export type PlanStatus =
|
||||
| 'borrador'
|
||||
| 'revision'
|
||||
| 'expertos'
|
||||
| 'consejo'
|
||||
| 'aprobado'
|
||||
| 'rechazado'
|
||||
|
||||
export type TipoPlan = 'Licenciatura' | 'Maestría' | 'Doctorado' | 'Especialidad';
|
||||
export type TipoPlan =
|
||||
| 'Licenciatura'
|
||||
| 'Maestría'
|
||||
| 'Doctorado'
|
||||
| 'Especialidad'
|
||||
|
||||
export type TipoMateria = 'obligatoria' | 'optativa' | 'troncal';
|
||||
export type TipoMateria = 'obligatoria' | 'optativa' | 'troncal'
|
||||
|
||||
export type MateriaStatus = 'borrador' | 'revisada' | 'aprobada';
|
||||
export type MateriaStatus = 'borrador' | 'revisada' | 'aprobada'
|
||||
|
||||
export interface Facultad {
|
||||
id: string;
|
||||
nombre: string;
|
||||
color: string;
|
||||
icono: string;
|
||||
id: string
|
||||
nombre: string
|
||||
color: string
|
||||
icono: string
|
||||
}
|
||||
|
||||
export interface Carrera {
|
||||
id: string;
|
||||
nombre: string;
|
||||
facultadId: string;
|
||||
id: string
|
||||
nombre: string
|
||||
facultadId: string
|
||||
}
|
||||
|
||||
export interface LineaCurricular {
|
||||
id: string;
|
||||
nombre: string;
|
||||
orden: number;
|
||||
color?: string;
|
||||
id: string
|
||||
nombre: string
|
||||
orden: number
|
||||
color?: string
|
||||
}
|
||||
|
||||
export interface Materia {
|
||||
id: string;
|
||||
clave: string;
|
||||
nombre: string;
|
||||
creditos: number;
|
||||
ciclo: number | null;
|
||||
lineaCurricularId: string | null;
|
||||
tipo: TipoMateria;
|
||||
estado: MateriaStatus;
|
||||
orden?: number;
|
||||
hd: number; // <--- Añadir
|
||||
hi: number; // <--- Añadir
|
||||
id: string
|
||||
clave: string
|
||||
nombre: string
|
||||
creditos: number
|
||||
ciclo: number | null
|
||||
lineaCurricularId: string | null
|
||||
tipo: TipoMateria
|
||||
estado: MateriaStatus
|
||||
orden?: number
|
||||
hd: number // <--- Añadir
|
||||
hi: number // <--- Añadir
|
||||
prerrequisitos: Array<string>
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
id: string;
|
||||
nombre: string;
|
||||
carrera: Carrera;
|
||||
facultad: Facultad;
|
||||
tipoPlan: TipoPlan;
|
||||
nivel?: string;
|
||||
modalidad?: string;
|
||||
duracionCiclos: number;
|
||||
creditosTotales: number;
|
||||
fechaCreacion: string;
|
||||
estadoActual: PlanStatus;
|
||||
id: string
|
||||
nombre: string
|
||||
carrera: Carrera
|
||||
facultad: Facultad
|
||||
tipoPlan: TipoPlan
|
||||
nivel?: string
|
||||
modalidad?: string
|
||||
duracionCiclos: number
|
||||
creditosTotales: number
|
||||
fechaCreacion: string
|
||||
estadoActual: PlanStatus
|
||||
}
|
||||
|
||||
export interface DatosGeneralesField {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
tipo: 'texto' | 'lista' | 'parrafo';
|
||||
requerido: boolean;
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
tipo: 'texto' | 'lista' | 'parrafo'
|
||||
requerido: boolean
|
||||
}
|
||||
|
||||
export interface CambioPlan {
|
||||
id: string;
|
||||
fecha: string;
|
||||
usuario: string;
|
||||
tab: string;
|
||||
descripcion: string;
|
||||
detalle?: string;
|
||||
id: string
|
||||
fecha: string
|
||||
usuario: string
|
||||
tab: string
|
||||
descripcion: string
|
||||
detalle?: string
|
||||
}
|
||||
|
||||
export interface ComentarioFlujo {
|
||||
id: string;
|
||||
usuario: string;
|
||||
fecha: string;
|
||||
texto: string;
|
||||
fase: PlanStatus;
|
||||
id: string
|
||||
usuario: string
|
||||
fecha: string
|
||||
texto: string
|
||||
fase: PlanStatus
|
||||
}
|
||||
|
||||
export interface DocumentoPlan {
|
||||
id: string;
|
||||
fechaGeneracion: string;
|
||||
version: number;
|
||||
url?: string;
|
||||
id: string
|
||||
fechaGeneracion: string
|
||||
version: number
|
||||
url?: string
|
||||
}
|
||||
|
||||
export type PlanTab =
|
||||
| 'datos-generales'
|
||||
| 'mapa-curricular'
|
||||
| 'materias'
|
||||
| 'flujo'
|
||||
| 'ia'
|
||||
| 'documento'
|
||||
| 'historial';
|
||||
export type PlanTab =
|
||||
| 'datos-generales'
|
||||
| 'mapa-curricular'
|
||||
| 'materias'
|
||||
| 'flujo'
|
||||
| 'ia'
|
||||
| 'documento'
|
||||
| 'historial'
|
||||
|
||||
Reference in New Issue
Block a user