import React, { useMemo, useState } from 'react'; import { useUserActivity } from '@/lib/krowHooks'; import { AdminPage } from '@/components/admin/PageShell'; import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer'; import { composePage } from '@/lib/ui/composition'; import { useUiEditing } from '@/components/ui-tree/UiEditingProvider'; import { UiEditor } from '@/components/ui-editor/UiEditor'; import '@/components/ui-tree/nodeTypes'; import '@/pages/admin/activity/nodes'; /** * Admin Activity — an audit centre. * * Trust comes from being able to answer "who did what, when" quickly, so this is * a filterable table rather than a feed of cards. Severity is derived rather * than stored: privileged actions and deletions are what an auditor looks for * first, so they are surfaced as such. */ /** Privileged actions carry more audit weight than reads. */ const SEVERITY = { hire_candidate: 'high', create_position: 'high', assign_employee: 'medium', screen_candidate: 'medium', start_interview: 'medium', signup: 'low', login: 'low', logout: 'low', apply_job: 'low', }; /** How many events the timeline shows before the table takes over. */ const TIMELINE_LIMIT = 12; /** Stable pseudo-IP from the email, so the demo shows metadata without inventing * a value that changes on every render. */ const ipFor = (email) => { let hash = 0; for (let i = 0; i < String(email).length; i += 1) hash = (hash * 31 + String(email).charCodeAt(i)) % 65536; return `10.${(hash >> 8) & 255}.${hash & 255}.${(hash % 200) + 20}`; }; export default function AdminActivity() { const { data: activity = [], isLoading } = useUserActivity(); const [search, setSearch] = useState(''); const [user, setUser] = useState('all'); const [action, setAction] = useState('all'); const [severity, setSeverity] = useState('all'); const [range, setRange] = useState('all'); const events = useMemo(() => activity.map((e) => ({ ...e, severity: SEVERITY[e.event_type] || 'low', ip: ipFor(e.user_email), })), [activity]); const users = useMemo( () => [...new Set(events.map((e) => e.user_email).filter(Boolean))].sort(), [events] ); const actions = useMemo( () => [...new Set(events.map((e) => e.event_type))].sort(), [events] ); const filtered = useMemo(() => { const q = search.toLowerCase(); const now = Date.now(); const windows = { '24h': 1, '7d': 7, '30d': 30 }; return events.filter((e) => { if (q && !`${e.user_name} ${e.user_email} ${e.event_type} ${e.details}`.toLowerCase().includes(q)) return false; if (user !== 'all' && e.user_email !== user) return false; if (action !== 'all' && e.event_type !== action) return false; if (severity !== 'all' && e.severity !== severity) return false; if (range !== 'all') { const days = windows[range]; if (now - new Date(e.created_date).getTime() > days * 86400000) return false; } return true; }); }, [events, search, user, action, severity, range]); const isFiltered = Boolean(search) || [user, action, severity, range].some((v) => v !== 'all'); const clearFilters = () => { setSearch(''); setUser('all'); setAction('all'); setSeverity('all'); setRange('all'); }; const highRisk = events.filter((e) => e.severity === 'high'); const last24h = events.filter((e) => Date.now() - new Date(e.created_date).getTime() < 86400000); /* The timeline reads newest first and is capped: a chronology is for context, and past a dozen entries the table below does the job better. */ const recent = useMemo( () => events .slice() .sort((a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime()) .slice(0, TIMELINE_LIMIT), [events] ); /* Grouped by calendar day, because "which day" is the first thing anyone reads off a timeline and repeating the date on every row buries it. */ const byDay = useMemo(() => { const groups = new Map(); recent.forEach((e) => { const key = new Date(e.created_date).toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric', }); if (!groups.has(key)) groups.set(key, []); groups.get(key).push(e); }); return [...groups.entries()]; }, [recent]); /** * What this page's sections read. * * Published once, as one bag. The renderer passes it down untouched and never * looks inside, which is what keeps it ignorant of what an Activity page * contains — and what lets the next page publish something entirely * different without the renderer changing. */ const context = useMemo(() => ({ isLoading, events, users, actions, filtered, isFiltered, clearFilters, search, setSearch, user, setUser, action, setAction, severity, setSeverity, range, setRange, highRisk, last24h, recent, byDay, }), [ isLoading, events, users, actions, filtered, isFiltered, search, user, action, severity, range, highRisk, last24h, recent, byDay, ]); return ( {/* The editing session is mounted by the layout, so the panel beside this page shares it. The page only renders what it is handed, which is why a preview in chat and the applied state cannot diverge. */} ); } /** The composed page. Split out so it can read the tree the provider computed. */ function ActivityComposition({ context }) { const editing = useUiEditing(); /* Outside a layout — a test, a storybook — there is no session, and the page still has to draw. It falls back to the composition as shipped. */ const tree = editing?.tree || composePage('activity').tree; return ; }