Files
krow_talent_app/src/pages/admin/Activity.jsx
Aravind 6249e00a3a
Some checks failed
CI / check (push) Failing after 4m58s
candidates and board ui agent issue
2026-09-05 10:46:06 +05:30

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} />;
}