156 lines
5.8 KiB
JavaScript
156 lines
5.8 KiB
JavaScript
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 (
|
|
<AdminPage
|
|
title="Activity"
|
|
subtitle="Monitor workforce operations, user actions and system events."
|
|
>
|
|
{/* 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. */}
|
|
<UiEditor />
|
|
<ActivityComposition context={context} />
|
|
</AdminPage>
|
|
);
|
|
}
|
|
|
|
/** 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 <UiTreeRenderer nodes={tree} context={context} />;
|
|
}
|