chore: clean up and restructure repository

This commit is contained in:
2026-08-21 11:11:21 +05:30
parent b2e6868824
commit dcf9770ada
106 changed files with 239 additions and 5654 deletions

View File

@@ -6,7 +6,7 @@ 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 { AdminPage, SectionTitle, Toolbar } from '@/components/admin/PageShell';
import { FilterSelect } from '@/pages/admin/Positions';
import { SkillSurface } from '@/components/skills/SkillSurface';

View File

@@ -1,5 +1,5 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { hasAdminSession } from './session';
import { hasAdminSession } from '@/lib/admin/session';
/**
* Gate on the Admin console.

View File

@@ -9,7 +9,7 @@ import { DepartmentPerformance } from '@/components/charts/DepartmentPerformance
import { HiringFlow } from '@/components/charts/HiringFlow';
import { HiringTrendChart } from '@/components/charts/HiringTrendChart';
import { useSize } from '@/hooks/use-size';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
import { SkillSurface } from '@/components/skills/SkillSurface';
import {
buildEfficiency, buildFunnel, buildInsights, buildTrend, byDepartment, byPosition,

View File

@@ -3,7 +3,7 @@ import { Button, SearchInput } from '@/components/ds';
import { useApplications, useInterviews, useJobPostings, useUpdateApplication, useHireCandidate } from '@/lib/krowHooks';
import { base44 } from '@/api/base44Client';
import { toast } from 'react-hot-toast';
import { AdminPage, Toolbar } from '@/pages/admin/_shell';
import { AdminPage, Toolbar } from '@/components/admin/PageShell';
import { FilterSelect } from '@/pages/admin/Positions';
import CandidateCard from '@/components/krow/CandidateCard';
import { SkillSurface } from '@/components/skills/SkillSurface';
@@ -29,7 +29,7 @@ const SORTS = {
export default function AdminCandidates() {
const { data: applications = [], isLoading } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
useInterviews();
const updateApp = useUpdateApplication();
const hireCandidate = useHireCandidate();
@@ -47,7 +47,7 @@ export default function AdminCandidates() {
try {
await updateApp.mutateAsync({ id: app.id, data: { status: 'rejected' } });
toast.success(`${app.applicant_name} declined`);
} catch (e) {
} catch {
toast.error('Failed to decline candidate');
}
};
@@ -56,7 +56,7 @@ export default function AdminCandidates() {
try {
await base44.entities.JobApplication.delete(app.id);
toast.success(`${app.applicant_name} deleted`);
} catch (e) {
} catch {
toast.error('Failed to delete candidate');
}
};
@@ -66,7 +66,7 @@ export default function AdminCandidates() {
const job = postings.find(p => p.id === app.job_posting_id);
await hireCandidate.mutateAsync({ application: app, job });
toast.success(`${app.applicant_name} hired!`);
} catch (e) {
} catch {
toast.error('Failed to hire candidate');
}
};

View File

@@ -10,7 +10,7 @@ import {
} from '@/components/ds';
import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
import { buildFacts } from '@/components/ai-assistant/insights';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
import { SkillSurface } from '@/components/skills/SkillSurface';
/**

View File

@@ -16,7 +16,7 @@ import {
useApplications, useInterviews, useJobPostings, useStaff, useUserActivity, useWorkerProfiles,
} from '@/lib/krowHooks';
import { buildFacts } from '@/components/ai-assistant/insights';
import { AdminPage } from '@/pages/admin/_shell';
import { AdminPage } from '@/components/admin/PageShell';
import { SkillSurface } from '@/components/skills/SkillSurface';
/**

View File

@@ -6,7 +6,7 @@ import {
Surface, Timeline,
} from '@/components/ds';
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { hiresWithFill, summarise } from '@/lib/hiringRecords';

View File

@@ -5,7 +5,7 @@ import { cn } from '@/lib/utils';
import { KROW_LOGO_URL } from '@/assets/brand';
import { Checkbox } from '@/components/ds';
import { base44 } from '@/api/base44Client';
import { startAdminSession } from '@/pages/admin/session';
import { startAdminSession } from '@/lib/admin/session';
const DEMO_EMAIL = 'demo@krow.app';
@@ -80,7 +80,7 @@ export default function AdminLogin() {
<div className="relative z-10 my-auto py-6 flex flex-col items-center">
<div className="relative rounded-2xl overflow-hidden shadow-md border border-[#E2E8F0] bg-white p-2 max-w-xs">
<img
src="/krow%20logo.jpeg"
src="/krow-logo-visual.jpeg"
alt="KROW Logo Visual"
className="w-full h-auto object-cover rounded-xl transition-transform duration-500 hover:scale-105"
/>

View File

@@ -1,72 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds';
import { SectionTitle } from '@/pages/admin/_shell';
/**
* The entry-point row, and the group it lives in.
*
* Settings and Workspace & Skills are two different subjects, but they are the
* same *kind* of page: a short list of destinations, each saying what it manages
* and how much of it there is. Sharing the furniture is what stops the two from
* drifting into different-looking navigation for the same job — and it is the
* reason a row added to either one needs no styling decisions.
*
* `meta` is always a real figure counted from real records. A row with nothing
* to count simply omits it rather than showing a zero that means "not measured".
*/
/** One destination. The whole row is the target, so there is no small link. */
export function NavRow({ icon: Icon, title, description, meta = null, to, action = null }) {
return (
<Link
to={to}
className="group flex items-center gap-3.5 px-4 py-3.5 transition-colors hover:bg-surface-subtle
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-krow-blue/50"
>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-krow-blue-tint text-krow-blue">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-body-sm font-semibold text-ink-1">{title}</span>
<span className="block text-caption leading-relaxed text-ink-3">{description}</span>
{/* Below `sm` the count sits under the description rather than being
dropped: on a phone it is the same fact, just in the only place
there is room for it. */}
{meta && <span className="mt-0.5 block text-caption tabular-nums text-ink-4 sm:hidden">{meta}</span>}
</span>
{meta && <span className="hidden shrink-0 text-caption tabular-nums text-ink-4 sm:block">{meta}</span>}
{action && (
<span className="hidden shrink-0 text-caption font-semibold text-krow-blue md:block">{action}</span>
)}
<ChevronRight
className="h-4 w-4 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
/>
</Link>
);
}
/** One group of rows. Same container and spacing in every group, on both pages. */
export function NavGroup({ id, title, children, className }) {
return (
<section aria-labelledby={id} className={cn('space-y-3', className)}>
<SectionTitle id={id} title={title} />
<Surface
variant="solid"
radius="lg"
padding="none"
elevation="xs"
className="divide-y divide-border overflow-hidden"
>
{children}
</Surface>
</section>
);
}

