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:
@@ -1,123 +1,117 @@
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
ArrowLeft,
|
||||
GraduationCap,
|
||||
Edit2, Save,
|
||||
Pencil
|
||||
} from 'lucide-react'
|
||||
import { ArrowLeft, GraduationCap, Edit2, Save, Pencil } from 'lucide-react'
|
||||
import { ContenidoTematico } from './ContenidoTematico'
|
||||
import { BibliographyItem } from './BibliographyItem'
|
||||
import { IAMateriaTab } from './IAMateriaTab'
|
||||
import type {
|
||||
import type {
|
||||
CampoEstructura,
|
||||
IAMessage,
|
||||
IASugerencia,
|
||||
UnidadTematica,
|
||||
} from '@/types/materia';
|
||||
IAMessage,
|
||||
IASugerencia,
|
||||
UnidadTematica,
|
||||
} from '@/types/materia'
|
||||
import {
|
||||
mockMateria,
|
||||
mockEstructura,
|
||||
mockDocumentoSep,
|
||||
mockHistorial
|
||||
} from '@/data/mockMateriaData';
|
||||
mockHistorial,
|
||||
} from '@/data/mockMateriaData'
|
||||
import { DocumentoSEPTab } from './DocumentoSEPTab'
|
||||
import { HistorialTab } from './HistorialTab'
|
||||
import { useSubject } from '@/data/hooks/useSubjects'
|
||||
|
||||
export interface BibliografiaEntry {
|
||||
id: string;
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA';
|
||||
cita: string;
|
||||
fuenteBibliotecaId?: string;
|
||||
fuenteBiblioteca?: any;
|
||||
id: string
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA'
|
||||
cita: string
|
||||
fuenteBibliotecaId?: string
|
||||
fuenteBiblioteca?: any
|
||||
}
|
||||
export interface BibliografiaTabProps {
|
||||
bibliografia: BibliografiaEntry[];
|
||||
onSave: (bibliografia: BibliografiaEntry[]) => void;
|
||||
isSaving: boolean;
|
||||
bibliografia: BibliografiaEntry[]
|
||||
onSave: (bibliografia: BibliografiaEntry[]) => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export default function MateriaDetailPage() {
|
||||
|
||||
// 1. Asegúrate de tener estos estados en tu componente principal
|
||||
const [messages, setMessages] = useState<IAMessage[]>([]);
|
||||
const [datosGenerales, setDatosGenerales] = useState({});
|
||||
const [campos, setCampos] = useState<CampoEstructura[]>([]);
|
||||
const [messages, setMessages] = useState<IAMessage[]>([])
|
||||
const [datosGenerales, setDatosGenerales] = useState({})
|
||||
const [campos, setCampos] = useState<CampoEstructura[]>([])
|
||||
|
||||
// 2. Funciones de manejo para la IA
|
||||
const handleSendMessage = (text: string, campoId?: string) => {
|
||||
const newMessage: IAMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date(),
|
||||
campoAfectado: campoId
|
||||
};
|
||||
setMessages([...messages, newMessage]);
|
||||
|
||||
// Aquí llamarías a tu API de OpenAI/Claude
|
||||
//toast.info("Enviando consulta a la IA...");
|
||||
};
|
||||
// 2. Funciones de manejo para la IA
|
||||
const handleSendMessage = (text: string, campoId?: string) => {
|
||||
const newMessage: IAMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date(),
|
||||
campoAfectado: campoId,
|
||||
}
|
||||
setMessages([...messages, newMessage])
|
||||
|
||||
const handleAcceptSuggestion = (sugerencia: IASugerencia) => {
|
||||
// Lógica para actualizar el valor del campo en tu estado de datosGenerales
|
||||
//toast.success(`Sugerencia aplicada a ${sugerencia.campoNombre}`);
|
||||
};
|
||||
// Aquí llamarías a tu API de OpenAI/Claude
|
||||
//toast.info("Enviando consulta a la IA...");
|
||||
}
|
||||
|
||||
const handleAcceptSuggestion = (sugerencia: IASugerencia) => {
|
||||
// Lógica para actualizar el valor del campo en tu estado de datosGenerales
|
||||
//toast.success(`Sugerencia aplicada a ${sugerencia.campoNombre}`);
|
||||
}
|
||||
|
||||
// Dentro de tu componente principal (donde están los Tabs)
|
||||
const [bibliografia, setBibliografia] = useState<BibliografiaEntry[]>([
|
||||
{
|
||||
id: '1',
|
||||
tipo: 'BASICA',
|
||||
cita: 'Russell, S., & Norvig, P. (2020). Artificial Intelligence: A Modern Approach. Pearson.'
|
||||
}
|
||||
]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [bibliografia, setBibliografia] = useState<BibliografiaEntry[]>([
|
||||
{
|
||||
id: '1',
|
||||
tipo: 'BASICA',
|
||||
cita: 'Russell, S., & Norvig, P. (2020). Artificial Intelligence: A Modern Approach. Pearson.',
|
||||
},
|
||||
])
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const handleSaveBibliografia = (data: BibliografiaEntry[]) => {
|
||||
setIsSaving(true);
|
||||
// Aquí iría tu llamada a la API
|
||||
setBibliografia(data);
|
||||
|
||||
// Simulamos un guardado
|
||||
setTimeout(() => {
|
||||
setIsSaving(false);
|
||||
//toast.success("Cambios guardados");
|
||||
}, 1000);
|
||||
};
|
||||
const handleSaveBibliografia = (data: BibliografiaEntry[]) => {
|
||||
setIsSaving(true)
|
||||
// Aquí iría tu llamada a la API
|
||||
setBibliografia(data)
|
||||
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
|
||||
const handleRegenerateDocument = useCallback(() => {
|
||||
setIsRegenerating(true);
|
||||
// Simulamos un guardado
|
||||
setTimeout(() => {
|
||||
setIsRegenerating(false);
|
||||
}, 2000);
|
||||
}, []);
|
||||
setIsSaving(false)
|
||||
//toast.success("Cambios guardados");
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const [isRegenerating, setIsRegenerating] = useState(false)
|
||||
|
||||
const handleRegenerateDocument = useCallback(() => {
|
||||
setIsRegenerating(true)
|
||||
setTimeout(() => {
|
||||
setIsRegenerating(false)
|
||||
}, 2000)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* ================= HEADER ================= */}
|
||||
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
|
||||
<div className="max-w-7xl mx-auto px-6 py-10">
|
||||
<div className="mx-auto max-w-7xl px-6 py-10">
|
||||
<Link
|
||||
to="/planes"
|
||||
className="flex items-center gap-2 text-sm text-blue-200 hover:text-white mb-4"
|
||||
className="mb-4 flex items-center gap-2 text-sm text-blue-200 hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Volver al plan
|
||||
</Link>
|
||||
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="space-y-3">
|
||||
<Badge className="bg-blue-900/50 border border-blue-700">
|
||||
<Badge className="border border-blue-700 bg-blue-900/50">
|
||||
IA-401
|
||||
</Badge>
|
||||
|
||||
@@ -127,7 +121,7 @@ const handleRegenerateDocument = useCallback(() => {
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
|
||||
<span className="flex items-center gap-1">
|
||||
<GraduationCap className="w-4 h-4" />
|
||||
<GraduationCap className="h-4 w-4" />
|
||||
Ingeniería en Sistemas Computacionales
|
||||
</span>
|
||||
|
||||
@@ -136,13 +130,13 @@ const handleRegenerateDocument = useCallback(() => {
|
||||
|
||||
<p className="text-sm text-blue-300">
|
||||
Pertenece al plan:{' '}
|
||||
<span className="underline cursor-pointer">
|
||||
<span className="cursor-pointer underline">
|
||||
Licenciatura en Ingeniería en Sistemas Computacionales 2024
|
||||
</span>
|
||||
</p>
|
||||
</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">7° semestre</Badge>
|
||||
<Badge variant="secondary">Sistemas Inteligentes</Badge>
|
||||
@@ -152,10 +146,10 @@ const handleRegenerateDocument = useCallback(() => {
|
||||
</section>
|
||||
|
||||
{/* ================= TABS ================= */}
|
||||
<section className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<section className="border-b bg-white">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<Tabs defaultValue="datos">
|
||||
<TabsList className="h-auto bg-transparent p-0 gap-6">
|
||||
<TabsList className="h-auto gap-6 bg-transparent p-0">
|
||||
<TabsTrigger value="datos">Datos generales</TabsTrigger>
|
||||
<TabsTrigger value="contenido">Contenido temático</TabsTrigger>
|
||||
<TabsTrigger value="bibliografia">Bibliografía</TabsTrigger>
|
||||
@@ -176,22 +170,27 @@ const handleRegenerateDocument = useCallback(() => {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bibliografia">
|
||||
<BibliographyItem
|
||||
bibliografia={bibliografia}
|
||||
onSave={handleSaveBibliografia}
|
||||
isSaving={isSaving}
|
||||
<BibliographyItem
|
||||
bibliografia={bibliografia}
|
||||
onSave={handleSaveBibliografia}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ia">
|
||||
<IAMateriaTab
|
||||
campos={campos}
|
||||
datosGenerales={datosGenerales}
|
||||
messages={messages}
|
||||
onSendMessage={handleSendMessage}
|
||||
onAcceptSuggestion={handleAcceptSuggestion}
|
||||
onRejectSuggestion={(id) => console.log("Rechazada") /*toast.error("Sugerencia rechazada")*/}
|
||||
/>
|
||||
<IAMateriaTab
|
||||
campos={campos}
|
||||
datosGenerales={datosGenerales}
|
||||
messages={messages}
|
||||
onSendMessage={handleSendMessage}
|
||||
onAcceptSuggestion={handleAcceptSuggestion}
|
||||
onRejectSuggestion={
|
||||
(id) =>
|
||||
console.log(
|
||||
'Rechazada',
|
||||
) /*toast.error("Sugerencia rechazada")*/
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sep">
|
||||
@@ -206,7 +205,7 @@ const handleRegenerateDocument = useCallback(() => {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="historial">
|
||||
<HistorialTab historial={mockHistorial} />
|
||||
<HistorialTab historial={mockHistorial} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -218,79 +217,93 @@ const handleRegenerateDocument = useCallback(() => {
|
||||
/* ================= TAB CONTENT ================= */
|
||||
|
||||
function DatosGenerales() {
|
||||
const { data: asignaturasApi, isLoading: loadingAsig } = useSubject(
|
||||
/*planId*/ '9d4dda6a-488f-428a-8a07-38081592a641',
|
||||
)
|
||||
|
||||
console.log(asignaturasApi.datos)
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-8 px-4 space-y-8 animate-in fade-in duration-500">
|
||||
|
||||
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
|
||||
{/* Encabezado de la Sección */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b pb-6">
|
||||
<div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">Datos Generales</h2>
|
||||
<p className="text-slate-500 mt-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||
Datos Generales
|
||||
</h2>
|
||||
<p className="mt-1 text-slate-500">
|
||||
Información oficial estructurada bajo los lineamientos de la SEP.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Edit2 className="w-4 h-4" /> Editar borrador
|
||||
<Edit2 className="h-4 w-4" /> Editar borrador
|
||||
</Button>
|
||||
<Button size="sm" className="gap-2 bg-blue-600 hover:bg-blue-700">
|
||||
<Save className="w-4 h-4" /> Guardar cambios
|
||||
<Save className="h-4 w-4" /> Guardar cambios
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid de Información */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{/* Columna Principal (Más ancha) */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<InfoCard
|
||||
title="Competencias a Desarrollar"
|
||||
subtitle="Competencias profesionales que se desarrollarán"
|
||||
isList={true}
|
||||
initialContent={`• Diseñar algoritmos de machine learning para clasificación y predicción\n• Implementar redes neuronales profundas para procesamiento de imágenes\n• Evaluar y optimizar modelos de IA considerando métricas`}
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
title="Objetivo General"
|
||||
initialContent="Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<InfoCard
|
||||
title="Justificación"
|
||||
initialContent="La inteligencia artificial es una de las tecnologías más disruptivas..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
<InfoCard
|
||||
title="Competencias a Desarrollar"
|
||||
subtitle="Competencias profesionales que se desarrollarán"
|
||||
isList={true}
|
||||
initialContent={`• Diseñar algoritmos de machine learning para clasificación y predicción\n• Implementar redes neuronales profundas para procesamiento de imágenes\n• Evaluar y optimizar modelos de IA considerando métricas`}
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
title="Objetivo General"
|
||||
initialContent="Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<InfoCard
|
||||
title="Justificación"
|
||||
initialContent="La inteligencia artificial es una de las tecnologías más disruptivas..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Columna Lateral (Información Secundaria) */}
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
{/* Tarjeta de Requisitos */}
|
||||
<InfoCard
|
||||
title="Requisitos y Seriación"
|
||||
type="requirements"
|
||||
initialContent={[
|
||||
{ type: "Pre-requisito", code: "PA-301", name: "Programación Avanzada" },
|
||||
{ type: "Co-requisito", code: "MAT-201", name: "Matemáticas Discretas" }
|
||||
]}
|
||||
/>
|
||||
{/* Tarjeta de Requisitos */}
|
||||
<InfoCard
|
||||
title="Requisitos y Seriación"
|
||||
type="requirements"
|
||||
initialContent={[
|
||||
{
|
||||
type: 'Pre-requisito',
|
||||
code: 'PA-301',
|
||||
name: 'Programación Avanzada',
|
||||
},
|
||||
{
|
||||
type: 'Co-requisito',
|
||||
code: 'MAT-201',
|
||||
name: 'Matemáticas Discretas',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Tarjeta de Evaluación */}
|
||||
<InfoCard
|
||||
title="Sistema de Evaluación"
|
||||
type="evaluation"
|
||||
initialContent={[
|
||||
{ label: "Exámenes parciales", value: "30%" },
|
||||
{ label: "Proyecto integrador", value: "35%" },
|
||||
{ label: "Prácticas de laboratorio", value: "20%" },
|
||||
{ label: "Participación", value: "15%" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{/* Tarjeta de Evaluación */}
|
||||
<InfoCard
|
||||
title="Sistema de Evaluación"
|
||||
type="evaluation"
|
||||
initialContent={[
|
||||
{ label: 'Exámenes parciales', value: '30%' },
|
||||
{ label: 'Proyecto integrador', value: '35%' },
|
||||
{ label: 'Prácticas de laboratorio', value: '20%' },
|
||||
{ label: 'Participación', value: '15%' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,9 +311,9 @@ function DatosGenerales() {
|
||||
}
|
||||
|
||||
interface InfoCardProps {
|
||||
title: string,
|
||||
title: string
|
||||
subtitle?: string
|
||||
isList?:boolean
|
||||
isList?: boolean
|
||||
initialContent: any // Puede ser string o array de objetos
|
||||
type?: 'text' | 'list' | 'requirements' | 'evaluation'
|
||||
}
|
||||
@@ -310,23 +323,30 @@ function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) {
|
||||
const [data, setData] = useState(initialContent)
|
||||
// Estado temporal para el área de texto (siempre editamos como texto por simplicidad)
|
||||
const [tempText, setTempText] = useState(
|
||||
type === 'text' || type === 'list'
|
||||
? initialContent
|
||||
: JSON.stringify(initialContent, null, 2) // O un formato legible
|
||||
type === 'text' || type === 'list'
|
||||
? initialContent
|
||||
: JSON.stringify(initialContent, null, 2), // O un formato legible
|
||||
)
|
||||
|
||||
const handleSave = () => {
|
||||
// Aquí podrías parsear el texto de vuelta si es necesario
|
||||
setData(tempText)
|
||||
setData(tempText)
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="transition-all hover:border-slate-300">
|
||||
<CardHeader className="pb-3 flex flex-row items-start justify-between space-y-0">
|
||||
<CardTitle className="text-sm font-bold text-slate-700">{title}</CardTitle>
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-bold text-slate-700">
|
||||
{title}
|
||||
</CardTitle>
|
||||
{!isEditing && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={() => setIsEditing(true)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
@@ -335,14 +355,22 @@ function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) {
|
||||
<CardContent>
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={tempText}
|
||||
<Textarea
|
||||
value={tempText}
|
||||
onChange={(e) => setTempText(e.target.value)}
|
||||
className="text-xs min-h-[100px]"
|
||||
className="min-h-[100px] text-xs"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)}>Cancelar</Button>
|
||||
<Button size="sm" className="bg-[#00a878]" onClick={handleSave}>Guardar</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button size="sm" className="bg-[#00a878]" onClick={handleSave}>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -362,9 +390,16 @@ function RequirementsView({ items }: { items: any[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map((req, i) => (
|
||||
<div key={i} className="p-3 bg-slate-50 rounded-lg border border-slate-100">
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-tight">{req.type}</p>
|
||||
<p className="text-sm font-medium text-slate-700">{req.code} {req.name}</p>
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border border-slate-100 bg-slate-50 p-3"
|
||||
>
|
||||
<p className="text-[10px] font-bold tracking-tight text-slate-400 uppercase">
|
||||
{req.type}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
{req.code} {req.name}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -376,7 +411,10 @@ function EvaluationView({ items }: { items: any[] }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex justify-between text-sm border-b border-slate-50 pb-1.5 italic">
|
||||
<div
|
||||
key={i}
|
||||
className="flex justify-between border-b border-slate-50 pb-1.5 text-sm italic"
|
||||
>
|
||||
<span className="text-slate-500">{item.label}</span>
|
||||
<span className="font-bold text-blue-600">{item.value}</span>
|
||||
</div>
|
||||
@@ -385,11 +423,9 @@ function EvaluationView({ items }: { items: any[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
function EmptyTab({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
<div className="text-muted-foreground py-16 text-center">
|
||||
{title} (pendiente)
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { usePlan } from '@/data';
|
||||
import { usePlan } from '@/data'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { DatosGeneralesField } from '@/types/plan'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Pencil,
|
||||
Check,
|
||||
X,
|
||||
Sparkles,
|
||||
AlertCircle
|
||||
} from 'lucide-react'
|
||||
import { 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')({
|
||||
@@ -18,39 +12,38 @@ export const Route = createFileRoute('/planes/$planId/_detalle/datos')({
|
||||
})
|
||||
|
||||
const formatLabel = (key: string) => {
|
||||
const result = key.replace(/_/g, ' ');
|
||||
return result.charAt(0).toUpperCase() + result.slice(1);
|
||||
};
|
||||
const result = key.replace(/_/g, ' ')
|
||||
return result.charAt(0).toUpperCase() + result.slice(1)
|
||||
}
|
||||
|
||||
function DatosGeneralesPage() {
|
||||
const { data, isFetching } = 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('');
|
||||
|
||||
|
||||
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('')
|
||||
|
||||
// Efecto para transformar data?.datos en el arreglo de campos
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
// Si data es directamente el objeto que mostraste, usamos data.
|
||||
// Si viene dentro de .datos, usamos data.datos.
|
||||
const sourceData = data?.datos;
|
||||
// 2. Validación de seguridad para sourceData
|
||||
const sourceData = data?.datos
|
||||
|
||||
const datosTransformados: DatosGeneralesField[] = Object.entries(sourceData).map(
|
||||
([key, value], index) => ({
|
||||
id: (index + 1).toString(), // Id basado en index (1, 2, 3...)
|
||||
label: formatLabel(key), // "perfil_de_ingreso" -> "Perfil de ingreso"
|
||||
value: value?.toString() || '', // Manejo de nulls
|
||||
requerido: true,
|
||||
tipo: 'texto' // Todos como texto según tu instrucción
|
||||
})
|
||||
);
|
||||
if (sourceData && typeof sourceData === 'object') {
|
||||
const datosTransformados: DatosGeneralesField[] = Object.entries(
|
||||
sourceData,
|
||||
).map(([key, value], index) => ({
|
||||
id: (index + 1).toString(),
|
||||
label: formatLabel(key),
|
||||
// Forzamos el valor a string de forma segura
|
||||
value: typeof value === 'string' ? value : value?.toString() || '',
|
||||
requerido: true,
|
||||
tipo: 'texto',
|
||||
}))
|
||||
|
||||
setCampos(datosTransformados);
|
||||
setCampos(datosTransformados)
|
||||
}
|
||||
}, [data]);
|
||||
}, [data])
|
||||
|
||||
// 3. Manejadores de acciones (Ahora como funciones locales)
|
||||
const handleEdit = (campo: DatosGeneralesField) => {
|
||||
@@ -65,9 +58,9 @@ function DatosGeneralesPage() {
|
||||
|
||||
const handleSave = (id: string) => {
|
||||
// Actualizamos el estado local de la lista
|
||||
setCampos(prev => prev.map(c =>
|
||||
c.id === id ? { ...c, value: editValue } : c
|
||||
))
|
||||
setCampos((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, value: editValue } : c)),
|
||||
)
|
||||
setEditingId(null)
|
||||
setEditValue('')
|
||||
//toast.success('Cambios guardados localmente')
|
||||
@@ -79,40 +72,56 @@ function DatosGeneralesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 animate-in fade-in duration-500">
|
||||
<div className="animate-in fade-in container mx-auto px-6 py-6 duration-500">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
Datos Generales del Plan
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Información estructural y descriptiva del plan de estudios
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{campos.map((campo) => {
|
||||
const isEditing = editingId === campo.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={campo.id}
|
||||
className={`border rounded-xl transition-all ${
|
||||
isEditing ? 'border-teal-500 ring-2 ring-teal-50 shadow-lg' : 'bg-white hover:shadow-md'
|
||||
className={`rounded-xl border transition-all ${
|
||||
isEditing
|
||||
? 'border-teal-500 shadow-lg ring-2 ring-teal-50'
|
||||
: 'bg-white hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
{/* Header de la Card */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b bg-slate-50/50">
|
||||
<div className="flex items-center justify-between border-b bg-slate-50/50 px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm text-slate-700">{campo.label}</h3>
|
||||
{campo.requerido && <span className="text-red-500 text-xs">*</span>}
|
||||
<h3 className="text-sm font-medium text-slate-700">
|
||||
{campo.label}
|
||||
</h3>
|
||||
{campo.requerido && (
|
||||
<span className="text-xs text-red-500">*</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-teal-600" onClick={() => handleIARequest(campo.id)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-teal-600"
|
||||
onClick={() => handleIARequest(campo.id)}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleEdit(campo)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleEdit(campo)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -129,10 +138,18 @@ function DatosGeneralesPage() {
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleCancel}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<X size={14} className="mr-1" /> Cancelar
|
||||
</Button>
|
||||
<Button size="sm" className="bg-teal-600 hover:bg-teal-700" onClick={() => handleSave(campo.id)}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-teal-600 hover:bg-teal-700"
|
||||
onClick={() => handleSave(campo.id)}
|
||||
>
|
||||
<Check size={14} className="mr-1" /> Guardar
|
||||
</Button>
|
||||
</div>
|
||||
@@ -140,12 +157,12 @@ function DatosGeneralesPage() {
|
||||
) : (
|
||||
<div className="min-h-[100px]">
|
||||
{campo.value ? (
|
||||
<div className="text-sm text-slate-600 leading-relaxed">
|
||||
<div className="text-sm leading-relaxed text-slate-600">
|
||||
{campo.tipo === 'lista' ? (
|
||||
<ul className="space-y-1">
|
||||
{campo.value.split('\n').map((item, i) => (
|
||||
<li key={i} className="flex gap-2">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-teal-500 shrink-0" />
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-teal-500" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
@@ -155,7 +172,7 @@ function DatosGeneralesPage() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<AlertCircle size={14} />
|
||||
<span>Sin contenido.</span>
|
||||
</div>
|
||||
@@ -169,4 +186,4 @@ function DatosGeneralesPage() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,118 +1,271 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Sparkles, Send, Paperclip, Target, UserCheck, Lightbulb, FileText } from "lucide-react"
|
||||
import { useState } from 'react' // Importamos useState
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import {
|
||||
Sparkles,
|
||||
Send,
|
||||
Paperclip,
|
||||
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')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
// 1. Estado para el texto del input
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
// 2. Estado para la lista de mensajes (iniciamos con los de la imagen)
|
||||
const [messages, setMessages] = useState([
|
||||
{ id: 1, role: 'ai', text: 'Hola, soy tu asistente de IA para el diseño del plan de estudios...' },
|
||||
{ id: 2, role: 'user', text: 'jkasakj' },
|
||||
{ id: 3, role: 'ai', text: 'Entendido. Estoy procesando tu solicitud.' },
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: '¡Hola! Soy tu asistente de IA. ¿En qué puedo ayudarte hoy?',
|
||||
},
|
||||
])
|
||||
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 handleSend = () => {
|
||||
if (!inputValue.trim()) return
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Agregamos el mensaje del usuario
|
||||
const newMessage = {
|
||||
id: Date.now(),
|
||||
role: 'user',
|
||||
text: inputValue
|
||||
// Función de scroll corregida para Radix
|
||||
const scrollToBottom = () => {
|
||||
const viewport = scrollRef.current?.querySelector(
|
||||
'[data-radix-scroll-area-viewport]',
|
||||
)
|
||||
if (viewport) {
|
||||
viewport.scrollTo({ top: viewport.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
setMessages([...messages, newMessage])
|
||||
setInputValue('') // Limpiamos el input
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<div className="flex h-[calc(100vh-200px)] gap-6 p-4">
|
||||
<div className="flex flex-col flex-1 bg-slate-50/50 rounded-xl border relative overflow-hidden">
|
||||
|
||||
<ScrollArea className="flex-1 p-6">
|
||||
<div className="space-y-6 max-w-3xl mx-auto">
|
||||
{/* 4. Mapeamos los mensajes dinámicamente */}
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} gap-3`}>
|
||||
{msg.role === 'ai' && (
|
||||
<Avatar className="h-8 w-8 border bg-teal-50">
|
||||
<AvatarFallback className="text-teal-600"><Sparkles size={16}/></AvatarFallback>
|
||||
/* CAMBIO CLAVE 1:
|
||||
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>
|
||||
|
||||
{/* CAMBIO CLAVE 2:
|
||||
El ScrollArea debe tener 'flex-1' y 'h-full'.
|
||||
Esto obliga al componente a colapsar su altura y activar el scroll.
|
||||
*/}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<ScrollArea ref={scrollRef} className="h-full w-full">
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`}
|
||||
>
|
||||
<Avatar
|
||||
className={`h-8 w-8 shrink-0 border ${msg.role === 'assistant' ? 'bg-teal-50' : 'bg-slate-200'}`}
|
||||
>
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{msg.role === 'assistant' ? (
|
||||
<Sparkles size={14} className="text-teal-600" />
|
||||
) : (
|
||||
<UserCheck size={14} />
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<div className={msg.role === 'ai' ? 'space-y-2' : ''}>
|
||||
{msg.role === 'ai' && <p className="text-xs font-bold text-teal-700 uppercase tracking-wider">Asistente IA</p>}
|
||||
<div className={`p-4 rounded-2xl text-sm shadow-sm ${
|
||||
msg.role === 'user'
|
||||
? 'bg-teal-600 text-white rounded-tr-none'
|
||||
: 'bg-white border text-slate-700 rounded-tl-none'
|
||||
}`}>
|
||||
{msg.text}
|
||||
<div
|
||||
className={`flex max-w-[85%] flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}
|
||||
>
|
||||
{msg.role === 'assistant' && (
|
||||
<span className="mb-1 ml-1 text-[9px] font-bold text-teal-700 uppercase">
|
||||
Asistente IA
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
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>
|
||||
</ScrollArea>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex gap-2 p-4">
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.2s]" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.4s]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 5. Input vinculado al estado */}
|
||||
<div className="p-4 bg-white border-t">
|
||||
<div className="max-w-4xl mx-auto flex gap-2 items-center bg-slate-50 border rounded-lg px-3 py-1 shadow-sm focus-within:ring-1 focus-within:ring-teal-500 transition-all">
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()} // Enviar con Enter
|
||||
className="border-none bg-transparent focus-visible:ring-0 text-sm"
|
||||
placeholder='Escribe tu solicitud... Usa ":" para mencionar campos'
|
||||
/>
|
||||
<Button variant="ghost" size="icon" className="text-slate-400">
|
||||
<Paperclip size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
size="icon"
|
||||
className="bg-teal-600 hover:bg-teal-700 h-8 w-8"
|
||||
>
|
||||
<Send size={16} />
|
||||
</Button>
|
||||
{/* Barra de aplicación flotante (dentro del contenedor relativo del scroll) */}
|
||||
{pendingSuggestion && !isLoading && (
|
||||
<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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPendingSuggestion(null)}
|
||||
className="h-8 rounded-full text-xs"
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" /> Descartar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {}}
|
||||
className="h-8 rounded-full bg-teal-600 text-xs text-white hover:bg-teal-700"
|
||||
>
|
||||
<Check className="mr-1 h-3 w-3" /> Aplicar cambios
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* INPUT FIJO AL FONDO */}
|
||||
<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>
|
||||
|
||||
{/* Panel lateral (se mantiene igual) */}
|
||||
<div className="w-72 space-y-4">
|
||||
<div className="flex items-center gap-2 text-orange-500 font-semibold text-sm mb-4">
|
||||
<Lightbulb size={18} />
|
||||
Acciones rápidas
|
||||
</div>
|
||||
{/* PANEL LATERAL */}
|
||||
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
|
||||
<h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800">
|
||||
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<ActionButton icon={<Target className="text-teal-500" size={18} />} text="Mejorar objetivo general" />
|
||||
<ActionButton icon={<UserCheck className="text-slate-500" size={18} />} text="Redactar perfil de egreso" />
|
||||
<ActionButton icon={<Lightbulb className="text-blue-500" size={18} />} text="Sugerir competencias" />
|
||||
<ActionButton icon={<FileText className="text-teal-500" size={18} />} text="Justificar pertinencia" />
|
||||
{PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => handleSend(preset.prompt)}
|
||||
className="group flex w-full items-center gap-3 rounded-xl border bg-white p-3 text-left text-sm shadow-sm transition-all hover:border-teal-500 hover:bg-teal-50"
|
||||
>
|
||||
<div className="rounded-lg bg-slate-100 p-2 text-slate-500 group-hover:bg-teal-100 group-hover:text-teal-600">
|
||||
<preset.icon size={16} />
|
||||
</div>
|
||||
<span className="leading-tight font-medium text-slate-700">
|
||||
{preset.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionButton({ icon, text }: { icon: React.ReactNode, text: string }) {
|
||||
return (
|
||||
<Button variant="outline" className="w-full justify-start gap-3 h-auto py-3 px-4 text-sm font-normal hover:bg-slate-50 border-slate-200 shadow-sm text-slate-700">
|
||||
{icon}
|
||||
{text}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
function generateMockResponse(prompt: string) {
|
||||
return 'Mock response content...'
|
||||
}
|
||||
|
||||
@@ -1,129 +1,44 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo, useState, useEffect } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Plus,
|
||||
ChevronDown,
|
||||
AlertTriangle,
|
||||
import {
|
||||
Plus,
|
||||
ChevronDown,
|
||||
AlertTriangle,
|
||||
GripVertical,
|
||||
Trash2
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import type { Materia, LineaCurricular } from '@/types/plan'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { usePlanAsignaturas, usePlanLineas } from '@/data';
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { usePlanAsignaturas, usePlanLineas } from '@/data'
|
||||
|
||||
|
||||
export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({
|
||||
component: MapaCurricularPage,
|
||||
})
|
||||
|
||||
|
||||
|
||||
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) => ({
|
||||
// --- Mapeadores (Fuera del componente para mayor limpieza) ---
|
||||
const mapLineasToLineaCurricular = (
|
||||
lineasApi: any[] = [],
|
||||
): LineaCurricular[] => {
|
||||
return lineasApi.map((linea) => ({
|
||||
id: linea.id,
|
||||
nombre: linea.nombre,
|
||||
orden: linea.orden ?? 0,
|
||||
color: '#1976d2', // default aceptado
|
||||
}));
|
||||
};
|
||||
color: '#1976d2',
|
||||
}))
|
||||
}
|
||||
|
||||
const mapAsignaturasToMaterias = (asigApi = []): Materia[] => {
|
||||
return asigApi.map((asig: any) => ({
|
||||
const mapAsignaturasToMaterias = (asigApi: any[] = []): Materia[] => {
|
||||
return asigApi.map((asig) => ({
|
||||
id: asig.id,
|
||||
clave: asig.codigo,
|
||||
nombre: asig.nombre,
|
||||
@@ -131,222 +46,449 @@ const mapAsignaturasToMaterias = (asigApi = []): Materia[] => {
|
||||
ciclo: asig.numero_ciclo ?? null,
|
||||
lineaCurricularId: asig.linea_plan_id ?? null,
|
||||
tipo: asig.tipo === 'OBLIGATORIA' ? 'obligatoria' : 'optativa',
|
||||
estado: 'borrador', // default válido
|
||||
estado: 'borrador',
|
||||
orden: asig.orden_celda ?? 0,
|
||||
hd: Math.floor((asig.horas_semana ?? 0) / 2),
|
||||
hi: Math.ceil((asig.horas_semana ?? 0) / 2),
|
||||
}));
|
||||
};
|
||||
}))
|
||||
}
|
||||
|
||||
const lineasFinales: LineaCurricular[] = useMemo(() => {
|
||||
return [
|
||||
...INITIAL_LINEAS,
|
||||
...mapLineasToLineaCurricular(lineas2),
|
||||
];
|
||||
}, [lineas2]);
|
||||
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 materiasFinales: Materia[] = useMemo(() => {
|
||||
return [
|
||||
...INITIAL_MATERIAS,
|
||||
...mapAsignaturasToMaterias(asignaturas),
|
||||
];
|
||||
}, [asignaturas]);
|
||||
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 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 ---
|
||||
const agregarLinea = (nombre: string) => {
|
||||
const nueva = { id: crypto.randomUUID(), nombre, orden: lineas.length + 1 };
|
||||
setLineas([...lineas, nueva]);
|
||||
};
|
||||
const nueva = { id: crypto.randomUUID(), nombre, orden: lineas.length + 1 }
|
||||
setLineas([...lineas, nueva])
|
||||
}
|
||||
|
||||
const borrarLinea = (id: string) => {
|
||||
setMaterias(prev => prev.map(m => m.lineaCurricularId === id ? { ...m, ciclo: null, lineaCurricularId: null } : m));
|
||||
setLineas(prev => prev.filter(l => l.id !== id));
|
||||
};
|
||||
setMaterias((prev) =>
|
||||
prev.map((m) =>
|
||||
m.lineaCurricularId === id
|
||||
? { ...m, ciclo: null, lineaCurricularId: null }
|
||||
: m,
|
||||
),
|
||||
)
|
||||
setLineas((prev) => prev.filter((l) => l.id !== id))
|
||||
}
|
||||
|
||||
// --- Selectores/Cálculos ---
|
||||
const getTotalesCiclo = (ciclo: number) => {
|
||||
return materias.filter(m => m.ciclo === ciclo).reduce((acc, m) => ({
|
||||
cr: acc.cr + (m.creditos || 0), hd: acc.hd + (m.hd || 0), hi: acc.hi + (m.hi || 0)
|
||||
}), { cr: 0, hd: 0, hi: 0 });
|
||||
};
|
||||
return materias
|
||||
.filter((m) => m.ciclo === ciclo)
|
||||
.reduce(
|
||||
(acc, m) => ({
|
||||
cr: acc.cr + (m.creditos || 0),
|
||||
hd: acc.hd + (m.hd || 0),
|
||||
hi: acc.hi + (m.hi || 0),
|
||||
}),
|
||||
{ cr: 0, hd: 0, hi: 0 },
|
||||
)
|
||||
}
|
||||
|
||||
const getSubtotalLinea = (lineaId: string) => {
|
||||
return materias.filter(m => m.lineaCurricularId === lineaId && m.ciclo !== null).reduce((acc, m) => ({
|
||||
cr: acc.cr + (m.creditos || 0), hd: acc.hd + (m.hd || 0), hi: acc.hi + (m.hi || 0)
|
||||
}), { cr: 0, hd: 0, hi: 0 });
|
||||
};
|
||||
return materias
|
||||
.filter((m) => m.lineaCurricularId === lineaId && m.ciclo !== null)
|
||||
.reduce(
|
||||
(acc, m) => ({
|
||||
cr: acc.cr + (m.creditos || 0),
|
||||
hd: acc.hd + (m.hd || 0),
|
||||
hi: acc.hi + (m.hi || 0),
|
||||
}),
|
||||
{ cr: 0, hd: 0, hi: 0 },
|
||||
)
|
||||
}
|
||||
|
||||
// --- Handlers Drag & Drop ---
|
||||
const handleDragStart = (e: React.DragEvent, id: string) => { setDraggedMateria(id); e.dataTransfer.effectAllowed = 'move'; };
|
||||
const handleDragOver = (e: React.DragEvent) => e.preventDefault();
|
||||
const handleDrop = (e: React.DragEvent, ciclo: number | null, lineaId: string | null) => {
|
||||
e.preventDefault();
|
||||
const handleDragStart = (e: React.DragEvent, id: string) => {
|
||||
setDraggedMateria(id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
const handleDragOver = (e: React.DragEvent) => e.preventDefault()
|
||||
const handleDrop = (
|
||||
e: React.DragEvent,
|
||||
ciclo: number | null,
|
||||
lineaId: string | null,
|
||||
) => {
|
||||
e.preventDefault()
|
||||
if (draggedMateria) {
|
||||
setMaterias(prev => prev.map(m => m.id === draggedMateria ? { ...m, ciclo, lineaCurricularId: lineaId } : m));
|
||||
setDraggedMateria(null);
|
||||
setMaterias((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === draggedMateria
|
||||
? { ...m, ciclo, lineaCurricularId: lineaId }
|
||||
: m,
|
||||
),
|
||||
)
|
||||
setDraggedMateria(null)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// --- Estadísticas Generales ---
|
||||
const stats = materias.reduce((acc, m) => {
|
||||
if (m.ciclo !== null) {
|
||||
acc.cr += m.creditos || 0; acc.hd += m.hd || 0; acc.hi += m.hi || 0;
|
||||
}
|
||||
return acc;
|
||||
}, { cr: 0, hd: 0, hi: 0 });
|
||||
const stats = useMemo(
|
||||
() =>
|
||||
materias.reduce(
|
||||
(acc, m) => {
|
||||
if (m.ciclo !== null) {
|
||||
acc.cr += m.creditos || 0
|
||||
acc.hd += m.hd || 0
|
||||
acc.hi += m.hi || 0
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ cr: 0, hd: 0, hi: 0 },
|
||||
),
|
||||
[materias],
|
||||
)
|
||||
|
||||
if (loadingAsig || loadingLineas)
|
||||
return <div className="p-10 text-center">Cargando mapa curricular...</div>
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-2 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">Mapa Curricular</h2>
|
||||
<p className="text-sm text-slate-500">Organiza las materias por línea curricular y ciclo</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
Organiza las materias de la petición por línea y ciclo
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{materias.filter(m => !m.ciclo).length > 0 && (
|
||||
<Badge className="bg-amber-50 text-amber-600 border-amber-100 hover:bg-amber-50">
|
||||
<AlertTriangle size={14} className="mr-1" /> {materias.filter(m => !m.ciclo).length} materias sin asignar
|
||||
{materias.filter((m) => !m.ciclo).length > 0 && (
|
||||
<Badge className="border-amber-100 bg-amber-50 text-amber-600 hover:bg-amber-50">
|
||||
<AlertTriangle size={14} className="mr-1" />{' '}
|
||||
{materias.filter((m) => !m.ciclo).length} sin asignar
|
||||
</Badge>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="bg-teal-700 hover:bg-teal-800 text-white">
|
||||
<Plus size={16} className="mr-2" /> Agregar <ChevronDown size={14} className="ml-2" />
|
||||
<Button className="bg-teal-700 text-white hover:bg-teal-800">
|
||||
<Plus size={16} className="mr-2" /> Agregar{' '}
|
||||
<ChevronDown size={14} className="ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => agregarLinea("Nueva Línea")}>Nueva Línea Curricular</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => agregarLinea("Área Común")}>Agregar Área Común</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => agregarLinea('Nueva Línea')}>
|
||||
Nueva Línea Curricular
|
||||
</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">
|
||||
<div className="mb-8 flex gap-10 rounded-xl border border-slate-200 bg-slate-50/80 p-4">
|
||||
<StatItem label="Total Créditos" value={stats.cr} total={320} />
|
||||
<StatItem label="Total HD" value={stats.hd} />
|
||||
<StatItem label="Total HI" value={stats.hi} />
|
||||
<StatItem label="Total Horas" value={stats.hd + stats.hi} />
|
||||
</div>
|
||||
|
||||
{/* Grid Principal */}
|
||||
<div className="overflow-x-auto pb-6">
|
||||
<div className="min-w-[1500px]">
|
||||
{/* Header Ciclos */}
|
||||
<div className="grid gap-3 mb-4" style={{ gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px` }}>
|
||||
<div className="text-xs font-bold text-slate-400 self-end px-2">LÍNEA CURRICULAR</div>
|
||||
{ciclosArray.map(n => <div key={n} className="bg-slate-100 rounded-lg p-2 text-center text-sm font-bold text-slate-600">Ciclo {n}</div>)}
|
||||
<div className="text-xs font-bold text-slate-400 self-end text-center">SUBTOTAL</div>
|
||||
<div
|
||||
className="mb-4 grid gap-3"
|
||||
style={{
|
||||
gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
|
||||
}}
|
||||
>
|
||||
<div className="self-end px-2 text-xs font-bold text-slate-400">
|
||||
LÍNEA CURRICULAR
|
||||
</div>
|
||||
{ciclosArray.map((n) => (
|
||||
<div
|
||||
key={n}
|
||||
className="rounded-lg bg-slate-100 p-2 text-center text-sm font-bold text-slate-600"
|
||||
>
|
||||
Ciclo {n}
|
||||
</div>
|
||||
))}
|
||||
<div className="self-end text-center text-xs font-bold text-slate-400">
|
||||
SUBTOTAL
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filas por Línea */}
|
||||
{lineasFinales.map((linea, idx) => {
|
||||
const sub = getSubtotalLinea(linea.id);
|
||||
{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]}`}>
|
||||
<div
|
||||
key={linea.id}
|
||||
className="mb-3 grid gap-3"
|
||||
style={{
|
||||
gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-between rounded-xl border-l-4 p-4 ${lineColors[idx % lineColors.length]}`}
|
||||
>
|
||||
<span className="text-xs font-bold">{linea.nombre}</span>
|
||||
<Trash2 size={14} className="text-slate-400 hover:text-red-500 cursor-pointer" onClick={() => borrarLinea(linea.id)} />
|
||||
<Trash2
|
||||
size={14}
|
||||
className="cursor-pointer text-slate-400 hover:text-red-500"
|
||||
onClick={() => borrarLinea(linea.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ciclosArray.map(ciclo => (
|
||||
{ciclosArray.map((ciclo) => (
|
||||
<div
|
||||
key={ciclo}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, ciclo, linea.id)}
|
||||
className="min-h-[140px] p-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 space-y-2"
|
||||
className="min-h-[140px] space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 p-2"
|
||||
>
|
||||
{materiasFinales.filter(m => m.ciclo === ciclo && m.lineaCurricularId === linea.id).map(m => (
|
||||
<MateriaCardItem key={m.id} materia={m} isDragging={draggedMateria === m.id} onDragStart={handleDragStart} onClick={() => { setSelectedMateria(m); setIsEditModalOpen(true); }} />
|
||||
))}
|
||||
{materias
|
||||
.filter(
|
||||
(m) =>
|
||||
m.ciclo === ciclo && m.lineaCurricularId === linea.id,
|
||||
)
|
||||
.map((m) => (
|
||||
<MateriaCardItem
|
||||
key={m.id}
|
||||
materia={m}
|
||||
isDragging={draggedMateria === m.id}
|
||||
onDragStart={handleDragStart}
|
||||
onClick={() => {
|
||||
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 className="flex flex-col justify-center rounded-xl border border-slate-100 bg-slate-50 p-4 text-[10px] font-medium text-slate-500">
|
||||
<div>Cr: {sub.cr}</div>
|
||||
<div>HD: {sub.hd}</div>
|
||||
<div>HI: {sub.hi}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Fila Totales Ciclo */}
|
||||
<div className="grid gap-3 mt-6 border-t pt-4" style={{ gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px` }}>
|
||||
<div className="p-2 font-bold text-slate-600">Totales por Ciclo</div>
|
||||
{ciclosArray.map(ciclo => {
|
||||
const t = getTotalesCiclo(ciclo);
|
||||
<div
|
||||
className="mt-6 grid gap-3 border-t pt-4"
|
||||
style={{
|
||||
gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
|
||||
}}
|
||||
>
|
||||
<div className="p-2 font-bold text-slate-600">
|
||||
Totales por Ciclo
|
||||
</div>
|
||||
{ciclosArray.map((ciclo) => {
|
||||
const t = getTotalesCiclo(ciclo)
|
||||
return (
|
||||
<div key={ciclo} className="text-[10px] text-center p-2 bg-slate-50 rounded-lg">
|
||||
<div
|
||||
key={ciclo}
|
||||
className="rounded-lg bg-slate-50 p-2 text-center text-[10px]"
|
||||
>
|
||||
<div className="font-bold text-slate-700">Cr: {t.cr}</div>
|
||||
<div>HD: {t.hd} • HI: {t.hi}</div>
|
||||
<div>
|
||||
HD: {t.hd} • HI: {t.hi}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="bg-teal-50 rounded-lg p-2 text-center text-teal-800 font-bold text-xs flex flex-col justify-center">
|
||||
<div>{stats.cr} Cr</div><div>{stats.hd + stats.hi} Hrs</div>
|
||||
<div className="flex flex-col justify-center rounded-lg bg-teal-50 p-2 text-center text-xs font-bold text-teal-800">
|
||||
<div>{stats.cr} Cr</div>
|
||||
<div>{stats.hd + stats.hi} Hrs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de Edición */}
|
||||
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader><DialogTitle>Editar Materia</DialogTitle></DialogHeader>
|
||||
{selectedMateria && (
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="space-y-2"><label className="text-xs font-bold uppercase">Clave</label><Input defaultValue={selectedMateria.clave} /></div>
|
||||
<div className="space-y-2"><label className="text-xs font-bold uppercase">Nombre</label><Input defaultValue={selectedMateria.nombre} /></div>
|
||||
<div className="space-y-2"><label className="text-xs font-bold uppercase">Créditos</label><Input type="number" defaultValue={selectedMateria.creditos} /></div>
|
||||
<div className="flex gap-2">
|
||||
<div className="space-y-2"><label className="text-xs font-bold uppercase">HD</label><Input type="number" defaultValue={selectedMateria.hd} /></div>
|
||||
<div className="space-y-2"><label className="text-xs font-bold uppercase">HI</label><Input type="number" defaultValue={selectedMateria.hi} /></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<Button variant="outline" onClick={() => setIsEditModalOpen(false)}>Cancelar</Button>
|
||||
<Button className="bg-teal-700 text-white">Guardar Cambios</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 4. Materias Pendientes (Sin Asignar) */}
|
||||
{materias.filter(m => m.ciclo === null).length > 0 && (
|
||||
<div className="mt-10 p-6 bg-slate-50 rounded-2xl border border-slate-200 shadow-sm animate-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex items-center gap-2 mb-4 text-amber-600">
|
||||
{/* Materias Sin Asignar */}
|
||||
{materias.filter((m) => m.ciclo === null).length > 0 && (
|
||||
<div className="mt-10 rounded-2xl border border-slate-200 bg-slate-50 p-6">
|
||||
<div className="mb-4 flex items-center gap-2 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 className="text-sm font-bold uppercase">
|
||||
Materias pendientes (
|
||||
{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'
|
||||
}`}
|
||||
<div
|
||||
className="flex min-h-[100px] flex-wrap gap-4 rounded-xl border-2 border-dashed bg-white/50 p-4"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, null, null)} // null devuelve la materia al estado "sin asignar"
|
||||
onDrop={(e) => handleDrop(e, null, null)}
|
||||
>
|
||||
{materias
|
||||
.filter(m => m.ciclo === null)
|
||||
.map(m => (
|
||||
.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); }}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -12,204 +12,263 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} 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')({
|
||||
component: Materias,
|
||||
})
|
||||
|
||||
type Materia = {
|
||||
id: string;
|
||||
clave: string
|
||||
nombre: string
|
||||
creditos: number
|
||||
hd: number
|
||||
hi: number
|
||||
ciclo: string
|
||||
linea: string
|
||||
tipo: 'Obligatoria' | 'Optativa' | 'Troncal'
|
||||
estado: 'Aprobada' | 'Revisada' | 'Borrador'
|
||||
// --- Configuración de Estilos ---
|
||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||
borrador: { label: 'Borrador', className: 'bg-slate-100 text-slate-600' },
|
||||
revisada: { label: 'Revisada', className: 'bg-amber-100 text-amber-700' },
|
||||
aprobada: { label: 'Aprobada', className: 'bg-emerald-100 text-emerald-700' },
|
||||
}
|
||||
|
||||
const MATERIAS: Materia[] = [
|
||||
{
|
||||
id: "1",
|
||||
clave: 'MAT101',
|
||||
nombre: 'Cálculo Diferencial',
|
||||
creditos: 8,
|
||||
hd: 4,
|
||||
hi: 4,
|
||||
ciclo: 'Ciclo 1',
|
||||
linea: 'Formación Básica',
|
||||
tipo: 'Obligatoria',
|
||||
estado: 'Aprobada',
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
clave: 'FIS101',
|
||||
nombre: 'Física Mecánica',
|
||||
creditos: 6,
|
||||
hd: 3,
|
||||
hi: 3,
|
||||
ciclo: 'Ciclo 1',
|
||||
linea: 'Formación Básica',
|
||||
tipo: 'Obligatoria',
|
||||
estado: 'Aprobada',
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
clave: 'PRO101',
|
||||
nombre: 'Fundamentos de Programación',
|
||||
creditos: 8,
|
||||
hd: 4,
|
||||
hi: 4,
|
||||
ciclo: 'Ciclo 1',
|
||||
linea: 'Ciencias de la Computación',
|
||||
tipo: 'Obligatoria',
|
||||
estado: 'Revisada',
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
clave: 'EST101',
|
||||
nombre: 'Estructura de Datos',
|
||||
creditos: 6,
|
||||
hd: 3,
|
||||
hi: 3,
|
||||
ciclo: 'Ciclo 2',
|
||||
linea: 'Ciencias de la Computación',
|
||||
tipo: 'Obligatoria',
|
||||
estado: 'Borrador',
|
||||
},
|
||||
]
|
||||
const tipoConfig: Record<string, { label: string; className: string }> = {
|
||||
obligatoria: { label: 'Obligatoria', className: 'bg-blue-100 text-blue-700' },
|
||||
optativa: { label: 'Optativa', className: 'bg-purple-100 text-purple-700' },
|
||||
troncal: { label: 'Troncal', className: 'bg-slate-100 text-slate-700' },
|
||||
}
|
||||
|
||||
function Materias() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [filtro, setFiltro] = useState<'Todas' | Materia['tipo']>('Todas')
|
||||
// --- Mapeadores de API ---
|
||||
const mapAsignaturas = (asigApi: any[] = []): Materia[] => {
|
||||
return asigApi.map((asig) => ({
|
||||
id: asig.id,
|
||||
clave: asig.codigo,
|
||||
nombre: asig.nombre,
|
||||
creditos: asig.creditos ?? 0,
|
||||
ciclo: asig.numero_ciclo ?? null,
|
||||
lineaCurricularId: asig.linea_plan_id ?? null,
|
||||
tipo:
|
||||
asig.tipo?.toLowerCase() === 'obligatoria' ? 'obligatoria' : 'optativa',
|
||||
estado: 'borrador', // O el campo que venga de tu API
|
||||
hd: Math.floor((asig.horas_semana ?? 0) / 2),
|
||||
hi: Math.ceil((asig.horas_semana ?? 0) / 2),
|
||||
}))
|
||||
}
|
||||
|
||||
const materiasFiltradas = MATERIAS.filter((m) => {
|
||||
const okFiltro = filtro === 'Todas' || m.tipo === filtro
|
||||
const okSearch =
|
||||
m.nombre.toLowerCase().includes(search.toLowerCase()) ||
|
||||
m.clave.toLowerCase().includes(search.toLowerCase())
|
||||
export const Route = createFileRoute('/planes/$planId/_detalle/materias')({
|
||||
component: MateriasPage,
|
||||
})
|
||||
|
||||
return okFiltro && okSearch
|
||||
})
|
||||
function MateriasPage() {
|
||||
const { planId } = Route.useParams()
|
||||
|
||||
const totalCreditos = materiasFiltradas.reduce(
|
||||
(acc, m) => acc + m.creditos,
|
||||
0
|
||||
// 1. Fetch de datos reales
|
||||
const { data: asignaturasApi, isLoading: loadingAsig } = usePlanAsignaturas(
|
||||
'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 (
|
||||
<div className="space-y-6">
|
||||
<div className="container mx-auto space-y-6 px-6 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Materias del Plan</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{materiasFiltradas.length} materias · {totalCreditos} créditos
|
||||
<h2 className="text-foreground text-xl font-bold">
|
||||
Materias del Plan
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{materias.length} materias en total • {filteredMaterias.length}{' '}
|
||||
filtradas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline">Clonar de mi Facultad</Button>
|
||||
<Button variant="outline">Clonar de otra Facultad</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Copy className="mr-2 h-4 w-4" /> Clonar
|
||||
</Button>
|
||||
<Button className="bg-emerald-700 hover:bg-emerald-800">
|
||||
+ Nueva Materia
|
||||
<Plus className="mr-2 h-4 w-4" /> Nueva Materia
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buscador y filtros */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Input
|
||||
placeholder="Buscar por nombre o clave..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
{/* Barra de Filtros Avanzada */}
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border bg-slate-50 p-4">
|
||||
<div className="relative min-w-[240px] flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="Buscar por nombre o clave..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="bg-white pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{['Todas', 'Obligatoria', 'Optativa', 'Troncal'].map((t) => (
|
||||
<Button
|
||||
key={t}
|
||||
variant={filtro === t ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setFiltro(t as any)}
|
||||
>
|
||||
{t === 'Obligatoria' ? 'Obligatorias' : t}
|
||||
</Button>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Filter className="text-muted-foreground mr-1 h-4 w-4" />
|
||||
|
||||
<Select value={filterTipo} onValueChange={setFilterTipo}>
|
||||
<SelectTrigger className="w-[140px] bg-white">
|
||||
<SelectValue placeholder="Tipo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos los tipos</SelectItem>
|
||||
<SelectItem value="obligatoria">Obligatoria</SelectItem>
|
||||
<SelectItem value="optativa">Optativa</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterEstado} onValueChange={setFilterEstado}>
|
||||
<SelectTrigger className="w-[140px] bg-white">
|
||||
<SelectValue placeholder="Estado" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos los estados</SelectItem>
|
||||
<SelectItem value="borrador">Borrador</SelectItem>
|
||||
<SelectItem value="revisada">Revisada</SelectItem>
|
||||
<SelectItem value="aprobada">Aprobada</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterLinea} onValueChange={setFilterLinea}>
|
||||
<SelectTrigger className="w-[180px] bg-white">
|
||||
<SelectValue placeholder="Línea" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas las líneas</SelectItem>
|
||||
{lineas.map((linea: any) => (
|
||||
<SelectItem key={linea.id} value={linea.id}>
|
||||
{linea.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabla */}
|
||||
<div className="rounded-md border">
|
||||
{/* Tabla Pro */}
|
||||
<div className="overflow-hidden rounded-xl border bg-white shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Clave</TableHead>
|
||||
<TableRow className="bg-slate-50/50">
|
||||
<TableHead className="w-[120px]">Clave</TableHead>
|
||||
<TableHead>Nombre</TableHead>
|
||||
<TableHead className="text-center">Créditos</TableHead>
|
||||
<TableHead className="text-center">HD</TableHead>
|
||||
<TableHead className="text-center">HI</TableHead>
|
||||
<TableHead>Ciclo</TableHead>
|
||||
<TableHead>Línea</TableHead>
|
||||
<TableHead className="text-center">Ciclo</TableHead>
|
||||
<TableHead>Línea Curricular</TableHead>
|
||||
<TableHead>Tipo</TableHead>
|
||||
<TableHead>Estado</TableHead>
|
||||
<TableHead className="text-center">Acciones</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{materiasFiltradas.map((m) => (
|
||||
<TableRow key={m.clave}>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{m.clave}
|
||||
</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 && (
|
||||
{filteredMaterias.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={10}
|
||||
className="text-center py-6 text-muted-foreground"
|
||||
>
|
||||
No se encontraron materias
|
||||
<TableCell colSpan={8} className="h-40 text-center">
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center">
|
||||
<BookOpen className="mb-2 h-10 w-10 opacity-20" />
|
||||
<p className="font-medium">No se encontraron materias</p>
|
||||
<p className="text-xs">
|
||||
Intenta cambiar los filtros de búsqueda
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</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>
|
||||
</Table>
|
||||
|
||||
Reference in New Issue
Block a user