Se agrega drag y nuevas funcionalidades al detalle de plan

This commit is contained in:
2026-01-08 16:03:42 -06:00
parent c02d75789e
commit 22e6fcb113
4 changed files with 528 additions and 140 deletions

View File

@@ -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;

View File

@@ -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)}>
<Sparkles size={14} />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleEdit(campo)}>
<Pencil size={14} />
</Button>
</div>
)}
</div>
{/* Contenido de la Card */}
<div className="p-5">
{isEditing ? (
<div className="space-y-3">
<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>
) )
} }
interface CustomCardProps {
title: string;
children: React.ReactNode;
}
function Card({ title, children }: CustomCardProps) {
return (
<div className="rounded-lg border bg-white p-4">
<h3 className="font-semibold mb-2">{title}</h3>
<p className="text-sm text-gray-600">{children}</p>
</div>
)
}

View File

@@ -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
View 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';