View File

@@ -19,7 +19,7 @@ import {
periodLabel, sectionTypeLabel, shapeForCapability, sourceSupportsOption, sourcesForShape,
surfaceFor,
} from '@/lib/skills/surfaces';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
/**
* Add or edit an Owliver skill.

View File

@@ -21,9 +21,9 @@ import {
import { SkillSurface } from '@/components/skills/SkillSurface';
import { useAssistantPanel, usePublishPageContext } from '@/components/ai-assistant';
import { continueDraftRequest } from '@/lib/skills/draftFlow';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
import { buildPosition, payLabel } from '@/pages/admin/positionInsights';
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
import { RoleGlyph } from '@/components/admin/RoleGlyph';
import { buildPosition, payLabel } from '@/lib/admin/positionInsights';
/**
* Admin Positions — a hiring workspace, one card per role.

View File

@@ -9,9 +9,9 @@ import { Button, Field, Input, Modal, Toggle, toast,
} from '@/components/ds';
import { base44 } from '@/api/base44Client';
import { useCurrentUser, useUpdateProfile, useUserActivity } from '@/lib/krowHooks';
import { AdminPage } from '@/pages/admin/_shell';
import { endAdminSession } from '@/pages/admin/session';
import { PERMISSIONS } from '@/pages/admin/permissions';
import { AdminPage } from '@/components/admin/PageShell';
import { endAdminSession } from '@/lib/admin/session';
import { PERMISSIONS } from '@/lib/admin/permissions';
import { SkillSurface } from '@/components/skills/SkillSurface';
/**

View File

@@ -1,73 +0,0 @@
import {
Briefcase, ChefHat, ConciergeBell, HardHat, PackageSearch, ShieldCheck, Sparkles, Truck, Wine,
} from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* RoleGlyph — the visual identity on a position card.
*
* Positions in this dataset have no photograph, and there is nothing to
* photograph anyway: a role is not a person. The two obvious fallbacks are both
* worse than this one — two-letter initials ("BA", "SE") read as a placeholder
* someone forgot to replace, and stock photography attaches an invented face to a
* job requisition, which is misleading on a page about hiring real people.
*
* So the glyph is the role's own trade: a chef's hat, a wine glass, a hard hat.
* It is drawn from the existing icon language and the existing brand tint, it is
* stable for a given category, and it tells you what kind of work the position is
* from across the room — which is the actual job of an image in a card grid.
*/
/** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
/** @type {[RegExp, React.ComponentType<any>][]} */
const GLYPHS = [
[/chef|cook|kitchen/i, ChefHat],
[/bartend|bar|mixolog/i, Wine],
[/server|wait|host|banquet|concierge/i, ConciergeBell],
[/security|guard|officer/i, ShieldCheck],
[/pick|warehouse|stock|inventory/i, PackageSearch],
[/driver|delivery|logistic/i, Truck],
[/construct|labour|labor|site/i, HardHat],
];
export function iconForRole(category, title = '') {
const subject = `${category || ''} ${title || ''}`;
const match = GLYPHS.find(([pattern]) => pattern.test(subject));
return match ? match[1] : Briefcase;
}
const SIZES = {
sm: { box: 'h-9 w-9 rounded-lg', icon: 'h-4 w-4' },
md: { box: 'h-11 w-11 rounded-xl', icon: 'h-5 w-5' },
lg: { box: 'h-14 w-14 rounded-xl', icon: 'h-6 w-6' },
};
/** @param {any} props */
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className = '' }) {
const Icon = iconForRole(category, title);
const s = SIZES[size] || SIZES.md;
return (
<span
className={cn(
'relative grid shrink-0 place-items-center bg-krow-blue-tint text-krow-blue ring-1 ring-krow-blue/10',
s.box,
className
)}
aria-hidden="true"
>
<Icon className={s.icon} />
{/* AI-built positions carry the same spark used everywhere else for
generated content, so provenance is legible without a second badge
competing for room in the card header. */}
{aiGenerated && (
<span className="absolute -right-1 -top-1 grid h-4 w-4 place-items-center rounded-full bg-krow-yellow text-ink-1 ring-2 ring-white">
<Sparkles className="h-2 w-2" />
</span>
)}
</span>
);
}
export default RoleGlyph;

