candidates and board ui agent issue
Some checks failed
CI / check (push) Failing after 4m58s

This commit is contained in:
2026-09-05 10:46:06 +05:30
parent e02a0c23d4
commit 6249e00a3a
78 changed files with 15071 additions and 1998 deletions

View File

@@ -1,15 +1,12 @@
import React, { useMemo, useState } from 'react';
import {
Activity, FilePlus2, LogIn, LogOut, Mic, ScanSearch, Send, ShieldAlert, UserCheck, UserPlus,
Users,
} from 'lucide-react';
import {
Avatar, Badge, DataTable, MetricStrip, SearchInput, Surface, Timeline,
} from '@/components/ds';
import { useUserActivity } from '@/lib/krowHooks';
import { AdminPage, SectionTitle, Toolbar } from '@/components/admin/PageShell';
import { FilterSelect } from '@/pages/admin/Positions';
import { SkillSurface } from '@/components/skills/SkillSurface';
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.
@@ -33,35 +30,9 @@ const SEVERITY = {
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,
assign_employee: Users,
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) => {
@@ -143,162 +114,42 @@ export default function AdminActivity() {
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."
>
<SkillSurface page="activity" placement="after-header" />
<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>
<SkillSurface page="activity" placement="before-footer" />
{/* 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} />;
}