From 4bf407ab7a71386b814acf71cc10070817eb7501 Mon Sep 17 00:00:00 2001 From: "Roberto.silva" Date: Fri, 16 Jan 2026 07:26:12 -0600 Subject: [PATCH] Se agrega modal para visualizar historial, se quitan botones de guardado que no se utilizan y se arreglan detalles --- .../asignaturas/detalle/HistorialTab.tsx | 144 +++++++- .../asignaturas/detalle/MateriaDetailPage.tsx | 202 ++++++++--- src/data/api/plans.api.ts | 329 ++++++++++-------- .../planes/$planId/_detalle/historial.tsx | 253 +++++++++----- 4 files changed, 639 insertions(+), 289 deletions(-) diff --git a/src/components/asignaturas/detalle/HistorialTab.tsx b/src/components/asignaturas/detalle/HistorialTab.tsx index a631c4f..862b6de 100644 --- a/src/components/asignaturas/detalle/HistorialTab.tsx +++ b/src/components/asignaturas/detalle/HistorialTab.tsx @@ -10,6 +10,7 @@ import { Filter, Calendar, Loader2, + Eye, } from 'lucide-react' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -21,15 +22,15 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { cn } from '@/lib/utils' -import { format, formatDistanceToNow, parseISO } from 'date-fns' +import { format, parseISO } from 'date-fns' import { es } from 'date-fns/locale' import { useSubjectHistorial } from '@/data/hooks/useSubjects' - -// Mapeo de tipos de la API a los tipos del componente -const TIPO_MAP: Record = { - ACTUALIZACION_CAMPO: 'contenido', // O 'datos' según el campo - CREACION: 'datos', -} +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' const tipoConfig: Record = { @@ -62,24 +63,88 @@ export function HistorialTab() { new Set(['datos', 'contenido', 'bibliografia', 'ia', 'documento']), ) - // 2. Transformamos los datos de la API al formato que usa el componente + // ESTADOS PARA EL MODAL + const [selectedChange, setSelectedChange] = useState(null) + const [isModalOpen, setIsModalOpen] = useState(false) + + const RenderValue = ({ value }: { value: any }) => { + // 1. Caso: Nulo o vacío + if ( + value === null || + value === undefined || + value === 'Sin información previa' + ) { + return ( + Sin información + ) + } + + // 2. Caso: Es un ARRAY (como tu lista de unidades) + if (Array.isArray(value)) { + return ( +
+ {value.map((item, index) => ( +
+ +
+ ))} +
+ ) + } + + // 3. Caso: Es un OBJETO (como cada unidad con titulo, temas, etc.) + if (typeof value === 'object') { + return ( +
+ {Object.entries(value).map(([key, val]) => ( +
+ + {key.replace(/_/g, ' ')} + +
+ {/* Llamada recursiva para manejar lo que haya dentro del valor */} + {typeof val === 'object' ? ( +
+ +
+ ) : ( + String(val) + )} +
+
+ ))} +
+ ) + } + + // 4. Caso: Texto o número simple + return {String(value)} + } + const historialTransformado = useMemo(() => { if (!rawData) return [] - return rawData.map((item: any) => ({ id: item.id, - // Intentamos determinar el tipo basándonos en el campo o el tipo de la API tipo: item.campo === 'contenido_tematico' ? 'contenido' : 'datos', descripcion: `Se actualizó el campo ${item.campo.replace('_', ' ')}`, fecha: parseISO(item.cambiado_en), usuario: item.fuente === 'HUMANO' ? 'Usuario Staff' : 'Sistema IA', detalles: { campo: item.campo, + valor_anterior: item.valor_anterior || 'Sin datos previos', // Asumiendo que existe en tu API valor_nuevo: item.valor_nuevo, }, })) }, [rawData]) + const openCompareModal = (cambio: any) => { + setSelectedChange(cambio) + setIsModalOpen(true) + } + const toggleFiltro = (tipo: string) => { const newFiltros = new Set(filtros) if (newFiltros.has(tipo)) newFiltros.delete(tipo) @@ -198,6 +263,16 @@ export function HistorialTab() {

