/** * activity-diff.tsx * * — renders the field-level diff of a single ActivityEvent. * * Three visual states driven by `event.action`: * - created → green "after" column only (no before) * - deleted → red "before" column only (no after) * - updated → yellow "before → after" side-by-side per field * * Consumers pass the declarative `columns` metadata array (same shape as * `TableMetadata.columns`) so labels and display types are resolved without * any internal fetch. Degrades gracefully when `columns` is empty/absent. * * Toggle: "Todos los campos / Solo cambios" (with changed-field counter). */ import * as React from 'react' import { ChevronDown, ChevronRight, ArrowRight } from 'lucide-react' import { cn } from '@asteby/metacore-ui/lib' import { Badge } from '@asteby/metacore-ui/primitives' import type { ColumnDefinition } from './types' import { ActivityValueRenderer } from './activity-value-renderer' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** * The canonical activity event shape as produced by the kernel / host backend. * Transport-agnostic — the component only reads the fields it needs. */ export interface ActivityEvent { id: string correlation_id?: string | null actor_id?: string | null actor_label?: string | null /** Storage path of the actor's avatar image, when the backend resolves one. */ actor_avatar?: string | null addon_key: string model: string record_id: string action: string kind?: string | null before?: Record | null after?: Record | null /** * Explicit diff map produced by the backend. When present, only these keys * are "changed" fields. When absent, the diff is derived from before/after. */ changes?: Record | null summary?: string | null occurred_at: string } export interface ActivityDiffProps { /** The activity event to render. */ event: ActivityEvent /** * Column metadata for the model. Used to resolve `col.label` and display * type. Pass `TableMetadata.columns` from the host's metadata cache. * Optional — field keys are shown raw when absent. */ columns?: ColumnDefinition[] /** IANA timezone for datetime cells (org config). */ timeZone?: string /** ISO 4217 currency for money cells (org config). */ currency?: string /** BCP-47 locale. Defaults to 'es'. */ locale?: string /** Class applied to the root element. */ className?: string } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // Meta-level keys that are always present and never meaningful in a // human-readable diff. const META_KEYS = new Set(['id', 'created_at', 'updated_at', 'organization_id', 'org_id', 'deleted_at']) /** True when an object is a backend-resolved sibling ({value,label} relation, {name,…} user). */ function isResolvedObject(v: unknown): boolean { if (!v || typeof v !== 'object' || Array.isArray(v)) return false const o = v as Record return typeof o.label === 'string' || typeof o.name === 'string' } /** Returns all field keys that appear in the diff. */ function diffKeys(event: ActivityEvent): string[] { const before = event.before ?? {} const after = event.after ?? {} const source = event.changes && Object.keys(event.changes).length > 0 ? new Set(Object.keys(event.changes)) : new Set([...Object.keys(before), ...Object.keys(after)]) META_KEYS.forEach((k) => source.delete(k)) // A resolved FK appears twice: the raw UUID key (created_by_id) and the // resolved sibling (created_by: {name/label,…}). Drop the raw key — the // sibling row already shows the human value. The sibling's value may live // in before/after or inside changes[sibling] ({from,to}/{before,after}). const siblingResolved = (sibling: string): boolean => { if (isResolvedObject(before[sibling]) || isResolvedObject(after[sibling])) return true const ch = (event.changes as Record> | undefined)?.[sibling] if (!ch || typeof ch !== 'object') return false return isResolvedObject(ch.from) || isResolvedObject(ch.to) || isResolvedObject(ch.before) || isResolvedObject(ch.after) } return Array.from(source).filter((k) => { if (!k.endsWith('_id')) return true const sibling = k.slice(0, -3) return !(source.has(sibling) && siblingResolved(sibling)) }) } /** Returns the set of keys where the value actually changed. */ function changedKeys(event: ActivityEvent): Set { if (event.changes && Object.keys(event.changes).length > 0) { return new Set(Object.keys(event.changes)) } const before = event.before ?? {} const after = event.after ?? {} const changed = new Set() const all = new Set([...Object.keys(before), ...Object.keys(after)]) all.forEach((k) => { if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) changed.add(k) }) return changed } function resolveColumn(key: string, columns?: ColumnDefinition[]): ColumnDefinition | undefined { if (!columns?.length) return undefined const exact = columns.find((c) => c.key === key) if (exact) return exact // A resolved relation key is the FK column minus `_id` (the backend injects a // `destination_warehouse` sibling next to `destination_warehouse_id`). The // served metadata carries the LOCALIZED label on the `*_id` column, so match // it — else the label falls back to humanizing the key in English // ("Destination Warehouse" instead of "Almacén destino"). const fk = columns.find((c) => c.key === `${key}_id`) if (fk) return fk // A diff key is the physical column (created_by); the served metadata may // only carry the dotted display column for it (created_by.avatar). Match on // the base segment so the diff cell inherits its label and rich renderer. return columns.find((c) => typeof c.key === 'string' && c.key.includes('.') && c.key.split('.')[0] === key) } function resolveLabel(key: string, columns?: ColumnDefinition[]): string { const col = resolveColumn(key, columns) if (col?.label) return col.label // Humanize snake_case as last resort return key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) } function actionVariant(action: string): 'created' | 'updated' | 'deleted' | 'other' { const a = action.toLowerCase() if (a === 'created' || a === 'create') return 'created' if (a === 'deleted' || a === 'delete') return 'deleted' if (a === 'updated' || a === 'update') return 'updated' return 'other' } const VARIANT_BADGE: Record = { created: { label: 'Creado', className: 'bg-green-50 text-green-700 border-green-200 dark:bg-green-950/30 dark:text-green-400 dark:border-green-900' }, updated: { label: 'Actualizado', className: 'bg-yellow-50 text-yellow-700 border-yellow-200 dark:bg-yellow-950/30 dark:text-yellow-400 dark:border-yellow-900' }, deleted: { label: 'Eliminado', className: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-950/30 dark:text-red-400 dark:border-red-900' }, other: { label: '', className: 'bg-muted text-muted-foreground border-border' }, } // Subtle row highlight colors — using inline style so arbitrary values are // never dropped by the host's Tailwind class scan. const ROW_STYLE = { created: { background: 'color-mix(in srgb, #22c55e 6%, transparent)' }, deleted: { background: 'color-mix(in srgb, #ef4444 6%, transparent)' }, updated: {}, other: {}, } as Record // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- /** * Renders the field-level diff of a single ActivityEvent. Shows each field * with its label (from `columns`) and value formatted using the column's * display type. Supports a toggle to show only changed fields vs. all fields. */ export const ActivityDiff: React.FC = ({ event, columns, timeZone, currency, locale = 'es', className, }) => { const variant = actionVariant(event.action) const allKeys = diffKeys(event) const changed = changedKeys(event) const [showOnlyChanged, setShowOnlyChanged] = React.useState(true) const displayedKeys = showOnlyChanged ? allKeys.filter((k) => changed.has(k)) : allKeys const isCreated = variant === 'created' const isDeleted = variant === 'deleted' const variantBadge = VARIANT_BADGE[variant] ?? VARIANT_BADGE.other if (allKeys.length === 0 && !event.summary) { return (
Sin campos registrados.
) } return (
{/* Header: action badge + field count + toggle */}
{variantBadge.label || event.action} {changed.size > 0 && variant === 'updated' && ( {changed.size} campo{changed.size !== 1 ? 's' : ''} modificado{changed.size !== 1 ? 's' : ''} )} {allKeys.length > 0 && variant === 'updated' && ( )}
{/* Summary line (if backend provided one) */} {event.summary && (

{event.summary}

)} {/* Diff table. Created/deleted events carry a single snapshot — no before/after pair — so they collapse to two columns (Campo + Valor); a third placeholder column would force the value cell to wrap onto its own row. Updated keeps Campo/Antes/Después. */} {displayedKeys.length > 0 && (
{/* Column headers */} {isCreated || isDeleted ? (
Campo {isDeleted ? 'Valor anterior' : 'Valor'}
) : (
Campo Antes Después
)} {displayedKeys.map((key, idx) => { const col = resolveColumn(key, columns) const label = resolveLabel(key, columns) const isChanged = changed.has(key) let fromVal: unknown let toVal: unknown if (event.changes?.[key]) { fromVal = event.changes[key].from toVal = event.changes[key].to } else { fromVal = event.before?.[key] toVal = event.after?.[key] } const rowStyle = isCreated ? ROW_STYLE.created : isDeleted ? ROW_STYLE.deleted : {} // Single-snapshot row: label + one value cell, aligned // with the two-column header above. if (isCreated || isDeleted) { return (
{label}
) } return (
{/* Field label */} {label} {/* Before value */} {/* After value */} {isChanged && variant === 'updated' && ( )}
) })}
)}
) }