View File

@@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Link } from 'react-router-dom';
import {
Activity, Bell, Building2, KeyRound, Shield, User as UserIcon, Users,
ChevronRight, Sliders,
@@ -10,14 +10,13 @@ import { useAssistantPanel } from '@/components/ai-assistant';
import {
useJobPostings, usePreferences, useStaff, useUpdatePreferences,
} from '@/lib/krowHooks';
import { AdminPage } from '@/pages/admin/_shell';
import { AdminPage } from '@/components/admin/PageShell';
/**
* Admin Settings — Full-width platform configuration control center.
*/
export default function AdminSettings() {
const navigate = useNavigate();
const preferences = usePreferences();
const updatePreferences = useUpdatePreferences();
const panel = useAssistantPanel();

View File

@@ -7,7 +7,7 @@ import { useCourses, useCurrentUser, useWorkerProfiles } from '@/lib/krowHooks';
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
import { profileForEmail } from '@/lib/skillGraph';
import { SkillProgressDetail } from '@/components/forge';
import { AdminPage } from '@/pages/admin/_shell';
import { AdminPage } from '@/components/admin/PageShell';
/**
* Skill development — every training path, as a management list.

View File

@@ -19,7 +19,7 @@ import {
owliverCapabilityLabel, periodLabel, placementLabel, sectionTypeLabel, sourceSupportsOption,
sourcesForShape, surfaceFor,
} from '@/lib/skills/surfaces';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
/**
* Add or edit a UI skill, as a page.

View File

@@ -6,7 +6,7 @@ import {
} from '@/components/ds';
import { useWorkerProfiles } from '@/lib/krowHooks';
import { getScoreBand, toFICO } from '@/lib/talentHome';
import { AdminPage, SectionTitle, Toolbar } from '@/pages/admin/_shell';
import { AdminPage, SectionTitle, Toolbar } from '@/components/admin/PageShell';
import { FilterSelect } from '@/pages/admin/Positions';
import { SkillSurface } from '@/components/skills/SkillSurface';

View File

@@ -7,7 +7,7 @@ import {
import { usePreferences } from '@/lib/krowHooks';
import { aiAgentSkills, allSkills, skillsWithFacet } from '@/lib/skills/registry';
import { allAgents } from '@/lib/agents/registry';
import { AdminPage } from '@/pages/admin/_shell';
import { AdminPage } from '@/components/admin/PageShell';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { cn } from '@/lib/utils';

View File

@@ -13,7 +13,7 @@ import { useAgents } from '@/lib/agents/useAgents';
import { searchAgents } from '@/lib/agents/registry';
import { agentIconFor } from '@/components/agents/icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { AdminPage } from '@/pages/admin/_shell';
import { AdminPage } from '@/components/admin/PageShell';
/**
* Agents — the workspace management list.

View File

@@ -21,7 +21,7 @@ import { countSections } from '@/lib/skills/uiConfig';
import {
owliverCapabilityLabel, placementLabel, sectionTypeLabel, surfaceFor,
} from '@/lib/skills/surfaces';
import { AdminPage } from '@/pages/admin/_shell';
import { AdminPage } from '@/components/admin/PageShell';
/**
* Skills — the management page.

View File

@@ -1,97 +0,0 @@
import * as React from 'react';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Shared page furniture for the Admin product.
*
* Admin pages are dense and numerous, so the header, the section labels and the
* vertical rhythm are defined once here. That is what makes seven independently
* built pages read as one product rather than seven takes on a dashboard.
*/
/**
* AdminPage — the standard page frame: title, subtitle, right-aligned controls,
* then stacked sections.
*
* Controls live in the page rather than in the global chrome, which is why the
* Admin header only needs one 56px bar.
*/
/** @param {any} props */
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }) {
return (
<div className={cn('space-y-6', className)}>
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2.5">
<h1 className="font-heading text-title-lg font-bold tracking-tight text-ink-1 sm:text-2xl">{title}</h1>
{/* A count of whatever the page is a list of, stated beside its
name rather than only inside the list. */}
{meta && <span className="text-body-sm tabular-nums text-ink-4">{meta}</span>}
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-600 dark:bg-blue-950/60 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
<span className="h-1.5 w-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
Live System
</span>
</div>
{subtitle && <p className="text-body-sm text-ink-3 font-normal">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2.5">{actions}</div>}
</header>
{tabs}
{children}
</div>
);
}
/**
* SectionTitle — the label above a region.
*
* Sentence-case and small rather than a heavy all-caps overline: on a page with
* four or five sections, loud labels compete with the data they introduce.
*/
/** @param {any} props */
export function SectionTitle({ id, title, meta, action, className }) {
return (
<div className={cn('flex flex-wrap items-baseline justify-between gap-2', className)}>
<div className="flex items-baseline gap-2">
<h2 id={id} className="font-heading text-body font-semibold text-ink-1">{title}</h2>
{meta && <span className="text-caption text-ink-4">{meta}</span>}
</div>
{action && (
<button
type="button"
onClick={action.onClick}
className="inline-flex items-center gap-0.5 rounded text-caption font-medium text-krow-blue transition-colors hover:underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
{action.label}
<ChevronRight className="h-3 w-3" aria-hidden="true" />
</button>
)}
</div>
);
}
/**
* Toolbar — the controls row above a table or list.
*
* Search takes the remaining space, filters keep their natural width, and the
* primary action is pushed to the end, so every management page has the same
* control geometry.
*/
/** @param {any} props */
export function Toolbar({ search = null, filters = null, actions = null, meta = null, className = '' }) {
return (
<div className={cn('space-y-2', className)}>
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
{search && <div className="min-w-0 lg:max-w-xs lg:flex-1">{search}</div>}
{filters && <div className="flex flex-wrap items-center gap-2">{filters}</div>}
{actions && <div className="flex items-center gap-2 lg:ml-auto">{actions}</div>}
</div>
{meta && <p className="text-caption text-ink-4">{meta}</p>}
</div>
);
}

View File

@@ -1,28 +0,0 @@
/**
* 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' },
];

View File

@@ -1,127 +0,0 @@
/**
* 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';

View File

@@ -1,50 +0,0 @@
/**
* 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.
}
}