@asteby/metacore-runtime-react 18.10.2 → 18.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,574 @@
1
+ /**
2
+ * activity-timeline.tsx
3
+ *
4
+ * <ActivityTimeline> — global activity feed grouped by correlation_id.
5
+ *
6
+ * Events that share a correlation_id are folded into a single "operation"
7
+ * group (e.g. "Juan · Pedido creado · 4 cambios"). Events without a
8
+ * correlation_id appear as standalone entries.
9
+ *
10
+ * Filters: by model, actor, action, and date range. All client-side.
11
+ *
12
+ * Transport-agnostic: events arrive via props; column metadata is resolved
13
+ * per-model via the `resolveColumns(model)` injected function. No fetch.
14
+ */
15
+
16
+ import * as React from 'react'
17
+ import { formatDistanceToNow } from 'date-fns'
18
+ import { es, enUS } from 'date-fns/locale'
19
+ import type { Locale } from 'date-fns'
20
+ import {
21
+ ChevronDown,
22
+ ChevronRight,
23
+ Clock,
24
+ Filter,
25
+ X,
26
+ Layers,
27
+ Activity,
28
+ } from 'lucide-react'
29
+ import { cn } from '@asteby/metacore-ui/lib'
30
+ import {
31
+ Avatar,
32
+ AvatarFallback,
33
+ Badge,
34
+ Collapsible,
35
+ CollapsibleContent,
36
+ CollapsibleTrigger,
37
+ Input,
38
+ Select,
39
+ SelectContent,
40
+ SelectItem,
41
+ SelectTrigger,
42
+ SelectValue,
43
+ Button,
44
+ Separator,
45
+ } from '@asteby/metacore-ui/primitives'
46
+ import { getInitials } from '@asteby/metacore-ui/lib'
47
+ import type { ColumnDefinition } from './types'
48
+ import type { ActivityEvent } from './activity-diff'
49
+ import { ActivityDiff } from './activity-diff'
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Types
53
+ // ---------------------------------------------------------------------------
54
+
55
+ export interface ActivityTimelineProps {
56
+ /**
57
+ * All activity events to display. The component groups, sorts, and filters
58
+ * them client-side — order does not matter.
59
+ */
60
+ events: ActivityEvent[]
61
+ /**
62
+ * Injectable column metadata resolver. Called once per unique model name
63
+ * encountered in the event list. Returns the column definitions for that
64
+ * model, or undefined/empty when the host has no metadata for it.
65
+ *
66
+ * The host typically implements this as a cache lookup against its
67
+ * MetadataService / metadata-cache store.
68
+ */
69
+ resolveColumns?: (model: string) => ColumnDefinition[] | undefined
70
+ /** IANA timezone for datetime cells. */
71
+ timeZone?: string
72
+ /** ISO 4217 currency for money cells. */
73
+ currency?: string
74
+ /** BCP-47 locale. Defaults to 'es'. */
75
+ locale?: string
76
+ /** Class applied to the root element. */
77
+ className?: string
78
+ /**
79
+ * When true, the filter bar is hidden. Useful when the host already
80
+ * provides external filter controls and wants to feed pre-filtered events.
81
+ */
82
+ hideFilters?: boolean
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Internal types
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /** A group of events sharing the same correlation_id, or a standalone event. */
90
+ interface EventGroup {
91
+ /** Shared correlation_id (or the single event's id for ungrouped). */
92
+ key: string
93
+ /** The "root" event — first (chronologically) in the group. */
94
+ root: ActivityEvent
95
+ /** All events in the group, sorted ascending (oldest first). */
96
+ events: ActivityEvent[]
97
+ /** Total number of changed fields across all events in the group. */
98
+ changedFieldCount: number
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Helpers
103
+ // ---------------------------------------------------------------------------
104
+
105
+ const ACTION_DOT_COLOR: Record<string, string> = {
106
+ created: '#22c55e',
107
+ create: '#22c55e',
108
+ updated: '#eab308',
109
+ update: '#eab308',
110
+ deleted: '#ef4444',
111
+ delete: '#ef4444',
112
+ }
113
+
114
+ function actionDotColor(action: string): string {
115
+ return ACTION_DOT_COLOR[action.toLowerCase()] ?? '#6b7280'
116
+ }
117
+
118
+ const ACTION_LABELS_ES: Record<string, string> = {
119
+ created: 'creó',
120
+ create: 'creó',
121
+ updated: 'actualizó',
122
+ update: 'actualizó',
123
+ deleted: 'eliminó',
124
+ delete: 'eliminó',
125
+ }
126
+
127
+ function groupEvents(events: ActivityEvent[]): EventGroup[] {
128
+ const grouped = new Map<string, ActivityEvent[]>()
129
+
130
+ for (const ev of events) {
131
+ const key = ev.correlation_id || ev.id
132
+ const arr = grouped.get(key) ?? []
133
+ arr.push(ev)
134
+ grouped.set(key, arr)
135
+ }
136
+
137
+ const result: EventGroup[] = []
138
+
139
+ grouped.forEach((evs, key) => {
140
+ // Sort ascending (oldest first within a group)
141
+ const sorted = [...evs].sort(
142
+ (a, b) => new Date(a.occurred_at).getTime() - new Date(b.occurred_at).getTime(),
143
+ )
144
+ const root = sorted[0]
145
+
146
+ // Count distinct changed fields across all events in this group
147
+ let changedFieldCount = 0
148
+ for (const ev of sorted) {
149
+ if (ev.changes) changedFieldCount += Object.keys(ev.changes).length
150
+ else if (ev.before || ev.after) {
151
+ const keys = new Set([...Object.keys(ev.before ?? {}), ...Object.keys(ev.after ?? {})])
152
+ changedFieldCount += keys.size
153
+ }
154
+ }
155
+
156
+ result.push({ key, root, events: sorted, changedFieldCount })
157
+ })
158
+
159
+ // Sort groups: most recent root event first
160
+ result.sort((a, b) => new Date(b.root.occurred_at).getTime() - new Date(a.root.occurred_at).getTime())
161
+
162
+ return result
163
+ }
164
+
165
+ function uniqueValues<K extends keyof ActivityEvent>(
166
+ events: ActivityEvent[],
167
+ key: K,
168
+ ): string[] {
169
+ const set = new Set<string>()
170
+ for (const ev of events) {
171
+ const v = ev[key]
172
+ if (v !== undefined && v !== null && v !== '') set.add(String(v))
173
+ }
174
+ return Array.from(set).sort()
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Component: individual group card
179
+ // ---------------------------------------------------------------------------
180
+
181
+ interface GroupCardProps {
182
+ group: EventGroup
183
+ resolveColumns: (model: string) => ColumnDefinition[] | undefined
184
+ timeZone?: string
185
+ currency?: string
186
+ locale: string
187
+ dateLocale: Locale
188
+ isOpen: boolean
189
+ onToggle: () => void
190
+ }
191
+
192
+ const GroupCard: React.FC<GroupCardProps> = ({
193
+ group,
194
+ resolveColumns,
195
+ timeZone,
196
+ currency,
197
+ locale,
198
+ dateLocale,
199
+ isOpen,
200
+ onToggle,
201
+ }) => {
202
+ const { root, events, changedFieldCount } = group
203
+ const isMulti = events.length > 1
204
+ const actor = root.actor_label || root.actor_id || 'Sistema'
205
+ const dotColor = actionDotColor(root.action)
206
+
207
+ const timeAgo = (() => {
208
+ try {
209
+ return formatDistanceToNow(new Date(root.occurred_at), { addSuffix: true, locale: dateLocale })
210
+ } catch {
211
+ return root.occurred_at
212
+ }
213
+ })()
214
+
215
+ const fullDate = (() => {
216
+ try {
217
+ return new Date(root.occurred_at).toLocaleString(locale === 'en' ? 'en-US' : 'es-MX', {
218
+ ...(timeZone ? { timeZone } : {}),
219
+ dateStyle: 'medium',
220
+ timeStyle: 'short',
221
+ })
222
+ } catch {
223
+ return root.occurred_at
224
+ }
225
+ })()
226
+
227
+ const summaryLine = root.summary
228
+ ? root.summary
229
+ : `${actor} ${ACTION_LABELS_ES[root.action.toLowerCase()] ?? root.action} ${root.model}`
230
+
231
+ return (
232
+ <Collapsible open={isOpen} onOpenChange={onToggle}>
233
+ <div className="relative">
234
+ {/* Timeline dot */}
235
+ <span
236
+ className="absolute -left-5 top-4 h-2.5 w-2.5 rounded-full border-2 border-background -translate-x-[4px]"
237
+ style={{ background: dotColor }}
238
+ aria-hidden="true"
239
+ />
240
+
241
+ <div className="rounded-lg border border-border/60 bg-card overflow-hidden">
242
+ {/* Group header */}
243
+ <CollapsibleTrigger asChild>
244
+ <button
245
+ type="button"
246
+ className="w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
247
+ >
248
+ <Avatar className="h-7 w-7 rounded-full shrink-0 mt-0.5">
249
+ <AvatarFallback className="text-[9px] font-bold bg-primary/10 text-primary">
250
+ {getInitials(actor)}
251
+ </AvatarFallback>
252
+ </Avatar>
253
+
254
+ <div className="flex-1 min-w-0">
255
+ {/* Summary line */}
256
+ <p className="text-sm font-medium text-foreground truncate" title={summaryLine}>
257
+ {summaryLine}
258
+ </p>
259
+
260
+ {/* Meta row */}
261
+ <div className="flex items-center gap-2 mt-1 flex-wrap">
262
+ <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
263
+ <Clock className="h-3 w-3 opacity-60 shrink-0" />
264
+ <span title={fullDate}>{timeAgo}</span>
265
+ </span>
266
+
267
+ <Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4">
268
+ {root.model}
269
+ </Badge>
270
+
271
+ {isMulti && (
272
+ <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
273
+ <Layers className="h-3 w-3 opacity-60 shrink-0" />
274
+ {events.length} eventos
275
+ </span>
276
+ )}
277
+
278
+ {changedFieldCount > 0 && (
279
+ <span className="text-xs text-muted-foreground">
280
+ {changedFieldCount} campo{changedFieldCount !== 1 ? 's' : ''}
281
+ </span>
282
+ )}
283
+ </div>
284
+ </div>
285
+
286
+ <span className="shrink-0 text-muted-foreground mt-1">
287
+ {isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
288
+ </span>
289
+ </button>
290
+ </CollapsibleTrigger>
291
+
292
+ {/* Expanded: one diff per event in the group */}
293
+ <CollapsibleContent>
294
+ <div className="border-t border-border/40 divide-y divide-border/30">
295
+ {events.map((ev, idx) => {
296
+ const cols = resolveColumns(ev.model)
297
+ const evActor = ev.actor_label || ev.actor_id || 'Sistema'
298
+ return (
299
+ <div key={ev.id} className="px-4 py-3 space-y-2">
300
+ {/* Show sub-actor + model when group has multiple events */}
301
+ {isMulti && (
302
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
303
+ <span className="font-medium text-foreground/70">{ev.model}</span>
304
+ <span>·</span>
305
+ <span>{evActor}</span>
306
+ {idx > 0 && (
307
+ <>
308
+ <span>·</span>
309
+ <span title={ev.occurred_at}>
310
+ {(() => {
311
+ try {
312
+ return formatDistanceToNow(new Date(ev.occurred_at), { addSuffix: true, locale: dateLocale })
313
+ } catch {
314
+ return ev.occurred_at
315
+ }
316
+ })()}
317
+ </span>
318
+ </>
319
+ )}
320
+ </div>
321
+ )}
322
+ <ActivityDiff
323
+ event={ev}
324
+ columns={cols}
325
+ timeZone={timeZone}
326
+ currency={currency}
327
+ locale={locale}
328
+ />
329
+ </div>
330
+ )
331
+ })}
332
+ </div>
333
+ </CollapsibleContent>
334
+ </div>
335
+ </div>
336
+ </Collapsible>
337
+ )
338
+ }
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // Main component
342
+ // ---------------------------------------------------------------------------
343
+
344
+ /**
345
+ * Global activity feed. Groups correlated events, renders a vertical timeline
346
+ * with filter controls (model, actor, action, date range).
347
+ *
348
+ * The `resolveColumns` function is injected by the host. It maps a model name
349
+ * to its `ColumnDefinition[]` (e.g. from the host's metadata-cache store).
350
+ * When omitted or when it returns nothing, field keys are shown raw.
351
+ */
352
+ export const ActivityTimeline: React.FC<ActivityTimelineProps> = ({
353
+ events,
354
+ resolveColumns = () => undefined,
355
+ timeZone,
356
+ currency,
357
+ locale = 'es',
358
+ className,
359
+ hideFilters = false,
360
+ }) => {
361
+ const dateLocale = locale === 'en' ? enUS : es
362
+
363
+ // -----------------------------------------------------------------------
364
+ // Filter state
365
+ // -----------------------------------------------------------------------
366
+
367
+ const [filterModel, setFilterModel] = React.useState<string>('__all__')
368
+ const [filterActor, setFilterActor] = React.useState<string>('__all__')
369
+ const [filterAction, setFilterAction] = React.useState<string>('__all__')
370
+ const [filterFrom, setFilterFrom] = React.useState<string>('')
371
+ const [filterTo, setFilterTo] = React.useState<string>('')
372
+
373
+ const models = React.useMemo(() => uniqueValues(events, 'model'), [events])
374
+ const actors = React.useMemo(
375
+ () => uniqueValues(events, 'actor_label').filter(Boolean),
376
+ [events],
377
+ )
378
+ const actions = React.useMemo(() => uniqueValues(events, 'action'), [events])
379
+
380
+ const hasFilters =
381
+ filterModel !== '__all__' ||
382
+ filterActor !== '__all__' ||
383
+ filterAction !== '__all__' ||
384
+ filterFrom !== '' ||
385
+ filterTo !== ''
386
+
387
+ const clearFilters = () => {
388
+ setFilterModel('__all__')
389
+ setFilterActor('__all__')
390
+ setFilterAction('__all__')
391
+ setFilterFrom('')
392
+ setFilterTo('')
393
+ }
394
+
395
+ // -----------------------------------------------------------------------
396
+ // Filtered + grouped events
397
+ // -----------------------------------------------------------------------
398
+
399
+ const filtered = React.useMemo(() => {
400
+ return events.filter((ev) => {
401
+ if (filterModel !== '__all__' && ev.model !== filterModel) return false
402
+ if (filterActor !== '__all__' && ev.actor_label !== filterActor) return false
403
+ if (filterAction !== '__all__' && ev.action !== filterAction) return false
404
+ if (filterFrom) {
405
+ const from = new Date(filterFrom)
406
+ if (new Date(ev.occurred_at) < from) return false
407
+ }
408
+ if (filterTo) {
409
+ const to = new Date(filterTo)
410
+ // inclusive end-of-day
411
+ to.setHours(23, 59, 59, 999)
412
+ if (new Date(ev.occurred_at) > to) return false
413
+ }
414
+ return true
415
+ })
416
+ }, [events, filterModel, filterActor, filterAction, filterFrom, filterTo])
417
+
418
+ const groups = React.useMemo(() => groupEvents(filtered), [filtered])
419
+
420
+ // -----------------------------------------------------------------------
421
+ // Open/close state (first group open by default)
422
+ // -----------------------------------------------------------------------
423
+
424
+ const [openKeys, setOpenKeys] = React.useState<Set<string>>(() =>
425
+ groups.length > 0 ? new Set([groups[0].key]) : new Set(),
426
+ )
427
+
428
+ // Reset open state when filtered groups change substantially
429
+ const prevGroupKeysRef = React.useRef<string>('')
430
+ React.useEffect(() => {
431
+ const current = groups.map((g) => g.key).join(',')
432
+ if (current !== prevGroupKeysRef.current) {
433
+ prevGroupKeysRef.current = current
434
+ setOpenKeys(groups.length > 0 ? new Set([groups[0].key]) : new Set())
435
+ }
436
+ }, [groups])
437
+
438
+ const toggleGroup = (key: string) => {
439
+ setOpenKeys((prev) => {
440
+ const next = new Set(prev)
441
+ if (next.has(key)) next.delete(key)
442
+ else next.add(key)
443
+ return next
444
+ })
445
+ }
446
+
447
+ // -----------------------------------------------------------------------
448
+ // Render
449
+ // -----------------------------------------------------------------------
450
+
451
+ return (
452
+ <div className={cn('space-y-4', className)}>
453
+ {/* Filter bar */}
454
+ {!hideFilters && (
455
+ <div className="rounded-lg border border-border/60 bg-muted/20 p-3 space-y-3">
456
+ <div className="flex items-center gap-2">
457
+ <Filter className="h-4 w-4 text-muted-foreground shrink-0" />
458
+ <span className="text-sm font-medium text-muted-foreground">Filtros</span>
459
+ {hasFilters && (
460
+ <Button
461
+ variant="ghost"
462
+ size="sm"
463
+ className="ml-auto h-7 px-2 text-xs"
464
+ onClick={clearFilters}
465
+ >
466
+ <X className="h-3 w-3 mr-1" />
467
+ Limpiar
468
+ </Button>
469
+ )}
470
+ </div>
471
+
472
+ <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
473
+ {/* Model filter */}
474
+ {models.length > 1 && (
475
+ <Select value={filterModel} onValueChange={setFilterModel}>
476
+ <SelectTrigger className="h-8 text-xs">
477
+ <SelectValue placeholder="Módulo" />
478
+ </SelectTrigger>
479
+ <SelectContent>
480
+ <SelectItem value="__all__">Todos los módulos</SelectItem>
481
+ {models.map((m) => (
482
+ <SelectItem key={m} value={m}>{m}</SelectItem>
483
+ ))}
484
+ </SelectContent>
485
+ </Select>
486
+ )}
487
+
488
+ {/* Actor filter */}
489
+ {actors.length > 1 && (
490
+ <Select value={filterActor} onValueChange={setFilterActor}>
491
+ <SelectTrigger className="h-8 text-xs">
492
+ <SelectValue placeholder="Usuario" />
493
+ </SelectTrigger>
494
+ <SelectContent>
495
+ <SelectItem value="__all__">Todos los usuarios</SelectItem>
496
+ {actors.map((a) => (
497
+ <SelectItem key={a} value={a}>{a}</SelectItem>
498
+ ))}
499
+ </SelectContent>
500
+ </Select>
501
+ )}
502
+
503
+ {/* Action filter */}
504
+ {actions.length > 1 && (
505
+ <Select value={filterAction} onValueChange={setFilterAction}>
506
+ <SelectTrigger className="h-8 text-xs">
507
+ <SelectValue placeholder="Acción" />
508
+ </SelectTrigger>
509
+ <SelectContent>
510
+ <SelectItem value="__all__">Todas las acciones</SelectItem>
511
+ {actions.map((a) => (
512
+ <SelectItem key={a} value={a}>{a}</SelectItem>
513
+ ))}
514
+ </SelectContent>
515
+ </Select>
516
+ )}
517
+
518
+ {/* Date range */}
519
+ <div className="col-span-2 sm:col-span-1 flex gap-1">
520
+ <Input
521
+ type="date"
522
+ value={filterFrom}
523
+ onChange={(e) => setFilterFrom(e.target.value)}
524
+ className="h-8 text-xs"
525
+ placeholder="Desde"
526
+ title="Desde"
527
+ />
528
+ <Input
529
+ type="date"
530
+ value={filterTo}
531
+ onChange={(e) => setFilterTo(e.target.value)}
532
+ className="h-8 text-xs"
533
+ placeholder="Hasta"
534
+ title="Hasta"
535
+ />
536
+ </div>
537
+ </div>
538
+ </div>
539
+ )}
540
+
541
+ {/* Timeline */}
542
+ {groups.length === 0 ? (
543
+ <div className="flex flex-col items-center justify-center py-16 gap-3 text-muted-foreground">
544
+ <Activity className="h-10 w-10 opacity-30" />
545
+ <p className="text-sm">
546
+ {hasFilters ? 'Sin resultados con los filtros actuales.' : 'Sin actividad registrada.'}
547
+ </p>
548
+ </div>
549
+ ) : (
550
+ <div className="relative pl-5 space-y-3">
551
+ {/* Vertical timeline line */}
552
+ <span
553
+ className="absolute left-2 top-2 bottom-2 w-px bg-border"
554
+ aria-hidden="true"
555
+ />
556
+
557
+ {groups.map((group) => (
558
+ <GroupCard
559
+ key={group.key}
560
+ group={group}
561
+ resolveColumns={resolveColumns}
562
+ timeZone={timeZone}
563
+ currency={currency}
564
+ locale={locale}
565
+ dateLocale={dateLocale}
566
+ isOpen={openKeys.has(group.key)}
567
+ onToggle={() => toggleGroup(group.key)}
568
+ />
569
+ ))}
570
+ </div>
571
+ )}
572
+ </div>
573
+ )
574
+ }