{cambio.descripcion}

+ {/* BOTÓN PARA VER CAMBIOS */} + {format(cambio.fecha, 'HH:mm')} @@ -225,6 +300,55 @@ export function HistorialTab() { ))} )} + {/* MODAL DE COMPARACIÓN */} + + + + + + Comparación de cambios + + {/* ... info de usuario y fecha */} + + +
+
+ {/* Lado Antes */} +
+
+
+ + Versión Anterior + +
+
+ +
+
+ + {/* Lado Después */} +
+
+
+ + Nueva Versión + +
+
+ +
+
+
+
+ +
+ Campo modificado:{' '} + {selectedChange?.detalles.campo} +
+ +
) } diff --git a/src/components/asignaturas/detalle/MateriaDetailPage.tsx b/src/components/asignaturas/detalle/MateriaDetailPage.tsx index 76abf0f..e303efd 100644 --- a/src/components/asignaturas/detalle/MateriaDetailPage.tsx +++ b/src/components/asignaturas/detalle/MateriaDetailPage.tsx @@ -6,7 +6,14 @@ 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, + Sparkles, +} from 'lucide-react' import { ContenidoTematico } from './ContenidoTematico' import { BibliographyItem } from './BibliographyItem' import { IAMateriaTab } from './IAMateriaTab' @@ -47,6 +54,41 @@ export interface AsignaturaResponse { datos: AsignaturaDatos } +function EditableHeaderField({ + value, + onSave, + className, +}: { + value: string | number + onSave: (val: string) => void + className?: string +}) { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + ;(e.currentTarget as HTMLElement).blur() // Quita el foco + } + } + + const handleBlur = (e: React.FocusEvent) => { + const newValue = e.currentTarget.textContent || '' + if (newValue !== value.toString()) { + onSave(newValue) + } + } + + return ( + + {value} + + ) +} export default function MateriaDetailPage() { const { data: asignaturasApi, isLoading: loadingAsig } = useSubject( '9d4dda6a-488f-428a-8a07-38081592a641', @@ -56,6 +98,31 @@ export default function MateriaDetailPage() { const [datosGenerales, setDatosGenerales] = useState({}) const [campos, setCampos] = useState([]) + // Dentro de MateriaDetailPage + const [headerData, setHeaderData] = useState({ + codigo: '', + nombre: '', + creditos: 0, + ciclo: 0, + }) + + // Sincronizar cuando llegue la API + useEffect(() => { + if (asignaturasApi) { + setHeaderData({ + codigo: asignaturasApi?.codigo ?? '', + nombre: asignaturasApi?.nombre ?? '', + creditos: asignaturasApi?.creditos ?? '', + ciclo: asignaturasApi?.numero_ciclo ?? 0, + }) + } + }, [asignaturasApi]) + + const handleUpdateHeader = (key: string, value: string | number) => { + const newData = { ...headerData, [key]: value } + setHeaderData(newData) + console.log('💾 Guardando en estado y base de datos:', key, value) + } /* ---------- sincronizar API ---------- */ useEffect(() => { if (asignaturasApi?.datos) { @@ -116,46 +183,76 @@ export default function MateriaDetailPage() { return (
- {/* ================= HEADER ================= */} + {/* ================= HEADER ACTUALIZADO ================= */}
- - Volver al plan + Volver al plan
+ {/* CÓDIGO EDITABLE */} - {asignaturasApi?.codigo} + handleUpdateHeader('codigo', val)} + /> -

{asignaturasApi?.nombre}

+ {/* NOMBRE EDITABLE */} +

+ handleUpdateHeader('nombre', val)} + /> +

{asignaturasApi?.planes_estudio?.datos?.nombre} - - Facultad de Ingeniería + + {asignaturasApi?.planes_estudio?.carreras?.facultades?.nombre} +

