feat(cc-049): Activity Timeline — audit-log timeline with date grouping
This commit is contained in:
parent
ca8e3e48e7
commit
db8eb1787b
1 changed files with 120 additions and 0 deletions
120
src/app/dashboard/activity/page.tsx
Normal file
120
src/app/dashboard/activity/page.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { apiFetch, type AuditLogEntry } from '../../../lib/api';
|
||||||
|
import { useSession } from '../../../components/session-provider';
|
||||||
|
|
||||||
|
function getActionMeta(action: string): { icon: string; colorClass: string } {
|
||||||
|
if (action.includes('created')) return { icon: '+', colorClass: 'text-signal-ok' };
|
||||||
|
if (action.includes('deleted') || action.includes('removed'))
|
||||||
|
return { icon: '×', colorClass: 'text-signal-danger' };
|
||||||
|
if (
|
||||||
|
action.includes('updated') ||
|
||||||
|
action.includes('changed') ||
|
||||||
|
action.includes('selected') ||
|
||||||
|
action.includes('rebuilt')
|
||||||
|
)
|
||||||
|
return { icon: '~', colorClass: 'text-signal-warn' };
|
||||||
|
if (action.includes('auth') || action.includes('login'))
|
||||||
|
return { icon: '⚿', colorClass: 'text-bronze-deep' };
|
||||||
|
return { icon: '·', colorClass: 'text-ink-faint' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAction(action: string): string {
|
||||||
|
return action.replace(/\./g, ' · ').replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByDate(entries: AuditLogEntry[]): [string, AuditLogEntry[]][] {
|
||||||
|
const map = new Map<string, AuditLogEntry[]>();
|
||||||
|
for (const e of entries) {
|
||||||
|
const key = new Date(e.createdAt).toLocaleDateString('ro-RO', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key)!.push(e);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityTimelinePage() {
|
||||||
|
const { activeTenant } = useSession();
|
||||||
|
const tenantId = activeTenant?.tenantId ?? '';
|
||||||
|
|
||||||
|
const { data: entries = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['audit-log-timeline', tenantId],
|
||||||
|
queryFn: () => apiFetch<AuditLogEntry[]>('/v1/audit-log?limit=200', { tenantId }),
|
||||||
|
enabled: Boolean(tenantId),
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = groupByDate(entries);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-2xl font-semibold text-ink">Timeline Activitate</h1>
|
||||||
|
<p className="mt-1 text-sm text-ink-faint">
|
||||||
|
Cronologia operațiunilor din workspace —{' '}
|
||||||
|
{isLoading ? '…' : `${entries.length} evenimente`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="card p-8 text-center">
|
||||||
|
<p className="text-sm text-ink-faint">Se încarcă activitatea…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && entries.length === 0 && (
|
||||||
|
<div className="card p-8 text-center">
|
||||||
|
<p className="text-sm font-medium text-ink">Nicio activitate înregistrată</p>
|
||||||
|
<p className="mt-1 text-xs text-ink-faint">
|
||||||
|
Timeline-ul se completează automat pe măsură ce sunt efectuate operațiuni.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groups.map(([date, dayEntries]) => (
|
||||||
|
<div key={date}>
|
||||||
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-ink-faint">
|
||||||
|
{date}
|
||||||
|
</p>
|
||||||
|
<div className="relative border-l-2 border-paper-sunken pl-5 space-y-2">
|
||||||
|
{dayEntries.map((entry) => {
|
||||||
|
const { icon, colorClass } = getActionMeta(entry.action);
|
||||||
|
return (
|
||||||
|
<div key={entry.id} className="relative">
|
||||||
|
<span
|
||||||
|
className={`absolute -left-[1.55rem] flex h-5 w-5 items-center justify-center rounded-full border border-paper-sunken bg-paper-raised text-[10px] font-bold ${colorClass}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<div className="card flex items-center justify-between gap-4 px-4 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className={`text-sm font-medium capitalize ${colorClass}`}>
|
||||||
|
{formatAction(entry.action)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 truncate font-mono text-[11px] text-ink-faint">
|
||||||
|
{entry.resource}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-[11px] tabular-nums text-ink-line">
|
||||||
|
{new Date(entry.createdAt).toLocaleTimeString('ro-RO', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue