@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 18.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 2cdb047: Add Activity / Time Machine components: `ActivityDiff`, `RecordHistory`, and
|
|
8
|
+
`ActivityTimeline`.
|
|
9
|
+
- `ActivityDiff` — renders the field-level diff of a single `ActivityEvent`
|
|
10
|
+
(created/updated/deleted states, before→after per field, toggle all/changed).
|
|
11
|
+
- `RecordHistory` — chronological timeline of all events for a single record,
|
|
12
|
+
collapsible cards, embeddable in a record dialog "Historial" tab.
|
|
13
|
+
- `ActivityTimeline` — global feed grouped by `correlation_id`, with client-side
|
|
14
|
+
filters (model, actor, action, date range) and injectable `resolveColumns(model)`
|
|
15
|
+
resolver so hosts supply metadata without any internal fetch.
|
|
16
|
+
|
|
17
|
+
All three components are transport-agnostic (no fetch, no API calls) and reuse
|
|
18
|
+
the existing display-type renderers (currency, status, date, boolean, relation
|
|
19
|
+
chips, tags, color, url) via the new `ActivityValueRenderer` helper, keeping
|
|
20
|
+
table cells and diff cells visually consistent.
|
|
21
|
+
|
|
22
|
+
Also exports `ActivityValueRenderer` as a standalone pure renderer for use
|
|
23
|
+
outside the activity components.
|
|
24
|
+
|
|
25
|
+
## 18.10.2
|
|
26
|
+
|
|
27
|
+
### Patch Changes
|
|
28
|
+
|
|
29
|
+
- 530ad31: The totals footer is now pinned to the bottom of the table box even with few
|
|
30
|
+
rows (the table fills its container height and a spacer row absorbs the slack),
|
|
31
|
+
instead of floating right under the last row.
|
|
32
|
+
|
|
3
33
|
## 18.10.1
|
|
4
34
|
|
|
5
35
|
### Patch Changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* activity-diff.tsx
|
|
3
|
+
*
|
|
4
|
+
* <ActivityDiff> — renders the field-level diff of a single ActivityEvent.
|
|
5
|
+
*
|
|
6
|
+
* Three visual states driven by `event.action`:
|
|
7
|
+
* - created → green "after" column only (no before)
|
|
8
|
+
* - deleted → red "before" column only (no after)
|
|
9
|
+
* - updated → yellow "before → after" side-by-side per field
|
|
10
|
+
*
|
|
11
|
+
* Consumers pass the declarative `columns` metadata array (same shape as
|
|
12
|
+
* `TableMetadata.columns`) so labels and display types are resolved without
|
|
13
|
+
* any internal fetch. Degrades gracefully when `columns` is empty/absent.
|
|
14
|
+
*
|
|
15
|
+
* Toggle: "Todos los campos / Solo cambios" (with changed-field counter).
|
|
16
|
+
*/
|
|
17
|
+
import * as React from 'react';
|
|
18
|
+
import type { ColumnDefinition } from './types';
|
|
19
|
+
/**
|
|
20
|
+
* The canonical activity event shape as produced by the kernel / host backend.
|
|
21
|
+
* Transport-agnostic — the component only reads the fields it needs.
|
|
22
|
+
*/
|
|
23
|
+
export interface ActivityEvent {
|
|
24
|
+
id: string;
|
|
25
|
+
correlation_id?: string | null;
|
|
26
|
+
actor_id?: string | null;
|
|
27
|
+
actor_label?: string | null;
|
|
28
|
+
addon_key: string;
|
|
29
|
+
model: string;
|
|
30
|
+
record_id: string;
|
|
31
|
+
action: string;
|
|
32
|
+
kind?: string | null;
|
|
33
|
+
before?: Record<string, unknown> | null;
|
|
34
|
+
after?: Record<string, unknown> | null;
|
|
35
|
+
/**
|
|
36
|
+
* Explicit diff map produced by the backend. When present, only these keys
|
|
37
|
+
* are "changed" fields. When absent, the diff is derived from before/after.
|
|
38
|
+
*/
|
|
39
|
+
changes?: Record<string, {
|
|
40
|
+
from: unknown;
|
|
41
|
+
to: unknown;
|
|
42
|
+
}> | null;
|
|
43
|
+
summary?: string | null;
|
|
44
|
+
occurred_at: string;
|
|
45
|
+
}
|
|
46
|
+
export interface ActivityDiffProps {
|
|
47
|
+
/** The activity event to render. */
|
|
48
|
+
event: ActivityEvent;
|
|
49
|
+
/**
|
|
50
|
+
* Column metadata for the model. Used to resolve `col.label` and display
|
|
51
|
+
* type. Pass `TableMetadata.columns` from the host's metadata cache.
|
|
52
|
+
* Optional — field keys are shown raw when absent.
|
|
53
|
+
*/
|
|
54
|
+
columns?: ColumnDefinition[];
|
|
55
|
+
/** IANA timezone for datetime cells (org config). */
|
|
56
|
+
timeZone?: string;
|
|
57
|
+
/** ISO 4217 currency for money cells (org config). */
|
|
58
|
+
currency?: string;
|
|
59
|
+
/** BCP-47 locale. Defaults to 'es'. */
|
|
60
|
+
locale?: string;
|
|
61
|
+
/** Class applied to the root element. */
|
|
62
|
+
className?: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Renders the field-level diff of a single ActivityEvent. Shows each field
|
|
66
|
+
* with its label (from `columns`) and value formatted using the column's
|
|
67
|
+
* display type. Supports a toggle to show only changed fields vs. all fields.
|
|
68
|
+
*/
|
|
69
|
+
export declare const ActivityDiff: React.FC<ActivityDiffProps>;
|
|
70
|
+
//# sourceMappingURL=activity-diff.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"activity-diff.d.ts","sourceRoot":"","sources":["../src/activity-diff.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAI9B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAO/C;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;IACvC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;IACtC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,EAAE,EAAE,OAAO,CAAA;KAAE,CAAC,GAAG,IAAI,CAAA;IAC/D,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,WAAW,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,WAAW,iBAAiB;IAC9B,oCAAoC;IACpC,KAAK,EAAE,aAAa,CAAA;IACpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,gBAAgB,EAAE,CAAA;IAC5B,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,sDAAsD;IACtD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,uCAAuC;IACvC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,yCAAyC;IACzC,SAAS,CAAC,EAAE,MAAM,CAAA;CACrB;AA2ED;;;;GAIG;AACH,eAAO,MAAM,YAAY,EAAE,KAAK,CAAC,EAAE,CAAC,iBAAiB,CAmJpD,CAAA"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* activity-diff.tsx
|
|
4
|
+
*
|
|
5
|
+
* <ActivityDiff> — renders the field-level diff of a single ActivityEvent.
|
|
6
|
+
*
|
|
7
|
+
* Three visual states driven by `event.action`:
|
|
8
|
+
* - created → green "after" column only (no before)
|
|
9
|
+
* - deleted → red "before" column only (no after)
|
|
10
|
+
* - updated → yellow "before → after" side-by-side per field
|
|
11
|
+
*
|
|
12
|
+
* Consumers pass the declarative `columns` metadata array (same shape as
|
|
13
|
+
* `TableMetadata.columns`) so labels and display types are resolved without
|
|
14
|
+
* any internal fetch. Degrades gracefully when `columns` is empty/absent.
|
|
15
|
+
*
|
|
16
|
+
* Toggle: "Todos los campos / Solo cambios" (with changed-field counter).
|
|
17
|
+
*/
|
|
18
|
+
import * as React from 'react';
|
|
19
|
+
import { ArrowRight } from 'lucide-react';
|
|
20
|
+
import { cn } from '@asteby/metacore-ui/lib';
|
|
21
|
+
import { Badge } from '@asteby/metacore-ui/primitives';
|
|
22
|
+
import { ActivityValueRenderer } from './activity-value-renderer';
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Helpers
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
/** Returns all field keys that appear in the diff. */
|
|
27
|
+
function diffKeys(event) {
|
|
28
|
+
if (event.changes && Object.keys(event.changes).length > 0) {
|
|
29
|
+
return Object.keys(event.changes);
|
|
30
|
+
}
|
|
31
|
+
const before = event.before ?? {};
|
|
32
|
+
const after = event.after ?? {};
|
|
33
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
34
|
+
// Filter out meta-level keys that are always present and rarely meaningful
|
|
35
|
+
// in a human-readable diff (id, created_at, updated_at, organization_id).
|
|
36
|
+
const META = new Set(['id', 'created_at', 'updated_at', 'organization_id', 'org_id']);
|
|
37
|
+
keys.forEach((k) => { if (META.has(k))
|
|
38
|
+
keys.delete(k); });
|
|
39
|
+
return Array.from(keys);
|
|
40
|
+
}
|
|
41
|
+
/** Returns the set of keys where the value actually changed. */
|
|
42
|
+
function changedKeys(event) {
|
|
43
|
+
if (event.changes && Object.keys(event.changes).length > 0) {
|
|
44
|
+
return new Set(Object.keys(event.changes));
|
|
45
|
+
}
|
|
46
|
+
const before = event.before ?? {};
|
|
47
|
+
const after = event.after ?? {};
|
|
48
|
+
const changed = new Set();
|
|
49
|
+
const all = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
50
|
+
all.forEach((k) => {
|
|
51
|
+
if (JSON.stringify(before[k]) !== JSON.stringify(after[k]))
|
|
52
|
+
changed.add(k);
|
|
53
|
+
});
|
|
54
|
+
return changed;
|
|
55
|
+
}
|
|
56
|
+
function resolveColumn(key, columns) {
|
|
57
|
+
return columns?.find((c) => c.key === key);
|
|
58
|
+
}
|
|
59
|
+
function resolveLabel(key, columns) {
|
|
60
|
+
const col = resolveColumn(key, columns);
|
|
61
|
+
if (col?.label)
|
|
62
|
+
return col.label;
|
|
63
|
+
// Humanize snake_case as last resort
|
|
64
|
+
return key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
65
|
+
}
|
|
66
|
+
function actionVariant(action) {
|
|
67
|
+
const a = action.toLowerCase();
|
|
68
|
+
if (a === 'created' || a === 'create')
|
|
69
|
+
return 'created';
|
|
70
|
+
if (a === 'deleted' || a === 'delete')
|
|
71
|
+
return 'deleted';
|
|
72
|
+
if (a === 'updated' || a === 'update')
|
|
73
|
+
return 'updated';
|
|
74
|
+
return 'other';
|
|
75
|
+
}
|
|
76
|
+
const VARIANT_BADGE = {
|
|
77
|
+
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' },
|
|
78
|
+
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' },
|
|
79
|
+
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' },
|
|
80
|
+
other: { label: '', className: 'bg-muted text-muted-foreground border-border' },
|
|
81
|
+
};
|
|
82
|
+
// Subtle row highlight colors — using inline style so arbitrary values are
|
|
83
|
+
// never dropped by the host's Tailwind class scan.
|
|
84
|
+
const ROW_STYLE = {
|
|
85
|
+
created: { background: 'color-mix(in srgb, #22c55e 6%, transparent)' },
|
|
86
|
+
deleted: { background: 'color-mix(in srgb, #ef4444 6%, transparent)' },
|
|
87
|
+
updated: {},
|
|
88
|
+
other: {},
|
|
89
|
+
};
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Component
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
/**
|
|
94
|
+
* Renders the field-level diff of a single ActivityEvent. Shows each field
|
|
95
|
+
* with its label (from `columns`) and value formatted using the column's
|
|
96
|
+
* display type. Supports a toggle to show only changed fields vs. all fields.
|
|
97
|
+
*/
|
|
98
|
+
export const ActivityDiff = ({ event, columns, timeZone, currency, locale = 'es', className, }) => {
|
|
99
|
+
const variant = actionVariant(event.action);
|
|
100
|
+
const allKeys = diffKeys(event);
|
|
101
|
+
const changed = changedKeys(event);
|
|
102
|
+
const [showOnlyChanged, setShowOnlyChanged] = React.useState(true);
|
|
103
|
+
const displayedKeys = showOnlyChanged ? allKeys.filter((k) => changed.has(k)) : allKeys;
|
|
104
|
+
const isCreated = variant === 'created';
|
|
105
|
+
const isDeleted = variant === 'deleted';
|
|
106
|
+
const variantBadge = VARIANT_BADGE[variant] ?? VARIANT_BADGE.other;
|
|
107
|
+
if (allKeys.length === 0 && !event.summary) {
|
|
108
|
+
return (_jsx("div", { className: cn('text-sm text-muted-foreground italic py-1', className), children: "Sin campos registrados." }));
|
|
109
|
+
}
|
|
110
|
+
return (_jsxs("div", { className: cn('space-y-2', className), children: [_jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [_jsx(Badge, { variant: "outline", className: cn('text-xs font-medium px-2 py-0.5', variantBadge.className), children: variantBadge.label || event.action }), changed.size > 0 && variant === 'updated' && (_jsxs("span", { className: "text-xs text-muted-foreground", children: [changed.size, " campo", changed.size !== 1 ? 's' : '', " modificado", changed.size !== 1 ? 's' : ''] })), allKeys.length > 0 && variant === 'updated' && (_jsx("button", { type: "button", onClick: () => setShowOnlyChanged((v) => !v), className: "ml-auto text-xs text-primary hover:underline", children: showOnlyChanged ? `Ver todos (${allKeys.length})` : 'Solo cambios' }))] }), event.summary && (_jsx("p", { className: "text-sm text-muted-foreground italic", children: event.summary })), displayedKeys.length > 0 && (_jsxs("div", { className: "rounded-lg border border-border/60 overflow-hidden text-sm", children: [_jsxs("div", { className: "grid grid-cols-[1fr_1fr_1fr] border-b border-border/40 bg-muted/40 px-3 py-1.5 text-xs font-medium text-muted-foreground", children: [_jsx("span", { children: "Campo" }), !isCreated && _jsx("span", { children: isDeleted ? 'Valor' : 'Antes' }), isCreated && _jsx("span", {}), !isDeleted && _jsx("span", { children: isCreated ? 'Valor' : 'Después' }), isDeleted && _jsx("span", {})] }), displayedKeys.map((key, idx) => {
|
|
111
|
+
const col = resolveColumn(key, columns);
|
|
112
|
+
const label = resolveLabel(key, columns);
|
|
113
|
+
const isChanged = changed.has(key);
|
|
114
|
+
let fromVal;
|
|
115
|
+
let toVal;
|
|
116
|
+
if (event.changes?.[key]) {
|
|
117
|
+
fromVal = event.changes[key].from;
|
|
118
|
+
toVal = event.changes[key].to;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
fromVal = event.before?.[key];
|
|
122
|
+
toVal = event.after?.[key];
|
|
123
|
+
}
|
|
124
|
+
const rowStyle = isCreated
|
|
125
|
+
? ROW_STYLE.created
|
|
126
|
+
: isDeleted
|
|
127
|
+
? ROW_STYLE.deleted
|
|
128
|
+
: isChanged
|
|
129
|
+
? {}
|
|
130
|
+
: {};
|
|
131
|
+
return (_jsxs("div", { style: rowStyle, className: cn('grid grid-cols-[1fr_1fr_1fr] items-start px-3 py-2 gap-x-2', idx !== displayedKeys.length - 1 && 'border-b border-border/30', isChanged && variant === 'updated' && 'bg-yellow-50/40 dark:bg-yellow-950/10'), children: [_jsx("span", { className: "text-xs font-medium text-foreground/70 pt-0.5 truncate", title: label, children: label }), !isCreated ? (_jsx("span", { className: cn(isDeleted ? 'col-span-2' : ''), children: _jsx(ActivityValueRenderer, { value: isDeleted ? fromVal : fromVal, col: col, timeZone: timeZone, currency: currency, locale: locale }) })) : (_jsx("span", {})), !isDeleted ? (_jsxs("span", { className: cn(isCreated ? 'col-span-2' : ''), children: [isChanged && variant === 'updated' && (_jsx("span", { className: "inline-flex items-center gap-1 align-middle mr-1", children: _jsx(ArrowRight, { className: "h-3 w-3 text-muted-foreground/50 shrink-0" }) })), _jsx(ActivityValueRenderer, { value: isCreated ? toVal : toVal, col: col, timeZone: timeZone, currency: currency, locale: locale })] })) : (_jsx("span", {}))] }, key));
|
|
132
|
+
})] }))] }));
|
|
133
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
import * as React from 'react';
|
|
16
|
+
import type { ColumnDefinition } from './types';
|
|
17
|
+
import type { ActivityEvent } from './activity-diff';
|
|
18
|
+
export interface ActivityTimelineProps {
|
|
19
|
+
/**
|
|
20
|
+
* All activity events to display. The component groups, sorts, and filters
|
|
21
|
+
* them client-side — order does not matter.
|
|
22
|
+
*/
|
|
23
|
+
events: ActivityEvent[];
|
|
24
|
+
/**
|
|
25
|
+
* Injectable column metadata resolver. Called once per unique model name
|
|
26
|
+
* encountered in the event list. Returns the column definitions for that
|
|
27
|
+
* model, or undefined/empty when the host has no metadata for it.
|
|
28
|
+
*
|
|
29
|
+
* The host typically implements this as a cache lookup against its
|
|
30
|
+
* MetadataService / metadata-cache store.
|
|
31
|
+
*/
|
|
32
|
+
resolveColumns?: (model: string) => ColumnDefinition[] | undefined;
|
|
33
|
+
/** IANA timezone for datetime cells. */
|
|
34
|
+
timeZone?: string;
|
|
35
|
+
/** ISO 4217 currency for money cells. */
|
|
36
|
+
currency?: string;
|
|
37
|
+
/** BCP-47 locale. Defaults to 'es'. */
|
|
38
|
+
locale?: string;
|
|
39
|
+
/** Class applied to the root element. */
|
|
40
|
+
className?: string;
|
|
41
|
+
/**
|
|
42
|
+
* When true, the filter bar is hidden. Useful when the host already
|
|
43
|
+
* provides external filter controls and wants to feed pre-filtered events.
|
|
44
|
+
*/
|
|
45
|
+
hideFilters?: boolean;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Global activity feed. Groups correlated events, renders a vertical timeline
|
|
49
|
+
* with filter controls (model, actor, action, date range).
|
|
50
|
+
*
|
|
51
|
+
* The `resolveColumns` function is injected by the host. It maps a model name
|
|
52
|
+
* to its `ColumnDefinition[]` (e.g. from the host's metadata-cache store).
|
|
53
|
+
* When omitted or when it returns nothing, field keys are shown raw.
|
|
54
|
+
*/
|
|
55
|
+
export declare const ActivityTimeline: React.FC<ActivityTimelineProps>;
|
|
56
|
+
//# sourceMappingURL=activity-timeline.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"activity-timeline.d.ts","sourceRoot":"","sources":["../src/activity-timeline.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AA+B9B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAC/C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAOpD,MAAM,WAAW,qBAAqB;IAClC;;;OAGG;IACH,MAAM,EAAE,aAAa,EAAE,CAAA;IACvB;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,gBAAgB,EAAE,GAAG,SAAS,CAAA;IAClE,wCAAwC;IACxC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,yCAAyC;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,uCAAuC;IACvC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,yCAAyC;IACzC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;OAGG;IACH,WAAW,CAAC,EAAE,OAAO,CAAA;CACxB;AAqQD;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,EAAE,KAAK,CAAC,EAAE,CAAC,qBAAqB,CA8N5D,CAAA"}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* activity-timeline.tsx
|
|
4
|
+
*
|
|
5
|
+
* <ActivityTimeline> — global activity feed grouped by correlation_id.
|
|
6
|
+
*
|
|
7
|
+
* Events that share a correlation_id are folded into a single "operation"
|
|
8
|
+
* group (e.g. "Juan · Pedido creado · 4 cambios"). Events without a
|
|
9
|
+
* correlation_id appear as standalone entries.
|
|
10
|
+
*
|
|
11
|
+
* Filters: by model, actor, action, and date range. All client-side.
|
|
12
|
+
*
|
|
13
|
+
* Transport-agnostic: events arrive via props; column metadata is resolved
|
|
14
|
+
* per-model via the `resolveColumns(model)` injected function. No fetch.
|
|
15
|
+
*/
|
|
16
|
+
import * as React from 'react';
|
|
17
|
+
import { formatDistanceToNow } from 'date-fns';
|
|
18
|
+
import { es, enUS } from 'date-fns/locale';
|
|
19
|
+
import { ChevronDown, ChevronRight, Clock, Filter, X, Layers, Activity, } from 'lucide-react';
|
|
20
|
+
import { cn } from '@asteby/metacore-ui/lib';
|
|
21
|
+
import { Avatar, AvatarFallback, Badge, Collapsible, CollapsibleContent, CollapsibleTrigger, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Button, } from '@asteby/metacore-ui/primitives';
|
|
22
|
+
import { getInitials } from '@asteby/metacore-ui/lib';
|
|
23
|
+
import { ActivityDiff } from './activity-diff';
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
const ACTION_DOT_COLOR = {
|
|
28
|
+
created: '#22c55e',
|
|
29
|
+
create: '#22c55e',
|
|
30
|
+
updated: '#eab308',
|
|
31
|
+
update: '#eab308',
|
|
32
|
+
deleted: '#ef4444',
|
|
33
|
+
delete: '#ef4444',
|
|
34
|
+
};
|
|
35
|
+
function actionDotColor(action) {
|
|
36
|
+
return ACTION_DOT_COLOR[action.toLowerCase()] ?? '#6b7280';
|
|
37
|
+
}
|
|
38
|
+
const ACTION_LABELS_ES = {
|
|
39
|
+
created: 'creó',
|
|
40
|
+
create: 'creó',
|
|
41
|
+
updated: 'actualizó',
|
|
42
|
+
update: 'actualizó',
|
|
43
|
+
deleted: 'eliminó',
|
|
44
|
+
delete: 'eliminó',
|
|
45
|
+
};
|
|
46
|
+
function groupEvents(events) {
|
|
47
|
+
const grouped = new Map();
|
|
48
|
+
for (const ev of events) {
|
|
49
|
+
const key = ev.correlation_id || ev.id;
|
|
50
|
+
const arr = grouped.get(key) ?? [];
|
|
51
|
+
arr.push(ev);
|
|
52
|
+
grouped.set(key, arr);
|
|
53
|
+
}
|
|
54
|
+
const result = [];
|
|
55
|
+
grouped.forEach((evs, key) => {
|
|
56
|
+
// Sort ascending (oldest first within a group)
|
|
57
|
+
const sorted = [...evs].sort((a, b) => new Date(a.occurred_at).getTime() - new Date(b.occurred_at).getTime());
|
|
58
|
+
const root = sorted[0];
|
|
59
|
+
// Count distinct changed fields across all events in this group
|
|
60
|
+
let changedFieldCount = 0;
|
|
61
|
+
for (const ev of sorted) {
|
|
62
|
+
if (ev.changes)
|
|
63
|
+
changedFieldCount += Object.keys(ev.changes).length;
|
|
64
|
+
else if (ev.before || ev.after) {
|
|
65
|
+
const keys = new Set([...Object.keys(ev.before ?? {}), ...Object.keys(ev.after ?? {})]);
|
|
66
|
+
changedFieldCount += keys.size;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
result.push({ key, root, events: sorted, changedFieldCount });
|
|
70
|
+
});
|
|
71
|
+
// Sort groups: most recent root event first
|
|
72
|
+
result.sort((a, b) => new Date(b.root.occurred_at).getTime() - new Date(a.root.occurred_at).getTime());
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
function uniqueValues(events, key) {
|
|
76
|
+
const set = new Set();
|
|
77
|
+
for (const ev of events) {
|
|
78
|
+
const v = ev[key];
|
|
79
|
+
if (v !== undefined && v !== null && v !== '')
|
|
80
|
+
set.add(String(v));
|
|
81
|
+
}
|
|
82
|
+
return Array.from(set).sort();
|
|
83
|
+
}
|
|
84
|
+
const GroupCard = ({ group, resolveColumns, timeZone, currency, locale, dateLocale, isOpen, onToggle, }) => {
|
|
85
|
+
const { root, events, changedFieldCount } = group;
|
|
86
|
+
const isMulti = events.length > 1;
|
|
87
|
+
const actor = root.actor_label || root.actor_id || 'Sistema';
|
|
88
|
+
const dotColor = actionDotColor(root.action);
|
|
89
|
+
const timeAgo = (() => {
|
|
90
|
+
try {
|
|
91
|
+
return formatDistanceToNow(new Date(root.occurred_at), { addSuffix: true, locale: dateLocale });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return root.occurred_at;
|
|
95
|
+
}
|
|
96
|
+
})();
|
|
97
|
+
const fullDate = (() => {
|
|
98
|
+
try {
|
|
99
|
+
return new Date(root.occurred_at).toLocaleString(locale === 'en' ? 'en-US' : 'es-MX', {
|
|
100
|
+
...(timeZone ? { timeZone } : {}),
|
|
101
|
+
dateStyle: 'medium',
|
|
102
|
+
timeStyle: 'short',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return root.occurred_at;
|
|
107
|
+
}
|
|
108
|
+
})();
|
|
109
|
+
const summaryLine = root.summary
|
|
110
|
+
? root.summary
|
|
111
|
+
: `${actor} ${ACTION_LABELS_ES[root.action.toLowerCase()] ?? root.action} ${root.model}`;
|
|
112
|
+
return (_jsx(Collapsible, { open: isOpen, onOpenChange: onToggle, children: _jsxs("div", { className: "relative", children: [_jsx("span", { className: "absolute -left-5 top-4 h-2.5 w-2.5 rounded-full border-2 border-background -translate-x-[4px]", style: { background: dotColor }, "aria-hidden": "true" }), _jsxs("div", { className: "rounded-lg border border-border/60 bg-card overflow-hidden", children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: "w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-muted/30 transition-colors", children: [_jsx(Avatar, { className: "h-7 w-7 rounded-full shrink-0 mt-0.5", children: _jsx(AvatarFallback, { className: "text-[9px] font-bold bg-primary/10 text-primary", children: getInitials(actor) }) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("p", { className: "text-sm font-medium text-foreground truncate", title: summaryLine, children: summaryLine }), _jsxs("div", { className: "flex items-center gap-2 mt-1 flex-wrap", children: [_jsxs("span", { className: "inline-flex items-center gap-1 text-xs text-muted-foreground", children: [_jsx(Clock, { className: "h-3 w-3 opacity-60 shrink-0" }), _jsx("span", { title: fullDate, children: timeAgo })] }), _jsx(Badge, { variant: "outline", className: "text-[10px] px-1.5 py-0 h-4", children: root.model }), isMulti && (_jsxs("span", { className: "inline-flex items-center gap-1 text-xs text-muted-foreground", children: [_jsx(Layers, { className: "h-3 w-3 opacity-60 shrink-0" }), events.length, " eventos"] })), changedFieldCount > 0 && (_jsxs("span", { className: "text-xs text-muted-foreground", children: [changedFieldCount, " campo", changedFieldCount !== 1 ? 's' : ''] }))] })] }), _jsx("span", { className: "shrink-0 text-muted-foreground mt-1", children: isOpen ? _jsx(ChevronDown, { className: "h-4 w-4" }) : _jsx(ChevronRight, { className: "h-4 w-4" }) })] }) }), _jsx(CollapsibleContent, { children: _jsx("div", { className: "border-t border-border/40 divide-y divide-border/30", children: events.map((ev, idx) => {
|
|
113
|
+
const cols = resolveColumns(ev.model);
|
|
114
|
+
const evActor = ev.actor_label || ev.actor_id || 'Sistema';
|
|
115
|
+
return (_jsxs("div", { className: "px-4 py-3 space-y-2", children: [isMulti && (_jsxs("div", { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [_jsx("span", { className: "font-medium text-foreground/70", children: ev.model }), _jsx("span", { children: "\u00B7" }), _jsx("span", { children: evActor }), idx > 0 && (_jsxs(_Fragment, { children: [_jsx("span", { children: "\u00B7" }), _jsx("span", { title: ev.occurred_at, children: (() => {
|
|
116
|
+
try {
|
|
117
|
+
return formatDistanceToNow(new Date(ev.occurred_at), { addSuffix: true, locale: dateLocale });
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return ev.occurred_at;
|
|
121
|
+
}
|
|
122
|
+
})() })] }))] })), _jsx(ActivityDiff, { event: ev, columns: cols, timeZone: timeZone, currency: currency, locale: locale })] }, ev.id));
|
|
123
|
+
}) }) })] })] }) }));
|
|
124
|
+
};
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Main component
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
/**
|
|
129
|
+
* Global activity feed. Groups correlated events, renders a vertical timeline
|
|
130
|
+
* with filter controls (model, actor, action, date range).
|
|
131
|
+
*
|
|
132
|
+
* The `resolveColumns` function is injected by the host. It maps a model name
|
|
133
|
+
* to its `ColumnDefinition[]` (e.g. from the host's metadata-cache store).
|
|
134
|
+
* When omitted or when it returns nothing, field keys are shown raw.
|
|
135
|
+
*/
|
|
136
|
+
export const ActivityTimeline = ({ events, resolveColumns = () => undefined, timeZone, currency, locale = 'es', className, hideFilters = false, }) => {
|
|
137
|
+
const dateLocale = locale === 'en' ? enUS : es;
|
|
138
|
+
// -----------------------------------------------------------------------
|
|
139
|
+
// Filter state
|
|
140
|
+
// -----------------------------------------------------------------------
|
|
141
|
+
const [filterModel, setFilterModel] = React.useState('__all__');
|
|
142
|
+
const [filterActor, setFilterActor] = React.useState('__all__');
|
|
143
|
+
const [filterAction, setFilterAction] = React.useState('__all__');
|
|
144
|
+
const [filterFrom, setFilterFrom] = React.useState('');
|
|
145
|
+
const [filterTo, setFilterTo] = React.useState('');
|
|
146
|
+
const models = React.useMemo(() => uniqueValues(events, 'model'), [events]);
|
|
147
|
+
const actors = React.useMemo(() => uniqueValues(events, 'actor_label').filter(Boolean), [events]);
|
|
148
|
+
const actions = React.useMemo(() => uniqueValues(events, 'action'), [events]);
|
|
149
|
+
const hasFilters = filterModel !== '__all__' ||
|
|
150
|
+
filterActor !== '__all__' ||
|
|
151
|
+
filterAction !== '__all__' ||
|
|
152
|
+
filterFrom !== '' ||
|
|
153
|
+
filterTo !== '';
|
|
154
|
+
const clearFilters = () => {
|
|
155
|
+
setFilterModel('__all__');
|
|
156
|
+
setFilterActor('__all__');
|
|
157
|
+
setFilterAction('__all__');
|
|
158
|
+
setFilterFrom('');
|
|
159
|
+
setFilterTo('');
|
|
160
|
+
};
|
|
161
|
+
// -----------------------------------------------------------------------
|
|
162
|
+
// Filtered + grouped events
|
|
163
|
+
// -----------------------------------------------------------------------
|
|
164
|
+
const filtered = React.useMemo(() => {
|
|
165
|
+
return events.filter((ev) => {
|
|
166
|
+
if (filterModel !== '__all__' && ev.model !== filterModel)
|
|
167
|
+
return false;
|
|
168
|
+
if (filterActor !== '__all__' && ev.actor_label !== filterActor)
|
|
169
|
+
return false;
|
|
170
|
+
if (filterAction !== '__all__' && ev.action !== filterAction)
|
|
171
|
+
return false;
|
|
172
|
+
if (filterFrom) {
|
|
173
|
+
const from = new Date(filterFrom);
|
|
174
|
+
if (new Date(ev.occurred_at) < from)
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
if (filterTo) {
|
|
178
|
+
const to = new Date(filterTo);
|
|
179
|
+
// inclusive end-of-day
|
|
180
|
+
to.setHours(23, 59, 59, 999);
|
|
181
|
+
if (new Date(ev.occurred_at) > to)
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
});
|
|
186
|
+
}, [events, filterModel, filterActor, filterAction, filterFrom, filterTo]);
|
|
187
|
+
const groups = React.useMemo(() => groupEvents(filtered), [filtered]);
|
|
188
|
+
// -----------------------------------------------------------------------
|
|
189
|
+
// Open/close state (first group open by default)
|
|
190
|
+
// -----------------------------------------------------------------------
|
|
191
|
+
const [openKeys, setOpenKeys] = React.useState(() => groups.length > 0 ? new Set([groups[0].key]) : new Set());
|
|
192
|
+
// Reset open state when filtered groups change substantially
|
|
193
|
+
const prevGroupKeysRef = React.useRef('');
|
|
194
|
+
React.useEffect(() => {
|
|
195
|
+
const current = groups.map((g) => g.key).join(',');
|
|
196
|
+
if (current !== prevGroupKeysRef.current) {
|
|
197
|
+
prevGroupKeysRef.current = current;
|
|
198
|
+
setOpenKeys(groups.length > 0 ? new Set([groups[0].key]) : new Set());
|
|
199
|
+
}
|
|
200
|
+
}, [groups]);
|
|
201
|
+
const toggleGroup = (key) => {
|
|
202
|
+
setOpenKeys((prev) => {
|
|
203
|
+
const next = new Set(prev);
|
|
204
|
+
if (next.has(key))
|
|
205
|
+
next.delete(key);
|
|
206
|
+
else
|
|
207
|
+
next.add(key);
|
|
208
|
+
return next;
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
// -----------------------------------------------------------------------
|
|
212
|
+
// Render
|
|
213
|
+
// -----------------------------------------------------------------------
|
|
214
|
+
return (_jsxs("div", { className: cn('space-y-4', className), children: [!hideFilters && (_jsxs("div", { className: "rounded-lg border border-border/60 bg-muted/20 p-3 space-y-3", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Filter, { className: "h-4 w-4 text-muted-foreground shrink-0" }), _jsx("span", { className: "text-sm font-medium text-muted-foreground", children: "Filtros" }), hasFilters && (_jsxs(Button, { variant: "ghost", size: "sm", className: "ml-auto h-7 px-2 text-xs", onClick: clearFilters, children: [_jsx(X, { className: "h-3 w-3 mr-1" }), "Limpiar"] }))] }), _jsxs("div", { className: "grid grid-cols-2 gap-2 sm:grid-cols-4", children: [models.length > 1 && (_jsxs(Select, { value: filterModel, onValueChange: setFilterModel, children: [_jsx(SelectTrigger, { className: "h-8 text-xs", children: _jsx(SelectValue, { placeholder: "M\u00F3dulo" }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "__all__", children: "Todos los m\u00F3dulos" }), models.map((m) => (_jsx(SelectItem, { value: m, children: m }, m)))] })] })), actors.length > 1 && (_jsxs(Select, { value: filterActor, onValueChange: setFilterActor, children: [_jsx(SelectTrigger, { className: "h-8 text-xs", children: _jsx(SelectValue, { placeholder: "Usuario" }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "__all__", children: "Todos los usuarios" }), actors.map((a) => (_jsx(SelectItem, { value: a, children: a }, a)))] })] })), actions.length > 1 && (_jsxs(Select, { value: filterAction, onValueChange: setFilterAction, children: [_jsx(SelectTrigger, { className: "h-8 text-xs", children: _jsx(SelectValue, { placeholder: "Acci\u00F3n" }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "__all__", children: "Todas las acciones" }), actions.map((a) => (_jsx(SelectItem, { value: a, children: a }, a)))] })] })), _jsxs("div", { className: "col-span-2 sm:col-span-1 flex gap-1", children: [_jsx(Input, { type: "date", value: filterFrom, onChange: (e) => setFilterFrom(e.target.value), className: "h-8 text-xs", placeholder: "Desde", title: "Desde" }), _jsx(Input, { type: "date", value: filterTo, onChange: (e) => setFilterTo(e.target.value), className: "h-8 text-xs", placeholder: "Hasta", title: "Hasta" })] })] })] })), groups.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center py-16 gap-3 text-muted-foreground", children: [_jsx(Activity, { className: "h-10 w-10 opacity-30" }), _jsx("p", { className: "text-sm", children: hasFilters ? 'Sin resultados con los filtros actuales.' : 'Sin actividad registrada.' })] })) : (_jsxs("div", { className: "relative pl-5 space-y-3", children: [_jsx("span", { className: "absolute left-2 top-2 bottom-2 w-px bg-border", "aria-hidden": "true" }), groups.map((group) => (_jsx(GroupCard, { group: group, resolveColumns: resolveColumns, timeZone: timeZone, currency: currency, locale: locale, dateLocale: dateLocale, isOpen: openKeys.has(group.key), onToggle: () => toggleGroup(group.key) }, group.key)))] }))] }));
|
|
215
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* activity-value-renderer.tsx
|
|
3
|
+
*
|
|
4
|
+
* Pure, transport-agnostic value renderer for Activity / Time Machine diffs.
|
|
5
|
+
* Reuses the same display-type logic as dynamic-columns.tsx (currency, status,
|
|
6
|
+
* date, boolean, badge, relation, url, tags, color, number, percent) so the
|
|
7
|
+
* diff cells and the table cells are always consistent.
|
|
8
|
+
*
|
|
9
|
+
* Kept in its own module so it has no dependency on tanstack-table or the
|
|
10
|
+
* column-factory machinery — only React + metacore-ui primitives.
|
|
11
|
+
*/
|
|
12
|
+
import * as React from 'react';
|
|
13
|
+
import type { ColumnDefinition } from './types';
|
|
14
|
+
export interface ActivityValueRendererProps {
|
|
15
|
+
/** The raw value to display (from before/after/changes). */
|
|
16
|
+
value: unknown;
|
|
17
|
+
/** Column metadata for display formatting. Optional — falls back to string. */
|
|
18
|
+
col?: ColumnDefinition;
|
|
19
|
+
/** IANA timezone (org config) for datetime cells. */
|
|
20
|
+
timeZone?: string;
|
|
21
|
+
/** ISO 4217 currency for money cells. */
|
|
22
|
+
currency?: string;
|
|
23
|
+
/** BCP-47 locale tag (e.g. 'es', 'en'). Defaults to 'es'. */
|
|
24
|
+
locale?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Renders a single field value from an activity event using the same display
|
|
28
|
+
* type logic as `defaultGetDynamicColumns`. Pass a `ColumnDefinition` to get
|
|
29
|
+
* rich formatting (currency, status badge, date, boolean, etc.); without it
|
|
30
|
+
* the component falls back to a plain string representation.
|
|
31
|
+
*/
|
|
32
|
+
export declare const ActivityValueRenderer: React.FC<ActivityValueRendererProps>;
|
|
33
|
+
//# sourceMappingURL=activity-value-renderer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"activity-value-renderer.d.ts","sourceRoot":"","sources":["../src/activity-value-renderer.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAU9B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAiD/C,MAAM,WAAW,0BAA0B;IACvC,4DAA4D;IAC5D,KAAK,EAAE,OAAO,CAAA;IACd,+EAA+E;IAC/E,GAAG,CAAC,EAAE,gBAAgB,CAAA;IACtB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,yCAAyC;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,6DAA6D;IAC7D,MAAM,CAAC,EAAE,MAAM,CAAA;CAClB;AAED;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,EAAE,CAAC,0BAA0B,CAwRtE,CAAA"}
|