Se llenan datos de las tabs de plan de estudios detalle ( datos, mapa, materia) y se agrega peticion de materia detalle en asignaturas

This commit is contained in:
2026-01-13 16:28:13 -06:00
parent bd0fcd5049
commit c4329785cc
5 changed files with 1114 additions and 707 deletions

View File

@@ -1,18 +1,12 @@
import { useCallback, useState } from 'react'
import { useCallback, useState } from 'react' import { Link } from '@tanstack/react-router'
import { Link } from '@tanstack/react-router'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { import { ArrowLeft, GraduationCap, Edit2, Save, Pencil } from 'lucide-react'
ArrowLeft,
GraduationCap,
Edit2, Save,
Pencil
} from 'lucide-react'
import { ContenidoTematico } from './ContenidoTematico' import { ContenidoTematico } from './ContenidoTematico'
import { BibliographyItem } from './BibliographyItem' import { BibliographyItem } from './BibliographyItem'
import { IAMateriaTab } from './IAMateriaTab' import { IAMateriaTab } from './IAMateriaTab'
@@ -21,103 +15,103 @@ import type {
IAMessage, IAMessage,
IASugerencia, IASugerencia,
UnidadTematica, UnidadTematica,
} from '@/types/materia'; } from '@/types/materia'
import { import {
mockMateria, mockMateria,
mockEstructura, mockEstructura,
mockDocumentoSep, mockDocumentoSep,
mockHistorial mockHistorial,
} from '@/data/mockMateriaData'; } from '@/data/mockMateriaData'
import { DocumentoSEPTab } from './DocumentoSEPTab' import { DocumentoSEPTab } from './DocumentoSEPTab'
import { HistorialTab } from './HistorialTab' import { HistorialTab } from './HistorialTab'
import { useSubject } from '@/data/hooks/useSubjects'
export interface BibliografiaEntry { export interface BibliografiaEntry {
id: string; id: string
tipo: 'BASICA' | 'COMPLEMENTARIA'; tipo: 'BASICA' | 'COMPLEMENTARIA'
cita: string; cita: string
fuenteBibliotecaId?: string; fuenteBibliotecaId?: string
fuenteBiblioteca?: any; fuenteBiblioteca?: any
} }
export interface BibliografiaTabProps { export interface BibliografiaTabProps {
bibliografia: BibliografiaEntry[]; bibliografia: BibliografiaEntry[]
onSave: (bibliografia: BibliografiaEntry[]) => void; onSave: (bibliografia: BibliografiaEntry[]) => void
isSaving: boolean; isSaving: boolean
} }
export default function MateriaDetailPage() { export default function MateriaDetailPage() {
// 1. Asegúrate de tener estos estados en tu componente principal // 1. Asegúrate de tener estos estados en tu componente principal
const [messages, setMessages] = useState<IAMessage[]>([]); const [messages, setMessages] = useState<IAMessage[]>([])
const [datosGenerales, setDatosGenerales] = useState({}); const [datosGenerales, setDatosGenerales] = useState({})
const [campos, setCampos] = useState<CampoEstructura[]>([]); const [campos, setCampos] = useState<CampoEstructura[]>([])
// 2. Funciones de manejo para la IA // 2. Funciones de manejo para la IA
const handleSendMessage = (text: string, campoId?: string) => { const handleSendMessage = (text: string, campoId?: string) => {
const newMessage: IAMessage = { const newMessage: IAMessage = {
id: Date.now().toString(), id: Date.now().toString(),
role: 'user', role: 'user',
content: text, content: text,
timestamp: new Date(), timestamp: new Date(),
campoAfectado: campoId campoAfectado: campoId,
}; }
setMessages([...messages, newMessage]); setMessages([...messages, newMessage])
// Aquí llamarías a tu API de OpenAI/Claude // Aquí llamarías a tu API de OpenAI/Claude
//toast.info("Enviando consulta a la IA..."); //toast.info("Enviando consulta a la IA...");
}; }
const handleAcceptSuggestion = (sugerencia: IASugerencia) => { const handleAcceptSuggestion = (sugerencia: IASugerencia) => {
// Lógica para actualizar el valor del campo en tu estado de datosGenerales // Lógica para actualizar el valor del campo en tu estado de datosGenerales
//toast.success(`Sugerencia aplicada a ${sugerencia.campoNombre}`); //toast.success(`Sugerencia aplicada a ${sugerencia.campoNombre}`);
}; }
// Dentro de tu componente principal (donde están los Tabs) // Dentro de tu componente principal (donde están los Tabs)
const [bibliografia, setBibliografia] = useState<BibliografiaEntry[]>([ const [bibliografia, setBibliografia] = useState<BibliografiaEntry[]>([
{ {
id: '1', id: '1',
tipo: 'BASICA', tipo: 'BASICA',
cita: 'Russell, S., & Norvig, P. (2020). Artificial Intelligence: A Modern Approach. Pearson.' cita: 'Russell, S., & Norvig, P. (2020). Artificial Intelligence: A Modern Approach. Pearson.',
} },
]); ])
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false)
const handleSaveBibliografia = (data: BibliografiaEntry[]) => { const handleSaveBibliografia = (data: BibliografiaEntry[]) => {
setIsSaving(true); setIsSaving(true)
// Aquí iría tu llamada a la API // Aquí iría tu llamada a la API
setBibliografia(data); setBibliografia(data)
// Simulamos un guardado // Simulamos un guardado
setTimeout(() => {
setIsSaving(false);
//toast.success("Cambios guardados");
}, 1000);
};
const [isRegenerating, setIsRegenerating] = useState(false);
const handleRegenerateDocument = useCallback(() => {
setIsRegenerating(true);
setTimeout(() => { setTimeout(() => {
setIsRegenerating(false); setIsSaving(false)
}, 2000); //toast.success("Cambios guardados");
}, []); }, 1000)
}
const [isRegenerating, setIsRegenerating] = useState(false)
const handleRegenerateDocument = useCallback(() => {
setIsRegenerating(true)
setTimeout(() => {
setIsRegenerating(false)
}, 2000)
}, [])
return ( return (
<div className="w-full"> <div className="w-full">
{/* ================= HEADER ================= */} {/* ================= HEADER ================= */}
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white"> <section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
<div className="max-w-7xl mx-auto px-6 py-10"> <div className="mx-auto max-w-7xl px-6 py-10">
<Link <Link
to="/planes" 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" /> <ArrowLeft className="h-4 w-4" />
Volver al plan Volver al plan
</Link> </Link>
<div className="flex items-start justify-between gap-6"> <div className="flex items-start justify-between gap-6">
<div className="space-y-3"> <div className="space-y-3">
<Badge className="bg-blue-900/50 border border-blue-700"> <Badge className="border border-blue-700 bg-blue-900/50">
IA-401 IA-401
</Badge> </Badge>
@@ -127,7 +121,7 @@ const handleRegenerateDocument = useCallback(() => {
<div className="flex flex-wrap gap-4 text-sm text-blue-200"> <div className="flex flex-wrap gap-4 text-sm text-blue-200">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<GraduationCap className="w-4 h-4" /> <GraduationCap className="h-4 w-4" />
Ingeniería en Sistemas Computacionales Ingeniería en Sistemas Computacionales
</span> </span>
@@ -136,13 +130,13 @@ const handleRegenerateDocument = useCallback(() => {
<p className="text-sm text-blue-300"> <p className="text-sm text-blue-300">
Pertenece al plan:{' '} Pertenece al plan:{' '}
<span className="underline cursor-pointer"> <span className="cursor-pointer underline">
Licenciatura en Ingeniería en Sistemas Computacionales 2024 Licenciatura en Ingeniería en Sistemas Computacionales 2024
</span> </span>
</p> </p>
</div> </div>
<div className="flex flex-col gap-2 items-end"> <div className="flex flex-col items-end gap-2">
<Badge variant="secondary">8 créditos</Badge> <Badge variant="secondary">8 créditos</Badge>
<Badge variant="secondary">7° semestre</Badge> <Badge variant="secondary">7° semestre</Badge>
<Badge variant="secondary">Sistemas Inteligentes</Badge> <Badge variant="secondary">Sistemas Inteligentes</Badge>
@@ -152,10 +146,10 @@ const handleRegenerateDocument = useCallback(() => {
</section> </section>
{/* ================= TABS ================= */} {/* ================= TABS ================= */}
<section className="bg-white border-b"> <section className="border-b bg-white">
<div className="max-w-7xl mx-auto px-6"> <div className="mx-auto max-w-7xl px-6">
<Tabs defaultValue="datos"> <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="datos">Datos generales</TabsTrigger>
<TabsTrigger value="contenido">Contenido temático</TabsTrigger> <TabsTrigger value="contenido">Contenido temático</TabsTrigger>
<TabsTrigger value="bibliografia">Bibliografía</TabsTrigger> <TabsTrigger value="bibliografia">Bibliografía</TabsTrigger>
@@ -185,13 +179,18 @@ const handleRegenerateDocument = useCallback(() => {
<TabsContent value="ia"> <TabsContent value="ia">
<IAMateriaTab <IAMateriaTab
campos={campos} campos={campos}
datosGenerales={datosGenerales} datosGenerales={datosGenerales}
messages={messages} messages={messages}
onSendMessage={handleSendMessage} onSendMessage={handleSendMessage}
onAcceptSuggestion={handleAcceptSuggestion} onAcceptSuggestion={handleAcceptSuggestion}
onRejectSuggestion={(id) => console.log("Rechazada") /*toast.error("Sugerencia rechazada")*/} onRejectSuggestion={
/> (id) =>
console.log(
'Rechazada',
) /*toast.error("Sugerencia rechazada")*/
}
/>
</TabsContent> </TabsContent>
<TabsContent value="sep"> <TabsContent value="sep">
@@ -206,7 +205,7 @@ const handleRegenerateDocument = useCallback(() => {
</TabsContent> </TabsContent>
<TabsContent value="historial"> <TabsContent value="historial">
<HistorialTab historial={mockHistorial} /> <HistorialTab historial={mockHistorial} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
@@ -218,79 +217,93 @@ const handleRegenerateDocument = useCallback(() => {
/* ================= TAB CONTENT ================= */ /* ================= TAB CONTENT ================= */
function DatosGenerales() { function DatosGenerales() {
return ( const { data: asignaturasApi, isLoading: loadingAsig } = useSubject(
<div className="max-w-7xl mx-auto py-8 px-4 space-y-8 animate-in fade-in duration-500"> /*planId*/ '9d4dda6a-488f-428a-8a07-38081592a641',
)
console.log(asignaturasApi.datos)
return (
<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 */} {/* 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> <div>
<h2 className="text-2xl font-bold tracking-tight text-slate-900">Datos Generales</h2> <h2 className="text-2xl font-bold tracking-tight text-slate-900">
<p className="text-slate-500 mt-1"> Datos Generales
</h2>
<p className="mt-1 text-slate-500">
Información oficial estructurada bajo los lineamientos de la SEP. Información oficial estructurada bajo los lineamientos de la SEP.
</p> </p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<Button variant="outline" size="sm" className="gap-2"> <Button variant="outline" size="sm" className="gap-2">
<Edit2 className="w-4 h-4" /> Editar borrador <Edit2 className="h-4 w-4" /> Editar borrador
</Button> </Button>
<Button size="sm" className="gap-2 bg-blue-600 hover:bg-blue-700"> <Button size="sm" className="gap-2 bg-blue-600 hover:bg-blue-700">
<Save className="w-4 h-4" /> Guardar cambios <Save className="h-4 w-4" /> Guardar cambios
</Button> </Button>
</div> </div>
</div> </div>
{/* Grid de Información */} {/* 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) */} {/* Columna Principal (Más ancha) */}
<div className="md:col-span-2 space-y-6"> <div className="space-y-6 md:col-span-2">
<div className="md:col-span-2 space-y-6"> <div className="space-y-6 md:col-span-2">
<InfoCard <InfoCard
title="Competencias a Desarrollar" title="Competencias a Desarrollar"
subtitle="Competencias profesionales que se desarrollarán" subtitle="Competencias profesionales que se desarrollarán"
isList={true} 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`} 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 <InfoCard
title="Objetivo General" title="Objetivo General"
initialContent="Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos." initialContent="Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos."
/> />
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<InfoCard <InfoCard
title="Justificación" title="Justificación"
initialContent="La inteligencia artificial es una de las tecnologías más disruptivas..." initialContent="La inteligencia artificial es una de las tecnologías más disruptivas..."
/> />
</div> </div>
</div> </div>
{/* Columna Lateral (Información Secundaria) */} {/* Columna Lateral (Información Secundaria) */}
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-6"> <div className="space-y-6">
{/* Tarjeta de Requisitos */} {/* Tarjeta de Requisitos */}
<InfoCard <InfoCard
title="Requisitos y Seriación" title="Requisitos y Seriación"
type="requirements" type="requirements"
initialContent={[ initialContent={[
{ type: "Pre-requisito", code: "PA-301", name: "Programación Avanzada" }, {
{ type: "Co-requisito", code: "MAT-201", name: "Matemáticas Discretas" } type: 'Pre-requisito',
]} code: 'PA-301',
/> name: 'Programación Avanzada',
},
{
type: 'Co-requisito',
code: 'MAT-201',
name: 'Matemáticas Discretas',
},
]}
/>
{/* Tarjeta de Evaluación */} {/* Tarjeta de Evaluación */}
<InfoCard <InfoCard
title="Sistema de Evaluación" title="Sistema de Evaluación"
type="evaluation" type="evaluation"
initialContent={[ initialContent={[
{ label: "Exámenes parciales", value: "30%" }, { label: 'Exámenes parciales', value: '30%' },
{ label: "Proyecto integrador", value: "35%" }, { label: 'Proyecto integrador', value: '35%' },
{ label: "Prácticas de laboratorio", value: "20%" }, { label: 'Prácticas de laboratorio', value: '20%' },
{ label: "Participación", value: "15%" }, { label: 'Participación', value: '15%' },
]} ]}
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -298,9 +311,9 @@ function DatosGenerales() {
} }
interface InfoCardProps { interface InfoCardProps {
title: string, title: string
subtitle?: string subtitle?: string
isList?:boolean isList?: boolean
initialContent: any // Puede ser string o array de objetos initialContent: any // Puede ser string o array de objetos
type?: 'text' | 'list' | 'requirements' | 'evaluation' type?: 'text' | 'list' | 'requirements' | 'evaluation'
} }
@@ -312,7 +325,7 @@ function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) {
const [tempText, setTempText] = useState( const [tempText, setTempText] = useState(
type === 'text' || type === 'list' type === 'text' || type === 'list'
? initialContent ? initialContent
: JSON.stringify(initialContent, null, 2) // O un formato legible : JSON.stringify(initialContent, null, 2), // O un formato legible
) )
const handleSave = () => { const handleSave = () => {
@@ -323,10 +336,17 @@ function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) {
return ( return (
<Card className="transition-all hover:border-slate-300"> <Card className="transition-all hover:border-slate-300">
<CardHeader className="pb-3 flex flex-row items-start justify-between space-y-0"> <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> <CardTitle className="text-sm font-bold text-slate-700">
{title}
</CardTitle>
{!isEditing && ( {!isEditing && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={() => setIsEditing(true)}> <Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400"
onClick={() => setIsEditing(true)}
>
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
</Button> </Button>
)} )}
@@ -338,11 +358,19 @@ function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) {
<Textarea <Textarea
value={tempText} value={tempText}
onChange={(e) => setTempText(e.target.value)} 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"> <div className="flex justify-end gap-2">
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)}>Cancelar</Button> <Button
<Button size="sm" className="bg-[#00a878]" onClick={handleSave}>Guardar</Button> size="sm"
variant="ghost"
onClick={() => setIsEditing(false)}
>
Cancelar
</Button>
<Button size="sm" className="bg-[#00a878]" onClick={handleSave}>
Guardar
</Button>
</div> </div>
</div> </div>
) : ( ) : (
@@ -362,9 +390,16 @@ function RequirementsView({ items }: { items: any[] }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{items.map((req, i) => ( {items.map((req, i) => (
<div key={i} className="p-3 bg-slate-50 rounded-lg border border-slate-100"> <div
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-tight">{req.type}</p> key={i}
<p className="text-sm font-medium text-slate-700">{req.code} {req.name}</p> 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>
))} ))}
</div> </div>
@@ -376,7 +411,10 @@ function EvaluationView({ items }: { items: any[] }) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{items.map((item, i) => ( {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="text-slate-500">{item.label}</span>
<span className="font-bold text-blue-600">{item.value}</span> <span className="font-bold text-blue-600">{item.value}</span>
</div> </div>
@@ -385,11 +423,9 @@ function EvaluationView({ items }: { items: any[] }) {
) )
} }
function EmptyTab({ title }: { title: string }) { function EmptyTab({ title }: { title: string }) {
return ( return (
<div className="py-16 text-center text-muted-foreground"> <div className="text-muted-foreground py-16 text-center">
{title} (pendiente) {title} (pendiente)
</div> </div>
) )

View File

@@ -1,16 +1,10 @@
import { usePlan } from '@/data'; import { usePlan } from '@/data'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import type { DatosGeneralesField } from '@/types/plan' import type { DatosGeneralesField } from '@/types/plan'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { import { Pencil, Check, X, Sparkles, AlertCircle } from 'lucide-react'
Pencil,
Check,
X,
Sparkles,
AlertCircle
} from 'lucide-react'
//import { toast } from 'sonner' // Asegúrate de tener sonner instalado o quita la línea //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')({
@@ -18,39 +12,38 @@ export const Route = createFileRoute('/planes/$planId/_detalle/datos')({
}) })
const formatLabel = (key: string) => { const formatLabel = (key: string) => {
const result = key.replace(/_/g, ' '); const result = key.replace(/_/g, ' ')
return result.charAt(0).toUpperCase() + result.slice(1); return result.charAt(0).toUpperCase() + result.slice(1)
}; }
function DatosGeneralesPage() { function DatosGeneralesPage() {
const { data, isFetching } = usePlan('0e0aea4d-b8b4-4e75-8279-6224c3ac769f'); const { data } = usePlan('0e0aea4d-b8b4-4e75-8279-6224c3ac769f')
// Inicializamos campos como un arreglo vacío
const [campos, setCampos] = useState<DatosGeneralesField[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
// Inicializamos campos como un arreglo vacío
const [campos, setCampos] = useState<DatosGeneralesField[]>([])
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
// Efecto para transformar data?.datos en el arreglo de campos // Efecto para transformar data?.datos en el arreglo de campos
useEffect(() => { useEffect(() => {
if (data) { // 2. Validación de seguridad para sourceData
// Si data es directamente el objeto que mostraste, usamos data. const sourceData = data?.datos
// Si viene dentro de .datos, usamos data.datos.
const sourceData = data?.datos;
const datosTransformados: DatosGeneralesField[] = Object.entries(sourceData).map( if (sourceData && typeof sourceData === 'object') {
([key, value], index) => ({ const datosTransformados: DatosGeneralesField[] = Object.entries(
id: (index + 1).toString(), // Id basado en index (1, 2, 3...) sourceData,
label: formatLabel(key), // "perfil_de_ingreso" -> "Perfil de ingreso" ).map(([key, value], index) => ({
value: value?.toString() || '', // Manejo de nulls id: (index + 1).toString(),
requerido: true, label: formatLabel(key),
tipo: 'texto' // Todos como texto según tu instrucción // Forzamos el valor a string de forma segura
}) value: typeof value === 'string' ? value : value?.toString() || '',
); requerido: true,
tipo: 'texto',
}))
setCampos(datosTransformados); setCampos(datosTransformados)
} }
}, [data]); }, [data])
// 3. Manejadores de acciones (Ahora como funciones locales) // 3. Manejadores de acciones (Ahora como funciones locales)
const handleEdit = (campo: DatosGeneralesField) => { const handleEdit = (campo: DatosGeneralesField) => {
@@ -65,9 +58,9 @@ function DatosGeneralesPage() {
const handleSave = (id: string) => { const handleSave = (id: string) => {
// Actualizamos el estado local de la lista // Actualizamos el estado local de la lista
setCampos(prev => prev.map(c => setCampos((prev) =>
c.id === id ? { ...c, value: editValue } : c prev.map((c) => (c.id === id ? { ...c, value: editValue } : c)),
)) )
setEditingId(null) setEditingId(null)
setEditValue('') setEditValue('')
//toast.success('Cambios guardados localmente') //toast.success('Cambios guardados localmente')
@@ -79,40 +72,56 @@ function DatosGeneralesPage() {
} }
return ( 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"> <div className="mb-6">
<h2 className="text-lg font-semibold text-foreground"> <h2 className="text-foreground text-lg font-semibold">
Datos Generales del Plan Datos Generales del Plan
</h2> </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 Información estructural y descriptiva del plan de estudios
</p> </p>
</div> </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) => { {campos.map((campo) => {
const isEditing = editingId === campo.id const isEditing = editingId === campo.id
return ( return (
<div <div
key={campo.id} key={campo.id}
className={`border rounded-xl transition-all ${ className={`rounded-xl border transition-all ${
isEditing ? 'border-teal-500 ring-2 ring-teal-50 shadow-lg' : 'bg-white hover:shadow-md' isEditing
? 'border-teal-500 shadow-lg ring-2 ring-teal-50'
: 'bg-white hover:shadow-md'
}`} }`}
> >
{/* Header de la Card */} {/* 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"> <div className="flex items-center gap-2">
<h3 className="font-medium text-sm text-slate-700">{campo.label}</h3> <h3 className="text-sm font-medium text-slate-700">
{campo.requerido && <span className="text-red-500 text-xs">*</span>} {campo.label}
</h3>
{campo.requerido && (
<span className="text-xs text-red-500">*</span>
)}
</div> </div>
{!isEditing && ( {!isEditing && (
<div className="flex gap-1"> <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.id)}
>
<Sparkles size={14} /> <Sparkles size={14} />
</Button> </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} /> <Pencil size={14} />
</Button> </Button>
</div> </div>
@@ -129,10 +138,18 @@ function DatosGeneralesPage() {
className="min-h-[120px]" className="min-h-[120px]"
/> />
<div className="flex justify-end gap-2"> <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 <X size={14} className="mr-1" /> Cancelar
</Button> </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 <Check size={14} className="mr-1" /> Guardar
</Button> </Button>
</div> </div>
@@ -140,12 +157,12 @@ function DatosGeneralesPage() {
) : ( ) : (
<div className="min-h-[100px]"> <div className="min-h-[100px]">
{campo.value ? ( {campo.value ? (
<div className="text-sm text-slate-600 leading-relaxed"> <div className="text-sm leading-relaxed text-slate-600">
{campo.tipo === 'lista' ? ( {campo.tipo === 'lista' ? (
<ul className="space-y-1"> <ul className="space-y-1">
{campo.value.split('\n').map((item, i) => ( {campo.value.split('\n').map((item, i) => (
<li key={i} className="flex gap-2"> <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} {item}
</li> </li>
))} ))}
@@ -155,7 +172,7 @@ function DatosGeneralesPage() {
)} )}
</div> </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} /> <AlertCircle size={14} />
<span>Sin contenido.</span> <span>Sin contenido.</span>
</div> </div>

View File

@@ -1,118 +1,271 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { Sparkles, Send, Paperclip, Target, UserCheck, Lightbulb, FileText } from "lucide-react" import { useState, useEffect, useRef } from 'react'
import { useState } from 'react' // Importamos useState import {
import { Button } from "@/components/ui/button" Sparkles,
import { Input } from "@/components/ui/input" Send,
import { ScrollArea } from "@/components/ui/scroll-area" Paperclip,
import { Avatar, AvatarFallback } from "@/components/ui/avatar" Target,
UserCheck,
Lightbulb,
FileText,
Users,
GraduationCap,
BookOpen,
Check,
X,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
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...',
},
]
export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({ export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({
component: RouteComponent, component: RouteComponent,
}) })
interface Message {
id: string
role: 'user' | 'assistant'
content: string
}
function RouteComponent() { function RouteComponent() {
// 1. Estado para el texto del input const [messages, setMessages] = useState<Message[]>([
const [inputValue, setInputValue] = useState('') {
id: '1',
// 2. Estado para la lista de mensajes (iniciamos con los de la imagen) role: 'assistant',
const [messages, setMessages] = useState([ content: '¡Hola! Soy tu asistente de IA. ¿En qué puedo ayudarte hoy?',
{ 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 [input, setInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [pendingSuggestion, setPendingSuggestion] = useState<{
field: string
text: string
} | null>(null)
// 3. Función para enviar el mensaje const scrollRef = useRef<HTMLDivElement>(null)
const handleSend = () => {
if (!inputValue.trim()) return
// Agregamos el mensaje del usuario // Función de scroll corregida para Radix
const newMessage = { const scrollToBottom = () => {
id: Date.now(), const viewport = scrollRef.current?.querySelector(
role: 'user', '[data-radix-scroll-area-viewport]',
text: inputValue )
if (viewport) {
viewport.scrollTo({ top: viewport.scrollHeight, behavior: 'smooth' })
} }
}
setMessages([...messages, newMessage]) useEffect(() => {
setInputValue('') // Limpiamos el input const timer = setTimeout(scrollToBottom, 100)
return () => clearTimeout(timer)
}, [messages, isLoading])
const handleSend = async (prompt?: string) => {
const messageText = prompt || input
if (!messageText.trim()) return
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: messageText,
}
setMessages((prev) => [...prev, userMessage])
setInput('')
setIsLoading(true)
setTimeout(() => {
const mockText =
'He analizado tu solicitud. Basado en los estándares actuales, sugiero fortalecer las competencias técnicas...'
const aiResponse: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: `He analizado tu solicitud. Aquí está mi sugerencia:\n\n"${mockText}"\n\n¿Te gustaría aplicar este texto al plan?`,
}
setMessages((prev) => [...prev, aiResponse])
setPendingSuggestion({ field: 'seccion-plan', text: mockText })
setIsLoading(false)
}, 1200)
} }
return ( return (
<div className="flex h-[calc(100vh-200px)] gap-6 p-4"> /* CAMBIO CLAVE 1:
<div className="flex flex-col flex-1 bg-slate-50/50 rounded-xl border relative overflow-hidden"> Aseguramos que el contenedor padre ocupe el espacio disponible pero NO MÁS.
'max-h-full' y 'flex-1' evitan que el chat empuje el layout hacia abajo.
*/
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
{/* PANEL DE CHAT */}
<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">
{/* Header Fijo (shrink-0 es vital para que no se aplaste) */}
<div className="flex shrink-0 items-center justify-between border-b bg-white p-4">
<div className="flex flex-col">
<h3 className="flex items-center gap-2 text-sm font-bold text-slate-700">
<Sparkles className="h-4 w-4 text-teal-600" />
Asistente de Diseño Curricular
</h3>
<p className="text-left text-[11px] text-slate-500">
Optimizado con IA
</p>
</div>
</div>
<ScrollArea className="flex-1 p-6"> {/* CAMBIO CLAVE 2:
<div className="space-y-6 max-w-3xl mx-auto"> El ScrollArea debe tener 'flex-1' y 'h-full'.
{/* 4. Mapeamos los mensajes dinámicamente */} Esto obliga al componente a colapsar su altura y activar el scroll.
{messages.map((msg) => ( */}
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} gap-3`}> <div className="relative min-h-0 flex-1">
{msg.role === 'ai' && ( <ScrollArea ref={scrollRef} className="h-full w-full">
<Avatar className="h-8 w-8 border bg-teal-50"> <div className="mx-auto max-w-3xl space-y-6 p-6">
<AvatarFallback className="text-teal-600"><Sparkles size={16}/></AvatarFallback> {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> </Avatar>
)} <div
className={`flex max-w-[85%] flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}
<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>} {msg.role === 'assistant' && (
<div className={`p-4 rounded-2xl text-sm shadow-sm ${ <span className="mb-1 ml-1 text-[9px] font-bold text-teal-700 uppercase">
msg.role === 'user' Asistente IA
? 'bg-teal-600 text-white rounded-tr-none' </span>
: 'bg-white border text-slate-700 rounded-tl-none' )}
}`}> <div
{msg.text} className={`rounded-2xl p-3 text-left text-sm whitespace-pre-wrap shadow-sm ${
msg.role === 'user'
? 'rounded-tr-none bg-teal-600 text-white'
: 'rounded-tl-none border border-slate-200 bg-white text-slate-700'
}`}
>
{msg.content}
</div>
</div> </div>
</div> </div>
</div> ))}
))} {isLoading && (
</div> <div className="flex gap-2 p-4">
</ScrollArea> <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>
{/* 5. Input vinculado al estado */} {/* Barra de aplicación flotante (dentro del contenedor relativo del scroll) */}
<div className="p-4 bg-white border-t"> {pendingSuggestion && !isLoading && (
<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"> <div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-2 rounded-full border bg-white p-1.5 shadow-2xl">
<Input <Button
value={inputValue} variant="ghost"
onChange={(e) => setInputValue(e.target.value)} size="sm"
onKeyDown={(e) => e.key === 'Enter' && handleSend()} // Enviar con Enter onClick={() => setPendingSuggestion(null)}
className="border-none bg-transparent focus-visible:ring-0 text-sm" className="h-8 rounded-full text-xs"
placeholder='Escribe tu solicitud... Usa ":" para mencionar campos' >
/> <X className="mr-1 h-3 w-3" /> Descartar
<Button variant="ghost" size="icon" className="text-slate-400"> </Button>
<Paperclip size={18} /> <Button
</Button> size="sm"
<Button onClick={() => {}}
onClick={handleSend} className="h-8 rounded-full bg-teal-600 text-xs text-white hover:bg-teal-700"
size="icon" >
className="bg-teal-600 hover:bg-teal-700 h-8 w-8" <Check className="mr-1 h-3 w-3" /> Aplicar cambios
> </Button>
<Send size={16} /> </div>
</Button> )}
</div>
{/* INPUT FIJO AL FONDO */}
<div className="shrink-0 border-t bg-white p-4">
<div className="relative mx-auto max-w-4xl">
<div className="flex items-end 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">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
placeholder="Escribe tu solicitud aquí..."
className="max-h-[120px] min-h-[40px] flex-1 resize-none border-none bg-transparent py-2 text-left text-sm focus-visible:ring-0"
/>
<Button
onClick={() => handleSend()}
disabled={!input.trim() || isLoading}
size="icon"
className="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>
</div> </div>
{/* Panel lateral (se mantiene igual) */} {/* PANEL LATERAL */}
<div className="w-72 space-y-4"> <div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
<div className="flex items-center gap-2 text-orange-500 font-semibold text-sm mb-4"> <h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800">
<Lightbulb size={18} /> <Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
Acciones rápidas </h4>
</div>
<div className="space-y-2"> <div className="space-y-2">
<ActionButton icon={<Target className="text-teal-500" size={18} />} text="Mejorar objetivo general" /> {PRESETS.map((preset) => (
<ActionButton icon={<UserCheck className="text-slate-500" size={18} />} text="Redactar perfil de egreso" /> <button
<ActionButton icon={<Lightbulb className="text-blue-500" size={18} />} text="Sugerir competencias" /> key={preset.id}
<ActionButton icon={<FileText className="text-teal-500" size={18} />} text="Justificar pertinencia" /> 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> </div>
</div> </div>
) )
} }
function ActionButton({ icon, text }: { icon: React.ReactNode, text: string }) { function generateMockResponse(prompt: string) {
return ( return 'Mock response content...'
<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>
)
} }

View File

@@ -1,5 +1,5 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useMemo, useState } from 'react' import { useMemo, useState, useEffect } from 'react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { import {
@@ -7,123 +7,38 @@ import {
ChevronDown, ChevronDown,
AlertTriangle, AlertTriangle,
GripVertical, GripVertical,
Trash2 Trash2,
} from 'lucide-react' } from 'lucide-react'
import type { Materia, LineaCurricular } from '@/types/plan' import type { Materia, LineaCurricular } from '@/types/plan'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle Dialog,
} from "@/components/ui/dialog" DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from '@/components/ui/dropdown-menu'
import { usePlanAsignaturas, usePlanLineas } from '@/data'; import { usePlanAsignaturas, usePlanLineas } from '@/data'
// --- Mapeadores (Fuera del componente para mayor limpieza) ---
export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({ const mapLineasToLineaCurricular = (
component: MapaCurricularPage, lineasApi: any[] = [],
}) ): LineaCurricular[] => {
return lineasApi.map((linea) => ({
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 (
<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-sm font-bold text-slate-700">
{value}{total ? <span className="text-slate-400 font-normal">/{total}</span> : ''}
</span>
</div>
)
}
function MateriaCardItem({ materia, onDragStart, isDragging, onClick }: {
materia: Materia,
onDragStart: (e: React.DragEvent, id: string) => void,
isDragging: boolean,
onClick: () => void
}) {
return (
<div
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'
}`}
>
<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] || ''}`}>
{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" />
</div>
</div>
)
}
// --- Componente Principal ---
function MapaCurricularPage() {
const { data: asignaturas, isFetching: loadingAsig } = usePlanAsignaturas('0e0aea4d-b8b4-4e75-8279-6224c3ac769f');
const { data: lineas2, isFetching: loadingLineas } = usePlanLineas('0e0aea4d-b8b4-4e75-8279-6224c3ac769f');
console.log(asignaturas);
console.log(lineas2);
// --- 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 [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 mapLineasToLineaCurricular = (lineasApi = []): LineaCurricular[] => {
return lineasApi.map((linea: any) => ({
id: linea.id, id: linea.id,
nombre: linea.nombre, nombre: linea.nombre,
orden: linea.orden ?? 0, orden: linea.orden ?? 0,
color: '#1976d2', // default aceptado color: '#1976d2',
})); }))
}; }
const mapAsignaturasToMaterias = (asigApi = []): Materia[] => { const mapAsignaturasToMaterias = (asigApi: any[] = []): Materia[] => {
return asigApi.map((asig: any) => ({ return asigApi.map((asig) => ({
id: asig.id, id: asig.id,
clave: asig.codigo, clave: asig.codigo,
nombre: asig.nombre, nombre: asig.nombre,
@@ -131,222 +46,449 @@ const mapAsignaturasToMaterias = (asigApi = []): Materia[] => {
ciclo: asig.numero_ciclo ?? null, ciclo: asig.numero_ciclo ?? null,
lineaCurricularId: asig.linea_plan_id ?? null, lineaCurricularId: asig.linea_plan_id ?? null,
tipo: asig.tipo === 'OBLIGATORIA' ? 'obligatoria' : 'optativa', tipo: asig.tipo === 'OBLIGATORIA' ? 'obligatoria' : 'optativa',
estado: 'borrador', // default válido estado: 'borrador',
orden: asig.orden_celda ?? 0, orden: asig.orden_celda ?? 0,
hd: Math.floor((asig.horas_semana ?? 0) / 2), hd: Math.floor((asig.horas_semana ?? 0) / 2),
hi: Math.ceil((asig.horas_semana ?? 0) / 2), hi: Math.ceil((asig.horas_semana ?? 0) / 2),
})); }))
}; }
const lineasFinales: LineaCurricular[] = useMemo(() => { const lineColors = [
return [ 'bg-blue-50 border-blue-200 text-blue-700',
...INITIAL_LINEAS, 'bg-purple-50 border-purple-200 text-purple-700',
...mapLineasToLineaCurricular(lineas2), 'bg-orange-50 border-orange-200 text-orange-700',
]; 'bg-emerald-50 border-emerald-200 text-emerald-700',
}, [lineas2]); ]
const materiasFinales: Materia[] = useMemo(() => { const statusBadge: Record<string, string> = {
return [ borrador: 'bg-slate-100 text-slate-600',
...INITIAL_MATERIAS, revisada: 'bg-amber-100 text-amber-700',
...mapAsignaturasToMaterias(asignaturas), aprobada: 'bg-emerald-100 text-emerald-700',
]; }
}, [asignaturas]);
// --- Subcomponentes ---
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 tracking-wider text-slate-400 uppercase">
{label}:
</span>
<span className="text-sm font-bold text-slate-700">
{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
onClick: () => void
}) {
return (
<div
draggable
onDragStart={(e) => onDragStart(e, materia.id)}
onClick={onClick}
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="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="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>
)
}
export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({
component: MapaCurricularPage,
})
function MapaCurricularPage() {
const { planId } = Route.useParams() // Idealmente usa el ID de la ruta
// 1. Fetch de Datos
const { data: asignaturasApi, isLoading: loadingAsig } = usePlanAsignaturas(
/*planId*/ '0e0aea4d-b8b4-4e75-8279-6224c3ac769f',
)
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(
/*planId*/ '0e0aea4d-b8b4-4e75-8279-6224c3ac769f',
)
// 2. Estado Local (Para interactividad)
const [materias, setMaterias] = useState<Materia[]>([])
const [lineas, setLineas] = useState<LineaCurricular[]>([])
const [draggedMateria, setDraggedMateria] = useState<string | null>(null)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [selectedMateria, setSelectedMateria] = useState<Materia | null>(null)
// 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)
// --- Lógica de Gestión --- // --- Lógica de Gestión ---
const agregarLinea = (nombre: string) => { const agregarLinea = (nombre: string) => {
const nueva = { id: crypto.randomUUID(), nombre, orden: lineas.length + 1 }; const nueva = { id: crypto.randomUUID(), nombre, orden: lineas.length + 1 }
setLineas([...lineas, nueva]); setLineas([...lineas, nueva])
}; }
const borrarLinea = (id: string) => { const borrarLinea = (id: string) => {
setMaterias(prev => prev.map(m => m.lineaCurricularId === id ? { ...m, ciclo: null, lineaCurricularId: null } : m)); setMaterias((prev) =>
setLineas(prev => prev.filter(l => l.id !== id)); 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) => { const getTotalesCiclo = (ciclo: number) => {
return materias.filter(m => m.ciclo === ciclo).reduce((acc, m) => ({ return materias
cr: acc.cr + (m.creditos || 0), hd: acc.hd + (m.hd || 0), hi: acc.hi + (m.hi || 0) .filter((m) => m.ciclo === ciclo)
}), { cr: 0, hd: 0, hi: 0 }); .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) => { const getSubtotalLinea = (lineaId: string) => {
return materias.filter(m => m.lineaCurricularId === lineaId && m.ciclo !== null).reduce((acc, m) => ({ return materias
cr: acc.cr + (m.creditos || 0), hd: acc.hd + (m.hd || 0), hi: acc.hi + (m.hi || 0) .filter((m) => m.lineaCurricularId === lineaId && m.ciclo !== null)
}), { cr: 0, hd: 0, hi: 0 }); .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) => {
const handleDragStart = (e: React.DragEvent, id: string) => { setDraggedMateria(id); e.dataTransfer.effectAllowed = 'move'; }; setDraggedMateria(id)
const handleDragOver = (e: React.DragEvent) => e.preventDefault(); e.dataTransfer.effectAllowed = 'move'
const handleDrop = (e: React.DragEvent, ciclo: number | null, lineaId: string | null) => { }
e.preventDefault(); const handleDragOver = (e: React.DragEvent) => e.preventDefault()
const handleDrop = (
e: React.DragEvent,
ciclo: number | null,
lineaId: string | null,
) => {
e.preventDefault()
if (draggedMateria) { if (draggedMateria) {
setMaterias(prev => prev.map(m => m.id === draggedMateria ? { ...m, ciclo, lineaCurricularId: lineaId } : m)); setMaterias((prev) =>
setDraggedMateria(null); prev.map((m) =>
m.id === draggedMateria
? { ...m, ciclo, lineaCurricularId: lineaId }
: m,
),
)
setDraggedMateria(null)
} }
}; }
// --- Estadísticas Generales --- const stats = useMemo(
const stats = materias.reduce((acc, m) => { () =>
if (m.ciclo !== null) { materias.reduce(
acc.cr += m.creditos || 0; acc.hd += m.hd || 0; acc.hi += m.hi || 0; (acc, m) => {
} if (m.ciclo !== null) {
return acc; acc.cr += m.creditos || 0
}, { cr: 0, hd: 0, hi: 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 ( return (
<div className="container mx-auto px-2 py-6"> <div className="container mx-auto px-2 py-6">
{/* Header */} {/* Header */}
<div className="flex justify-between items-center mb-6"> <div className="mb-6 flex items-center justify-between">
<div> <div>
<h2 className="text-xl font-bold">Mapa Curricular</h2> <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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{materias.filter(m => !m.ciclo).length > 0 && ( {materias.filter((m) => !m.ciclo).length > 0 && (
<Badge className="bg-amber-50 text-amber-600 border-amber-100 hover:bg-amber-50"> <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} materias sin asignar <AlertTriangle size={14} className="mr-1" />{' '}
{materias.filter((m) => !m.ciclo).length} sin asignar
</Badge> </Badge>
)} )}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button className="bg-teal-700 hover:bg-teal-800 text-white"> <Button className="bg-teal-700 text-white hover:bg-teal-800">
<Plus size={16} className="mr-2" /> Agregar <ChevronDown size={14} className="ml-2" /> <Plus size={16} className="mr-2" /> Agregar{' '}
<ChevronDown size={14} className="ml-2" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => agregarLinea("Nueva Línea")}>Nueva Línea Curricular</DropdownMenuItem> <DropdownMenuItem onClick={() => agregarLinea('Nueva Línea')}>
<DropdownMenuItem onClick={() => agregarLinea("Área Común")}>Agregar Área Común</DropdownMenuItem> Nueva Línea Curricular
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
{/* Barra Totales */} {/* 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 Créditos" value={stats.cr} total={320} />
<StatItem label="Total HD" value={stats.hd} /> <StatItem label="Total HD" value={stats.hd} />
<StatItem label="Total HI" value={stats.hi} /> <StatItem label="Total HI" value={stats.hi} />
<StatItem label="Total Horas" value={stats.hd + stats.hi} /> <StatItem label="Total Horas" value={stats.hd + stats.hi} />
</div> </div>
{/* Grid Principal */}
<div className="overflow-x-auto pb-6"> <div className="overflow-x-auto pb-6">
<div className="min-w-[1500px]"> <div className="min-w-[1500px]">
{/* Header Ciclos */} <div
<div className="grid gap-3 mb-4" style={{ gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px` }}> className="mb-4 grid gap-3"
<div className="text-xs font-bold text-slate-400 self-end px-2">LÍNEA CURRICULAR</div> style={{
{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>)} gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
<div className="text-xs font-bold text-slate-400 self-end text-center">SUBTOTAL</div> }}
>
<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> </div>
{/* Filas por Línea */} {lineas.map((linea, idx) => {
{lineasFinales.map((linea, idx) => { const sub = getSubtotalLinea(linea.id)
const sub = getSubtotalLinea(linea.id);
return ( return (
<div key={linea.id} className="grid gap-3 mb-3" style={{ gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px` }}> <div
<div className={`p-4 rounded-xl border-l-4 flex justify-between items-center ${lineColors[idx % lineColors.length]}`}> 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> <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> </div>
{ciclosArray.map(ciclo => ( {ciclosArray.map((ciclo) => (
<div <div
key={ciclo} key={ciclo}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, ciclo, linea.id)} 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"
> >
{materiasFinales.filter(m => m.ciclo === ciclo && m.lineaCurricularId === linea.id).map(m => ( {materias
<MateriaCardItem key={m.id} materia={m} isDragging={draggedMateria === m.id} onDragStart={handleDragStart} onClick={() => { setSelectedMateria(m); setIsEditModalOpen(true); }} /> .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>
))} ))}
<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 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>Cr: {sub.cr}</div>
<div>HD: {sub.hd}</div>
<div>HI: {sub.hi}</div>
</div> </div>
</div> </div>
) )
})} })}
{/* Fila Totales Ciclo */} <div
<div className="grid gap-3 mt-6 border-t pt-4" style={{ gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px` }}> className="mt-6 grid gap-3 border-t pt-4"
<div className="p-2 font-bold text-slate-600">Totales por Ciclo</div> style={{
{ciclosArray.map(ciclo => { gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
const t = getTotalesCiclo(ciclo); }}
>
<div className="p-2 font-bold text-slate-600">
Totales por Ciclo
</div>
{ciclosArray.map((ciclo) => {
const t = getTotalesCiclo(ciclo)
return ( 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 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>
) )
})} })}
<div className="bg-teal-50 rounded-lg p-2 text-center text-teal-800 font-bold text-xs flex flex-col justify-center"> <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>{stats.cr} Cr</div>
<div>{stats.hd + stats.hi} Hrs</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* Modal de Edición */} {/* Materias Sin Asignar */}
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}> {materias.filter((m) => m.ciclo === null).length > 0 && (
<DialogContent className="sm:max-w-[500px]"> <div className="mt-10 rounded-2xl border border-slate-200 bg-slate-50 p-6">
<DialogHeader><DialogTitle>Editar Materia</DialogTitle></DialogHeader> <div className="mb-4 flex items-center gap-2 text-amber-600">
{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} /> <AlertTriangle size={20} />
<h3 className="font-bold text-sm uppercase tracking-tight"> <h3 className="text-sm font-bold uppercase">
Materias pendientes de asignar ({materias.filter(m => m.ciclo === null).length}) Materias pendientes (
{materias.filter((m) => m.ciclo === null).length})
</h3> </h3>
</div> </div>
<div <div
className={`flex flex-wrap gap-4 min-h-[100px] p-4 rounded-xl border-2 border-dashed transition-all ${ className="flex min-h-[100px] flex-wrap gap-4 rounded-xl border-2 border-dashed bg-white/50 p-4"
draggedMateria ? 'border-amber-200 bg-amber-50/50' : 'border-slate-200 bg-white/50'
}`}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, null, null)} // null devuelve la materia al estado "sin asignar" onDrop={(e) => handleDrop(e, null, null)}
> >
{materias {materias
.filter(m => m.ciclo === null) .filter((m) => m.ciclo === null)
.map(m => ( .map((m) => (
<div key={m.id} className="w-[200px]"> <div key={m.id} className="w-[200px]">
<MateriaCardItem <MateriaCardItem
materia={m} materia={m}
isDragging={draggedMateria === m.id} isDragging={draggedMateria === m.id}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onClick={() => { setSelectedMateria(m); setIsEditModalOpen(true); }} onClick={() => {
setSelectedMateria(m)
setIsEditModalOpen(true)
}}
/> />
</div> </div>
))} ))}
</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>
)} )}
{/* 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="mt-4 flex justify-end gap-3">
<Button variant="outline" onClick={() => setIsEditModalOpen(false)}>
Cancelar
</Button>
<Button className="bg-teal-700 text-white">Guardar Cambios</Button>
</div>
</DialogContent>
</Dialog>
</div> </div>
) )
} }

View File

@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react' import { useState, useMemo } from 'react'
import type { Materia, LineaCurricular } from '@/types/plan'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -12,204 +12,263 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Plus,
Copy,
Search,
Filter,
ChevronRight,
BookOpen,
Loader2,
} from 'lucide-react'
import { usePlanAsignaturas, usePlanLineas } from '@/data'
export const Route = createFileRoute('/planes/$planId/_detalle/materias')({ // --- Configuración de Estilos ---
component: Materias, 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' },
type Materia = { aprobada: { label: 'Aprobada', className: 'bg-emerald-100 text-emerald-700' },
id: string;
clave: string
nombre: string
creditos: number
hd: number
hi: number
ciclo: string
linea: string
tipo: 'Obligatoria' | 'Optativa' | 'Troncal'
estado: 'Aprobada' | 'Revisada' | 'Borrador'
} }
const MATERIAS: Materia[] = [ const tipoConfig: Record<string, { label: string; className: string }> = {
{ obligatoria: { label: 'Obligatoria', className: 'bg-blue-100 text-blue-700' },
id: "1", optativa: { label: 'Optativa', className: 'bg-purple-100 text-purple-700' },
clave: 'MAT101', troncal: { label: 'Troncal', className: 'bg-slate-100 text-slate-700' },
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',
},
]
function Materias() { // --- Mapeadores de API ---
const [search, setSearch] = useState('') const mapAsignaturas = (asigApi: any[] = []): Materia[] => {
const [filtro, setFiltro] = useState<'Todas' | Materia['tipo']>('Todas') 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) => { export const Route = createFileRoute('/planes/$planId/_detalle/materias')({
const okFiltro = filtro === 'Todas' || m.tipo === filtro component: MateriasPage,
const okSearch = })
m.nombre.toLowerCase().includes(search.toLowerCase()) ||
m.clave.toLowerCase().includes(search.toLowerCase())
return okFiltro && okSearch function MateriasPage() {
}) const { planId } = Route.useParams()
const totalCreditos = materiasFiltradas.reduce( // 1. Fetch de datos reales
(acc, m) => acc + m.creditos, const { data: asignaturasApi, isLoading: loadingAsig } = usePlanAsignaturas(
0 '0e0aea4d-b8b4-4e75-8279-6224c3ac769f',
)
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(
'0e0aea4d-b8b4-4e75-8279-6224c3ac769f',
) )
// 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 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 ( return (
<div className="space-y-6"> <div className="container mx-auto space-y-6 px-6 py-6">
{/* Header */} {/* Header */}
<div className="flex justify-between items-start"> <div className="flex flex-wrap items-center justify-between gap-4">
<div> <div>
<h2 className="text-xl font-semibold">Materias del Plan</h2> <h2 className="text-foreground text-xl font-bold">
<p className="text-sm text-muted-foreground"> Materias del Plan
{materiasFiltradas.length} materias · {totalCreditos} créditos </h2>
<p className="text-muted-foreground mt-1 text-sm">
{materias.length} materias en total {filteredMaterias.length}{' '}
filtradas
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline">Clonar de mi Facultad</Button> <Button variant="outline" size="sm">
<Button variant="outline">Clonar de otra Facultad</Button> <Copy className="mr-2 h-4 w-4" /> Clonar
</Button>
<Button className="bg-emerald-700 hover:bg-emerald-800"> <Button className="bg-emerald-700 hover:bg-emerald-800">
+ Nueva Materia <Plus className="mr-2 h-4 w-4" /> Nueva Materia
</Button> </Button>
</div> </div>
</div> </div>
{/* Buscador y filtros */} {/* Barra de Filtros Avanzada */}
<div className="flex items-center gap-4"> <div className="flex flex-wrap items-center gap-3 rounded-xl border bg-slate-50 p-4">
<Input <div className="relative min-w-[240px] flex-1">
placeholder="Buscar por nombre o clave..." <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
value={search} <Input
onChange={(e) => setSearch(e.target.value)} placeholder="Buscar por nombre o clave..."
className="w-64" value={searchTerm}
/> onChange={(e) => setSearchTerm(e.target.value)}
className="bg-white pl-9"
/>
</div>
<div className="flex gap-2"> <div className="flex flex-wrap items-center gap-2">
{['Todas', 'Obligatoria', 'Optativa', 'Troncal'].map((t) => ( <Filter className="text-muted-foreground mr-1 h-4 w-4" />
<Button
key={t} <Select value={filterTipo} onValueChange={setFilterTipo}>
variant={filtro === t ? 'secondary' : 'ghost'} <SelectTrigger className="w-[140px] bg-white">
size="sm" <SelectValue placeholder="Tipo" />
onClick={() => setFiltro(t as any)} </SelectTrigger>
> <SelectContent>
{t === 'Obligatoria' ? 'Obligatorias' : t} <SelectItem value="all">Todos los tipos</SelectItem>
</Button> <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>
</div> </div>
{/* Tabla */} {/* Tabla Pro */}
<div className="rounded-md border"> <div className="overflow-hidden rounded-xl border bg-white shadow-sm">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow className="bg-slate-50/50">
<TableHead>Clave</TableHead> <TableHead className="w-[120px]">Clave</TableHead>
<TableHead>Nombre</TableHead> <TableHead>Nombre</TableHead>
<TableHead className="text-center">Créditos</TableHead> <TableHead className="text-center">Créditos</TableHead>
<TableHead className="text-center">HD</TableHead> <TableHead className="text-center">Ciclo</TableHead>
<TableHead className="text-center">HI</TableHead> <TableHead>Línea Curricular</TableHead>
<TableHead>Ciclo</TableHead>
<TableHead>Línea</TableHead>
<TableHead>Tipo</TableHead> <TableHead>Tipo</TableHead>
<TableHead>Estado</TableHead> <TableHead>Estado</TableHead>
<TableHead className="text-center">Acciones</TableHead> <TableHead className="w-[50px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{materiasFiltradas.map((m) => ( {filteredMaterias.length === 0 ? (
<TableRow key={m.clave}>
<TableCell className="text-muted-foreground">
{m.clave}
</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'
}
>
{m.estado}
</Badge>
</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="icon">
</Button>
</TableCell>
</TableRow>
))}
{materiasFiltradas.length === 0 && (
<TableRow> <TableRow>
<TableCell <TableCell colSpan={8} className="h-40 text-center">
colSpan={10} <div className="text-muted-foreground flex flex-col items-center justify-center">
className="text-center py-6 text-muted-foreground" <BookOpen className="mb-2 h-10 w-10 opacity-20" />
> <p className="font-medium">No se encontraron materias</p>
No se encontraron materias <p className="text-xs">
Intenta cambiar los filtros de búsqueda
</p>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : (
filteredMaterias.map((materia) => (
<TableRow
key={materia.id}
className="group cursor-pointer transition-colors hover:bg-slate-50/80"
>
<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> </TableBody>
</Table> </Table>