Pertenece al plan:{' '} - Licenciatura en Ingeniería en Sistemas Computacionales 2024 + {asignaturasApi?.planes_estudio?.nombre}

-
- 8 créditos - 7° semestre - Sistemas Inteligentes +
+ {/* CRÉDITOS EDITABLES */} + + + handleUpdateHeader('creditos', parseInt(val) || 0) + } + /> + créditos + + + {/* SEMESTRE EDITABLE */} + + + handleUpdateHeader('ciclo', parseInt(val) || 0) + } + /> + ° ciclo + + + {asignaturasApi?.tipo}
@@ -224,7 +321,7 @@ export default function MateriaDetailPage() { - +
@@ -254,14 +351,6 @@ function DatosGenerales({ data, isLoading }: DatosGeneralesProps) { Información oficial estructurada bajo los lineamientos de la SEP.

-
- - -
{/* Grid de Información */} @@ -276,6 +365,10 @@ function DatosGenerales({ data, isLoading }: DatosGeneralesProps) { key={key} title={formatTitle(key)} initialContent={value} + onEnhanceAI={(contenido) => { + console.log('Llevar a IA:', contenido) + // Aquí tu lógica: setPestañaActiva('mejorar-con-ia'); + }} /> ))} @@ -321,24 +414,24 @@ function DatosGenerales({ data, isLoading }: DatosGeneralesProps) { interface InfoCardProps { title: string - subtitle?: string - isList?: boolean - initialContent: any // Puede ser string o array de objetos - type?: 'text' | 'list' | 'requirements' | 'evaluation' + initialContent: any + type?: 'text' | 'requirements' | 'evaluation' + onEnhanceAI?: (content: any) => void // Nueva prop para la acción de IA } -function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) { +function InfoCard({ + title, + initialContent, + type = 'text', + onEnhanceAI, +}: InfoCardProps) { const [isEditing, setIsEditing] = useState(false) const [data, setData] = useState(initialContent) - // Estado temporal para el área de texto (siempre editamos como texto por simplicidad) const [tempText, setTempText] = useState( - type === 'text' || type === 'list' - ? initialContent - : JSON.stringify(initialContent, null, 2), // O un formato legible + type === 'text' ? initialContent : JSON.stringify(initialContent, null, 2), ) const handleSave = () => { - // Aquí podrías parsear el texto de vuelta si es necesario setData(tempText) setIsEditing(false) } @@ -349,15 +442,30 @@ function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) { {title} + {!isEditing && ( - +
+ {/* NUEVO: Botón de Mejorar con IA */} + + + {/* Botón de Editar original */} + +
)} @@ -377,7 +485,11 @@ function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) { > Cancelar - @@ -431,11 +543,3 @@ function EvaluationView({ items }: { items: any[] }) { ) } - -function EmptyTab({ title }: { title: string }) { - return ( -
- {title} (pendiente) -
- ) -} diff --git a/src/data/api/plans.api.ts b/src/data/api/plans.api.ts index 815a6a6..3a53513 100644 --- a/src/data/api/plans.api.ts +++ b/src/data/api/plans.api.ts @@ -1,6 +1,6 @@ -import { supabaseBrowser } from "../supabase/client"; -import { invokeEdge } from "../supabase/invokeEdge"; -import { buildRange, throwIfError, requireData } from "./_helpers"; +import { supabaseBrowser } from '../supabase/client' +import { invokeEdge } from '../supabase/invokeEdge' +import { buildRange, throwIfError, requireData } from './_helpers' import type { Asignatura, CambioPlan, @@ -11,39 +11,41 @@ import type { PlanEstudio, TipoCiclo, UUID, -} from "../types/domain"; +} from '../types/domain' const EDGE = { - plans_create_manual: "plans_create_manual", - ai_generate_plan: "ai_generate_plan", - plans_persist_from_ai: "plans_persist_from_ai", - plans_clone_from_existing: "plans_clone_from_existing", - plans_import_from_files: "plans_import_from_files", + plans_create_manual: 'plans_create_manual', + ai_generate_plan: 'ai_generate_plan', + plans_persist_from_ai: 'plans_persist_from_ai', + plans_clone_from_existing: 'plans_clone_from_existing', + plans_import_from_files: 'plans_import_from_files', - plans_update_fields: "plans_update_fields", - plans_update_map: "plans_update_map", - plans_transition_state: "plans_transition_state", + plans_update_fields: 'plans_update_fields', + plans_update_map: 'plans_update_map', + plans_transition_state: 'plans_transition_state', - plans_generate_document: "plans_generate_document", - plans_get_document: "plans_get_document", -} as const; + plans_generate_document: 'plans_generate_document', + plans_get_document: 'plans_get_document', +} as const export type PlanListFilters = { - search?: string; - carreraId?: UUID; - facultadId?: UUID; // filtra por carreras.facultad_id - estadoId?: UUID; - activo?: boolean; + search?: string + carreraId?: UUID + facultadId?: UUID // filtra por carreras.facultad_id + estadoId?: UUID + activo?: boolean - limit?: number; - offset?: number; -}; + limit?: number + offset?: number +} -export async function plans_list(filters: PlanListFilters = {}): Promise> { - const supabase = supabaseBrowser(); +export async function plans_list( + filters: PlanListFilters = {}, +): Promise> { + const supabase = supabaseBrowser() let q = supabase - .from("planes_estudio") + .from('planes_estudio') .select( ` id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en, @@ -51,210 +53,233 @@ export async function plans_list(filters: PlanListFilters = {}): Promise { - const supabase = supabaseBrowser(); + const supabase = supabaseBrowser() const { data, error } = await supabase - .from("planes_estudio") + .from('planes_estudio') .select( ` id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en, carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono)), - estructuras_plan(id,nombre,tipo,version,definicion), + estructuras_plan(id,nombre,tipo,template_id,definicion), estados_plan(id,clave,etiqueta,orden,es_final) - ` + `, ) - .eq("id", planId) - .single(); + .eq('id', planId) + .single() - throwIfError(error); - return requireData(data, "Plan no encontrado."); + throwIfError(error) + return requireData(data, 'Plan no encontrado.') } export async function plan_lineas_list(planId: UUID): Promise { - const supabase = supabaseBrowser(); + const supabase = supabaseBrowser() const { data, error } = await supabase - .from("lineas_plan") - .select("id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en") - .eq("plan_estudio_id", planId) - .order("orden", { ascending: true }); + .from('lineas_plan') + .select('id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en') + .eq('plan_estudio_id', planId) + .order('orden', { ascending: true }) - throwIfError(error); - return data ?? []; + throwIfError(error) + return data ?? [] } -export async function plan_asignaturas_list(planId: UUID): Promise { - const supabase = supabaseBrowser(); +export async function plan_asignaturas_list( + planId: UUID, +): Promise { + const supabase = supabaseBrowser() const { data, error } = await supabase - .from("asignaturas") + .from('asignaturas') .select( - "id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en" + 'id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en', ) - .eq("plan_estudio_id", planId) - .order("numero_ciclo", { ascending: true, nullsFirst: false }) - .order("orden_celda", { ascending: true, nullsFirst: false }) - .order("nombre", { ascending: true }); + .eq('plan_estudio_id', planId) + .order('numero_ciclo', { ascending: true, nullsFirst: false }) + .order('orden_celda', { ascending: true, nullsFirst: false }) + .order('nombre', { ascending: true }) - throwIfError(error); - return data ?? []; + throwIfError(error) + return data ?? [] } export async function plans_history(planId: UUID): Promise { - const supabase = supabaseBrowser(); + const supabase = supabaseBrowser() const { data, error } = await supabase - .from("cambios_plan") - .select("id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,interaccion_ia_id") - .eq("plan_estudio_id", planId) - .order("cambiado_en", { ascending: false }); + .from('cambios_plan') + .select( + 'id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,interaccion_ia_id', + ) + .eq('plan_estudio_id', planId) + .order('cambiado_en', { ascending: false }) - throwIfError(error); - return data ?? []; + throwIfError(error) + return data ?? [] } /** Wizard: crear plan manual (Edge Function) */ export type PlansCreateManualInput = { - carreraId: UUID; - estructuraId: UUID; - nombre: string; - nivel: NivelPlanEstudio; - tipoCiclo: TipoCiclo; - numCiclos: number; - datos?: Partial & Record; -}; + carreraId: UUID + estructuraId: UUID + nombre: string + nivel: NivelPlanEstudio + tipoCiclo: TipoCiclo + numCiclos: number + datos?: Partial & Record +} -export async function plans_create_manual(input: PlansCreateManualInput): Promise { - return invokeEdge(EDGE.plans_create_manual, input); +export async function plans_create_manual( + input: PlansCreateManualInput, +): Promise { + return invokeEdge(EDGE.plans_create_manual, input) } /** Wizard: IA genera preview JSON (Edge Function) */ export type AIGeneratePlanInput = { datosBasicos: { - nombrePlan: string; - carreraId: UUID; - facultadId?: UUID; - nivel: string; - tipoCiclo: TipoCiclo; - numCiclos: number; - }; + nombrePlan: string + carreraId: UUID + facultadId?: UUID + nivel: string + tipoCiclo: TipoCiclo + numCiclos: number + } iaConfig: { - descripcionEnfoque: string; - poblacionObjetivo?: string; - notasAdicionales?: string; - archivosReferencia?: UUID[]; - repositoriosIds?: UUID[]; - usarMCP?: boolean; - }; -}; - -export async function ai_generate_plan(input: AIGeneratePlanInput): Promise { - return invokeEdge(EDGE.ai_generate_plan, input); + descripcionEnfoque: string + poblacionObjetivo?: string + notasAdicionales?: string + archivosReferencia?: UUID[] + repositoriosIds?: UUID[] + usarMCP?: boolean + } } -export async function plans_persist_from_ai(payload: { jsonPlan: any }): Promise { - return invokeEdge(EDGE.plans_persist_from_ai, payload); +export async function ai_generate_plan( + input: AIGeneratePlanInput, +): Promise { + return invokeEdge(EDGE.ai_generate_plan, input) +} + +export async function plans_persist_from_ai(payload: { + jsonPlan: any +}): Promise { + return invokeEdge(EDGE.plans_persist_from_ai, payload) } export async function plans_clone_from_existing(payload: { - planOrigenId: UUID; - overrides: Partial> & { - carrera_id?: UUID; - estructura_id?: UUID; - datos?: Partial & Record; - }; + planOrigenId: UUID + overrides: Partial< + Pick + > & { + carrera_id?: UUID + estructura_id?: UUID + datos?: Partial & Record + } }): Promise { - return invokeEdge(EDGE.plans_clone_from_existing, payload); + return invokeEdge(EDGE.plans_clone_from_existing, payload) } export async function plans_import_from_files(payload: { datosBasicos: { - nombrePlan: string; - carreraId: UUID; - estructuraId: UUID; - nivel: string; - tipoCiclo: TipoCiclo; - numCiclos: number; - }; - archivoWordPlanId: UUID; - archivoMapaExcelId?: UUID | null; - archivoMateriasExcelId?: UUID | null; + nombrePlan: string + carreraId: UUID + estructuraId: UUID + nivel: string + tipoCiclo: TipoCiclo + numCiclos: number + } + archivoWordPlanId: UUID + archivoMapaExcelId?: UUID | null + archivoMateriasExcelId?: UUID | null }): Promise { - return invokeEdge(EDGE.plans_import_from_files, payload); + return invokeEdge(EDGE.plans_import_from_files, payload) } /** Update de tarjetas/fields del plan (Edge Function: merge server-side) */ export type PlansUpdateFieldsPatch = { - nombre?: string; - nivel?: NivelPlanEstudio; - tipo_ciclo?: TipoCiclo; - numero_ciclos?: number; - datos?: Partial & Record; -}; + nombre?: string + nivel?: NivelPlanEstudio + tipo_ciclo?: TipoCiclo + numero_ciclos?: number + datos?: Partial & Record +} -export async function plans_update_fields(planId: UUID, patch: PlansUpdateFieldsPatch): Promise { - return invokeEdge(EDGE.plans_update_fields, { planId, patch }); +export async function plans_update_fields( + planId: UUID, + patch: PlansUpdateFieldsPatch, +): Promise { + return invokeEdge(EDGE.plans_update_fields, { planId, patch }) } /** Operaciones del mapa curricular (mover/reordenar) */ export type PlanMapOperation = | { - op: "MOVE_ASIGNATURA"; - asignaturaId: UUID; - numero_ciclo: number | null; - linea_plan_id: UUID | null; - orden_celda?: number | null; + op: 'MOVE_ASIGNATURA' + asignaturaId: UUID + numero_ciclo: number | null + linea_plan_id: UUID | null + orden_celda?: number | null } | { - op: "REORDER_CELDA"; - linea_plan_id: UUID; - numero_ciclo: number; - asignaturaIdsOrdenados: UUID[]; - }; + op: 'REORDER_CELDA' + linea_plan_id: UUID + numero_ciclo: number + asignaturaIdsOrdenados: UUID[] + } -export async function plans_update_map(planId: UUID, ops: PlanMapOperation[]): Promise<{ ok: true }> { - return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops }); +export async function plans_update_map( + planId: UUID, + ops: PlanMapOperation[], +): Promise<{ ok: true }> { + return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops }) } export async function plans_transition_state(payload: { - planId: UUID; - haciaEstadoId: UUID; - comentario?: string; + planId: UUID + haciaEstadoId: UUID + comentario?: string }): Promise<{ ok: true }> { - return invokeEdge<{ ok: true }>(EDGE.plans_transition_state, payload); + return invokeEdge<{ ok: true }>(EDGE.plans_transition_state, payload) } /** Documento (Edge Function: genera y devuelve URL firmada o metadata) */ export type DocumentoResult = { - archivoId: UUID; - signedUrl: string; - mimeType?: string; - nombre?: string; -}; - -export async function plans_generate_document(planId: UUID): Promise { - return invokeEdge(EDGE.plans_generate_document, { planId }); + archivoId: UUID + signedUrl: string + mimeType?: string + nombre?: string } -export async function plans_get_document(planId: UUID): Promise { - return invokeEdge(EDGE.plans_get_document, { planId }); +export async function plans_generate_document( + planId: UUID, +): Promise { + return invokeEdge(EDGE.plans_generate_document, { planId }) +} + +export async function plans_get_document( + planId: UUID, +): Promise { + return invokeEdge(EDGE.plans_get_document, { planId }) } diff --git a/src/routes/planes/$planId/_detalle/historial.tsx b/src/routes/planes/$planId/_detalle/historial.tsx index 17efa38..b29004d 100644 --- a/src/routes/planes/$planId/_detalle/historial.tsx +++ b/src/routes/planes/$planId/_detalle/historial.tsx @@ -1,18 +1,27 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { createFileRoute } from '@tanstack/react-router' import { GitBranch, Edit3, PlusCircle, - FileText, RefreshCw, User, Loader2, Clock, + Eye, + History, + Calendar, } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Card, CardContent } from '@/components/ui/card' import { Avatar, AvatarFallback } from '@/components/ui/avatar' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' import { usePlanHistorial } from '@/data/hooks/usePlans' import { format, formatDistanceToNow, parseISO } from 'date-fns' import { es } from 'date-fns/locale' @@ -21,7 +30,6 @@ export const Route = createFileRoute('/planes/$planId/_detalle/historial')({ component: RouteComponent, }) -// Función para determinar el icono y tipo según la respuesta de la API const getEventConfig = (tipo: string, campo: string) => { if (tipo === 'CREACION') return { @@ -51,13 +59,15 @@ const getEventConfig = (tipo: string, campo: string) => { function RouteComponent() { const { planId } = Route.useParams() const { data: rawData, isLoading } = usePlanHistorial( - '0e0aea4d-b8b4-4e75-8279-6224c3ac769f' /*planId*/, + '0e0aea4d-b8b4-4e75-8279-6224c3ac769f', ) - // Transformación de datos de la API al formato de la UI + // ESTADOS PARA EL MODAL + const [selectedEvent, setSelectedEvent] = useState(null) + const [isModalOpen, setIsModalOpen] = useState(false) + const historyEvents = useMemo(() => { if (!rawData) return [] - return rawData.map((item: any) => { const config = getEventConfig(item.tipo, item.campo) return { @@ -73,115 +83,202 @@ function RouteComponent() { : `Se modificó el campo ${item.campo}`, date: parseISO(item.cambiado_en), icon: config.icon, - details: - item.valor_anterior && item.valor_nuevo - ? { - from: String(item.valor_anterior), - to: String(item.valor_nuevo), - } - : null, + campo: item.campo, + details: { + from: item.valor_anterior, + to: item.valor_nuevo, + }, } }) }, [rawData]) - if (isLoading) { + const openCompareModal = (event: any) => { + setSelectedEvent(event) + setIsModalOpen(true) + } + + const renderValue = (val: any) => { + if (!val) return 'Sin información' + if (typeof val === 'object') return JSON.stringify(val, null, 2) + return String(val) + } + + if (isLoading) return (
) - } return (
-
-

- - Historial de Cambios del Plan -

-

- Registro cronológico de modificaciones realizadas -

+
+
+

+ Historial de Cambios del + Plan +

+

+ Registro cronológico de modificaciones realizadas +

+
- {/* Línea vertical de fondo */}
- {historyEvents.length === 0 ? ( -
- No hay registros en el historial. -
+
No hay registros.
) : ( historyEvents.map((event) => (
- {/* Indicador con Icono */}
{event.icon}
- {/* Tarjeta de Contenido */} -
-
- - {event.type} - - - {formatDistanceToNow(event.date, { - addSuffix: true, - locale: es, - })} - +
+ {/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */} +
+
+ + {event.type} + + + {formatDistanceToNow(event.date, { + addSuffix: true, + locale: es, + })} + +
+ + {/* Grupo de elementos alineados a la derecha */} +
+ {/* Usuario e Icono */} +
+ + + {event.user} + +
+ + {/* Botón Ver Cambios */} + + + {/* Fecha exacta (Solo visible en desktop para no amontonar) */} + + {format(event.date, 'yyyy-MM-dd HH:mm')} + +
-
- - - - - - {event.user} + + {/* LÍNEA INFERIOR: Descripción */} +
+

+ {event.description} +

+ + {/* Badges de transición opcionales (de estado) */} + {event.details && + typeof event.details.from === 'string' && + event.campo === 'estado' && ( +
+ + {event.details.from} + + + → + + + {event.details.to} + +
+ )}
- -

- {event.description} -

- -

- {format(event.date, "PPP 'a las' HH:mm", { locale: es })} -

- - {/* Badges de transición (Si aplica para estados) */} - {event.details && ( -
- - {event.details.from} - - - - {event.details.to} - -
- )}
)) )}
+ + {/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */} + + + + + Comparación de + Versiones + +
+ + {selectedEvent?.user} + + + {' '} + {selectedEvent && + format(selectedEvent.date, "d 'de' MMMM, HH:mm", { + locale: es, + })} + +
+
+ +
+
+ {/* Lado Antes */} +
+
+
+ + Versión Anterior + +
+
+ {renderValue(selectedEvent?.details.from)} +
+
+ + {/* Lado Después */} +
+
+
+ + Nueva Versión + +
+
+ {renderValue(selectedEvent?.details.to)} +
+
+
+
+ +
+ + Campo: {selectedEvent?.campo} + +
+ +
) }