chore: clean up and restructure repository
This commit is contained in:
28
src/lib/admin/permissions.js
Normal file
28
src/lib/admin/permissions.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* The administrator's permission scopes.
|
||||
*
|
||||
* Lives outside the page because two things now read it: the Profile page
|
||||
* renders it, and Owliver answers "what are my permissions?" from it. One list,
|
||||
* so the panel can never describe a scope the page does not show.
|
||||
*/
|
||||
export const PERMISSIONS = [
|
||||
{ scope: 'Positions', level: 'Full access', detail: 'Create, edit, pause and close roles' },
|
||||
{ scope: 'Candidates', level: 'Full access', detail: 'Screen, advance, decline and hire' },
|
||||
{ scope: 'Talent Pool', level: 'Full access', detail: 'Search, contact and shortlist' },
|
||||
{ scope: 'Analytics', level: 'Read only', detail: 'View reports and export' },
|
||||
{ scope: 'Audit log', level: 'Read only', detail: 'View all platform activity' },
|
||||
{ scope: 'Billing', level: 'No access', detail: 'Restricted to account owners' },
|
||||
];
|
||||
|
||||
export const LEVEL_TONE = { 'Full access': 'success', 'Read only': 'info', 'No access': 'neutral' };
|
||||
|
||||
/** What the Profile page can actually do, for the assistant to point at. */
|
||||
export const PROFILE_ACTIONS = [
|
||||
{ label: 'Edit profile', detail: 'Change your display name' },
|
||||
{ label: 'Change password', detail: 'Rotate your administrator password' },
|
||||
{ label: 'Manage sessions', detail: 'Review devices signed in to this account' },
|
||||
{ label: 'Two-factor authentication', detail: 'Required for privileged actions' },
|
||||
{ label: 'Owliver workspace', detail: 'Open the contextual panel by default on supported pages' },
|
||||
{ label: 'Compact density', detail: 'Tighter row heights across Admin tables' },
|
||||
{ label: 'Email digest', detail: 'A daily summary of what needs attention' },
|
||||
];
|
||||
127
src/lib/admin/positionInsights.js
Normal file
127
src/lib/admin/positionInsights.js
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Derived hiring facts for one position.
|
||||
*
|
||||
* Kept out of the components because the card, the drawer and the summary line
|
||||
* all need the same numbers, and three independent derivations is how a card ends
|
||||
* up disagreeing with the drawer it opens.
|
||||
*/
|
||||
|
||||
const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
|
||||
|
||||
/** Everyone who reached this stage or went past it. */
|
||||
const atOrBeyond = (applications, stage) => {
|
||||
const from = STAGE_ORDER.indexOf(stage);
|
||||
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hiring health.
|
||||
*
|
||||
* Three states, and the thresholds are chosen so the label means something an
|
||||
* operator would act on rather than describing the shape of the data:
|
||||
*
|
||||
* - **At risk** — nothing to hire from. No applicants at all, or applicants but
|
||||
* nobody who clears the bar.
|
||||
* - **Needs attention** — supply exists but is stuck: a screening backlog, or a
|
||||
* role with candidates and no shortlist.
|
||||
* - **Strong** — moving.
|
||||
*/
|
||||
function healthFor({ applied, unscreened, qualified, shortlisted, hired, status }) {
|
||||
if (status !== 'active') {
|
||||
return { label: 'Paused', tone: 'neutral', note: 'Not currently hiring.' };
|
||||
}
|
||||
if (!applied) {
|
||||
return { label: 'At risk', tone: 'risk', note: 'No applicants yet.' };
|
||||
}
|
||||
/* Order matters here. "Nobody clears the bar" is only a safe conclusion once
|
||||
everyone has actually been looked at — with applications still unscreened it
|
||||
would be reporting a gap in our own process as a verdict on the candidates. */
|
||||
if (unscreened > 0) {
|
||||
return { label: 'Needs attention', tone: 'warning', note: `${unscreened} awaiting screening.` };
|
||||
}
|
||||
if (!qualified && !hired) {
|
||||
return { label: 'At risk', tone: 'risk', note: 'Everyone screened, nobody clears the bar.' };
|
||||
}
|
||||
if (!shortlisted && !hired && applied >= 3) {
|
||||
return { label: 'Needs attention', tone: 'warning', note: 'Screened but nobody shortlisted.' };
|
||||
}
|
||||
return { label: 'Strong', tone: 'success', note: 'Pipeline is moving.' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The one thing worth saying about this position.
|
||||
*
|
||||
* Deliberately one. A card that lists every observation makes the reader do the
|
||||
* triage the card was supposed to do for them, so this returns the most
|
||||
* actionable finding and nothing else.
|
||||
*/
|
||||
function insightFor({ applied, unscreened, readyForInterview, coverage, hired, shortlisted, atRiskCount }) {
|
||||
if (!applied) return 'No applicants yet — check the pay band and where this is posted.';
|
||||
if (readyForInterview) {
|
||||
return `${readyForInterview} candidate${readyForInterview === 1 ? '' : 's'} ready for interview`;
|
||||
}
|
||||
if (unscreened) {
|
||||
return `${unscreened} candidate${unscreened === 1 ? '' : 's'} awaiting review`;
|
||||
}
|
||||
if (atRiskCount) {
|
||||
return `${atRiskCount} candidate${atRiskCount === 1 ? '' : 's'} at risk — no credentials on file`;
|
||||
}
|
||||
if (coverage < 100) return `Screening coverage ${coverage}%`;
|
||||
if (hired) return `${hired} hired${shortlisted > hired ? ` · ${shortlisted - hired} still shortlisted` : ''}`;
|
||||
return 'Fully screened and awaiting a decision';
|
||||
}
|
||||
|
||||
/** Builds one position row: the posting plus everything derived from its applicants. */
|
||||
export function buildPosition(posting, applications) {
|
||||
const apps = applications.filter((a) => a.job_posting_id === posting.id);
|
||||
|
||||
const applied = apps.length;
|
||||
const unscreened = apps.filter((a) => a.status === 'applied').length;
|
||||
const screened = atOrBeyond(apps, 'ai_screened').length;
|
||||
const shortlisted = atOrBeyond(apps, 'shortlisted').length;
|
||||
const interviews = atOrBeyond(apps, 'interview').length;
|
||||
const hired = apps.filter((a) => a.status === 'hired').length;
|
||||
|
||||
const scored = apps.filter((a) => a.ai_score > 0);
|
||||
const qualified = scored.filter((a) => a.ai_score >= 70).length;
|
||||
const coverage = applied ? Math.round((scored.length / applied) * 100) : 0;
|
||||
const avgScore = scored.length
|
||||
? Math.round(scored.reduce((sum, a) => sum + a.ai_score, 0) / scored.length)
|
||||
: 0;
|
||||
|
||||
/* Screened, strong, and not yet moved — the people a decision is owed to. */
|
||||
const readyForInterview = apps.filter(
|
||||
(a) => a.ai_score >= 70 && ['ai_screened', 'shortlisted'].includes(a.status)
|
||||
).length;
|
||||
const atRiskCount = scored.filter((a) => !(a.certifications || []).length).length;
|
||||
|
||||
const stats = {
|
||||
applied, unscreened, screened, shortlisted, interviews, hired,
|
||||
qualified, coverage, avgScore, readyForInterview, atRiskCount,
|
||||
};
|
||||
|
||||
return {
|
||||
...posting,
|
||||
applications: apps,
|
||||
stats,
|
||||
health: healthFor({ ...stats, status: posting.status }),
|
||||
insight: insightFor(stats),
|
||||
/* The funnel, for the progression strip. Counts are cumulative, so each step
|
||||
is a subset of the one before it and the bar never grows as it advances. */
|
||||
progression: [
|
||||
{ key: 'applied', label: 'Applied', count: applied },
|
||||
{ key: 'screened', label: 'Screened', count: screened },
|
||||
{ key: 'shortlisted', label: 'Shortlisted', count: shortlisted },
|
||||
{ key: 'interview', label: 'Interviewed', count: interviews },
|
||||
{ key: 'hired', label: 'Hired', count: hired },
|
||||
],
|
||||
topCandidates: [...apps]
|
||||
.filter((a) => a.ai_score > 0)
|
||||
.sort((a, b) => b.ai_score - a.ai_score)
|
||||
.slice(0, 4),
|
||||
};
|
||||
}
|
||||
|
||||
/** Formats a pay band, or says plainly that there is not one. */
|
||||
export const payLabel = (p) =>
|
||||
p.pay_range_max ? `$${p.pay_range_min}–$${p.pay_range_max}/hr` : 'Pay not set';
|
||||
50
src/lib/admin/session.js
Normal file
50
src/lib/admin/session.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Admin sign-in state.
|
||||
*
|
||||
* The demo's data client (`base44.auth`) reports the seeded user as permanently
|
||||
* signed in — there is no identity provider behind it. That is fine for the
|
||||
* Employer and Talent demos, but the Admin console is supposed to be reached
|
||||
* through its own sign-in, and "always authenticated" would mean /admin opened
|
||||
* straight into the Control Center with the login page never seen.
|
||||
*
|
||||
* So Admin keeps a session marker of its own. It is deliberately thin: a single
|
||||
* flag, set by the login page and read by the route guard. It is not security —
|
||||
* a browser flag never is — it is the state a real session would occupy, put in
|
||||
* the one place that both the guard and the login screen can agree on, so the
|
||||
* flow is right now and swapping in a real provider later means changing this
|
||||
* file and nothing else.
|
||||
*
|
||||
* `sessionStorage`, not `localStorage`: closing the tab should end the session,
|
||||
* which is both the more defensible default for an administrative console and
|
||||
* what makes the login page the app's actual starting point.
|
||||
*/
|
||||
|
||||
const SESSION_KEY = 'krow_admin_session';
|
||||
|
||||
/** True when this tab has signed in to the Admin console. */
|
||||
export function hasAdminSession() {
|
||||
try {
|
||||
return sessionStorage.getItem(SESSION_KEY) === '1';
|
||||
} catch {
|
||||
// Private mode with storage denied: fail closed, so the login page shows.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Records a successful sign-in. */
|
||||
export function startAdminSession() {
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_KEY, '1');
|
||||
} catch {
|
||||
// Nothing to do — the guard will simply ask for sign-in again.
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears the session. Used by sign-out. */
|
||||
export function endAdminSession() {
|
||||
try {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { activitySignals, signalLabel } from '@/lib/activitySignals';
|
||||
import { demandFor } from '@/lib/workforce';
|
||||
import { getScoreBand } from '@/lib/talentHome';
|
||||
import { buildPosition } from '@/pages/admin/positionInsights';
|
||||
import { buildPosition } from '@/lib/admin/positionInsights';
|
||||
|
||||
/**
|
||||
* The one place a skill's declared data source becomes real data.
|
||||
|
||||
Reference in New Issue
Block a user