Se agrega modal para visualizar historial, se quitan botones de guardado que no se utilizan y se arreglan detalles

This commit is contained in:
2026-01-16 07:26:12 -06:00
parent b4b5134cb2
commit 4bf407ab7a
4 changed files with 639 additions and 289 deletions

View File

@@ -10,6 +10,7 @@ import {
Filter, Filter,
Calendar, Calendar,
Loader2, Loader2,
Eye,
} from 'lucide-react' } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -21,15 +22,15 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils' 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 { es } from 'date-fns/locale'
import { useSubjectHistorial } from '@/data/hooks/useSubjects' import { useSubjectHistorial } from '@/data/hooks/useSubjects'
import {
// Mapeo de tipos de la API a los tipos del componente Dialog,
const TIPO_MAP: Record<string, string> = { DialogContent,
ACTUALIZACION_CAMPO: 'contenido', // O 'datos' según el campo DialogHeader,
CREACION: 'datos', DialogTitle,
} } from '@/components/ui/dialog'
const tipoConfig: Record<string, { label: string; icon: any; color: string }> = const tipoConfig: Record<string, { label: string; icon: any; color: string }> =
{ {
@@ -62,24 +63,88 @@ export function HistorialTab() {
new Set(['datos', 'contenido', 'bibliografia', 'ia', 'documento']), 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<any>(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 (
<span className="text-muted-foreground italic">Sin información</span>
)
}
// 2. Caso: Es un ARRAY (como tu lista de unidades)
if (Array.isArray(value)) {
return (
<div className="space-y-4">
{value.map((item, index) => (
<div
key={index}
className="rounded-lg border bg-white/50 p-3 shadow-sm"
>
<RenderValue value={item} />
</div>
))}
</div>
)
}
// 3. Caso: Es un OBJETO (como cada unidad con titulo, temas, etc.)
if (typeof value === 'object') {
return (
<div className="grid gap-2">
{Object.entries(value).map(([key, val]) => (
<div key={key} className="flex flex-col">
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
{key.replace(/_/g, ' ')}
</span>
<div className="text-sm text-slate-700">
{/* Llamada recursiva para manejar lo que haya dentro del valor */}
{typeof val === 'object' ? (
<div className="mt-1 border-l-2 border-slate-100 pl-2">
<RenderValue value={val} />
</div>
) : (
String(val)
)}
</div>
</div>
))}
</div>
)
}
// 4. Caso: Texto o número simple
return <span className="text-sm leading-relaxed">{String(value)}</span>
}
const historialTransformado = useMemo(() => { const historialTransformado = useMemo(() => {
if (!rawData) return [] if (!rawData) return []
return rawData.map((item: any) => ({ return rawData.map((item: any) => ({
id: item.id, 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', tipo: item.campo === 'contenido_tematico' ? 'contenido' : 'datos',
descripcion: `Se actualizó el campo ${item.campo.replace('_', ' ')}`, descripcion: `Se actualizó el campo ${item.campo.replace('_', ' ')}`,
fecha: parseISO(item.cambiado_en), fecha: parseISO(item.cambiado_en),
usuario: item.fuente === 'HUMANO' ? 'Usuario Staff' : 'Sistema IA', usuario: item.fuente === 'HUMANO' ? 'Usuario Staff' : 'Sistema IA',
detalles: { detalles: {
campo: item.campo, campo: item.campo,
valor_anterior: item.valor_anterior || 'Sin datos previos', // Asumiendo que existe en tu API
valor_nuevo: item.valor_nuevo, valor_nuevo: item.valor_nuevo,
}, },
})) }))
}, [rawData]) }, [rawData])
const openCompareModal = (cambio: any) => {
setSelectedChange(cambio)
setIsModalOpen(true)
}
const toggleFiltro = (tipo: string) => { const toggleFiltro = (tipo: string) => {
const newFiltros = new Set(filtros) const newFiltros = new Set(filtros)
if (newFiltros.has(tipo)) newFiltros.delete(tipo) if (newFiltros.has(tipo)) newFiltros.delete(tipo)
@@ -198,6 +263,16 @@ export function HistorialTab() {
<p className="font-medium"> <p className="font-medium">
{cambio.descripcion} {cambio.descripcion}
</p> </p>
{/* BOTÓN PARA VER CAMBIOS */}
<Button
variant="ghost"
size="sm"
className="gap-2 text-blue-600 hover:bg-blue-50 hover:text-blue-700"
onClick={() => openCompareModal(cambio)}
>
<Eye className="h-4 w-4" />
Ver cambios
</Button>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{format(cambio.fecha, 'HH:mm')} {format(cambio.fecha, 'HH:mm')}
</span> </span>
@@ -225,6 +300,55 @@ export function HistorialTab() {
))} ))}
</div> </div>
)} )}
{/* MODAL DE COMPARACIÓN */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col overflow-hidden">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2 text-xl">
<History className="h-5 w-5 text-blue-500" />
Comparación de cambios
</DialogTitle>
{/* ... info de usuario y fecha */}
</DialogHeader>
<div className="custom-scrollbar mt-4 flex-1 overflow-y-auto pr-2">
<div className="grid h-full grid-cols-2 gap-6">
{/* Lado Antes */}
<div className="flex flex-col space-y-3">
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white pb-2">
<div className="h-2 w-2 rounded-full bg-red-400" />
<span className="text-xs font-bold text-slate-500 uppercase">
Versión Anterior
</span>
</div>
<div className="flex-1 rounded-xl border border-red-100 bg-red-50/30 p-4">
<RenderValue
value={selectedChange?.detalles.valor_anterior}
/>
</div>
</div>
{/* Lado Después */}
<div className="flex flex-col space-y-3">
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white pb-2">
<div className="h-2 w-2 rounded-full bg-emerald-400" />
<span className="text-xs font-bold text-slate-500 uppercase">
Nueva Versión
</span>
</div>
<div className="flex-1 rounded-xl border border-emerald-100 bg-emerald-50/40 p-4">
<RenderValue value={selectedChange?.detalles.valor_nuevo} />
</div>
</div>
</div>
</div>
<div className="mt-4 flex flex-shrink-0 items-center justify-center gap-2 rounded-lg border border-slate-100 bg-slate-50 p-3 text-xs text-slate-500">
Campo modificado:{' '}
<Badge variant="secondary">{selectedChange?.detalles.campo}</Badge>
</div>
</DialogContent>
</Dialog>
</div> </div>
) )
} }

