ui changes
This commit is contained in:
298
src/pages/admin/Activity.jsx
Normal file
298
src/pages/admin/Activity.jsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Activity, FilePlus2, LogIn, LogOut, Mic, ScanSearch, Send, ShieldAlert, UserCheck, UserPlus,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Avatar, Badge, DataTable, MetricStrip, SearchInput, Surface, Timeline,
|
||||
} from '@/components/ds';
|
||||
import { useUserActivity } from '@/lib/krowHooks';
|
||||
import { AdminPage, SectionTitle, Toolbar } from '@/pages/admin/_shell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
|
||||
/**
|
||||
* 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',
|
||||
screen_candidate: 'medium',
|
||||
start_interview: 'medium',
|
||||
signup: 'low',
|
||||
login: 'low',
|
||||
logout: 'low',
|
||||
apply_job: 'low',
|
||||
};
|
||||
|
||||
const SEVERITY_META = {
|
||||
high: { label: 'High', variant: 'destructive' },
|
||||
medium: { label: 'Medium', variant: 'warning' },
|
||||
low: { label: 'Low', variant: 'neutral' },
|
||||
};
|
||||
|
||||
/** Severity carried through to the timeline node, so the two views agree. */
|
||||
const TIMELINE_TONE = { high: 'destructive', medium: 'warning', low: 'neutral' };
|
||||
|
||||
/** How many events the timeline shows before the table takes over. */
|
||||
const TIMELINE_LIMIT = 12;
|
||||
|
||||
/**
|
||||
* An icon per event type. Worth the table: on a chronology the glyph is what
|
||||
* makes a run of hires distinguishable from a run of logins at a glance, which is
|
||||
* the whole reason to show a timeline rather than another list of rows.
|
||||
*/
|
||||
const EVENT_ICON = {
|
||||
hire_candidate: UserCheck,
|
||||
create_position: FilePlus2,
|
||||
screen_candidate: ScanSearch,
|
||||
start_interview: Mic,
|
||||
apply_job: Send,
|
||||
signup: UserPlus,
|
||||
login: LogIn,
|
||||
logout: LogOut,
|
||||
};
|
||||
|
||||
/** 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) - new Date(a.created_date))
|
||||
.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]);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Activity"
|
||||
subtitle="Monitor workforce operations, user actions and system events."
|
||||
>
|
||||
<MetricStrip
|
||||
columns={4}
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{ label: 'Total events', value: events.length },
|
||||
{ label: 'Last 24 hours', value: last24h.length, tone: last24h.length ? 'default' : 'warning' },
|
||||
{ label: 'Privileged actions', value: highRisk.length, tone: 'warning', sub: 'hires and position changes' },
|
||||
{ label: 'Distinct users', value: users.length },
|
||||
]}
|
||||
/>
|
||||
|
||||
{highRisk.length > 0 && (
|
||||
<Surface variant="solid" radius="lg" padding="sm" elevation="xs" className="border-warning/30 bg-warning-muted">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" aria-hidden="true" />
|
||||
<p className="text-body-sm text-ink-2">
|
||||
<span className="font-semibold text-ink-1">{highRisk.length} privileged actions</span> in this log —
|
||||
hires and position changes. These should always trace to a named person.
|
||||
</p>
|
||||
</div>
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
{/* The recent timeline. Deliberately above the log and deliberately not
|
||||
filtered: this answers "what just happened", which is a different
|
||||
question from the one the toolbar below exists to ask. Reading a
|
||||
chronology is also how an auditor starts — sequence first, then
|
||||
interrogate the specifics. */}
|
||||
<section aria-labelledby="timeline" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="timeline"
|
||||
title="Operational timeline"
|
||||
meta={`Most recent ${recent.length} events`}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
{recent.length ? (
|
||||
<div className="divide-y divide-border">
|
||||
{byDay.map(([day, dayEvents]) => (
|
||||
<div key={day} className="px-4 py-3.5">
|
||||
<p className="mb-3 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
{day}
|
||||
</p>
|
||||
<Timeline
|
||||
compact
|
||||
items={dayEvents.map((e) => ({
|
||||
id: e.id,
|
||||
icon: EVENT_ICON[e.event_type] || Activity,
|
||||
tone: TIMELINE_TONE[e.severity],
|
||||
title: e.event_type.replace(/_/g, ' '),
|
||||
description: e.details || undefined,
|
||||
meta: `${e.user_name || e.user_email}${e.account_type ? ` · ${e.account_type}` : ''}`,
|
||||
timestamp: new Date(e.created_date).toLocaleTimeString(undefined, {
|
||||
hour: 'numeric', minute: '2-digit',
|
||||
}),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
|
||||
No activity has been logged yet.
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="audit" className="space-y-3">
|
||||
<SectionTitle id="audit" title="Audit log" meta={`${filtered.length} of ${events.length} events`} />
|
||||
|
||||
<Toolbar
|
||||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search events" size="sm" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSelect value={user} onChange={setUser} label="User"
|
||||
options={[{ value: 'all', label: 'All users' }, ...users.map((u) => ({ value: u, label: u }))]} />
|
||||
<FilterSelect value={action} onChange={setAction} label="Action"
|
||||
options={[{ value: 'all', label: 'All actions' }, ...actions.map((a) => ({ value: a, label: a.replace(/_/g, ' ') }))]} />
|
||||
<FilterSelect value={severity} onChange={setSeverity} label="Severity" options={[
|
||||
{ value: 'all', label: 'All severities' }, { value: 'high', label: 'High' },
|
||||
{ value: 'medium', label: 'Medium' }, { value: 'low', label: 'Low' },
|
||||
]} />
|
||||
<FilterSelect value={range} onChange={setRange} label="Date" options={[
|
||||
{ value: 'all', label: 'All time' }, { value: '24h', label: 'Last 24 hours' },
|
||||
{ value: '7d', label: 'Last 7 days' }, { value: '30d', label: 'Last 30 days' },
|
||||
]} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
loading={isLoading}
|
||||
rows={filtered}
|
||||
pageSize={20}
|
||||
isFiltered={isFiltered}
|
||||
onClearFilters={clearFilters}
|
||||
caption="Platform audit log"
|
||||
rowClassName={(e) => (e.severity === 'high' ? 'bg-warning-muted/40' : undefined)}
|
||||
columns={[
|
||||
{
|
||||
key: 'user_name', header: 'User', sortable: true,
|
||||
cell: (e) => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar name={e.user_name || e.user_email} size="xs" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-ink-1">{e.user_name || '—'}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{e.user_email}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'event_type', header: 'Action', sortable: true,
|
||||
cell: (e) => <span className="whitespace-nowrap">{e.event_type.replace(/_/g, ' ')}</span>,
|
||||
},
|
||||
{
|
||||
key: 'details', header: 'Entity', hideBelow: 'md',
|
||||
cell: (e) => <span className="line-clamp-1 text-ink-3">{e.details || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'account_type', header: 'Role', hideBelow: 'lg',
|
||||
cell: (e) => <Badge variant={e.account_type === 'employer' ? 'info' : 'neutral'} size="sm">{e.account_type || 'unknown'}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'ip', header: 'Source IP', hideBelow: 'lg',
|
||||
cell: (e) => <span className="font-mono text-[11px] text-ink-4">{e.ip}</span>,
|
||||
},
|
||||
{
|
||||
key: 'created_date', header: 'Timestamp', align: 'right', sortable: true,
|
||||
sortValue: (e) => new Date(e.created_date).getTime(),
|
||||
cell: (e) => (
|
||||
<span className="whitespace-nowrap text-[11px] text-ink-4">
|
||||
{new Date(e.created_date).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'severity', header: 'Severity', align: 'right',
|
||||
cell: (e) => (
|
||||
<Badge variant={SEVERITY_META[e.severity].variant} size="sm">
|
||||
{SEVERITY_META[e.severity].label}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user