Se agrega drag y nuevas funcionalidades al detalle de plan
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import * as Dialog from '@radix-ui/react-dialog';
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
import { Pencil, X, Info } from 'lucide-react';
|
import { Pencil, X } from 'lucide-react';
|
||||||
export type Materia = {
|
export type Materia = {
|
||||||
id: string;
|
id: string;
|
||||||
clave: string;
|
clave: string;
|
||||||
|
|||||||
@@ -1,41 +1,148 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useState } 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
|
||||||
|
|
||||||
export const Route = createFileRoute('/planes/$planId/_detalle/datos')({
|
export const Route = createFileRoute('/planes/$planId/_detalle/datos')({
|
||||||
component: DatosGenerales,
|
component: DatosGeneralesPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function DatosGenerales() {
|
function DatosGeneralesPage() {
|
||||||
|
// 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' },
|
||||||
|
])
|
||||||
|
|
||||||
|
// 2. Estados de edición
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [editValue, setEditValue] = useState('')
|
||||||
|
|
||||||
|
// 3. Manejadores de acciones (Ahora como funciones locales)
|
||||||
|
const handleEdit = (campo: DatosGeneralesField) => {
|
||||||
|
setEditingId(campo.id)
|
||||||
|
setEditValue(campo.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setEditingId(null)
|
||||||
|
setEditValue('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = (id: string) => {
|
||||||
|
// Actualizamos el estado local de la lista
|
||||||
|
setCampos(prev => prev.map(c =>
|
||||||
|
c.id === id ? { ...c, value: editValue } : c
|
||||||
|
))
|
||||||
|
setEditingId(null)
|
||||||
|
setEditValue('')
|
||||||
|
//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
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="container mx-auto px-6 py-6 animate-in fade-in duration-500">
|
||||||
<Card title="Objetivo General">
|
<div className="mb-6">
|
||||||
Formar profesionales altamente capacitados...
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
</Card>
|
Datos Generales del Plan
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Información estructural y descriptiva del plan de estudios
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card title="Perfil de Ingreso">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
Egresados de educación media superior...
|
{campos.map((campo) => {
|
||||||
</Card>
|
const isEditing = editingId === campo.id
|
||||||
|
|
||||||
<Card title="Perfil de Egreso">
|
return (
|
||||||
Profesional capaz de diseñar...
|
<div
|
||||||
</Card>
|
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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 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 gap-2">
|
||||||
|
<h3 className="font-medium text-sm text-slate-700">{campo.label}</h3>
|
||||||
|
{campo.requerido && <span className="text-red-500 text-xs">*</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card title="Competencias Genéricas">
|
{!isEditing && (
|
||||||
Pensamiento crítico, comunicación efectiva...
|
<div className="flex gap-1">
|
||||||
</Card>
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-teal-600" onClick={() => handleIARequest(campo.id)}>
|
||||||
</div>
|
<Sparkles size={14} />
|
||||||
)
|
</Button>
|
||||||
}
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleEdit(campo)}>
|
||||||
|
<Pencil size={14} />
|
||||||
interface CustomCardProps {
|
</Button>
|
||||||
title: string;
|
</div>
|
||||||
children: React.ReactNode;
|
)}
|
||||||
}
|
</div>
|
||||||
|
|
||||||
function Card({ title, children }: CustomCardProps) {
|
{/* Contenido de la Card */}
|
||||||
return (
|
<div className="p-5">
|
||||||
<div className="rounded-lg border bg-white p-4">
|
{isEditing ? (
|
||||||
<h3 className="font-semibold mb-2">{title}</h3>
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-gray-600">{children}</p>
|
<Textarea
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
className="min-h-[120px]"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<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)}>
|
||||||
|
<Check size={14} className="mr-1" /> Guardar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="min-h-[100px]">
|
||||||
|
{campo.value ? (
|
||||||
|
<div className="text-sm text-slate-600 leading-relaxed">
|
||||||
|
{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" />
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="whitespace-pre-wrap">{campo.value}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
<span>Sin contenido.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,123 +1,302 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { MateriaCard } from './MateriaCard';
|
import { useState } from 'react'
|
||||||
import type { Materia } from './MateriaCard'; // Agregamos 'type' aquí
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
ChevronDown,
|
||||||
|
AlertTriangle,
|
||||||
|
GripVertical,
|
||||||
|
Trash2
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { Materia, LineaCurricular } from '@/types/plan'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
|
||||||
export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({
|
export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({
|
||||||
component: MapaCurricular,
|
component: MapaCurricularPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const CICLOS = ["Ciclo 1", "Ciclo 2", "Ciclo 3", "Ciclo 4", "Ciclo 5", "Ciclo 6", "Ciclo 7", "Ciclo 8", "Ciclo 9"];
|
// --- Constantes de Estilo y Datos ---
|
||||||
const LINEAS = ["Formación Básica", "Ciencias de la Computación", "Desarrollo de Software", "Redes y Seguridad", "Gestión y Profesionalización"];
|
const INITIAL_LINEAS: LineaCurricular[] = [
|
||||||
|
{ id: 'l1', nombre: 'Formación Básica', orden: 1 },
|
||||||
|
{ id: 'l2', nombre: 'Ciencias de la Computación', orden: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
// Ejemplo de materia
|
const INITIAL_MATERIAS: Materia[] = [
|
||||||
const 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: "1",
|
{ id: "3", clave: 'PRO101', nombre: 'Fundamentos de Programación', creditos: 8, hd: 4, hi: 4, ciclo: null, lineaCurricularId: null, tipo: 'obligatoria', estado: 'borrador' },
|
||||||
clave: 'MAT101',
|
];
|
||||||
nombre: 'Cálculo Diferencial',
|
|
||||||
creditos: 8,
|
|
||||||
hd: 4,
|
|
||||||
hi: 4,
|
|
||||||
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: 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: 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: 2,
|
|
||||||
linea: 'Ciencias de la Computación',
|
|
||||||
tipo: 'Obligatoria',
|
|
||||||
estado: 'Borrador',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function MapaCurricular() {
|
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 }) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 overflow-x-auto">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<h2 className="text-xl font-semibold mb-6">Mapa Curricular</h2>
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">{label}:</span>
|
||||||
|
<span className="text-sm font-bold text-slate-700">
|
||||||
|
{value}{total ? <span className="text-slate-400 font-normal">/{total}</span> : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{/* Contenedor de la Grid */}
|
function MateriaCardItem({ materia, onDragStart, isDragging, onClick }: {
|
||||||
<div
|
materia: Materia,
|
||||||
className="grid min-w-[1200px] border-l border-t border-slate-200"
|
onDragStart: (e: React.DragEvent, id: string) => void,
|
||||||
style={{
|
isDragging: boolean,
|
||||||
// 1 columna para nombres de líneas + 9 ciclos
|
onClick: () => void
|
||||||
gridTemplateColumns: '200px repeat(9, 1fr)',
|
}) {
|
||||||
}}
|
return (
|
||||||
>
|
<div
|
||||||
{/* Header: Espacio vacío + Ciclos */}
|
draggable
|
||||||
<div className="bg-slate-50 p-2 border-r border-b border-slate-200 font-medium text-sm text-slate-500">
|
onDragStart={(e) => onDragStart(e, materia.id)}
|
||||||
Línea Curricular
|
onClick={onClick}
|
||||||
</div>
|
className={`group p-3 rounded-lg border bg-white shadow-sm cursor-grab active:cursor-grabbing transition-all ${
|
||||||
{CICLOS.map((ciclo) => (
|
isDragging ? 'opacity-40 scale-95' : 'hover:border-teal-400 hover:shadow-md'
|
||||||
<div key={ciclo} className="bg-slate-50 p-2 border-r border-b border-slate-200 text-center font-medium text-sm text-slate-500">
|
}`}
|
||||||
{ciclo}
|
>
|
||||||
</div>
|
<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] || ''}`}>
|
||||||
{/* Filas por cada Línea Curricular */}
|
{materia.estado}
|
||||||
{LINEAS.map((linea) => (
|
</Badge>
|
||||||
<>
|
|
||||||
{/* Nombre de la línea (Primera columna) */}
|
|
||||||
<div className="bg-slate-50 p-3 border-r border-b border-slate-200 flex items-center text-xs font-bold uppercase text-slate-600">
|
|
||||||
{linea}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Celdas para cada ciclo en esta línea */}
|
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((numCiclo) => (
|
|
||||||
<div
|
|
||||||
key={`${linea}-${numCiclo}`}
|
|
||||||
className="p-2 border-r border-b border-slate-100 min-h-[120px] bg-white/50"
|
|
||||||
>
|
|
||||||
{/* Filtrar materias que pertenecen a esta posición */}
|
|
||||||
{MATERIAS.filter(m => m.linea === linea && m.ciclo === numCiclo).map((materia) => (
|
|
||||||
<MateriaCard key={materia.id} materia={materia} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs font-bold text-slate-700 leading-tight mb-1">{materia.nombre}</p>
|
||||||
{/* Sección de materias sin asignar (como en tu imagen) */}
|
<div className="flex items-center justify-between mt-2">
|
||||||
<div className="mt-8">
|
<span className="text-[10px] text-slate-500">{materia.creditos} CR • HD:{materia.hd} • HI:{materia.hi}</span>
|
||||||
<h3 className="text-sm font-bold text-slate-500 mb-3 uppercase tracking-wider">Materias sin asignar</h3>
|
<GripVertical size={12} className="text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
<div className="flex gap-4">
|
|
||||||
<div className="p-3 border rounded-lg bg-slate-50 border-dashed border-slate-300 w-48 text-[10px]">
|
|
||||||
<div className="font-bold">Inglés Técnico</div>
|
|
||||||
<div className="text-slate-500">4 cr • HD: 2 • HI: 2</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Componente Principal ---
|
||||||
|
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);
|
||||||
|
|
||||||
|
// --- Lógica de Gestión ---
|
||||||
|
const agregarLinea = (nombre: string) => {
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
|
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 });
|
||||||
|
};
|
||||||
|
|
||||||
|
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 });
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 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();
|
||||||
|
if (draggedMateria) {
|
||||||
|
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 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-2 py-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<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>
|
||||||
|
</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
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Filas por Línea */}
|
||||||
|
{lineas.map((linea, idx) => {
|
||||||
|
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]}`}>
|
||||||
|
<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)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
{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); }} />
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
</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);
|
||||||
|
return (
|
||||||
|
<div key={ciclo} className="text-[10px] text-center p-2 bg-slate-50 rounded-lg">
|
||||||
|
<div className="font-bold text-slate-700">Cr: {t.cr}</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>
|
||||||
|
</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})
|
||||||
|
</h3>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
102
src/types/plan.ts
Normal file
102
src/types/plan.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
export type PlanStatus =
|
||||||
|
| 'borrador'
|
||||||
|
| 'revision'
|
||||||
|
| 'expertos'
|
||||||
|
| 'consejo'
|
||||||
|
| 'aprobado'
|
||||||
|
| 'rechazado';
|
||||||
|
|
||||||
|
export type TipoPlan = 'Licenciatura' | 'Maestría' | 'Doctorado' | 'Especialidad';
|
||||||
|
|
||||||
|
export type TipoMateria = 'obligatoria' | 'optativa' | 'troncal';
|
||||||
|
|
||||||
|
export type MateriaStatus = 'borrador' | 'revisada' | 'aprobada';
|
||||||
|
|
||||||
|
export interface Facultad {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
color: string;
|
||||||
|
icono: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Carrera {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
facultadId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LineaCurricular {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DatosGeneralesField {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComentarioFlujo {
|
||||||
|
id: string;
|
||||||
|
usuario: string;
|
||||||
|
fecha: string;
|
||||||
|
texto: string;
|
||||||
|
fase: PlanStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentoPlan {
|
||||||
|
id: string;
|
||||||
|
fechaGeneracion: string;
|
||||||
|
version: number;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlanTab =
|
||||||
|
| 'datos-generales'
|
||||||
|
| 'mapa-curricular'
|
||||||
|
| 'materias'
|
||||||
|
| 'flujo'
|
||||||
|
| 'ia'
|
||||||
|
| 'documento'
|
||||||
|
| 'historial';
|
||||||
Reference in New Issue
Block a user