Tabla change_history, pantalla en Agencia OS y registro automático al guardar o enriquecer Zona de marca. Co-authored-by: Cursor <cursoragent@cursor.com>
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import React, { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
History,
|
|
RefreshCw,
|
|
User,
|
|
Calendar,
|
|
FileText,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Filter,
|
|
} from 'lucide-react';
|
|
import { PageHeader, Surface, EmptyState } from '@/components/ui';
|
|
|
|
type Row = {
|
|
id: string;
|
|
tenant_id: string | null;
|
|
tenant_name: string | null;
|
|
user_email: string;
|
|
user_role: string | null;
|
|
entity_type: string;
|
|
entity_id: string | null;
|
|
action: string;
|
|
summary: string;
|
|
detail: Record<string, unknown>;
|
|
source: string | null;
|
|
created_at: string;
|
|
};
|
|
|
|
const ENTITY_LABELS: Record<string, string> = {
|
|
brandbook: 'Zona de marca',
|
|
creative: 'Creatividad',
|
|
calendar: 'Calendario',
|
|
tenant: 'Cliente',
|
|
document: 'Documento',
|
|
enrichment: 'Enriquecimiento',
|
|
};
|
|
|
|
function fmtDate(iso: string): string {
|
|
try {
|
|
return new Intl.DateTimeFormat('es-ES', {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
}).format(new Date(iso));
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function entityLabel(t: string): string {
|
|
return ENTITY_LABELS[t] || t;
|
|
}
|
|
|
|
function DetailBlock({ detail }: { detail: Record<string, unknown> }) {
|
|
const changes = Array.isArray(detail.changes)
|
|
? (detail.changes as { field?: string; from?: unknown; to?: unknown }[])
|
|
: null;
|
|
const fields = Array.isArray(detail.filled_fields)
|
|
? (detail.filled_fields as string[])
|
|
: null;
|
|
const sources = Array.isArray(detail.sources)
|
|
? (detail.sources as string[])
|
|
: null;
|
|
|
|
if (changes?.length) {
|
|
return (
|
|
<ul className="space-y-1.5 text-xs text-gray-600">
|
|
{changes.map((c, i) => (
|
|
<li
|
|
key={`${c.field}-${i}`}
|
|
className="rounded-lg bg-gray-50 border border-gray-100 px-2.5 py-1.5"
|
|
>
|
|
<span className="font-semibold text-gray-800">{c.field}</span>
|
|
<div className="mt-0.5 grid gap-0.5 sm:grid-cols-2">
|
|
<span className="text-gray-400 line-through break-all">
|
|
{c.from == null || c.from === ''
|
|
? '∅'
|
|
: typeof c.from === 'string'
|
|
? c.from
|
|
: JSON.stringify(c.from)}
|
|
</span>
|
|
<span className="text-emerald-700 break-all">
|
|
{c.to == null || c.to === ''
|
|
? '∅'
|
|
: typeof c.to === 'string'
|
|
? c.to
|
|
: JSON.stringify(c.to)}
|
|
</span>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
if (fields?.length || sources?.length) {
|
|
return (
|
|
<div className="text-xs text-gray-600 space-y-1">
|
|
{sources?.length ? (
|
|
<p>
|
|
<span className="font-medium text-gray-800">Fuentes:</span>{' '}
|
|
{sources.join(', ')}
|
|
</p>
|
|
) : null}
|
|
{fields?.length ? (
|
|
<p>
|
|
<span className="font-medium text-gray-800">Campos:</span>{' '}
|
|
{fields.join(', ')}
|
|
</p>
|
|
) : null}
|
|
{typeof detail.note === 'string' ? (
|
|
<p className="text-gray-500">{detail.note}</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const keys = Object.keys(detail || {});
|
|
if (!keys.length) {
|
|
return <p className="text-xs text-gray-400">Sin detalle adicional.</p>;
|
|
}
|
|
return (
|
|
<pre className="text-[11px] text-gray-600 bg-gray-50 border border-gray-100 rounded-lg p-2.5 overflow-x-auto max-h-48">
|
|
{JSON.stringify(detail, null, 2)}
|
|
</pre>
|
|
);
|
|
}
|
|
|
|
export default function ChangeHistoryView({
|
|
isAgency,
|
|
initialTenantId,
|
|
}: {
|
|
isAgency: boolean;
|
|
initialTenantId?: string | null;
|
|
}) {
|
|
const [rows, setRows] = useState<Row[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [needsMigration, setNeedsMigration] = useState(false);
|
|
const [entityType, setEntityType] = useState('');
|
|
const [portfolioWide, setPortfolioWide] = useState(false);
|
|
const [openId, setOpenId] = useState<string | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const q = new URLSearchParams();
|
|
q.set('limit', '80');
|
|
if (entityType) q.set('entity_type', entityType);
|
|
if (initialTenantId) q.set('tenant_id', initialTenantId);
|
|
const r = await fetch(`/api/change-history?${q}`, { cache: 'no-store' });
|
|
const j = await r.json();
|
|
if (!r.ok || j.ok === false) {
|
|
setNeedsMigration(Boolean(j.needsMigration));
|
|
setError(j.error || 'No se pudo cargar el historial');
|
|
setRows([]);
|
|
return;
|
|
}
|
|
setNeedsMigration(false);
|
|
setRows(j.rows || []);
|
|
setPortfolioWide(Boolean(j.portfolio_wide));
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Error de red');
|
|
setRows([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [entityType, initialTenantId]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
eyebrow="Auditoría"
|
|
title="Historial de cambios"
|
|
description="Usuario, fecha, cambio y detalle de lo que se modifica en el Centro de Mando."
|
|
actions={
|
|
<button
|
|
type="button"
|
|
onClick={() => void load()}
|
|
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
|
Actualizar
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
<Surface className="flex flex-wrap items-center gap-3">
|
|
<Filter className="w-4 h-4 text-gray-400" />
|
|
<label className="text-xs font-medium text-gray-600">Tipo</label>
|
|
<select
|
|
value={entityType}
|
|
onChange={(e) => setEntityType(e.target.value)}
|
|
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-800"
|
|
>
|
|
<option value="">Todos</option>
|
|
<option value="brandbook">Zona de marca</option>
|
|
<option value="enrichment">Enriquecimiento</option>
|
|
<option value="creative">Creatividades</option>
|
|
<option value="calendar">Calendario</option>
|
|
<option value="tenant">Cliente</option>
|
|
</select>
|
|
{isAgency ? (
|
|
<p className="text-xs text-gray-500 ml-auto">
|
|
{portfolioWide
|
|
? 'Vista portafolio (todos los clientes)'
|
|
: 'Filtrado por el cliente del contexto'}
|
|
</p>
|
|
) : null}
|
|
</Surface>
|
|
|
|
{needsMigration ? (
|
|
<Surface className="border-amber-200 bg-amber-50">
|
|
<p className="text-sm text-amber-900">
|
|
Falta aplicar la migración SQL{' '}
|
|
<code className="text-xs bg-amber-100 px-1 rounded">
|
|
sql/change_history.sql
|
|
</code>{' '}
|
|
en Supabase. Hasta entonces no se pueden guardar ni listar cambios.
|
|
</p>
|
|
</Surface>
|
|
) : null}
|
|
|
|
{error && !needsMigration ? (
|
|
<Surface className="border-red-200 bg-red-50">
|
|
<p className="text-sm text-red-800">{error}</p>
|
|
</Surface>
|
|
) : null}
|
|
|
|
<Surface padded={false}>
|
|
{loading && !rows.length ? (
|
|
<div className="p-8 text-sm text-gray-500 flex items-center gap-2">
|
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
|
Cargando historial…
|
|
</div>
|
|
) : !rows.length ? (
|
|
<div className="p-6">
|
|
<EmptyState
|
|
title="Aún no hay cambios registrados"
|
|
description="Cuando guardes Zona de marca, enriquezcas un brandbook u otras acciones instrumentadas, aparecerán aquí."
|
|
/>
|
|
</div>
|
|
) : (
|
|
<ul className="divide-y divide-gray-100">
|
|
{rows.map((row) => {
|
|
const open = openId === row.id;
|
|
return (
|
|
<li key={row.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpenId(open ? null : row.id)}
|
|
className="w-full text-left px-4 md:px-5 py-3.5 hover:bg-gray-50/80 transition-colors"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className="mt-0.5 text-gray-400">
|
|
{open ? (
|
|
<ChevronDown className="w-4 h-4" />
|
|
) : (
|
|
<ChevronRight className="w-4 h-4" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1 space-y-1.5">
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-gray-500">
|
|
<span className="inline-flex items-center gap-1 font-medium text-gray-700">
|
|
<User className="w-3 h-3" />
|
|
{row.user_email}
|
|
{row.user_role ? (
|
|
<span className="text-gray-400">
|
|
· {row.user_role}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1">
|
|
<Calendar className="w-3 h-3" />
|
|
{fmtDate(row.created_at)}
|
|
</span>
|
|
{row.tenant_name ? (
|
|
<span className="rounded-full bg-brand-50 text-brand-700 px-2 py-0.5 font-medium">
|
|
{row.tenant_name}
|
|
</span>
|
|
) : null}
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-gray-600">
|
|
<FileText className="w-3 h-3" />
|
|
{entityLabel(row.entity_type)} · {row.action}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm font-medium text-gray-900">
|
|
{row.summary}
|
|
</p>
|
|
</div>
|
|
<History className="w-4 h-4 text-gray-300 shrink-0 mt-1" />
|
|
</div>
|
|
</button>
|
|
{open ? (
|
|
<div className="px-4 md:px-5 pb-4 pl-11 md:pl-12">
|
|
<DetailBlock detail={row.detail || {}} />
|
|
{row.source ? (
|
|
<p className="mt-2 text-[11px] text-gray-400">
|
|
Origen: {row.source}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</Surface>
|
|
</div>
|
|
);
|
|
}
|