View File

@@ -6,7 +6,14 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { ArrowLeft, GraduationCap, Edit2, Save, Pencil } from 'lucide-react' import {
ArrowLeft,
GraduationCap,
Edit2,
Save,
Pencil,
Sparkles,
} from 'lucide-react'
import { ContenidoTematico } from './ContenidoTematico' import { ContenidoTematico } from './ContenidoTematico'
import { BibliographyItem } from './BibliographyItem' import { BibliographyItem } from './BibliographyItem'
import { IAMateriaTab } from './IAMateriaTab' import { IAMateriaTab } from './IAMateriaTab'
@@ -47,6 +54,41 @@ export interface AsignaturaResponse {
datos: AsignaturaDatos 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<HTMLElement>) => {
const newValue = e.currentTarget.textContent || ''
if (newValue !== value.toString()) {
onSave(newValue)
}
}
return (
<span
contentEditable
suppressContentEditableWarning
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className={`cursor-text rounded px-1 transition-all outline-none focus:ring-2 focus:ring-blue-400 ${className}`}
>
{value}
</span>
)
}
export default function MateriaDetailPage() { export default function MateriaDetailPage() {
const { data: asignaturasApi, isLoading: loadingAsig } = useSubject( const { data: asignaturasApi, isLoading: loadingAsig } = useSubject(
'9d4dda6a-488f-428a-8a07-38081592a641', '9d4dda6a-488f-428a-8a07-38081592a641',
@@ -56,6 +98,31 @@ export default function MateriaDetailPage() {
const [datosGenerales, setDatosGenerales] = useState({}) const [datosGenerales, setDatosGenerales] = useState({})
const [campos, setCampos] = useState<CampoEstructura[]>([]) const [campos, setCampos] = useState<CampoEstructura[]>([])
// 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 ---------- */ /* ---------- sincronizar API ---------- */
useEffect(() => { useEffect(() => {
if (asignaturasApi?.datos) { if (asignaturasApi?.datos) {
@@ -116,46 +183,76 @@ export default function MateriaDetailPage() {
return ( return (
<div className="w-full"> <div className="w-full">
{/* ================= HEADER ================= */} {/* ================= HEADER ACTUALIZADO ================= */}
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white"> <section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
<div className="mx-auto max-w-7xl px-6 py-10"> <div className="mx-auto max-w-7xl px-6 py-10">
<Link <Link
to="/planes" to="/planes"
className="mb-4 flex items-center gap-2 text-sm text-blue-200 hover:text-white" className="mb-4 flex items-center gap-2 text-sm text-blue-200 hover:text-white"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" /> Volver al plan
Volver al plan
</Link> </Link>
<div className="flex items-start justify-between gap-6"> <div className="flex items-start justify-between gap-6">
<div className="space-y-3"> <div className="space-y-3">
{/* CÓDIGO EDITABLE */}
<Badge className="border border-blue-700 bg-blue-900/50"> <Badge className="border border-blue-700 bg-blue-900/50">
{asignaturasApi?.codigo} <EditableHeaderField
value={headerData.codigo}
onSave={(val) => handleUpdateHeader('codigo', val)}
/>
</Badge> </Badge>
<h1 className="text-3xl font-bold">{asignaturasApi?.nombre}</h1> {/* NOMBRE EDITABLE */}
<h1 className="text-3xl font-bold">
<EditableHeaderField
value={headerData.nombre}
onSave={(val) => handleUpdateHeader('nombre', val)}
/>
</h1>
<div className="flex flex-wrap gap-4 text-sm text-blue-200"> <div className="flex flex-wrap gap-4 text-sm text-blue-200">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<GraduationCap className="h-4 w-4" /> <GraduationCap className="h-4 w-4" />
{asignaturasApi?.planes_estudio?.datos?.nombre} {asignaturasApi?.planes_estudio?.datos?.nombre}
</span> </span>
<span>
<span>Facultad de Ingeniería</span> {asignaturasApi?.planes_estudio?.carreras?.facultades?.nombre}
</span>
</div> </div>
<p className="text-sm text-blue-300"> <p className="text-sm text-blue-300">
Pertenece al plan:{' '} Pertenece al plan:{' '}
<span className="cursor-pointer underline"> <span className="cursor-pointer underline">
Licenciatura en Ingeniería en Sistemas Computacionales 2024 {asignaturasApi?.planes_estudio?.nombre}
</span> </span>
</p> </p>
</div> </div>
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-2 text-right">
<Badge variant="secondary">8 créditos</Badge> {/* CRÉDITOS EDITABLES */}
<Badge variant="secondary">7° semestre</Badge> <Badge variant="secondary" className="gap-1">
<Badge variant="secondary">Sistemas Inteligentes</Badge> <EditableHeaderField
value={headerData.creditos}
onSave={(val) =>
handleUpdateHeader('creditos', parseInt(val) || 0)
}
/>
<span>créditos</span>
</Badge>
{/* SEMESTRE EDITABLE */}
<Badge variant="secondary" className="gap-1">
<EditableHeaderField
value={headerData.ciclo}
onSave={(val) =>
handleUpdateHeader('ciclo', parseInt(val) || 0)
}
/>
<span>° ciclo</span>
</Badge>
<Badge variant="secondary">{asignaturasApi?.tipo}</Badge>
</div> </div>
</div> </div>
</div> </div>
@@ -224,7 +321,7 @@ export default function MateriaDetailPage() {
</TabsContent> </TabsContent>
<TabsContent value="historial"> <TabsContent value="historial">
<HistorialTab historial={mockHistorial} /> <HistorialTab />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
@@ -254,14 +351,6 @@ function DatosGenerales({ data, isLoading }: DatosGeneralesProps) {
Información oficial estructurada bajo los lineamientos de la SEP. Información oficial estructurada bajo los lineamientos de la SEP.
</p> </p>
</div> </div>
<div className="flex gap-3">
<Button variant="outline" size="sm" className="gap-2">
<Edit2 className="h-4 w-4" /> Editar borrador
</Button>
<Button size="sm" className="gap-2 bg-blue-600 hover:bg-blue-700">
<Save className="h-4 w-4" /> Guardar cambios
</Button>
</div>
</div> </div>
{/* Grid de Información */} {/* Grid de Información */}
@@ -276,6 +365,10 @@ function DatosGenerales({ data, isLoading }: DatosGeneralesProps) {
key={key} key={key}
title={formatTitle(key)} title={formatTitle(key)}
initialContent={value} initialContent={value}
onEnhanceAI={(contenido) => {
console.log('Llevar a IA:', contenido)
// Aquí tu lógica: setPestañaActiva('mejorar-con-ia');
}}
/> />
))} ))}
</div> </div>
@@ -321,24 +414,24 @@ function DatosGenerales({ data, isLoading }: DatosGeneralesProps) {
interface InfoCardProps { interface InfoCardProps {
title: string title: string
subtitle?: string initialContent: any
isList?: boolean type?: 'text' | 'requirements' | 'evaluation'
initialContent: any // Puede ser string o array de objetos onEnhanceAI?: (content: any) => void // Nueva prop para la acción de IA
type?: 'text' | 'list' | 'requirements' | 'evaluation'
} }
function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) { function InfoCard({
title,
initialContent,
type = 'text',
onEnhanceAI,
}: InfoCardProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [data, setData] = useState(initialContent) const [data, setData] = useState(initialContent)
// Estado temporal para el área de texto (siempre editamos como texto por simplicidad)
const [tempText, setTempText] = useState( const [tempText, setTempText] = useState(
type === 'text' || type === 'list' type === 'text' ? initialContent : JSON.stringify(initialContent, null, 2),
? initialContent
: JSON.stringify(initialContent, null, 2), // O un formato legible
) )
const handleSave = () => { const handleSave = () => {
// Aquí podrías parsear el texto de vuelta si es necesario
setData(tempText) setData(tempText)
setIsEditing(false) setIsEditing(false)
} }
@@ -349,15 +442,30 @@ function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) {
<CardTitle className="text-sm font-bold text-slate-700"> <CardTitle className="text-sm font-bold text-slate-700">
{title} {title}
</CardTitle> </CardTitle>
{!isEditing && ( {!isEditing && (
<Button <div className="flex gap-1">
variant="ghost" {/* NUEVO: Botón de Mejorar con IA */}
size="icon" <Button
className="h-8 w-8 text-slate-400" variant="ghost"
onClick={() => setIsEditing(true)} size="icon"
> className="h-8 w-8 text-blue-500 hover:bg-blue-50 hover:text-blue-600"
<Pencil className="h-3 w-3" /> onClick={() => onEnhanceAI?.(data)} // Enviamos la data actual a la IA
</Button> title="Mejorar con IA"
>
<Sparkles className="h-4 w-4" />
</Button>
{/* Botón de Editar original */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400"
onClick={() => setIsEditing(true)}
>
<Pencil className="h-3 w-3" />
</Button>
</div>
)} )}
</CardHeader> </CardHeader>
@@ -377,7 +485,11 @@ function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) {
> >
Cancelar Cancelar
</Button> </Button>
<Button size="sm" className="bg-[#00a878]" onClick={handleSave}> <Button
size="sm"
className="bg-[#00a878] hover:bg-[#008f66]"
onClick={handleSave}
>
Guardar Guardar
</Button> </Button>
</div> </div>
@@ -431,11 +543,3 @@ function EvaluationView({ items }: { items: any[] }) {
</div> </div>
) )
} }
function EmptyTab({ title }: { title: string }) {
return (
<div className="text-muted-foreground py-16 text-center">
{title} (pendiente)
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { supabaseBrowser } from "../supabase/client"; import { supabaseBrowser } from '../supabase/client'
import { invokeEdge } from "../supabase/invokeEdge"; import { invokeEdge } from '../supabase/invokeEdge'
import { buildRange, throwIfError, requireData } from "./_helpers"; import { buildRange, throwIfError, requireData } from './_helpers'
import type { import type {
Asignatura, Asignatura,
CambioPlan, CambioPlan,
@@ -11,39 +11,41 @@ import type {
PlanEstudio, PlanEstudio,
TipoCiclo, TipoCiclo,
UUID, UUID,
} from "../types/domain"; } from '../types/domain'
const EDGE = { const EDGE = {
plans_create_manual: "plans_create_manual", plans_create_manual: 'plans_create_manual',
ai_generate_plan: "ai_generate_plan", ai_generate_plan: 'ai_generate_plan',
plans_persist_from_ai: "plans_persist_from_ai", plans_persist_from_ai: 'plans_persist_from_ai',
plans_clone_from_existing: "plans_clone_from_existing", plans_clone_from_existing: 'plans_clone_from_existing',
plans_import_from_files: "plans_import_from_files", plans_import_from_files: 'plans_import_from_files',
plans_update_fields: "plans_update_fields", plans_update_fields: 'plans_update_fields',
plans_update_map: "plans_update_map", plans_update_map: 'plans_update_map',
plans_transition_state: "plans_transition_state", plans_transition_state: 'plans_transition_state',
plans_generate_document: "plans_generate_document", plans_generate_document: 'plans_generate_document',
plans_get_document: "plans_get_document", plans_get_document: 'plans_get_document',
} as const; } as const
export type PlanListFilters = { export type PlanListFilters = {
search?: string; search?: string
carreraId?: UUID; carreraId?: UUID
facultadId?: UUID; // filtra por carreras.facultad_id facultadId?: UUID // filtra por carreras.facultad_id
estadoId?: UUID; estadoId?: UUID
activo?: boolean; activo?: boolean
limit?: number; limit?: number
offset?: number; offset?: number
}; }
export async function plans_list(filters: PlanListFilters = {}): Promise<Paged<PlanEstudio>> { export async function plans_list(
const supabase = supabaseBrowser(); filters: PlanListFilters = {},
): Promise<Paged<PlanEstudio>> {
const supabase = supabaseBrowser()
let q = supabase let q = supabase
.from("planes_estudio") .from('planes_estudio')
.select( .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, 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<Paged<P
estructuras_plan(id,nombre,tipo,version,definicion), estructuras_plan(id,nombre,tipo,version,definicion),
estados_plan(id,clave,etiqueta,orden,es_final) estados_plan(id,clave,etiqueta,orden,es_final)
`, `,
{ count: "exact" } { count: 'exact' },
) )
.order("actualizado_en", { ascending: false }); .order('actualizado_en', { ascending: false })
if (filters.search?.trim()) q = q.ilike("nombre", `%${filters.search.trim()}%`); if (filters.search?.trim())
if (filters.carreraId) q = q.eq("carrera_id", filters.carreraId); q = q.ilike('nombre', `%${filters.search.trim()}%`)
if (filters.estadoId) q = q.eq("estado_actual_id", filters.estadoId); if (filters.carreraId) q = q.eq('carrera_id', filters.carreraId)
if (typeof filters.activo === "boolean") q = q.eq("activo", filters.activo); if (filters.estadoId) q = q.eq('estado_actual_id', filters.estadoId)
if (typeof filters.activo === 'boolean') q = q.eq('activo', filters.activo)
// filtro por FK “hacia arriba” (PostgREST soporta filtros en recursos embebidos) // filtro por FK “hacia arriba” (PostgREST soporta filtros en recursos embebidos)
if (filters.facultadId) q = q.eq("carreras.facultad_id", filters.facultadId); if (filters.facultadId) q = q.eq('carreras.facultad_id', filters.facultadId)
const { from, to } = buildRange(filters.limit, filters.offset); const { from, to } = buildRange(filters.limit, filters.offset)
if (typeof from === "number" && typeof to === "number") q = q.range(from, to); if (typeof from === 'number' && typeof to === 'number') q = q.range(from, to)
const { data, error, count } = await q; const { data, error, count } = await q
throwIfError(error); throwIfError(error)
return { data: data ?? [], count: count ?? null }; return { data: data ?? [], count: count ?? null }
} }
export async function plans_get(planId: UUID): Promise<PlanEstudio> { export async function plans_get(planId: UUID): Promise<PlanEstudio> {
const supabase = supabaseBrowser(); const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
.from("planes_estudio") .from('planes_estudio')
.select( .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, 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)), 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) estados_plan(id,clave,etiqueta,orden,es_final)
` `,
) )
.eq("id", planId) .eq('id', planId)
.single(); .single()
throwIfError(error); throwIfError(error)
return requireData(data, "Plan no encontrado."); return requireData(data, 'Plan no encontrado.')
} }
export async function plan_lineas_list(planId: UUID): Promise<LineaPlan[]> { export async function plan_lineas_list(planId: UUID): Promise<LineaPlan[]> {
const supabase = supabaseBrowser(); const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
.from("lineas_plan") .from('lineas_plan')
.select("id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en") .select('id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en')
.eq("plan_estudio_id", planId) .eq('plan_estudio_id', planId)
.order("orden", { ascending: true }); .order('orden', { ascending: true })
throwIfError(error); throwIfError(error)
return data ?? []; return data ?? []
} }
export async function plan_asignaturas_list(planId: UUID): Promise<Asignatura[]> { export async function plan_asignaturas_list(
const supabase = supabaseBrowser(); planId: UUID,
): Promise<Asignatura[]> {
const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
.from("asignaturas") .from('asignaturas')
.select( .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) .eq('plan_estudio_id', planId)
.order("numero_ciclo", { ascending: true, nullsFirst: false }) .order('numero_ciclo', { ascending: true, nullsFirst: false })
.order("orden_celda", { ascending: true, nullsFirst: false }) .order('orden_celda', { ascending: true, nullsFirst: false })
.order("nombre", { ascending: true }); .order('nombre', { ascending: true })
throwIfError(error); throwIfError(error)
return data ?? []; return data ?? []
} }
export async function plans_history(planId: UUID): Promise<CambioPlan[]> { export async function plans_history(planId: UUID): Promise<CambioPlan[]> {
const supabase = supabaseBrowser(); const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
.from("cambios_plan") .from('cambios_plan')
.select("id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,interaccion_ia_id") .select(
.eq("plan_estudio_id", planId) 'id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,interaccion_ia_id',
.order("cambiado_en", { ascending: false }); )
.eq('plan_estudio_id', planId)
.order('cambiado_en', { ascending: false })
throwIfError(error); throwIfError(error)
return data ?? []; return data ?? []
} }
/** Wizard: crear plan manual (Edge Function) */ /** Wizard: crear plan manual (Edge Function) */
export type PlansCreateManualInput = { export type PlansCreateManualInput = {
carreraId: UUID; carreraId: UUID
estructuraId: UUID; estructuraId: UUID
nombre: string; nombre: string
nivel: NivelPlanEstudio; nivel: NivelPlanEstudio
tipoCiclo: TipoCiclo; tipoCiclo: TipoCiclo
numCiclos: number; numCiclos: number
datos?: Partial<PlanDatosSep> & Record<string, any>; datos?: Partial<PlanDatosSep> & Record<string, any>
}; }
export async function plans_create_manual(input: PlansCreateManualInput): Promise<PlanEstudio> { export async function plans_create_manual(
return invokeEdge<PlanEstudio>(EDGE.plans_create_manual, input); input: PlansCreateManualInput,
): Promise<PlanEstudio> {
return invokeEdge<PlanEstudio>(EDGE.plans_create_manual, input)
} }
/** Wizard: IA genera preview JSON (Edge Function) */ /** Wizard: IA genera preview JSON (Edge Function) */
export type AIGeneratePlanInput = { export type AIGeneratePlanInput = {
datosBasicos: { datosBasicos: {
nombrePlan: string; nombrePlan: string
carreraId: UUID; carreraId: UUID
facultadId?: UUID; facultadId?: UUID
nivel: string; nivel: string
tipoCiclo: TipoCiclo; tipoCiclo: TipoCiclo
numCiclos: number; numCiclos: number
}; }
iaConfig: { iaConfig: {
descripcionEnfoque: string; descripcionEnfoque: string
poblacionObjetivo?: string; poblacionObjetivo?: string
notasAdicionales?: string; notasAdicionales?: string
archivosReferencia?: UUID[]; archivosReferencia?: UUID[]
repositoriosIds?: UUID[]; repositoriosIds?: UUID[]
usarMCP?: boolean; usarMCP?: boolean
}; }
};
export async function ai_generate_plan(input: AIGeneratePlanInput): Promise<any> {
return invokeEdge<any>(EDGE.ai_generate_plan, input);
} }
export async function plans_persist_from_ai(payload: { jsonPlan: any }): Promise<PlanEstudio> { export async function ai_generate_plan(
return invokeEdge<PlanEstudio>(EDGE.plans_persist_from_ai, payload); input: AIGeneratePlanInput,
): Promise<any> {
return invokeEdge<any>(EDGE.ai_generate_plan, input)
}
export async function plans_persist_from_ai(payload: {
jsonPlan: any
}): Promise<PlanEstudio> {
return invokeEdge<PlanEstudio>(EDGE.plans_persist_from_ai, payload)
} }
export async function plans_clone_from_existing(payload: { export async function plans_clone_from_existing(payload: {
planOrigenId: UUID; planOrigenId: UUID
overrides: Partial<Pick<PlanEstudio, "nombre" | "nivel" | "tipo_ciclo" | "numero_ciclos">> & { overrides: Partial<
carrera_id?: UUID; Pick<PlanEstudio, 'nombre' | 'nivel' | 'tipo_ciclo' | 'numero_ciclos'>
estructura_id?: UUID; > & {
datos?: Partial<PlanDatosSep> & Record<string, any>; carrera_id?: UUID
}; estructura_id?: UUID
datos?: Partial<PlanDatosSep> & Record<string, any>
}
}): Promise<PlanEstudio> { }): Promise<PlanEstudio> {
return invokeEdge<PlanEstudio>(EDGE.plans_clone_from_existing, payload); return invokeEdge<PlanEstudio>(EDGE.plans_clone_from_existing, payload)
} }
export async function plans_import_from_files(payload: { export async function plans_import_from_files(payload: {
datosBasicos: { datosBasicos: {
nombrePlan: string; nombrePlan: string
carreraId: UUID; carreraId: UUID
estructuraId: UUID; estructuraId: UUID
nivel: string; nivel: string
tipoCiclo: TipoCiclo; tipoCiclo: TipoCiclo
numCiclos: number; numCiclos: number
}; }
archivoWordPlanId: UUID; archivoWordPlanId: UUID
archivoMapaExcelId?: UUID | null; archivoMapaExcelId?: UUID | null
archivoMateriasExcelId?: UUID | null; archivoMateriasExcelId?: UUID | null
}): Promise<PlanEstudio> { }): Promise<PlanEstudio> {
return invokeEdge<PlanEstudio>(EDGE.plans_import_from_files, payload); return invokeEdge<PlanEstudio>(EDGE.plans_import_from_files, payload)
} }
/** Update de tarjetas/fields del plan (Edge Function: merge server-side) */ /** Update de tarjetas/fields del plan (Edge Function: merge server-side) */
export type PlansUpdateFieldsPatch = { export type PlansUpdateFieldsPatch = {
nombre?: string; nombre?: string
nivel?: NivelPlanEstudio; nivel?: NivelPlanEstudio
tipo_ciclo?: TipoCiclo; tipo_ciclo?: TipoCiclo
numero_ciclos?: number; numero_ciclos?: number
datos?: Partial<PlanDatosSep> & Record<string, any>; datos?: Partial<PlanDatosSep> & Record<string, any>
}; }
export async function plans_update_fields(planId: UUID, patch: PlansUpdateFieldsPatch): Promise<PlanEstudio> { export async function plans_update_fields(
return invokeEdge<PlanEstudio>(EDGE.plans_update_fields, { planId, patch }); planId: UUID,
patch: PlansUpdateFieldsPatch,
): Promise<PlanEstudio> {
return invokeEdge<PlanEstudio>(EDGE.plans_update_fields, { planId, patch })
} }
/** Operaciones del mapa curricular (mover/reordenar) */ /** Operaciones del mapa curricular (mover/reordenar) */
export type PlanMapOperation = export type PlanMapOperation =
| { | {
op: "MOVE_ASIGNATURA"; op: 'MOVE_ASIGNATURA'
asignaturaId: UUID; asignaturaId: UUID
numero_ciclo: number | null; numero_ciclo: number | null
linea_plan_id: UUID | null; linea_plan_id: UUID | null
orden_celda?: number | null; orden_celda?: number | null
} }
| { | {
op: "REORDER_CELDA"; op: 'REORDER_CELDA'
linea_plan_id: UUID; linea_plan_id: UUID
numero_ciclo: number; numero_ciclo: number
asignaturaIdsOrdenados: UUID[]; asignaturaIdsOrdenados: UUID[]
}; }
export async function plans_update_map(planId: UUID, ops: PlanMapOperation[]): Promise<{ ok: true }> { export async function plans_update_map(
return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops }); planId: UUID,
ops: PlanMapOperation[],
): Promise<{ ok: true }> {
return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops })
} }
export async function plans_transition_state(payload: { export async function plans_transition_state(payload: {
planId: UUID; planId: UUID
haciaEstadoId: UUID; haciaEstadoId: UUID
comentario?: string; comentario?: string
}): Promise<{ ok: true }> { }): 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) */ /** Documento (Edge Function: genera y devuelve URL firmada o metadata) */
export type DocumentoResult = { export type DocumentoResult = {
archivoId: UUID; archivoId: UUID
signedUrl: string; signedUrl: string
mimeType?: string; mimeType?: string
nombre?: string; nombre?: string
};
export async function plans_generate_document(planId: UUID): Promise<DocumentoResult> {
return invokeEdge<DocumentoResult>(EDGE.plans_generate_document, { planId });
} }
export async function plans_get_document(planId: UUID): Promise<DocumentoResult | null> { export async function plans_generate_document(
return invokeEdge<DocumentoResult | null>(EDGE.plans_get_document, { planId }); planId: UUID,
): Promise<DocumentoResult> {
return invokeEdge<DocumentoResult>(EDGE.plans_generate_document, { planId })
}
export async function plans_get_document(
planId: UUID,
): Promise<DocumentoResult | null> {
return invokeEdge<DocumentoResult | null>(EDGE.plans_get_document, { planId })
} }

View File

@@ -1,18 +1,27 @@
import { useMemo } from 'react' import { useMemo, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { import {
GitBranch, GitBranch,
Edit3, Edit3,
PlusCircle, PlusCircle,
FileText,
RefreshCw, RefreshCw,
User, User,
Loader2, Loader2,
Clock, Clock,
Eye,
History,
Calendar,
} from 'lucide-react' } from 'lucide-react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Avatar, AvatarFallback } from '@/components/ui/avatar' 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 { usePlanHistorial } from '@/data/hooks/usePlans'
import { format, formatDistanceToNow, parseISO } from 'date-fns' import { format, formatDistanceToNow, parseISO } from 'date-fns'
import { es } from 'date-fns/locale' import { es } from 'date-fns/locale'
@@ -21,7 +30,6 @@ export const Route = createFileRoute('/planes/$planId/_detalle/historial')({
component: RouteComponent, component: RouteComponent,
}) })
// Función para determinar el icono y tipo según la respuesta de la API
const getEventConfig = (tipo: string, campo: string) => { const getEventConfig = (tipo: string, campo: string) => {
if (tipo === 'CREACION') if (tipo === 'CREACION')
return { return {
@@ -51,13 +59,15 @@ const getEventConfig = (tipo: string, campo: string) => {
function RouteComponent() { function RouteComponent() {
const { planId } = Route.useParams() const { planId } = Route.useParams()
const { data: rawData, isLoading } = usePlanHistorial( 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<any>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const historyEvents = useMemo(() => { const historyEvents = useMemo(() => {
if (!rawData) return [] if (!rawData) return []
return rawData.map((item: any) => { return rawData.map((item: any) => {
const config = getEventConfig(item.tipo, item.campo) const config = getEventConfig(item.tipo, item.campo)
return { return {
@@ -73,115 +83,202 @@ function RouteComponent() {
: `Se modificó el campo ${item.campo}`, : `Se modificó el campo ${item.campo}`,
date: parseISO(item.cambiado_en), date: parseISO(item.cambiado_en),
icon: config.icon, icon: config.icon,
details: campo: item.campo,
item.valor_anterior && item.valor_nuevo details: {
? { from: item.valor_anterior,
from: String(item.valor_anterior), to: item.valor_nuevo,
to: String(item.valor_nuevo), },
}
: null,
} }
}) })
}, [rawData]) }, [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 (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-teal-600" /> <Loader2 className="h-8 w-8 animate-spin text-teal-600" />
</div> </div>
) )
}
return ( return (
<div className="mx-auto max-w-5xl p-6"> <div className="mx-auto max-w-5xl p-6">
<div className="mb-8"> <div className="mb-8 flex items-end justify-between">
<h1 className="flex items-center gap-2 text-xl font-bold text-slate-800"> <div>
<Clock className="h-5 w-5 text-teal-600" /> <h1 className="flex items-center gap-2 text-xl font-bold text-slate-800">
Historial de Cambios del Plan <Clock className="h-5 w-5 text-teal-600" /> Historial de Cambios del
</h1> Plan
<p className="text-muted-foreground text-sm"> </h1>
Registro cronológico de modificaciones realizadas <p className="text-muted-foreground text-sm">
</p> Registro cronológico de modificaciones realizadas
</p>
</div>
</div> </div>
<div className="relative space-y-0"> <div className="relative space-y-0">
{/* Línea vertical de fondo */}
<div className="absolute top-0 bottom-0 left-9 w-px bg-slate-200" /> <div className="absolute top-0 bottom-0 left-9 w-px bg-slate-200" />
{historyEvents.length === 0 ? ( {historyEvents.length === 0 ? (
<div className="ml-20 py-10 text-slate-500"> <div className="ml-20 py-10 text-slate-500">No hay registros.</div>
No hay registros en el historial.
</div>
) : ( ) : (
historyEvents.map((event) => ( historyEvents.map((event) => (
<div key={event.id} className="group relative flex gap-6 pb-8"> <div key={event.id} className="group relative flex gap-6 pb-8">
{/* Indicador con Icono */}
<div className="relative z-10 flex h-18 flex-col items-center"> <div className="relative z-10 flex h-18 flex-col items-center">
<div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm transition-colors group-hover:bg-teal-50 group-hover:text-teal-600"> <div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm transition-colors group-hover:bg-teal-50 group-hover:text-teal-600">
{event.icon} {event.icon}
</div> </div>
</div> </div>
{/* Tarjeta de Contenido */}
<Card className="flex-1 border-slate-200 shadow-none transition-colors hover:border-teal-200"> <Card className="flex-1 border-slate-200 shadow-none transition-colors hover:border-teal-200">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="mb-2 flex flex-col justify-between gap-2 md:flex-row md:items-center"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-2"> {/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */}
<span className="text-sm font-bold text-slate-800"> <div className="flex items-center justify-between">
{event.type} <div className="flex items-center gap-2">
</span> <span className="text-sm font-bold text-slate-800">
<Badge {event.type}
variant="outline" </span>
className="py-0 text-[10px] font-normal capitalize" <Badge
> variant="outline"
{formatDistanceToNow(event.date, { className="h-5 py-0 text-[10px] font-normal"
addSuffix: true, >
locale: es, {formatDistanceToNow(event.date, {
})} addSuffix: true,
</Badge> locale: es,
})}
</Badge>
</div>
{/* Grupo de elementos alineados a la derecha */}
<div className="flex items-center gap-4 text-slate-500">
{/* Usuario e Icono */}
<div className="flex items-center gap-2 text-xs">
<User className="h-3.5 w-3.5" />
<span className="text-muted-foreground">
{event.user}
</span>
</div>
{/* Botón Ver Cambios */}
<button
onClick={() => openCompareModal(event)}
className="group/btn flex items-center gap-1.5 text-xs transition-colors hover:text-teal-600"
>
<Eye className="h-4 w-4 text-slate-400 group-hover/btn:text-teal-600" />
<span>Ver cambios</span>
</button>
{/* Fecha exacta (Solo visible en desktop para no amontonar) */}
<span className="hidden text-[11px] text-slate-400 md:block">
{format(event.date, 'yyyy-MM-dd HH:mm')}
</span>
</div>
</div> </div>
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Avatar className="h-5 w-5 border"> {/* LÍNEA INFERIOR: Descripción */}
<AvatarFallback className="bg-slate-50 text-[8px]"> <div className="mt-1">
<User size={10} /> <p className="text-sm text-slate-600">
</AvatarFallback> {event.description}
</Avatar> </p>
{event.user}
{/* Badges de transición opcionales (de estado) */}
{event.details &&
typeof event.details.from === 'string' &&
event.campo === 'estado' && (
<div className="mt-2 flex items-center gap-1.5">
<Badge
variant="secondary"
className="bg-red-50 px-1.5 text-[9px] text-red-700"
>
{event.details.from}
</Badge>
<span className="text-[10px] text-slate-400">
</span>
<Badge
variant="secondary"
className="bg-emerald-50 px-1.5 text-[9px] text-emerald-700"
>
{event.details.to}
</Badge>
</div>
)}
</div> </div>
</div> </div>
<p className="mb-1 text-sm text-slate-600">
{event.description}
</p>
<p className="mb-3 text-[10px] text-slate-400">
{format(event.date, "PPP 'a las' HH:mm", { locale: es })}
</p>
{/* Badges de transición (Si aplica para estados) */}
{event.details && (
<div className="mt-2 flex items-center gap-2">
<Badge
variant="secondary"
className="bg-slate-100 text-[10px]"
>
{event.details.from}
</Badge>
<span className="text-xs text-slate-400"></span>
<Badge
variant="secondary"
className="bg-teal-50 text-[10px] text-teal-700"
>
{event.details.to}
</Badge>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
)) ))
)} )}
</div> </div>
{/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="border-b bg-slate-50/50 p-6">
<DialogTitle className="flex items-center gap-2">
<History className="h-5 w-5 text-teal-600" /> Comparación de
Versiones
</DialogTitle>
<div className="text-muted-foreground flex items-center gap-4 pt-2 text-xs">
<span className="flex items-center gap-1">
<User className="h-3 w-3" /> {selectedEvent?.user}
</span>
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />{' '}
{selectedEvent &&
format(selectedEvent.date, "d 'de' MMMM, HH:mm", {
locale: es,
})}
</span>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid h-full grid-cols-2 gap-6">
{/* Lado Antes */}
<div className="flex flex-col space-y-2">
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1">
<div className="h-2 w-2 rounded-full bg-red-400" />
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
Versión Anterior
</span>
</div>
<div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-red-100 bg-red-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700">
{renderValue(selectedEvent?.details.from)}
</div>
</div>
{/* Lado Después */}
<div className="flex flex-col space-y-2">
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1">
<div className="h-2 w-2 rounded-full bg-emerald-400" />
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
Nueva Versión
</span>
</div>
<div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-emerald-100 bg-emerald-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700">
{renderValue(selectedEvent?.details.to)}
</div>
</div>
</div>
</div>
<div className="flex justify-center border-t bg-slate-50 p-4">
<Badge variant="outline" className="font-mono text-[10px]">
Campo: {selectedEvent?.campo}
</Badge>
</div>
</DialogContent>
</Dialog>
</div> </div>
) )
} }