@asteby/metacore-runtime-react 18.10.1 → 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.
- package/CHANGELOG.md +30 -0
- package/dist/activity-diff.d.ts +70 -0
- package/dist/activity-diff.d.ts.map +1 -0
- package/dist/activity-diff.js +133 -0
- package/dist/activity-timeline.d.ts +56 -0
- package/dist/activity-timeline.d.ts.map +1 -0
- package/dist/activity-timeline.js +215 -0
- package/dist/activity-value-renderer.d.ts +33 -0
- package/dist/activity-value-renderer.d.ts.map +1 -0
- package/dist/activity-value-renderer.js +213 -0
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +5 -5
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/record-history.d.ts +41 -0
- package/dist/record-history.d.ts.map +1 -0
- package/dist/record-history.js +99 -0
- package/package.json +3 -3
- package/src/activity-diff.tsx +298 -0
- package/src/activity-timeline.tsx +574 -0
- package/src/activity-value-renderer.tsx +371 -0
- package/src/dynamic-table.tsx +13 -3
- package/src/index.ts +17 -0
- package/src/record-history.tsx +243 -0
|
@@ -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
|
+
}
|