update position page design

This commit is contained in:
2026-08-13 21:41:14 +05:30
parent 6340962907
commit 1c653264b5
124 changed files with 5746 additions and 596 deletions

View File

@@ -18,6 +18,10 @@ const ENTITY_NAMES = [
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
'Course', 'Badge', 'LearningPath', 'Certification', 'RoleCategory',
'UserActivity', 'Evidence', 'User',
/* Who is on which position, and for how long. The record that turns "hired"
into workforce allocation: without it a position knows its demand and its
applicants but not who is actually covering it. */
'Assignment',
];
const entities = Object.fromEntries(

View File

@@ -33,10 +33,54 @@ const CERTIFICATIONS = [
// created_date descending drives list order on Positions and Overview.
const JOB_POSTINGS = [
{
id: 'job_senior_server',
title: 'Senior Server – Fine Dining',
role_category: 'Server',
description:
'An advanced service role for seated fine dining events in San Jose. You carry the standard for your section, recover a table before a client notices there was anything to recover, and set the pace for the servers around you.',
responsibilities: [
'Run a section at fine dining standard without supervision',
'Recover service issues before they reach the client',
'Set timing and pace for the servers around you',
'Handle allergens and special requests accurately',
'Support the captain through arrivals, toasts and close',
],
qualifications: [
'4+ years of fine dining or banquet service',
'Verified advanced service capability',
'Comfortable as the senior server on a floor',
'Available evenings and weekends',
],
nice_to_haves: ['Wine service knowledge', 'Captain experience', 'Bilingual'],
min_experience_years: 4,
english_required: 'fluent',
certifications_required: ['Food Handler Card'],
/* What the work actually requires, in verified skill levels. This is what
the matching engine reads — see lib/skillGraph.js. Weights say which
requirement carries the role; they are normalised, so they need not total
100 to produce a meaningful percentage. */
skill_requirements: [
{ skill_id: 'server', level: 'advanced', weight: 40 },
{ skill_id: 'customer_service', level: 'intermediate', weight: 25 },
{ skill_id: 'food_safety', level: 'beginner', weight: 20 },
{ skill_id: 'leadership', level: 'beginner', weight: 15 },
],
pay_range_min: 24,
pay_range_max: 34,
location: 'San Jose, CA',
status: 'active',
ai_generated: false,
created_date: iso('2026-08-08'),
},
{
id: 'job_bartender',
title: 'Bartender',
role_category: 'Bartender',
skill_requirements: [
{ skill_id: 'bartending', level: 'intermediate', weight: 60 },
{ skill_id: 'customer_service', level: 'beginner', weight: 40 },
],
description:
'We are building a bench of dependable bartenders for high-volume private events across the region. You will own your bar from setup to breakdown, pour with speed and consistency, and keep guests feeling looked after even when the room is three deep.',
responsibilities: [
@@ -65,6 +109,11 @@ const JOB_POSTINGS = [
},
{
id: 'job_server_fine',
skill_requirements: [
{ skill_id: 'server', level: 'advanced', weight: 45 },
{ skill_id: 'customer_service', level: 'intermediate', weight: 30 },
{ skill_id: 'food_safety', level: 'beginner', weight: 25 },
],
title: 'Event Server – Fine Dining',
role_category: 'Server',
description:
@@ -125,6 +174,11 @@ const JOB_POSTINGS = [
},
{
id: 'job_chef',
skill_requirements: [
{ skill_id: 'food_safety', level: 'advanced', weight: 40 },
{ skill_id: 'leadership', level: 'advanced', weight: 35 },
{ skill_id: 'customer_service', level: 'beginner', weight: 25 },
],
title: 'Executive Chef – Catering',
role_category: 'Chef',
description:
@@ -155,6 +209,11 @@ const JOB_POSTINGS = [
},
{
id: 'job_bartender_corp',
skill_requirements: [
{ skill_id: 'bartending', level: 'advanced', weight: 50 },
{ skill_id: 'customer_service', level: 'intermediate', weight: 30 },
{ skill_id: 'leadership', level: 'beginner', weight: 20 },
],
title: 'Experienced Bartender – Corporate Events',
role_category: 'Bartender',
description:
@@ -215,6 +274,11 @@ const JOB_POSTINGS = [
},
{
id: 'job_banquet',
skill_requirements: [
{ skill_id: 'server', level: 'advanced', weight: 35 },
{ skill_id: 'leadership', level: 'intermediate', weight: 35 },
{ skill_id: 'customer_service', level: 'advanced', weight: 30 },
],
title: 'Banquet Captain – Hotel Events',
role_category: 'Server',
description:
@@ -243,6 +307,12 @@ const JOB_POSTINGS = [
ai_generated: true,
created_date: iso('2026-07-18'),
},
/* ── The multi-company demand the allocation engine exists for ──────────
Three companies wanting the same skill at the same time, with different
urgency, start dates and run lengths. Total demand is 90 chefs against a
workforce that cannot cover it, which is the situation priority ordering
has to resolve — and the reason it is seeded rather than described. */
];
/* ── Applications ──────────────────────────────────────────────────────── */
@@ -270,6 +340,53 @@ const unscreened = (over) => ({
});
const JOB_APPLICATIONS = [
/* Senior Server – Fine Dining. Two applications with no AI screening on them
at all: what the recruiter sees for these is the skill match, computed from
verified training rather than from a model's reading of a résumé. */
{
id: 'app_arun',
applicant_name: 'Arun Kumar',
email: 'arun.kumar@example.com',
phone: '+1-408-555-0210',
years_experience: 4,
english_level: 'fluent',
certifications: ['Food Handler Card'],
availability: ['Weekdays', 'Weekends', 'Evenings'],
skills: ['Coursed service', 'Guest recovery', 'Allergen handling'],
companies_worked: ['Valley Catering Co.'],
client_rating: 4.7,
professional_summary:
'Four years of event service in San Jose, working towards the senior server role on a fine dining floor.',
selfie_url: 'https://i.pravatar.cc/240?img=68',
job_posting_id: 'job_senior_server',
job_title: 'Senior Server – Fine Dining',
status: 'applied',
ai_score: 0,
created_date: iso('2026-08-13'),
updated_date: iso('2026-08-13'),
},
{
id: 'app_priya_nair',
applicant_name: 'Priya Nair',
email: 'priya.nair@example.com',
phone: '+1-408-555-0211',
years_experience: 2,
english_level: 'fluent',
certifications: ['Food Handler Card'],
availability: ['Weekends', 'Evenings'],
skills: ['Table service', 'Guest relations'],
companies_worked: ['Bay Event Staffing'],
client_rating: 4.5,
professional_summary: 'Two years of event service, moving towards fine dining sections.',
selfie_url: 'https://i.pravatar.cc/240?img=47',
job_posting_id: 'job_senior_server',
job_title: 'Senior Server – Fine Dining',
status: 'applied',
ai_score: 0,
created_date: iso('2026-08-12'),
updated_date: iso('2026-08-12'),
},
/* Executive Chef – Catering: 2 applicants · 1 screened · 1 hired */
{
id: 'app_antoine',
@@ -736,6 +853,7 @@ const JOB_APPLICATIONS = [
phone: '+1-415-555-0122', years_experience: 2, english_level: 'fluent',
job_posting_id: 'job_banquet', job_title: 'Banquet Captain – Hotel Events',
}),
];
/* ── AI interviews ─────────────────────────────────────────────────────── */
@@ -906,6 +1024,14 @@ const COURSES = [
badge_reward: 'Service Fundamentals',
estimated_minutes: 18,
proof_skill: 'Coursed Table Service',
/* The four skill-graph fields on a record that predates them. Nothing else
about these originals changes — the ladder simply now knows where they
sit, which is what lets completing one move a worker's level. */
skill_id: 'server',
target_level: 'advanced',
required_level: 'intermediate',
completion_criteria: ['Work through the training material', 'Submit evidence for the challenge'],
verification_criteria: ['Acknowledges without excuses', 'Offers a concrete remedy', 'Protects the table experience'],
challenge: {
type: 'roleplay',
prompt:
@@ -944,6 +1070,11 @@ const COURSES = [
badge_reward: 'Responsible Service',
estimated_minutes: 22,
proof_skill: 'Responsible Service Judgment',
skill_id: 'bartending',
target_level: 'intermediate',
required_level: 'beginner',
completion_criteria: ['Work through the training material', 'Submit evidence for the challenge'],
verification_criteria: ['Refuses clearly and kindly', 'Offers an alternative', 'Escalates appropriately'],
challenge: {
type: 'roleplay',
prompt: 'A guest who has clearly had enough asks for one more round, and their colleagues are watching. Talk it through.',
@@ -976,6 +1107,11 @@ const COURSES = [
badge_reward: 'Safety Aware',
estimated_minutes: 14,
proof_skill: 'Hazard Identification',
skill_id: 'food_safety',
target_level: 'beginner',
required_level: null,
completion_criteria: ['Work through the training material', 'Submit evidence for the challenge'],
verification_criteria: ['Identifies temperature violations', 'Spots cross-contamination risk', 'Notes obstruction hazards'],
challenge: {
type: 'photo_identify',
prompt: 'Mark every food-safety hazard you can find in this prep station.',
@@ -1008,6 +1144,11 @@ const COURSES = [
badge_reward: 'Floor Leader',
estimated_minutes: 30,
proof_skill: 'Team Leadership',
skill_id: 'leadership',
target_level: 'advanced',
required_level: 'intermediate',
completion_criteria: ['Work through the training material', 'Submit evidence for the challenge'],
verification_criteria: ['Assigns clear ownership', 'Communicates timing', 'Sets a tone people follow'],
challenge: {
type: 'video',
prompt: 'Record the pre-shift briefing you would give a floor team of eight before a 300-guest gala.',
@@ -1025,6 +1166,170 @@ const COURSES = [
},
];
/* ── Training modules (the skill ladders) ──────────────────────────────── */
/**
* The training behind every level of every skill.
*
* Written as a table rather than 36 hand-built records: each row is one module,
* and the shape it expands to is the same Course entity the four skills above
* use, so the challenge page, the Forge library and Owliver's evaluation all
* read these without knowing they were generated.
*
* Columns: skill · level · title · minutes · proof type · description ·
* what Owliver checks. `required_level` is the rung below, which is what makes
* the ladder a ladder rather than a menu.
*/
const MODULE_TABLE = [
/* Server — the full four-rung ladder. */
['server', 'beginner', 'Server Fundamentals', 18, 'roleplay', 'Sequence of service, tray discipline, and the first ninety seconds at a table.', ['Greets and sets expectations', 'Follows the service sequence', 'Handles the tray safely']],
['server', 'beginner', 'Workplace Safety', 14, 'photo_identify', 'Spot the hazard before it becomes an incident — spills, traffic, hot pass.', ['Identifies floor hazards', 'Spots obstruction risks', 'Knows when to stop service']],
['server', 'beginner', 'Basic Guest Service', 16, 'roleplay', 'Reading a table, timing an approach, and answering what you actually know.', ['Reads the table before approaching', 'Answers accurately', 'Closes the interaction cleanly']],
['server', 'intermediate', 'Advanced Guest Service', 20, 'roleplay', 'Difficult tables, competing demands, and keeping a section moving anyway.', ['Prioritises across tables', 'Keeps guests informed', 'Recovers timing without panic']],
['server', 'intermediate', 'Table Management', 18, 'roleplay', 'Running a section: turns, holds, and the order you touch tables in.', ['Sequences the section', 'Manages holds and turns', 'Communicates with the pass']],
['server', 'intermediate', 'Order Accuracy', 15, 'roleplay', 'Modifiers, allergens, and repeating back what you are about to send.', ['Confirms modifiers', 'Flags allergens correctly', 'Verifies before firing']],
['server', 'advanced', 'Guest Recovery', 22, 'roleplay', 'Turning a failed experience around without giving away the room.', ['Acknowledges without excuses', 'Offers a concrete remedy', 'Protects the table experience']],
['server', 'advanced', 'High-Volume Service Operations', 25, 'video', 'Three hundred covers, one pass, and a plan that survives contact.', ['Plans the run', 'Holds the standard at volume', 'Adjusts without dropping tables']],
['server', 'expert', 'Floor Leadership', 30, 'video', 'Owning the floor: assignments, pace, and the call nobody else will make.', ['Assigns clear ownership', 'Sets and holds the pace', 'Makes the decision on time']],
['server', 'expert', 'Service Coaching', 25, 'roleplay', 'Correcting a teammate mid-service without deflating them.', ['Names the behaviour, not the person', 'Gives an actionable correction', 'Confirms understanding']],
['server', 'expert', 'Operations Excellence', 28, 'roleplay', 'Reading a BEO, staffing to it, and defending the standard to a client.', ['Reads the event brief accurately', 'Staffs to the requirement', 'Holds the standard with the client']],
/* Bartending. */
['bartending', 'beginner', 'Bar Fundamentals', 16, 'roleplay', 'Station setup, glassware, and pouring to spec under a clock.', ['Sets up a working station', 'Pours to spec', 'Keeps the bar clean during service']],
['bartending', 'beginner', 'Responsible Service Basics', 14, 'roleplay', 'Checking age, reading a guest, and knowing where the line is.', ['Verifies age correctly', 'Reads intoxication signs', 'Knows the escalation path']],
['bartending', 'intermediate', 'Cocktail Specs & Speed', 20, 'video', 'Classic specs from memory, built at event pace without losing accuracy.', ['Builds to spec', 'Maintains pace', 'Recovers from a mistake cleanly']],
['bartending', 'advanced', 'High-Volume Bar Operations', 24, 'video', 'Running a bar three deep: batching, sequencing, and restock timing.', ['Batches sensibly', 'Sequences the rail', 'Restocks before it hurts']],
['bartending', 'advanced', 'Guest Judgment Behind the Bar', 20, 'roleplay', 'The refusal, the regular, and the colleague watching you make the call.', ['Refuses clearly and kindly', 'Protects the guest and the room', 'Documents what happened']],
['bartending', 'expert', 'Bar Program Leadership', 28, 'roleplay', 'Menu, training, and holding a team of bartenders to one standard.', ['Sets a standard others can follow', 'Trains rather than corrects', 'Owns the program end to end']],
['bartending', 'expert', 'Inventory & Cost Control', 24, 'roleplay', 'Par levels, variance, and explaining a number to an operator.', ['Tracks variance accurately', 'Explains the number', 'Proposes a real fix']],
/* Leadership. */
['leadership', 'beginner', 'Shift Communication', 14, 'roleplay', 'Saying the necessary thing, once, so the room hears it.', ['States the message clearly', 'Confirms it landed', 'Keeps it short']],
['leadership', 'beginner', 'Giving & Taking Direction', 16, 'roleplay', 'Taking a correction well, and giving one that can be acted on.', ['Receives correction without friction', 'Gives an actionable instruction', 'Follows up']],
['leadership', 'intermediate', 'Pre-Shift Briefings', 18, 'video', 'Two minutes that decide how the next six hours go.', ['Covers ownership and timing', 'Sets the tone', 'Ends with questions answered']],
['leadership', 'intermediate', 'Handling Conflict on the Floor', 20, 'roleplay', 'Two staff, one section, and a room that must not notice.', ['De-escalates in private', 'Resolves rather than defers', 'Protects service']],
['leadership', 'advanced', 'Section Assignment & Coverage', 22, 'roleplay', 'Matching people to sections, and covering the gap when someone drops.', ['Assigns to strength', 'Plans for absence', 'Rebalances mid-service']],
['leadership', 'expert', 'Developing Your Team', 26, 'roleplay', 'Turning a good server into the person who runs the floor next year.', ['Identifies real potential', 'Gives progressive responsibility', 'Follows through over time']],
['leadership', 'expert', 'Operations Under Pressure', 28, 'video', 'The gala that goes wrong, and the twenty minutes that decide it.', ['Triages correctly', 'Communicates under load', 'Owns the outcome']],
/* Customer Service. */
['customer_service', 'beginner', 'Guest First Impressions', 12, 'roleplay', 'The greeting, the eye contact, and the tone that sets the whole visit.', ['Opens warmly', 'Sets expectations', 'Hands off cleanly']],
['customer_service', 'beginner', 'Reading the Room', 14, 'roleplay', 'Knowing when a table wants attention and when it wants to be left alone.', ['Reads guest signals', 'Times the approach', 'Adjusts to the table']],
['customer_service', 'intermediate', 'Handling Complaints', 18, 'roleplay', 'Hearing the complaint behind the complaint, and fixing that one.', ['Listens before solving', 'Identifies the real issue', 'Resolves within authority']],
['customer_service', 'intermediate', 'Service Recovery Basics', 16, 'roleplay', 'What you can offer, what you cannot, and how to escalate fast.', ['Offers a concrete remedy', 'Stays within policy', 'Escalates without stalling']],
['customer_service', 'advanced', 'VIP & High-Stakes Guests', 20, 'roleplay', 'The head table, the client, and the guest everyone is watching.', ['Handles scrutiny calmly', 'Anticipates needs', 'Protects discretion']],
['customer_service', 'advanced', 'Client Relations', 22, 'roleplay', 'The conversation with the person who books the next event.', ['Represents the operation', 'Handles pushback', 'Secures the follow-up']],
/* Food Safety. */
['food_safety', 'beginner', 'Personal Hygiene Standards', 10, 'photo_identify', 'Handwashing, gloves, and the habits an inspector looks for first.', ['Follows hand hygiene', 'Uses gloves correctly', 'Presents to standard']],
['food_safety', 'intermediate', 'Temperature Control & HACCP', 18, 'photo_identify', 'Danger zone, hold times, and the log that proves you held it.', ['Knows the danger zone', 'Records holds accurately', 'Acts on a violation']],
['food_safety', 'intermediate', 'Allergen Management', 16, 'roleplay', 'Stopping the plate, checking the ticket, and saying it out loud.', ['Stops the plate', 'Verifies with the kitchen', 'Communicates to the guest']],
['food_safety', 'advanced', 'Food Safety Auditing', 22, 'photo_identify', 'Walking a kitchen the way an inspector does, and writing it up.', ['Finds the violations', 'Ranks by severity', 'Writes an actionable report']],
['food_safety', 'advanced', 'Incident Response & Reporting', 18, 'roleplay', 'A suspected foodborne incident, in the hour it matters.', ['Isolates the product', 'Documents accurately', 'Notifies the right people']],
];
const SKILL_CATEGORY = {
server: 'Service',
bartending: 'Bar',
leadership: 'Leadership',
customer_service: 'Service',
food_safety: 'Kitchen',
};
const LEVEL_ORDER = ['beginner', 'intermediate', 'advanced', 'expert'];
/** Level → the rung below it, which is what a module requires before it opens. */
const requiredLevelFor = (level) => LEVEL_ORDER[LEVEL_ORDER.indexOf(level) - 1] || null;
/** Difficulty is the existing field; level drives it so the two never disagree. */
const DIFFICULTY_FOR = { beginner: 'beginner', intermediate: 'intermediate', advanced: 'advanced', expert: 'advanced' };
const XP_FOR = { beginner: 100, intermediate: 150, advanced: 220, expert: 300 };
const PROMPT_FOR = {
roleplay: (title) => `Work through a ${title.toLowerCase()} scenario with Owliver, start to finish.`,
video: (title) => `Record yourself demonstrating ${title.toLowerCase()} as you would on a live shift.`,
photo_identify: (title) => `Mark everything a ${title.toLowerCase()} check should catch in this photo.`,
};
const slug = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
const TRAINING_MODULES = MODULE_TABLE.map(
([skill_id, target_level, title, minutes, type, description, criteria], i) => ({
id: `mod_${skill_id}_${slug(title)}`,
title,
description,
category: SKILL_CATEGORY[skill_id],
difficulty: DIFFICULTY_FOR[target_level],
xp: XP_FOR[target_level],
estimated_minutes: minutes,
proof_skill: title,
/* The four fields that make a course a rung on a ladder. */
skill_id,
target_level,
required_level: requiredLevelFor(target_level),
completion_criteria: ['Work through the training material', 'Submit evidence for the challenge'],
verification_criteria: criteria,
challenge: {
type,
prompt: PROMPT_FOR[type](title),
ai_persona: type === 'roleplay' ? 'A guest or teammate in a live service situation.' : undefined,
rubric: criteria.map((criterion, n) => ({ criterion, weight: n === 0 ? 40 : 30 })),
},
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
quiz: [],
pass_score: 70,
status: 'active',
created_date: iso('2026-06-20'),
_order: i,
})
);
/** Every course the platform holds — the four originals plus the ladders. */
const COURSE_LIBRARY = [...COURSES, ...TRAINING_MODULES];
/** A module id from its skill and title, so profiles read like the ladder does. */
const mod = (skill, title) => `mod_${skill}_${slug(title)}`;
/**
* Completed training on a worker profile.
*
* Titles are looked up rather than repeated, so a renamed module cannot leave a
* profile claiming a course that no longer goes by that name.
*/
const completions = (ids, date, score = 88) =>
ids.map((id) => ({
course_id: id,
title: COURSE_LIBRARY.find((c) => c.id === id)?.title || id,
score,
completed_date: iso(date),
}));
/* The ladders, as id lists — profiles below compose them. */
const SERVER_BEGINNER = ['Server Fundamentals', 'Workplace Safety', 'Basic Guest Service'].map((t) => mod('server', t));
const SERVER_INTERMEDIATE = ['Advanced Guest Service', 'Table Management', 'Order Accuracy'].map((t) => mod('server', t));
const SERVER_ADVANCED = ['course_service_basics', mod('server', 'Guest Recovery'), mod('server', 'High-Volume Service Operations')];
const CS_BEGINNER = ['Guest First Impressions', 'Reading the Room'].map((t) => mod('customer_service', t));
const CS_INTERMEDIATE = ['Handling Complaints', 'Service Recovery Basics'].map((t) => mod('customer_service', t));
const CS_ADVANCED = ['VIP & High-Stakes Guests', 'Client Relations'].map((t) => mod('customer_service', t));
const FOOD_BEGINNER = ['course_kitchen_safety', mod('food_safety', 'Personal Hygiene Standards')];
const LEAD_BEGINNER = ['Shift Communication', 'Giving & Taking Direction'].map((t) => mod('leadership', t));
const LEAD_INTERMEDIATE = ['Pre-Shift Briefings', 'Handling Conflict on the Floor'].map((t) => mod('leadership', t));
const LEAD_ADVANCED = ['course_leadership', mod('leadership', 'Section Assignment & Coverage')];
const BADGES = [
{ id: 'badge_service', name: 'Service Fundamentals', description: 'Verified coursed table service.', level: 'bronze', requirements: 'Complete Fine Dining Service Standards', verification_status: 'verified', created_date: iso('2026-06-10') },
{ id: 'badge_responsible', name: 'Responsible Service', description: 'Verified responsible alcohol service judgment.', level: 'silver', requirements: 'Complete Responsible Alcohol Service', expiration_months: 24, verification_status: 'verified', created_date: iso('2026-06-12') },
@@ -1096,11 +1401,18 @@ const WORKER_PROFILES = [
reliability_score: 95,
profile_completion: 100,
xp: 1480,
completed_courses: [
{ course_id: 'course_service_basics', title: 'Fine Dining Service Standards', score: 96, completed_date: iso('2026-06-22') },
{ course_id: 'course_kitchen_safety', title: 'Kitchen Safety & Hazard Spotting', score: 92, completed_date: iso('2026-07-01') },
{ course_id: 'course_leadership', title: 'Leading a Floor Team', score: 89, completed_date: iso('2026-07-14') },
],
/* Seven years on the floor, as a completed ladder: Server and Leadership to
Advanced, Customer Service to Advanced, Food Safety to Beginner. */
completed_courses: completions(
[
...SERVER_BEGINNER, ...SERVER_INTERMEDIATE, ...SERVER_ADVANCED,
...CS_BEGINNER, ...CS_INTERMEDIATE, ...CS_ADVANCED,
...FOOD_BEGINNER,
...LEAD_BEGINNER, ...LEAD_INTERMEDIATE, ...LEAD_ADVANCED,
],
'2026-07-14',
93
),
earned_badges: [
{ name: 'Service Fundamentals', level: 'bronze', course_id: 'course_service_basics', earned_date: iso('2026-06-22') },
{ name: 'Safety Aware', level: 'bronze', course_id: 'course_kitchen_safety', earned_date: iso('2026-07-01') },
@@ -1142,9 +1454,14 @@ const WORKER_PROFILES = [
reliability_score: 38,
profile_completion: 62,
xp: 120,
completed_courses: [
{ course_id: 'course_service_basics', title: 'Fine Dining Service Standards', score: 78, completed_date: iso('2026-07-28') },
],
/* One year in: the Beginner rung of Server, and one Intermediate module
started — so her card reads "Beginner, advancing" rather than a flat
label that hides the work in progress. */
completed_courses: completions(
[...SERVER_BEGINNER, mod('server', 'Advanced Guest Service'), ...CS_BEGINNER],
'2026-07-28',
79
),
earned_badges: [
{ name: 'Service Fundamentals', level: 'bronze', course_id: 'course_service_basics', earned_date: iso('2026-07-28') },
],
@@ -1157,6 +1474,146 @@ const WORKER_PROFILES = [
status: 'active',
created_date: iso('2026-07-22'),
},
/**
* Arun, Priya and Rahul are the same position read three ways.
*
* All three are measured against Senior Server – Fine Dining, and the only
* thing that separates them is verified training: Arun is one module short of
* qualifying, Priya is a level behind on the skill that carries the role, and
* Rahul has the ground floor but nothing above it. Nothing about their match
* is stored — completing training is what moves it.
*/
{
id: 'wp_arun',
full_name: 'Arun Kumar',
email: 'arun.kumar@example.com',
phone: '+1-408-555-0210',
address: 'San Jose, CA',
selfie_url: 'https://i.pravatar.cc/240?img=68',
languages: ['English', 'Tamil'],
availability: ['Weekdays', 'Weekends', 'Evenings'],
transportation: 'Own vehicle',
certifications: ['Food Handler Card'],
experience: [{ company: 'Valley Catering Co.', role: 'Event Server', years: 4 }],
experience_years: 4,
current_position: 'Event Server',
desired_position: 'Senior Server',
career_goals: 'Run a fine dining section as the senior server on the floor.',
skills: ['Coursed service', 'Guest recovery', 'Allergen handling'],
industries: ['Hospitality', 'Events'],
personality: 'Unflappable. Notices the table nobody else is watching.',
strengths: ['Calm at volume', 'Accurate under pressure'],
weaknesses: ['Quiet in briefings'],
communication_style: 'Measured; confirms detail before acting.',
salary_expectations: '$26–$32/hr',
leadership_potential: 68,
ai_interview_score: 86,
krow_score: 82,
reliability_score: 91,
profile_completion: 94,
xp: 1180,
/* Server through Intermediate, plus two of the three Advanced modules —
Fine Dining Service Standards is the one thing between him and Advanced,
and therefore between him and this role. */
completed_courses: completions(
[
...SERVER_BEGINNER, ...SERVER_INTERMEDIATE,
mod('server', 'Guest Recovery'), mod('server', 'High-Volume Service Operations'),
...CS_BEGINNER, ...CS_INTERMEDIATE,
...FOOD_BEGINNER,
...LEAD_BEGINNER,
],
'2026-08-02',
89
),
earned_badges: [],
capabilities: [],
shifts_completed: 38,
attendance_score: 96,
performance_score: 88,
client_rating: 4.7,
supervisor_rating: 4.6,
status: 'active',
created_date: iso('2026-07-05'),
},
{
id: 'wp_priya',
full_name: 'Priya Nair',
email: 'priya.nair@example.com',
phone: '+1-408-555-0211',
address: 'Santa Clara, CA',
selfie_url: 'https://i.pravatar.cc/240?img=47',
languages: ['English', 'Hindi'],
availability: ['Weekends', 'Evenings'],
transportation: 'Own vehicle',
certifications: ['Food Handler Card'],
experience: [{ company: 'Bay Event Staffing', role: 'Event Server', years: 2 }],
experience_years: 2,
current_position: 'Event Server',
desired_position: 'Senior Server',
career_goals: 'Move up to fine dining sections.',
skills: ['Table service', 'Guest relations'],
industries: ['Hospitality'],
leadership_potential: 45,
ai_interview_score: 74,
krow_score: 68,
reliability_score: 84,
profile_completion: 80,
xp: 760,
completed_courses: completions(
[...SERVER_BEGINNER, ...SERVER_INTERMEDIATE, ...CS_BEGINNER, ...CS_INTERMEDIATE, ...FOOD_BEGINNER],
'2026-07-30',
85
),
earned_badges: [],
capabilities: [],
shifts_completed: 21,
attendance_score: 93,
performance_score: 81,
client_rating: 4.5,
supervisor_rating: 4.4,
status: 'active',
created_date: iso('2026-07-12'),
},
{
id: 'wp_rahul',
full_name: 'Rahul Menon',
email: 'rahul.menon@example.com',
phone: '+1-408-555-0212',
address: 'Milpitas, CA',
selfie_url: 'https://i.pravatar.cc/240?img=59',
languages: ['English'],
availability: ['Weekends'],
transportation: 'Public transit',
certifications: [],
experience: [],
experience_years: 0,
current_position: '',
desired_position: 'Event Server',
career_goals: 'Get on a floor and learn the work properly.',
skills: [],
industries: ['Hospitality'],
leadership_potential: 20,
ai_interview_score: 0,
krow_score: 41,
reliability_score: 72,
profile_completion: 58,
xp: 320,
completed_courses: completions(
[...SERVER_BEGINNER, ...CS_BEGINNER, ...FOOD_BEGINNER, ...LEAD_BEGINNER],
'2026-08-05',
76
),
earned_badges: [],
capabilities: [],
shifts_completed: 4,
attendance_score: 100,
performance_score: 0,
client_rating: 0,
supervisor_rating: 0,
status: 'active',
created_date: iso('2026-07-28'),
},
{
id: 'wp_aravind',
full_name: 'Aravind Kumar',
@@ -1408,18 +1865,31 @@ export const DEMO_USER = {
},
};
/* ── Assignments ───────────────────────────────────────────────────────────
Who is on which position, and until when — the record that turns a hire into
workforce allocation, and the one that says who is therefore not free for
anything else.
Empty by design. This deployment has no assignment records yet: they are
written by the assignment flow, not shipped as seed. The engine reads an
empty set correctly — every position simply reports nobody assigned — and
the UI omits the demand figures rather than inventing coverage that does
not exist. */
const ASSIGNMENTS = [];
export const seedData = {
JobPosting: JOB_POSTINGS,
JobApplication: JOB_APPLICATIONS,
AIInterview: AI_INTERVIEWS,
Staff: STAFF,
WorkerProfile: WORKER_PROFILES,
Course: COURSES,
Course: COURSE_LIBRARY,
Badge: BADGES,
LearningPath: LEARNING_PATHS,
Certification: CERTIFICATIONS,
RoleCategory: ROLE_CATEGORIES,
UserActivity: USER_ACTIVITY,
Assignment: ASSIGNMENTS,
Evidence: EVIDENCE,
User: [DEMO_USER],
};

View File

@@ -9,7 +9,7 @@
*/
const STORAGE_KEY = 'krow_demo_db';
const STORAGE_VERSION = 3;
const STORAGE_VERSION = 6;
/** Simulated network latency, in ms, so loading states are real. */
const LATENCY = { read: 140, write: 220 };

View File

@@ -37,7 +37,7 @@ export function ThinkingIndicator() {
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
* column on padding around them.
*/
export const Message = React.memo(({ role, text, blocks, streaming, stopped }) => {
export const Message = React.memo(({ role, text, blocks, streaming, stopped, onPrompt }) => {
if (role === 'user') {
return (
<div className="flex justify-end">
@@ -57,7 +57,7 @@ export const Message = React.memo(({ role, text, blocks, streaming, stopped }) =
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Owliver</span>
</div>
<ResponseDocument blocks={blocks} streaming={streaming} />
<ResponseDocument blocks={blocks} streaming={streaming} onPrompt={onPrompt} />
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}
</div>

View File

@@ -5,7 +5,10 @@ import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert';
import { useCreateJobPosting, usePreferences, useRoleCategories } from '@/lib/krowHooks';
import {
useAssignments, useAssignWorkers, useCreateJobPosting, useMarkInterviewReady,
usePreferences, useRoleCategories,
} from '@/lib/krowHooks';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { AddSkillDialog } from '@/components/skills/AddSkillDialog';
import { runAction } from '@/lib/skills/actions';
@@ -114,12 +117,67 @@ export default function KrowAssistant({
return createJob.mutateAsync(result.data);
}, [createJob]);
/**
* The workforce picture the panel reasons over.
*
* The same collections the Positions page reads, so a recommendation and the
* card behind it are looking at one moment. Assembled here rather than in the
* routing layer because this is where the app's data already is; routing stays
* pure and testable.
*/
const { data: assignments = [] } = useAssignments();
const workforce = React.useMemo(() => ({
positions: facts.postings || [],
currentPositionId: null,
context: {
profiles: facts.profiles || [],
applications: facts.applications || [],
assignments,
courses: facts.forge?.library || [],
staff: facts.staff || [],
},
}), [facts.postings, facts.profiles, facts.applications, facts.forge, facts.staff, assignments]);
/**
* The workforce write, through the mutation the app already has.
*
* `useAssignWorkers` creates the assignment, moves the application and logs
* the activity — one path, reused rather than reimplemented. It only ever runs
* on a turn the admin explicitly confirmed.
*/
const assignWorkers = useAssignWorkers();
const onAssignWorkers = React.useCallback(async (plan) => {
const created = await assignWorkers.mutateAsync({
position: plan.position,
workers: plan.workers,
applications: facts.applications || [],
});
return created?.length ? created : null;
}, [assignWorkers, facts.applications]);
/**
* Move an assigned candidate to interview stage.
*
* The status transition the Position and Candidates pages already perform —
* not a new interview record. `AIInterviewModal` writes the AIInterview when
* the interview is actually conducted, and creating an empty one here would be
* a second interview store claiming something that has not happened.
*/
const markInterviewReady = useMarkInterviewReady();
const onScheduleInterview = React.useCallback(
(plan) => markInterviewReady.mutateAsync({ application: plan.application, position: plan.position }),
[markInterviewReady]
);
const { messages, pending, error, busy, send, stop, reset } = useConversation({
contextId: context.id,
facts,
onNavigate: goToPage,
onAction: performAction,
onCreatePosition: createPosition,
onAssignWorkers,
onScheduleInterview,
workforce,
disabledSkills,
customSkills,
roles,
@@ -127,6 +185,13 @@ export default function KrowAssistant({
courses: facts.forge?.library || [],
});
/* A block inside an answer asking the next question, in place. Same entry
point as the composer and the chips, so an in-panel action is an ordinary
turn rather than a second way for the panel to change. */
const askOwliver = React.useCallback((question) => {
if (!busy) send({ question });
}, [busy, send]);
const [input, setInput] = React.useState('');
const scrollRef = React.useRef(null);
const isEmpty = messages.length === 0 && !pending;
@@ -146,8 +211,8 @@ export default function KrowAssistant({
const skillPrompts = skills
.filter((s) => s.prompt)
.map((s) => ({ label: s.prompt, prompt: s.prompt }));
return [...skillPrompts, ...buildPrompts(context.id, facts)];
}, [followUp, skills, context.id, facts]);
return [...skillPrompts, ...buildPrompts(context.id, facts, workforce)];
}, [followUp, skills, context.id, facts, workforce]);
/* Follow the newest content. Direct `scrollTop` rather than smooth scrolling:
at streaming frequency a smooth scroll never catches up and the thread
@@ -281,6 +346,7 @@ export default function KrowAssistant({
text={message.text}
blocks={message.blocks}
stopped={message.stopped}
onPrompt={askOwliver}
/>
</React.Fragment>
))}

View File

@@ -1,5 +1,6 @@
import * as React from 'react';
import { ArrowDownRight, ArrowUpRight, Check, TriangleAlert } from 'lucide-react';
import { Link } from 'react-router-dom';
import { ArrowDownRight, ArrowUpRight, Check, ChevronRight, TriangleAlert } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { ProgressBar, toneForScore } from '@/components/ds/Progress';
@@ -269,20 +270,63 @@ const ListBlock = React.memo(({ block }) => {
});
ListBlock.displayName = 'ListBlock';
/** Findings, each in a tinted card so severity is scannable. */
const InsightsBlock = React.memo(({ block }) => (
/**
* Findings, and — where the item carries one — a way to act on it.
*
* Two kinds of action, and the distinction is the point:
*
* `prompt` asks Owliver the next question, in place. The panel stays open,
* the thread keeps its history, and the reader never loses the
* position they were working on. This is the default for a
* recommended person: inspecting a candidate is part of the
* conversation, not a trip to another page.
* `to` leaves for a route the app already has. Reserved for the explicit
* "open the full record" case.
*
* `hint` is for an item that can do neither — it says why, instead of looking
* clickable and doing nothing.
*/
const InsightsBlock = React.memo(({ block, onPrompt }) => (
<div className="space-y-2">
{block.items.map((item, i) => (
<div
key={i}
className={cn('rounded-xl border px-3 py-2.5', TONE_SURFACE[item.tone] || TONE_SURFACE.neutral)}
>
<p className="text-body-sm font-semibold text-ink-1">{item.title}</p>
{item.body && (
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
)}
</div>
))}
{block.items.map((item, i) => {
const surface = cn(
'block w-full text-left rounded-xl border px-3 py-2.5',
TONE_SURFACE[item.tone] || TONE_SURFACE.neutral
);
const interactive = cn(
surface,
'transition-colors hover:border-krow-blue/50',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50'
);
const actionable = Boolean(item.prompt && onPrompt) || Boolean(item.to);
const body = (
<>
<p className="flex items-center gap-1 text-body-sm font-semibold text-ink-1">
{item.title}
{actionable && <ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-60" aria-hidden="true" />}
</p>
{item.body && (
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
)}
{item.hint && (
<p className="mt-1 text-[11px] italic leading-relaxed text-ink-4">{item.hint}</p>
)}
</>
);
if (item.prompt && onPrompt) {
return (
<button key={i} type="button" onClick={() => onPrompt(item.prompt)} className={interactive}>
{body}
</button>
);
}
if (item.to) {
return <Link key={i} to={item.to} className={interactive}>{body}</Link>;
}
return <div key={i} className={surface}>{body}</div>;
})}
</div>
));
InsightsBlock.displayName = 'InsightsBlock';
@@ -381,7 +425,7 @@ const RENDERERS = {
* blocks has consistent rhythm — a heading hugs what follows it, everything
* else breathes.
*/
export const ResponseDocument = React.memo(({ blocks = [], streaming = false }) => (
export const ResponseDocument = React.memo(({ blocks = [], streaming = false, onPrompt }) => (
<div className="space-y-3">
{blocks.map((block, i) => {
const Renderer = RENDERERS[block.type];
@@ -391,7 +435,7 @@ export const ResponseDocument = React.memo(({ blocks = [], streaming = false })
return (
<div key={i} className={cn(hugsNext && '-mb-1.5', 'animate-fade-in')}>
<Renderer block={block} />
<Renderer block={block} onPrompt={onPrompt} />
{/* The cursor trails the final block only while text is still arriving. */}
{streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
<span

View File

@@ -47,6 +47,14 @@ export const list = (items, options = {}) =>
items?.filter(Boolean).length && { type: 'list', items: items.filter(Boolean), ...options };
/** Findings. `items: [{ tone, title, body }]` — tone: info|success|warning|risk */
/**
* Findings. `items: [{ title, body, tone?, to?, hint? }]`
*
* `to` makes the card a link into the app — a recommended person opening their
* own record, rather than a name the reader has to go and look up. It is a route
* the app already has; nothing here invents an address, and an item without one
* renders exactly as it always did.
*/
export const insights = (items) =>
items?.filter(Boolean).length && { type: 'insights', items: items.filter(Boolean) };

View File

@@ -1,4 +1,5 @@
import { plural, verb } from './insights';
import { prioritise, workforceStatusFor } from '@/lib/workforce';
import { PERMISSIONS } from '@/pages/admin/permissions';
/**
@@ -390,18 +391,71 @@ const PROMPTS = {
{ label: 'Identify operational risks', prompt: 'Identify operational risks and what I should fix first', capability: 'recommendations' },
],
'admin.positions': (f) => [
f.starvedPositions.length
? { label: `${f.starvedPositions.length} roles need attention`, prompt: 'What needs attention across my positions?', capability: 'positions-attention' }
: { label: 'What needs attention?', prompt: 'What needs attention across my positions?', capability: 'positions-attention' },
{ label: 'Which position has the strongest pipeline?', prompt: 'Which position has the strongest pipeline?', capability: 'position-strength' },
f.unscreened.length
? { label: `${f.unscreened.length} waiting for review`, prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' }
: { label: 'Show candidates waiting for review', prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' },
{ label: 'Which position should I hire for first?', prompt: 'Which position should I hire for first?', capability: 'hiring-priority' },
{ label: 'Summarize hiring activity', prompt: 'Summarize hiring activity across my positions', capability: 'hiring-operations' },
{ label: 'Identify hiring bottlenecks', prompt: 'Identify hiring bottlenecks', capability: 'pipeline-health' },
],
/**
* Positions suggestions are the current workforce situation, phrased as
* questions.
*
* Each one is offered only when the data behind it exists — a prompt about
* strong matches on a board with none is an invitation to a dead end. None of
* them names a person: the question is asked generically and the candidates
* are resolved live, so a suggestion cannot go stale between being rendered
* and being clicked.
*
* Workforce prompts carry no `capability`, which is what sends them through
* intent routing to the engine rather than to a page analytics responder.
*/
'admin.positions': (f, workforce) => {
const out = [];
if (workforce?.positions?.length) {
const { positions, context } = workforce;
const ranked = prioritise(positions, context);
const board = positions
.filter((p) => p.status === 'active')
.map((p) => ({ position: p, status: workforceStatusFor(p, context) }));
const withStrong = board.filter((r) => r.status.strong.length);
const newToday = board.reduce((n, r) => n + r.status.newToday.length, 0);
const readyCount = board.reduce(
(n, r) => n + r.status.applicants.filter(
(a) => ['ai_screened', 'shortlisted'].includes(a.application.status)
).length,
0
);
if (withStrong.length) {
const best = withStrong.sort((a, b) => b.status.strong.length - a.status.strong.length)[0];
out.push({
label: `${best.status.strong.length} strong for ${best.position.title}`,
prompt: `Who matches ${best.position.title}?`,
});
}
if (ranked.length) {
out.push({ label: 'Which position needs people first?', prompt: 'Which position needs people first?' });
}
if (newToday) {
out.push({ label: `${newToday} applied today`, prompt: 'Who applied today?' });
}
if (readyCount) {
out.push({ label: `${readyCount} ready for interview`, prompt: 'Who is ready for interview?' });
}
}
/* The page's own analytics questions keep their place behind the workforce
ones — they are still the right answer to "how is hiring going". */
out.push(
f.starvedPositions.length
? { label: `${f.starvedPositions.length} roles need attention`, prompt: 'What needs attention across my positions?', capability: 'positions-attention' }
: { label: 'What needs attention?', prompt: 'What needs attention across my positions?', capability: 'positions-attention' },
{ label: 'Which position has the strongest pipeline?', prompt: 'Which position has the strongest pipeline?', capability: 'position-strength' },
f.unscreened.length
? { label: `${f.unscreened.length} waiting for review`, prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' }
: { label: 'Show candidates waiting for review', prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' },
{ label: 'Summarize hiring activity', prompt: 'Summarize hiring activity across my positions', capability: 'hiring-operations' }
);
return out;
},
'admin.candidatesList': (f) => [
f.stalled.length
@@ -548,6 +602,6 @@ export function buildIntro(contextId, facts, userName) {
}
/** Suggested prompts for the current page and data. */
export function buildPrompts(contextId, facts) {
return PROMPTS[contextId]?.(facts) ?? [];
export function buildPrompts(contextId, facts, workforce = null) {
return PROMPTS[contextId]?.(facts, workforce) ?? [];
}

View File

@@ -1,6 +1,14 @@
import { doc, list, note, text } from './blocks';
import { doc, insights, list, note, text } from './blocks';
import { ASSISTANT_CONTEXTS } from './contexts';
import { poolFor } from '@/lib/workforce';
import { matchSkill } from '@/lib/skills/registry';
import {
appliedToday, askWhichPosition, assignmentPreview, assignmentToExecute, availability,
candidateDetail, candidateMatches, candidateRoute, interviewReady, matchWorkforceIntent,
namedAssignmentPreview,
interviewPreview, interviewToExecute,
namedAssignmentToExecute, positionPriority, resolveCandidate, resolvePosition,
} from '@/lib/skills/workforceFlow';
import {
buildSkillPrefill, buildTrainingPrefill, extractReviewSubject,
extractSkillName, findCourseByName,
@@ -168,6 +176,168 @@ export function outOfScopeAnswer(context) {
* { kind: 'navigate', destination, doc } — other page; go there
* { kind: 'outOfScope', doc } — nothing here can answer it
*/
/* ── Workforce ──────────────────────────────────────────────────────────── */
/** The pages that carry enough context to answer a workforce question. */
const WORKFORCE_CONTEXTS = new Set(['admin.positions']);
/**
* A workforce question, resolved against the engine.
*
* Reading questions return their answer directly. The two that lead to a write
* are shaped differently on purpose:
*
* `assign` builds a preview and returns it as a normal answer, with a
* confirmation offered as a follow-up. Nothing is written.
* `confirm_assign` re-derives the plan from live data and hands it back as
* `{ kind: 'workforce', assign }` for `useAssistant` to
* execute through the existing mutation — the same shape the
* position-creation flow already uses.
*
* A question naming no position, on a page listing many, asks which rather than
* choosing one.
*/
function resolveWorkforce(question, workforce) {
const intent = matchWorkforceIntent(question);
if (!intent) return null;
const { positions = [], context = {}, currentPositionId = null } = workforce;
const position = resolvePosition(question, positions, currentPositionId);
/* Board-wide questions answer without a position. */
if (intent === 'priority') {
return { kind: 'answer-doc', ...positionPriority(positions, context) };
}
if (intent === 'applied_today') {
return { kind: 'answer-doc', ...appliedToday(position, context, positions) };
}
if (intent === 'interview_ready') {
return { kind: 'answer-doc', ...interviewReady(position, context, positions) };
}
/* Everything below is about one role, and guessing which is not acceptable. */
if (!position) return { kind: 'answer-doc', ...askWhichPosition(positions) };
if (intent === 'matches') return { kind: 'answer-doc', ...candidateMatches(position, context) };
if (intent === 'availability') return { kind: 'answer-doc', ...availability(position, context) };
/**
* Inspecting, explaining and opening a record are all about one person, so
* they resolve the name first and say so plainly when they cannot.
*/
if (intent === 'candidate_detail' || intent === 'eligibility' || intent === 'open_profile') {
const found = resolveCandidate(question, poolFor(position, context));
if (found.ambiguous) {
return {
kind: 'answer-doc',
doc: doc(text('More than one person matches that name.'), list(found.ambiguous.map((r) => r.name))),
followUp: found.ambiguous.slice(0, 4).map((r) => ({
label: r.name, prompt: `Show ${r.name} for ${position.title}`,
})),
};
}
if (!found.row) {
return { kind: 'answer-doc', ...candidateMatches(position, context) };
}
/* The one case that deliberately leaves the panel — and only because the
admin asked for the full record by name. */
if (intent === 'open_profile') {
const to = candidateRoute(found.row, context.applications || [], position);
return {
kind: 'answer-doc',
doc: to
? doc(
text(`Opening the full record for **${found.row.name}**.`),
insights([{ tone: 'info', title: `${found.row.name} — full profile`, body: 'Match analysis, reputation, experience, endorsements and activity.', to }])
)
: doc(
text(`**${found.row.name}** has no application on file, so there is no candidate record to open.`),
note('Assigning them creates one, and the full profile becomes available after that.')
),
followUp: [{ label: '← Back to candidate', prompt: `Show ${found.row.name} for ${position.title}` }],
};
}
return { kind: 'answer-doc', ...candidateDetail(position, found.row, context) };
}
/**
* A named person is a different request from "the best available": the admin
* has already chosen, and the job is to validate that choice rather than to
* rank. Both preview and confirmation check for a name first, so
* "assign Maria" and "confirm assignment of Maria" stay one conversation.
*/
const named = (intent === 'assign' || intent === 'confirm_assign')
? resolveCandidate(question, poolFor(position, context))
: {};
if (named.ambiguous) {
return {
kind: 'answer-doc',
doc: doc(
text('More than one person matches that name.'),
list(named.ambiguous.map((r) => r.name)),
note('Give me the full name and I will check them against this role.')
),
followUp: named.ambiguous.slice(0, 4).map((r) => ({
label: r.name,
prompt: `Assign ${r.name} to ${position.title}`,
})),
};
}
if (intent === 'assign') {
return named.row
? { kind: 'answer-doc', ...namedAssignmentPreview(position, named.row, context) }
: { kind: 'answer-doc', ...assignmentPreview(position, context) };
}
/**
* Interview setup, for one named person against this role. Both steps resolve
* the name the same way assignment does, so the conversation stays on the same
* candidate without the admin repeating themselves.
*/
if (intent === 'interview_setup' || intent === 'confirm_interview') {
const found = resolveCandidate(question, poolFor(position, context));
if (!found.row) {
return { kind: 'answer-doc', ...interviewReady(position, context, positions) };
}
if (intent === 'interview_setup') {
return { kind: 'answer-doc', ...interviewPreview(position, found.row, context) };
}
const plan = interviewToExecute(position, found.row, context);
if (!plan) {
/* Re-checked at confirmation: if it can no longer proceed, say why rather
than reporting a step that did not happen. */
return { kind: 'answer-doc', ...interviewPreview(position, found.row, context) };
}
return { kind: 'workforce', interview: plan };
}
if (intent === 'confirm_assign') {
/* Re-derived from live data at the moment of confirmation, so the people
written are the people who are still eligible. */
const plan = named.row
? namedAssignmentToExecute(position, named.row, context)
: assignmentToExecute(position, context);
if (!plan) {
/* The preview no longer holds — say so rather than writing something
different from what was agreed to. */
return named.row
? { kind: 'answer-doc', ...namedAssignmentPreview(position, named.row, context) }
: { kind: 'answer-doc', ...assignmentPreview(position, context) };
}
return { kind: 'workforce', assign: plan };
}
return null;
}
/* ── Forge authoring ────────────────────────────────────────────────────── */
/** The five steps the Forge authoring flow actually walks through. */
@@ -368,7 +538,7 @@ function resolveSkill({ question, contextId, disabledSkills, customSkills, roles
export function resolveIntent({
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
courses = [],
courses = [], workforce = null,
}) {
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
@@ -378,6 +548,18 @@ export function resolveIntent({
});
if (skill) return skill;
/**
* 3. Workforce questions, on the pages that hold workforce context.
*
* Ahead of the page responder because these are the same words it answers
* with analytics — "which position needs people" would otherwise return a
* pipeline report rather than the ranked answer the engine can give.
*/
const workforceIntent = workforce && WORKFORCE_CONTEXTS.has(contextId)
? resolveWorkforce(question, workforce)
: null;
if (workforceIntent) return workforceIntent;
/* The page you are on wins. Several subjects live on more than one page —
departments are on both Analytics and Hired History — and moving someone
off a page that can already answer them is worse than answering. Routing is

View File

@@ -9,6 +9,9 @@ import { skillsForContext } from '@/lib/skills/registry';
import {
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
} from '@/lib/skills/positionFlow';
import {
assignmentDone, assignmentFailed, assignmentFollowUp, interviewDone, interviewFailed,
} from '@/lib/skills/workforceFlow';
import { buildFacts } from './insights';
import { createAssistantProvider } from './provider';
import { resolveIntent } from './routing';
@@ -74,14 +77,33 @@ export function useCurrentUserName() {
*/
async function streamDocument({ document, signal, onFrame, onDone }) {
const snapshots = toSnapshots(document);
const complete = snapshots[snapshots.length - 1] || [];
let latest = [];
let interrupted = false;
for (const snapshot of snapshots) {
if (signal?.aborted) break;
if (signal?.aborted) { interrupted = true; break; }
latest = snapshot;
onFrame(snapshot);
await new Promise((resolve) => setTimeout(resolve, 26));
}
if (latest.length) onDone(latest);
/**
* An interrupted local document still settles complete.
*
* These documents are already fully computed before the first frame — the
* reveal is presentation, not generation. So an interruption should not leave
* a half-written sentence in the thread the way stopping a real generation
* would: there is nothing partial about the answer, only about the animation.
*
* This matters most after a write. The mutation refreshes every query it
* touched, the panel re-renders on the new data, and the reveal can be cut
* short — leaving the confirmation of something that definitely happened
* truncated mid-word.
*/
if (interrupted) onDone(complete);
else if (latest.length) onDone(latest);
}
/**
@@ -97,7 +119,8 @@ async function streamDocument({ document, signal, onFrame, onDone }) {
* routing applies to both without either knowing it exists.
*/
export function useConversation({
contextId, facts, onNavigate, onAction, onCreatePosition, disabledSkills = [], customSkills = [],
contextId, facts, onNavigate, onAction, onCreatePosition, onAssignWorkers, onScheduleInterview,
workforce = null, disabledSkills = [], customSkills = [],
roles = [], skillCategories = [], courses = [],
}) {
const storageKey = `krow_assistant:${contextId}`;
@@ -200,7 +223,8 @@ export function useConversation({
intent = capability
? { kind: 'answer' }
: resolveIntent({
question: text, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
question: text, contextId, disabledSkills, customSkills, roles, skillCategories,
courses, workforce,
});
}
@@ -235,6 +259,51 @@ export function useConversation({
}
}
/**
* The workforce write, on the same terms as the one above: it happens
* before the reply, because the reply states the new counts and those have
* to be true when they are read.
*
* `intent.assign` only exists on a turn the admin explicitly confirmed, and
* the plan inside it was re-derived from live data at that moment — so this
* writes what was agreed to or reports that it could not.
*/
if (intent.kind === 'workforce' && intent.assign) {
let done = null;
try {
done = await onAssignWorkers?.(intent.assign);
} catch {
done = null;
}
intent = {
...intent,
kind: 'answer-doc',
doc: done ? assignmentDone(intent.assign) : assignmentFailed(),
followUp: done ? assignmentFollowUp(intent.assign) : undefined,
};
}
/**
* The interview step. Same contract as the assignment above: it runs before
* the reply, only on a confirmed turn, and through the app's existing
* status transition rather than a second interview store.
*/
if (intent.kind === 'workforce' && intent.interview) {
let moved = null;
try {
moved = await onScheduleInterview?.(intent.interview);
} catch {
moved = null;
}
intent = {
...intent,
kind: 'answer-doc',
doc: moved ? interviewDone(intent.interview) : interviewFailed(),
};
}
/* A skill that collects its input in the chat carries the state of that
collection on every turn — including the turn that starts it. */
if ('flow' in intent) setFlow(intent.flow);
@@ -300,7 +369,9 @@ export function useConversation({
setPending(null);
abortRef.current = null;
}
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, setFlow, disabledSkills,
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onAssignWorkers,
onScheduleInterview,
workforce, setFlow, disabledSkills,
customSkills, roles, skillCategories, courses]);
const stop = React.useCallback(() => abortRef.current?.abort(), []);

View File

@@ -12,11 +12,11 @@ import { BarChart3 } from 'lucide-react';
* the brand values are mirrored here — this array is the only place a chart
* should get a colour from.
*/
export const CHART_COLORS = ['#0A39DF', '#F9E547', '#333F48', '#F8E08E', '#D1E0D7', '#082EB4'];
export const CHART_COLORS = ['#0838E0', '#F9E547', '#333F48', '#F8E08E', '#D1E0D7', '#062BAF'];
export const CHART_TONES = {
brand: '#0A39DF',
brandDark: '#082EB4',
brand: '#0838E0',
brandDark: '#062BAF',
accent: '#F9E547',
accentPale: '#F8E08E',
navy: '#333F48',

View File

@@ -115,7 +115,7 @@ export function ProgressRing({
const resolvedTone = tone === 'score' ? toneForScore(ratio * 100) : tone;
const strokeColor = {
brand: '#0A39DF',
brand: '#0838E0',
navy: '#333F48',
accent: '#F9E547',
success: 'hsl(142 71% 36%)',

View File

@@ -43,7 +43,7 @@ const surfaceVariants = cva(
lg: 'p-6',
},
interactive: {
true: 'cursor-pointer hover:shadow-lg hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0A39DF]/60 focus-visible:ring-offset-2',
true: 'cursor-pointer hover:shadow-lg hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/60 focus-visible:ring-offset-2',
},
},
defaultVariants: {

View File

@@ -74,35 +74,67 @@ export function Toggle({
* SegmentedToggle — a small pill group for switching between 2–4 mutually
* exclusive views. Used where Tabs would be too heavy (chart ranges, EN/ES).
*/
export function SegmentedToggle({ options = [], value, onChange, size = 'default', className, ariaLabel }) {
/**
* Two treatments for the same control.
*
* `brand` states a choice that changes what the data *says* — a date range, a
* scope — so the selection carries the page's accent colour and reads as a
* filter. `surface` states a choice that changes only how the same data is
* *drawn*, so the selection is a raised white chip on a sunken track: present
* enough to show which mode is on, quiet enough not to compete with the real
* filters sitting beside it in the same toolbar.
*/
const SEGMENT_VARIANTS = {
brand: {
track: 'bg-surface-sunken border-border',
active: 'bg-krow-blue text-white shadow-sm',
inactive: 'text-ink-3 hover:text-ink-1',
},
surface: {
track: 'bg-surface-sunken border-border',
active: 'bg-surface text-ink-1 shadow-xs',
inactive: 'text-ink-4 hover:text-ink-2',
},
};
export function SegmentedToggle({
options = [], value, onChange, size = 'default', variant = 'brand', className, ariaLabel,
}) {
const normalized = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
const tone = SEGMENT_VARIANTS[variant] || SEGMENT_VARIANTS.brand;
return (
<div
role="radiogroup"
aria-label={ariaLabel}
className={cn(
'inline-flex items-center gap-0.5 rounded-full bg-surface-sunken p-0.5 border border-border',
className
)}
className={cn('inline-flex items-center gap-0.5 rounded-full border p-0.5', tone.track, className)}
>
{normalized.map((option) => {
const active = option.value === value;
const Icon = option.icon;
/* Icon-only options carry their name on the button instead of beside
it, so the control stays compact without becoming unlabelled. */
const iconOnly = Boolean(Icon) && !option.label;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={active}
aria-label={option.ariaLabel || (iconOnly ? String(option.value) : undefined)}
title={option.ariaLabel}
onClick={() => onChange?.(option.value)}
className={cn(
'rounded-full font-medium transition-all duration-base ease-out focus-ring-inset',
size === 'sm' ? 'px-2.5 py-1 text-caption' : 'px-3 py-1.5 text-body-sm',
active
? 'bg-krow-blue text-white shadow-sm'
: 'text-ink-3 hover:text-ink-1'
'inline-flex items-center justify-center gap-1.5 rounded-full font-medium',
'transition-all duration-base ease-out focus-ring-inset',
iconOnly
? (size === 'sm' ? 'h-6 w-7' : 'h-7 w-8')
: (size === 'sm' ? 'px-2.5 py-1 text-caption' : 'px-3 py-1.5 text-body-sm'),
active ? tone.active : tone.inactive
)}
>
{Icon && <Icon className={size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'} aria-hidden="true" />}
{option.label}
</button>
);

View File

@@ -1,14 +1,17 @@
import React from 'react';
import { BookOpen, Layers, ShieldCheck, Target } from 'lucide-react';
import { BookOpen, Layers, ShieldCheck, Target, TrendingUp } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Skill overview — the state of the library, in four figures.
* Skill overview — progression, in counted figures.
*
* Each figure counts records that exist: skills in the library, skills carrying
* training material, skills carrying a challenge, and verifications the
* workforce has actually earned. Nothing here is a target or a projection, so
* there is nothing on this strip that cannot be traced back to a row.
* Every figure is a count of records or of states derived from them: skills on
* the ladder, skills being worked on, levels held, levels one module away, and
* modules queued next. Nothing here is a target or a projection, so there is
* nothing on this strip that cannot be traced back to a row.
*
* The page supplies the figures and their order; this only styles them, which is
* why adding a fifth needed no change to the layout.
*/
const STAT_CONFIG = {
@@ -19,6 +22,34 @@ const STAT_CONFIG = {
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
accent: 'from-blue-600 to-indigo-600',
},
'Skills in progress': {
icon: BookOpen,
bg: 'bg-indigo-50 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400',
text: 'text-indigo-600 dark:text-indigo-400',
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
accent: 'from-indigo-500 to-purple-500',
},
'Skills verified': {
icon: ShieldCheck,
bg: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400',
text: 'text-emerald-600 dark:text-emerald-400',
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
accent: 'from-emerald-500 to-teal-500',
},
'Next level ready': {
icon: TrendingUp,
bg: 'bg-amber-50 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400',
text: 'text-amber-600 dark:text-amber-400',
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
accent: 'from-amber-500 to-orange-500',
},
'Recommended training': {
icon: Target,
bg: 'bg-violet-50 text-violet-600 dark:bg-violet-950/60 dark:text-violet-400',
text: 'text-violet-600 dark:text-violet-400',
border: 'border-violet-200/50 dark:border-violet-800/40 hover:border-violet-400',
accent: 'from-violet-500 to-fuchsia-500',
},
'Training modules': {
icon: BookOpen,
bg: 'bg-indigo-50 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400',
@@ -54,8 +85,9 @@ export function ForgeProgress({ figures = [], loading = false }) {
</div>
</div>
{/* 4 Modern Stat Cards */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
{/* Sized off this column rather than the viewport: with Owliver open the
page is wide while this strip is not. */}
<div className="grid grid-cols-2 gap-3.5 [grid-template-columns:repeat(auto-fit,minmax(min(100%,9rem),1fr))]">
{figures.map((f) => {
const cfg = STAT_CONFIG[f.label] || STAT_CONFIG['Active skills'];
const Icon = cfg.icon;

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { ArrowRight, Clock, Sparkles, BookOpen, ShieldCheck, Flame, Wine, Users, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ds';
import { unlocksLabel } from '@/lib/skillGraph';
import {
DIFF_TONE, owliverCriteria, statusOf, STATUS_TONE, TYPE_ICON, TYPE_LABEL, trainingType, typeOf,
} from './challengeMeta';
@@ -41,7 +42,17 @@ function getCategoryMeta(category = '') {
};
}
export function SkillCard({ course, onOpen }) {
/**
* The state of this module for the person being viewed — the four states a
* training card can be in, named the way a learner would name them.
*/
const MODULE_STATE = {
completed: { label: 'Completed', className: 'bg-success-muted text-success border-success/30' },
available: { label: 'Available', className: 'bg-krow-blue-tint text-krow-blue border-krow-blue/30' },
locked: { label: 'Locked', className: 'bg-surface-sunken text-ink-4 border-border' },
};
export function SkillCard({ course, onOpen, moduleState }) {
const status = statusOf(course);
const type = typeOf(course);
const ProofIcon = TYPE_ICON[type] || TYPE_ICON.roleplay;
@@ -49,6 +60,7 @@ export function SkillCard({ course, onOpen }) {
const hasChallenge = Boolean(course.challenge);
const catMeta = getCategoryMeta(course.category);
const CatIcon = catMeta.icon;
const unlocks = unlocksLabel(course);
const isPublished = status === 'published';
@@ -74,16 +86,30 @@ export function SkillCard({ course, onOpen }) {
</span>
</div>
<Badge
variant={STATUS_TONE[status] || 'neutral'}
size="sm"
className={cn(
'shrink-0 font-bold text-[10px] shadow-2xs',
isPublished && 'bg-emerald-500/10 text-emerald-600 border-emerald-500/30 dark:bg-emerald-950/60 dark:text-emerald-400'
)}
>
{status}
</Badge>
{/* What this module is *to the reader* leads: completed, available or
locked. Publication state is an authoring fact and only shows when
there is no learner state to report. */}
{moduleState ? (
<span
className={cn(
'shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-bold',
(MODULE_STATE[moduleState] || MODULE_STATE.available).className
)}
>
{(MODULE_STATE[moduleState] || MODULE_STATE.available).label}
</span>
) : (
<Badge
variant={STATUS_TONE[status] || 'neutral'}
size="sm"
className={cn(
'shrink-0 font-bold text-[10px] shadow-2xs',
isPublished && 'bg-emerald-500/10 text-emerald-600 border-emerald-500/30 dark:bg-emerald-950/60 dark:text-emerald-400'
)}
>
{status}
</Badge>
)}
</div>
{/* Title & Difficulty Tag */}
@@ -143,7 +169,18 @@ export function SkillCard({ course, onOpen }) {
{/* Footer Divider & Action Button */}
<div className="relative z-10 mt-4 flex items-center justify-between gap-2 border-t border-border/60 pt-3 font-medium">
{course.proof_skill ? (
{/* What completing this does to your record — the reason to take it.
"Verifies <skill>" is the fallback for library material that no level
depends on, so those cards still say what they prove. */}
{unlocks ? (
<span
title={`Unlocks ${unlocks}`}
className="flex min-w-0 flex-1 items-center gap-1 truncate text-[10px] font-bold uppercase tracking-wider text-ink-4"
>
<CheckCircle2 className="h-3 w-3 shrink-0 text-emerald-500" />
<span className="truncate">Unlocks {unlocks}</span>
</span>
) : course.proof_skill ? (
<span
title={`Verifies ${course.proof_skill}`}
className="min-w-0 flex-1 truncate text-[10px] font-bold uppercase tracking-wider text-ink-4 flex items-center gap-1"

View File

@@ -38,6 +38,7 @@ export function SkillLibrary({
onOpen,
onCreate,
sectionRef,
moduleStateFor,
}) {
return (
<section aria-labelledby="skill-library" className="min-w-0" ref={sectionRef}>
@@ -132,7 +133,12 @@ export function SkillLibrary({
) : (
<div className={CARD_GRID}>
{results.map((course) => (
<SkillCard key={course.id} course={course} onOpen={onOpen} />
<SkillCard
key={course.id}
course={course}
onOpen={onOpen}
moduleState={moduleStateFor?.(course)}
/>
))}
</div>
)}

View File

@@ -0,0 +1,285 @@
import React from 'react';
import { ArrowRight, Check, Circle, Lock, ShieldCheck, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge, Button, Drawer, ProgressBar } from '@/components/ds';
import { levelLabel } from '@/lib/skillGraph';
/**
* Skill progression — a skill as a ladder rather than a pile of courses.
*
* The old Forge answered "what training exists". These answer the question a
* person actually arrives with: *where am I, and what is the next thing.* That
* is one rail (the levels), one number (progress to the next level) and one
* named module (the thing to do now) — and nothing else, because a progression
* view that lists everything is a course catalogue again.
*
* Nothing here derives anything. `lib/skillGraph.js` computes the state and
* these arrange it, which is what keeps the card, the drawer and the profile
* page from ever disagreeing about someone's level.
*/
const STATUS_META = {
verified: { label: 'Verified', variant: 'success' },
advancing: { label: 'Advancing', variant: 'soft' },
in_progress: { label: 'In progress', variant: 'warning' },
not_started: { label: 'Not started', variant: 'neutral' },
};
/**
* The level rail: ✓ earned, ● in progress, ○ not reached.
*
* Deliberately a single line. The distance between where someone is and the top
* of their ladder is the one thing this component exists to make obvious, and a
* stack of rows makes that a reading exercise instead of a glance.
*/
export function SkillLadder({ state, className }) {
return (
<ol className={cn('flex items-start', className)} aria-label={`${state.name} levels`}>
{state.levels.map((rung, i) => {
const earned = Boolean(rung.earned);
const current = !earned && state.nextLevel === rung.level;
const started = current && rung.completed.length > 0;
return (
<li key={rung.level} className="flex min-w-0 flex-1 flex-col items-center gap-1.5">
<div className="flex w-full items-center">
{/* Connectors are drawn by the rungs either side, so the rail is
continuous without a separate track element to keep aligned. */}
<span className={cn('h-px flex-1', i === 0 ? 'bg-transparent' : earned ? 'bg-success' : 'bg-border')} aria-hidden="true" />
<span
className={cn(
'grid h-5 w-5 shrink-0 place-items-center rounded-full border text-[10px] font-bold',
earned && 'border-success bg-success text-white',
current && 'border-krow-blue bg-krow-blue-tint text-krow-blue',
!earned && !current && 'border-border bg-surface text-ink-4'
)}
>
{earned ? <Check className="h-3 w-3" /> : current ? <Circle className="h-2 w-2 fill-current" /> : <Circle className="h-2 w-2" />}
</span>
<span
className={cn(
'h-px flex-1',
i === state.levels.length - 1 ? 'bg-transparent' : state.levels[i + 1]?.earned ? 'bg-success' : 'bg-border'
)}
aria-hidden="true"
/>
</div>
<span
className={cn(
'w-full truncate text-center text-[10px] font-semibold uppercase tracking-wide',
earned ? 'text-success' : current ? 'text-krow-blue' : 'text-ink-4'
)}
>
{rung.label}
</span>
{started && (
<span className="text-[10px] tabular-nums text-ink-4">
{rung.completed.length}/{rung.modules.length}
</span>
)}
</li>
);
})}
</ol>
);
}
/** One skill: where this person stands on it, and the next thing to do. */
export function SkillProgressCard({ state, onOpen }) {
const status = STATUS_META[state.status] || STATUS_META.not_started;
const atTop = !state.nextLevel;
return (
<article
role="button"
tabIndex={0}
onClick={() => onOpen(state)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen(state);
}
}}
className="group flex h-full cursor-pointer flex-col rounded-2xl border border-border bg-surface p-5 text-left
transition-[border-color,box-shadow] duration-base ease-out hover:border-krow-blue/40 hover:shadow-md
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate font-heading text-body font-semibold text-ink-1">{state.name}</h3>
<p className="mt-0.5 flex items-center gap-1 text-caption text-ink-3">
{state.verifiedLevel && <ShieldCheck className="h-3 w-3 text-success" aria-hidden="true" />}
{state.verifiedLabel}
{state.verifiedByOverride && <span className="text-ink-4">· admin verified</span>}
</p>
</div>
<Badge variant={status.variant} size="sm">{status.label}</Badge>
</div>
<SkillLadder state={state} className="mt-4" />
<div className="mb-4 mt-4">
{atTop ? (
<p className="text-caption text-ink-3">Top of the ladder — every module verified.</p>
) : (
<>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="truncate text-caption text-ink-2">Progress to {levelLabel(state.nextLevel)}</span>
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-1">
{state.completedInNext}/{state.requiredForNext}
</span>
</div>
<ProgressBar value={state.progressToNext} tone={state.nearlyThere ? 'success' : 'brand'} size="sm" />
</>
)}
</div>
{/* `mt-auto` pins the footer down; the spacing above it comes from the
block before, so the two never fight over a margin. */}
<div className="mt-auto flex items-end justify-between gap-3 border-t border-border pt-3">
<p className="min-w-0 text-caption text-ink-3">
{state.nextRequirement ? (
<>
<span className="text-ink-4">Next</span>{' '}
<span className="font-medium text-ink-2">{state.nextRequirement.title}</span>
</>
) : atTop ? 'Nothing outstanding' : 'No training authored for this level yet'}
</p>
<span className="inline-flex shrink-0 items-center gap-1 text-caption font-semibold text-krow-blue">
View
<ArrowRight className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5" aria-hidden="true" />
</span>
</div>
</article>
);
}
/**
* The whole ladder, level by level, with every module and its state.
*
* Modules below the person's verified level are shown complete rather than
* hidden: the evidence behind a level is the reason to trust the level, and
* folding it away would leave the label standing on nothing.
*/
export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining }) {
if (!state) return null;
const done = new Set(state.levels.flatMap((l) => l.completed.map((m) => m.id)));
const next = state.nextRequirement;
return (
<Drawer
open={open}
onOpenChange={onOpenChange}
title={`${state.name} skill`}
description={`Current level: ${state.verifiedLabel}${state.nextLevel ? ` · Next: ${levelLabel(state.nextLevel)}` : ''}`}
size="lg"
footer={
next ? (
<>
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>Close</Button>
<Button size="sm" onClick={() => onStartTraining(next)}>Start training</Button>
</>
) : (
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>Close</Button>
)
}
>
<div className="space-y-5">
{/* Where they stand */}
<div className="rounded-xl border border-border bg-surface-subtle/60 p-4">
<SkillLadder state={state} />
{state.nextLevel && (
<div className="mt-4">
<div className="mb-1 flex items-baseline justify-between">
<span className="text-caption text-ink-2">Progress to {levelLabel(state.nextLevel)}</span>
<span className="text-caption font-semibold tabular-nums text-ink-1">{state.progressToNext}%</span>
</div>
<ProgressBar value={state.progressToNext} tone="brand" size="sm" />
</div>
)}
{state.verifiedByOverride && (
<p className="mt-3 flex items-center gap-1.5 text-caption text-ink-3">
<ShieldCheck className="h-3.5 w-3.5 text-krow-blue" aria-hidden="true" />
{levelLabel(state.verifiedByOverride.level)} verified manually by {state.verifiedByOverride.verified_by || 'an administrator'}.
</p>
)}
</div>
{/* The ladder itself */}
{state.levels.map((rung, i) => (
<section key={rung.level}>
<div className="mb-2 flex items-baseline justify-between gap-2">
<h3 className="font-heading text-caption font-bold uppercase tracking-wide text-ink-4">
Level {i + 1} — {rung.label}
</h3>
<span className="text-caption text-ink-4 tabular-nums">
{rung.completed.length}/{rung.modules.length}
</span>
</div>
{rung.modules.length ? (
<ul className="divide-y divide-border rounded-lg border border-border">
{rung.modules.map((module) => {
const complete = done.has(module.id);
const isNext = next?.id === module.id;
/* Locked means the rung below is unfinished — the same rule the
level derivation uses, so nothing can look startable here and
be refused there. */
const locked = !complete && !isNext && i > 0 && !state.levels[i - 1].earned;
return (
<li key={module.id} className="flex items-center gap-2.5 px-3 py-2.5">
<span
className={cn(
'grid h-5 w-5 shrink-0 place-items-center rounded-full border',
complete ? 'border-success bg-success text-white' : locked ? 'border-border text-ink-4' : 'border-krow-blue text-krow-blue'
)}
>
{complete ? <Check className="h-3 w-3" /> : locked ? <Lock className="h-2.5 w-2.5" /> : <Circle className="h-2 w-2" />}
</span>
<div className="min-w-0 flex-1">
<p className={cn('truncate text-body-sm', complete ? 'text-ink-2' : 'font-medium text-ink-1')}>
{module.title}
</p>
<p className="truncate text-[11px] text-ink-4">
{module.estimated_minutes} min · {(module.verification_criteria || []).length} verification criteria
</p>
</div>
{complete ? (
<span className="shrink-0 text-[11px] font-semibold text-success">Verified</span>
) : isNext ? (
<Button size="xs" variant="outline" onClick={() => onStartTraining(module)}>Start</Button>
) : (
<span className="shrink-0 text-[11px] text-ink-4">{locked ? 'Locked' : 'Available'}</span>
)}
</li>
);
})}
</ul>
) : (
<p className="text-body-sm text-ink-3">No training authored for this level yet.</p>
)}
</section>
))}
{next && (
<div className="flex items-start gap-2.5 rounded-xl border border-krow-blue/30 bg-krow-blue-tint/50 p-4">
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-krow-blue" aria-hidden="true" />
<div className="min-w-0">
<p className="text-body-sm font-semibold text-ink-1">Next milestone</p>
<p className="text-caption text-ink-2">
Complete {next.title} to reach {state.name} — {levelLabel(next.target_level)}.
Owliver verifies the evidence against {(next.verification_criteria || []).length} criteria.
</p>
</div>
</div>
)}
</div>
</Drawer>
);
}

View File

@@ -10,6 +10,7 @@
* SkillLibrary every skill, filterable
* SkillCard one skill and its verification journey
* SkillDetail one skill read end to end
* SkillProgression a skill as a ladder: levels, modules, and what is next
* AddSkillTraining skill → training → proof → Owliver → publish
* ForgeRail the authoring job, the rules, and the way to Owliver
*
@@ -31,6 +32,7 @@ export { ForgeProgress } from './ForgeProgress';
export { SkillLibrary } from './SkillLibrary';
export { SkillCard } from './SkillCard';
export { SkillDetail } from './SkillDetail';
export { SkillLadder, SkillProgressCard, SkillProgressDetail } from './SkillProgression';
export { AddSkillTraining } from './AddSkillTraining';
export { ForgeRail } from './ForgeRail';

View File

@@ -245,7 +245,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
{isOwliver ? (
<OwliverAvatar className="w-8 h-8" />
) : (
<div className="w-8 h-8 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-[13px] font-bold">K</div>
<div className="w-8 h-8 rounded-full bg-[#0838E0] flex items-center justify-center text-white text-[13px] font-bold">K</div>
)}
AI Interview with {conductor}
</span>
@@ -262,7 +262,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
{isOwliver ? (
<OwliverAvatar className="w-16 h-16 mx-auto mb-4" />
) : (
<div className="w-16 h-16 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-2xl font-bold mx-auto mb-4">K</div>
<div className="w-16 h-16 rounded-full bg-[#0838E0] flex items-center justify-center text-white text-2xl font-bold mx-auto mb-4">K</div>
)}
<h3 className="text-lg font-semibold text-[#111827] mb-2">Hi {candidateName.split(' ')[0]}, I'm {conductor}</h3>
<p className="text-[14px] text-[#6B7280] max-w-md mx-auto mb-6">
@@ -272,7 +272,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
</p>
<div className="flex items-center justify-center gap-3 mb-4 text-[12px] text-[#6B7280]">
<span className="inline-flex items-center gap-1"><Volume2 className="w-3.5 h-3.5" /> Voice enabled</span>
<button onClick={() => setTtsEnabled(!ttsEnabled)} className="text-[#0A39DF] underline">
<button onClick={() => setTtsEnabled(!ttsEnabled)} className="text-[#0838E0] underline">
{ttsEnabled ? 'Mute' : 'Unmute'}
</button>
</div>
@@ -285,7 +285,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
onClick={() => setLanguage('en')}
className={cn(
'px-3 py-1.5 text-[12px] font-medium transition-colors',
language === 'en' ? 'bg-[#0A39DF] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
language === 'en' ? 'bg-[#0838E0] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
)}
>
English
@@ -294,7 +294,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
onClick={() => setLanguage('es')}
className={cn(
'px-3 py-1.5 text-[12px] font-medium transition-colors',
language === 'es' ? 'bg-[#0A39DF] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
language === 'es' ? 'bg-[#0838E0] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
)}
>
Español
@@ -302,7 +302,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
</div>
</div>
<Button onClick={startInterview} className="bg-[#0A39DF] hover:bg-[#082EB4] px-8">
<Button onClick={startInterview} className="bg-[#0838E0] hover:bg-[#062BAF] px-8">
<Mic className="w-4 h-4 mr-2" /> Start Interview
</Button>
</div>
@@ -316,7 +316,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
<div className={cn(
'max-w-[80%] rounded-2xl px-4 py-2.5 text-[14px]',
msg.role === 'user'
? 'bg-[#0A39DF] text-white rounded-br-sm'
? 'bg-[#0838E0] text-white rounded-br-sm'
: 'bg-[#F3F4F6] text-[#111827] rounded-bl-sm'
)}>
<p>{msg.content}</p>
@@ -347,7 +347,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
value={transcript}
onChange={(e) => setTranscript(e.target.value)}
placeholder={isListening ? 'Listening...' : 'Type your answer or click mic to speak'}
className="w-full min-h-[60px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
className="w-full min-h-[60px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -369,7 +369,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
<Button
onClick={submitAnswer}
disabled={!transcript.trim()}
className="bg-[#0A39DF] hover:bg-[#082EB4]"
className="bg-[#0838E0] hover:bg-[#062BAF]"
>
Send Answer
</Button>
@@ -380,7 +380,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
{phase === 'evaluating' && (
<div className="py-12 text-center">
<Loader2 className="w-10 h-10 text-[#0A39DF] animate-spin mx-auto mb-4" />
<Loader2 className="w-10 h-10 text-[#0838E0] animate-spin mx-auto mb-4" />
<p className="text-[14px] text-[#6B7280]">{conductor} is evaluating your interview...</p>
</div>
)}
@@ -442,7 +442,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
</div>
)}
<Button onClick={handleClose} className="w-full bg-[#0A39DF] hover:bg-[#082EB4]">Done</Button>
<Button onClick={handleClose} className="w-full bg-[#0838E0] hover:bg-[#062BAF]">Done</Button>
</div>
)}
</DialogContent>

View File

@@ -15,7 +15,7 @@ import {
Legend,
} from 'recharts';
const PIE_COLORS = ['#0A39DF', '#059669', '#333F48', '#D97706', '#DC2626', '#0891B2', '#DB2777'];
const PIE_COLORS = ['#0838E0', '#059669', '#333F48', '#D97706', '#DC2626', '#0891B2', '#DB2777'];
function last7Days() {
const days = [];
@@ -68,7 +68,7 @@ export default function ActivityCharts({ activities }) {
<YAxis tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line type="monotone" dataKey="signups" stroke="#0A39DF" strokeWidth={2} />
<Line type="monotone" dataKey="signups" stroke="#0838E0" strokeWidth={2} />
<Line type="monotone" dataKey="logins" stroke="#059669" strokeWidth={2} />
<Line type="monotone" dataKey="actions" stroke="#D97706" strokeWidth={2} />
</LineChart>
@@ -83,7 +83,7 @@ export default function ActivityCharts({ activities }) {
<XAxis dataKey="name" tick={{ fontSize: 10 }} stroke="#9CA3AF" interval={0} angle={-20} textAnchor="end" height={50} />
<YAxis tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
<Tooltip />
<Bar dataKey="value" fill="#0A39DF" radius={[4, 4, 0, 0]} />
<Bar dataKey="value" fill="#0838E0" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>

View File

@@ -13,13 +13,13 @@ const EVENT_LABELS = {
};
const EVENT_COLORS = {
signup: 'bg-[#EFF6FF] text-[#1E40AF]',
signup: 'bg-[#EEF3FE] text-[#1E40AF]',
login: 'bg-[#ECFDF5] text-[#065F46]',
logout: 'bg-[#F3F4F6] text-[#374151]',
create_position: 'bg-[#F5F3FF] text-[#5B21B6]',
screen_candidate: 'bg-[#FFFBEB] text-[#92400E]',
hire_candidate: 'bg-[#ECFDF5] text-[#065F46]',
apply_job: 'bg-[#EFF6FF] text-[#1E40AF]',
apply_job: 'bg-[#EEF3FE] text-[#1E40AF]',
start_interview: 'bg-[#FEF2F2] text-[#991B1B]',
};

View File

@@ -14,7 +14,7 @@ export default function ActivityMetrics({ activities }) {
const activeUsers24h = new Set(recentUsers.map((a) => a.user_email)).size;
const metrics = [
{ label: 'Total Sign-ups', value: signups.length, icon: UserCheck, color: '#0A39DF', bg: '#EFF6FF' },
{ label: 'Total Sign-ups', value: signups.length, icon: UserCheck, color: '#0838E0', bg: '#EEF3FE' },
{ label: 'Logins (24h)', value: logins24h.length, icon: LogIn, color: '#059669', bg: '#ECFDF5' },
{ label: 'Active Users (24h)', value: activeUsers24h, icon: Users, color: '#333F48', bg: '#F3F4F6' },
{ label: 'Total Actions', value: activities.length, icon: Activity, color: '#D97706', bg: '#FFFBEB' },

View File

@@ -16,7 +16,7 @@ export default function ActivitySnapshot({ profile }) {
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
{SNAPSHOTS.map((s) => (
<div key={s.key} className="bg-white rounded-2xl shadow-sm p-4 flex flex-col items-center justify-center aspect-square">
<span className="text-[34px] font-bold leading-none" style={{ color: '#0A39DF' }}>
<span className="text-[34px] font-bold leading-none" style={{ color: '#0838E0' }}>
{s.get(profile)}
</span>
<span className="text-[12px] text-[#333F48] mt-2 text-center">{s.label}</span>

View File

@@ -11,11 +11,11 @@ export default function AnalyticsMetrics({ applications, interviews, staff }) {
const hireRate = totalApplicants ? Math.round((hired.length / totalApplicants) * 100) : 0;
const metrics = [
{ label: 'Total Applicants', value: totalApplicants, icon: Users, color: '#0A39DF', bg: '#EFF6FF' },
{ label: 'Total Applicants', value: totalApplicants, icon: Users, color: '#0838E0', bg: '#EEF3FE' },
{ label: 'Avg AI Score', value: avgScore, icon: Brain, color: '#333F48', bg: '#F3F4F6' },
{ label: 'Interviews Done', value: interviewed.length, icon: Mic, color: '#0A39DF', bg: '#FEFCE8' },
{ label: 'Interviews Done', value: interviewed.length, icon: Mic, color: '#0838E0', bg: '#FEFCE8' },
{ label: 'Hired', value: hired.length, icon: UserCheck, color: '#333F48', bg: '#F3F4F6' },
{ label: 'Hire Rate', value: `${hireRate}%`, icon: Target, color: '#0A39DF', bg: '#EFF6FF' },
{ label: 'Hire Rate', value: `${hireRate}%`, icon: Target, color: '#0838E0', bg: '#EEF3FE' },
{ label: 'On Staff', value: staff.length, icon: TrendingUp, color: '#333F48', bg: '#D1E0D7' },
];

View File

@@ -121,7 +121,7 @@ export default function ApplicantForm({ onSubmit, submitting }) {
</div>
<div>
<Label className="text-[13px] text-[#374151]">English Level</Label>
<select value={form.english_level} onChange={(e) => update('english_level', e.target.value)} className="w-full mt-1.5 h-9 rounded-md border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none">
<select value={form.english_level} onChange={(e) => update('english_level', e.target.value)} className="w-full mt-1.5 h-9 rounded-md border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none">
{ENGLISH_LEVELS.map(l => <option key={l} value={l}>{l.charAt(0).toUpperCase() + l.slice(1)}</option>)}
</select>
</div>
@@ -154,7 +154,7 @@ export default function ApplicantForm({ onSubmit, submitting }) {
{form.companies_worked.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{form.companies_worked.map((c, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-[#EFF6FF] text-[12px] text-[#1E40AF] font-medium border border-[#DBEAFE]">
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-[#EEF3FE] text-[12px] text-[#1E40AF] font-medium border border-[#DBEAFE]">
{c}
<button type="button" onClick={() => update('companies_worked', form.companies_worked.filter((_, idx) => idx !== i))} className="text-[#9CA3AF] hover:text-[#EF4444]">×</button>
</span>
@@ -172,7 +172,7 @@ export default function ApplicantForm({ onSubmit, submitting }) {
type="button"
onClick={() => toggleCert(cert)}
className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-all ${
form.certifications.includes(cert) ? 'bg-[#0A39DF] border-[#0A39DF] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'
form.certifications.includes(cert) ? 'bg-[#0838E0] border-[#0838E0] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'
}`}
>
{cert}
@@ -183,9 +183,9 @@ export default function ApplicantForm({ onSubmit, submitting }) {
{/* Certification file upload */}
<div className="mt-3">
<Label className="text-[12px] text-[#6B7280]">Upload certification documents</Label>
<label className="mt-1.5 flex flex-col items-center justify-center gap-1.5 border-2 border-dashed border-[#E5E7EB] rounded-lg py-4 cursor-pointer hover:border-[#0A39DF] hover:bg-[#EFF6FF]/40 transition-colors">
<label className="mt-1.5 flex flex-col items-center justify-center gap-1.5 border-2 border-dashed border-[#E5E7EB] rounded-lg py-4 cursor-pointer hover:border-[#0838E0] hover:bg-[#EEF3FE]/40 transition-colors">
{uploadingCert ? (
<Loader2 className="w-5 h-5 text-[#0A39DF] animate-spin" />
<Loader2 className="w-5 h-5 text-[#0838E0] animate-spin" />
) : (
<UploadCloud className="w-5 h-5 text-[#9CA3AF]" />
)}
@@ -238,12 +238,12 @@ export default function ApplicantForm({ onSubmit, submitting }) {
</button>
</div>
) : (
<label className="flex items-center gap-3 border-2 border-dashed border-[#E5E7EB] rounded-xl p-4 cursor-pointer hover:border-[#0A39DF] hover:bg-[#EFF6FF]/40 transition-colors">
<label className="flex items-center gap-3 border-2 border-dashed border-[#E5E7EB] rounded-xl p-4 cursor-pointer hover:border-[#0838E0] hover:bg-[#EEF3FE]/40 transition-colors">
{uploadingSelfie ? (
<Loader2 className="w-8 h-8 text-[#0A39DF] animate-spin shrink-0" />
<Loader2 className="w-8 h-8 text-[#0838E0] animate-spin shrink-0" />
) : (
<div className="w-12 h-12 rounded-full bg-[#EFF6FF] flex items-center justify-center shrink-0">
<Camera className="w-6 h-6 text-[#0A39DF]" />
<div className="w-12 h-12 rounded-full bg-[#EEF3FE] flex items-center justify-center shrink-0">
<Camera className="w-6 h-6 text-[#0838E0]" />
</div>
)}
<div className="flex-1">
@@ -284,10 +284,10 @@ export default function ApplicantForm({ onSubmit, submitting }) {
<div>
<Label className="text-[13px] text-[#374151]">Professional Summary</Label>
<textarea value={form.professional_summary} onChange={(e) => update('professional_summary', e.target.value)} className="w-full mt-1.5 min-h-[70px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none" />
<textarea value={form.professional_summary} onChange={(e) => update('professional_summary', e.target.value)} className="w-full mt-1.5 min-h-[70px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none" />
</div>
<Button type="submit" disabled={submitting} className="w-full bg-[#0A39DF] hover:bg-[#082EB4] h-11 text-[14px]">
<Button type="submit" disabled={submitting} className="w-full bg-[#0838E0] hover:bg-[#062BAF] h-11 text-[14px]">
{submitting ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
Submit Application
</Button>

View File

@@ -51,7 +51,7 @@ export default function ApplyIdentityCard({ profile, job, onStart, submitting, o
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-[17px] font-bold text-[#111827] truncate">{profile?.full_name || 'Your KROW Identity'}</h3>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[#EFF6FF] text-[#1E40AF]">Verified</span>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[#EEF3FE] text-[#1E40AF]">Verified</span>
</div>
<p className="text-[12px] text-[#6B7280] mt-1">
Your KROW Identity is loaded — no form needed. Owliver will run a short AI interview to confirm fit for this role.
@@ -110,7 +110,7 @@ export default function ApplyIdentityCard({ profile, job, onStart, submitting, o
onClick={onStart}
disabled={!ready || submitting}
className={cn(
'flex-1 inline-flex items-center justify-center gap-2 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[14px] font-semibold px-5 py-3 rounded-xl transition-colors disabled:opacity-60 disabled:cursor-not-allowed'
'flex-1 inline-flex items-center justify-center gap-2 bg-[#0838E0] hover:bg-[#062BAF] text-white text-[14px] font-semibold px-5 py-3 rounded-xl transition-colors disabled:opacity-60 disabled:cursor-not-allowed'
)}
>
<Mic className="w-4 h-4" />

View File

@@ -6,7 +6,7 @@ const LEVEL_STYLES = {
bronze: 'bg-[#FEF3C7] text-[#92400E] border-[#FCD34D]',
silver: 'bg-[#F3F4F6] text-[#4B5563] border-[#D1D5DB]',
gold: 'bg-[#FEF9E7] text-[#92400E] border-[#F9E547]',
platinum: 'bg-[#EFF6FF] text-[#1E40AF] border-[#BFDBFE]',
platinum: 'bg-[#EEF3FE] text-[#1E40AF] border-[#BFDBFE]',
};
export default function BadgeChip({ name, level = 'bronze', earned_date, size = 'md' }) {

View File

@@ -6,6 +6,10 @@ import { MessageSquare, Phone, Calendar, X, Trash2, ChevronDown, UserCheck, Brie
import { Image } from '@/components/ui/image';
import { getScoreBand } from '@/lib/talentHome';
import CandidateExpandedDetails from '@/components/krow/CandidateExpandedDetails';
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
import { toast } from 'react-hot-toast';
import { base44 } from '@/api/base44Client';
const STATUS_STYLES = {
hired: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'Hired', card: 'bg-white border-[#E5E7EB]' },
@@ -40,7 +44,7 @@ function getPortraitSeed(name) {
}
function getScoreColor(score) {
if (score > 0) return '#0A39DF';
if (score > 0) return '#0838E0';
return '#EF4444';
}
@@ -72,6 +76,66 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
const canTakeAction = status !== 'hired' && status !== 'rejected';
const [expanded, setExpanded] = useState(false);
const [internalMessageOpen, setInternalMessageOpen] = useState(false);
const [internalScheduleOpen, setInternalScheduleOpen] = useState(false);
const handleMessage = (e) => {
e?.stopPropagation();
if (onMessage) {
onMessage(application);
} else {
setInternalMessageOpen(true);
}
};
const handleCall = (e) => {
e?.stopPropagation();
if (onCall) {
onCall(application);
} else if (application.phone) {
window.location.href = `tel:${application.phone}`;
} else {
setInternalMessageOpen(true);
}
};
const handleSchedule = (e) => {
e?.stopPropagation();
if (onSchedule) {
onSchedule(application);
} else {
setInternalScheduleOpen(true);
}
};
const handleDecline = async (e) => {
e?.stopPropagation();
if (onDecline) {
onDecline(application);
} else {
try {
await base44.entities.JobApplication.update(application.id, { status: 'rejected' });
toast.success(`${application.applicant_name} declined`);
} catch (err) {
toast.error('Failed to decline candidate');
}
}
};
const handleDelete = async (e) => {
e?.stopPropagation();
if (onDelete) {
onDelete(application);
} else {
try {
await base44.entities.JobApplication.delete(application.id);
toast.success(`${application.applicant_name} deleted`);
} catch (err) {
toast.error('Failed to delete candidate');
}
}
};
return (
<div className={cn('border rounded-xl p-4 transition-all hover:shadow-sm', style.card)}>
<div
@@ -80,7 +144,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
>
{/* Rank */}
<div className="flex flex-col items-center justify-center gap-0.5 shrink-0 w-8">
<span className="text-[18px] font-bold text-[#0A39DF]">{rank}</span>
<span className="text-[18px] font-bold text-[#0838E0]">{rank}</span>
</div>
{/* Avatar — 88-110px target size */}
@@ -112,7 +176,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] text-[#6B7280] flex-wrap">
{position && (
<span className="inline-flex items-center gap-1 font-medium text-[#0A39DF]">
<span className="inline-flex items-center gap-1 font-medium text-[#0838E0]">
<Briefcase className="w-3 h-3" />
{position}
</span>
@@ -129,7 +193,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
</div>
{application.ai_match_label && (
<div className="mt-1.5">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-[#0A39DF] text-white text-[10px] font-bold">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-[#0838E0] text-white text-[10px] font-bold">
+ {application.ai_match_label}
</span>
</div>
@@ -144,7 +208,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
{onAction && canTakeAction && (
<button
onClick={() => onAction(application)}
className="px-3 py-1.5 rounded-lg bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[12px] font-medium transition-colors whitespace-nowrap"
className="px-3 py-1.5 rounded-lg bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium transition-colors whitespace-nowrap"
>
{status === 'interview' ? 'AI Interview' : 'Interview'}
</button>
@@ -156,48 +220,50 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
<ChevronDown className="w-3.5 h-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{onMessage && (
<DropdownMenuItem onClick={() => onMessage(application)} className="cursor-pointer py-2">
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0A39DF]" />
<span className="text-[#374151]">Message</span>
</DropdownMenuItem>
)}
{onCall && (
<DropdownMenuItem onClick={() => onCall(application)} className="cursor-pointer py-2">
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
<span className="text-[#059669]">Call</span>
</DropdownMenuItem>
)}
{onSchedule && (
<DropdownMenuItem onClick={() => onSchedule(application)} className="cursor-pointer py-2">
<Calendar className="w-4 h-4 mr-2.5 text-[#0A39DF]" />
<span className="text-[#0A39DF]">Set up interview</span>
</DropdownMenuItem>
)}
<DropdownMenuContent align="end" className="w-52 p-1.5 bg-white border border-[#E5E7EB] shadow-xl rounded-xl">
<DropdownMenuItem onClick={handleMessage} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0838E0]" />
<span className="text-[#374151] text-[13px] font-medium">Message</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCall} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
<span className="text-[#059669] text-[13px] font-medium">Call</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSchedule} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<Calendar className="w-4 h-4 mr-2.5 text-[#0838E0]" />
<span className="text-[#0838E0] text-[13px] font-medium">Set up interview</span>
</DropdownMenuItem>
{onHire && (
<DropdownMenuItem onClick={() => onHire(application)} className="cursor-pointer py-2">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onHire(application); }} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<UserCheck className="w-4 h-4 mr-2.5 text-[#059669]" />
<span className="text-[#059669]">Hire candidate</span>
</DropdownMenuItem>
)}
{onDecline && (
<DropdownMenuItem onClick={() => onDecline(application)} className="cursor-pointer py-2">
<X className="w-4 h-4 mr-2.5 text-[#DC2626]" />
<span className="text-[#DC2626]">Decline candidate</span>
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem onClick={() => onDelete(application)} className="cursor-pointer py-2">
<Trash2 className="w-4 h-4 mr-2.5 text-[#DC2626]" />
<span className="text-[#DC2626]">Delete candidate</span>
<span className="text-[#059669] text-[13px] font-medium">Hire candidate</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleDecline} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<X className="w-4 h-4 mr-2.5 text-[#DC2626]" />
<span className="text-[#DC2626] text-[13px] font-medium">Decline candidate</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDelete} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<Trash2 className="w-4 h-4 mr-2.5 text-[#DC2626]" />
<span className="text-[#DC2626] text-[13px] font-medium">Delete candidate</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{expanded && <CandidateExpandedDetails application={application} />}
{internalScheduleOpen && (
<ScheduleInterviewModal open={true} onClose={() => setInternalScheduleOpen(false)} application={application} />
)}
{internalMessageOpen && (
<MessageCandidateModal open={true} onClose={() => setInternalMessageOpen(false)} application={application} />
)}
</div>
);
}

View File

@@ -26,7 +26,7 @@ function MetricPill({ label, value, Icon }) {
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white border border-[#E5E7EB]">
<Icon className="w-3 h-3 text-[#9CA3AF]" strokeWidth={2} />
<span className="text-[11px] text-[#6B7280] font-medium">{label}</span>
<span className="text-[12px] font-bold text-[#0A39DF] tabular-nums">{value || 0}</span>
<span className="text-[12px] font-bold text-[#0838E0] tabular-nums">{value || 0}</span>
</span>
);
}
@@ -65,7 +65,7 @@ export default function CandidateExpandedDetails({ application }) {
<Icon className="w-3.5 h-3.5 text-[#94A3B8]" />
{label}
</span>
<span className="font-bold text-[#0A39DF] tabular-nums">{val}</span>
<span className="font-bold text-[#0838E0] tabular-nums">{val}</span>
</div>
);
})}
@@ -85,15 +85,15 @@ export default function CandidateExpandedDetails({ application }) {
{(strengths.length > 0 || gaps.length > 0) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{strengths.length > 0 && (
<div className="bg-[#EFF6FF] rounded-xl p-3.5 border border-[#DBEAFE]">
<div className="bg-[#EEF3FE] rounded-xl p-3.5 border border-[#DBEAFE]">
<div className="flex items-center gap-1.5 mb-2">
<Check className="w-3.5 h-3.5 text-[#0A39DF]" strokeWidth={2.5} />
<h4 className="text-[11px] font-bold text-[#0A39DF] tracking-wide">STRENGTHS</h4>
<Check className="w-3.5 h-3.5 text-[#0838E0]" strokeWidth={2.5} />
<h4 className="text-[11px] font-bold text-[#0838E0] tracking-wide">STRENGTHS</h4>
</div>
<ul className="space-y-1.5">
{strengths.map((s, i) => (
<li key={i} className="text-[12px] text-[#1E40AF] flex gap-2">
<span className="text-[#0A39DF] font-bold">✓</span>
<span className="text-[#0838E0] font-bold">✓</span>
<span>{s}</span>
</li>
))}
@@ -123,7 +123,7 @@ export default function CandidateExpandedDetails({ application }) {
{certs.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{certs.map((cert, i) => (
<span key={i} className="px-2.5 py-1 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium border border-[#DBEAFE]">
<span key={i} className="px-2.5 py-1 rounded-full bg-[#EEF3FE] text-[#0838E0] text-[11px] font-medium border border-[#DBEAFE]">
{cert}
</span>
))}

View File

@@ -28,13 +28,13 @@ export default function CareerJourney({ path, completedIds = [] }) {
<div key={i} className="relative flex items-center gap-3 py-2">
<div
className={`absolute -left-6 w-3.5 h-3.5 rounded-full ${
r.done ? 'bg-[#0A39DF]' : 'bg-white border-2 border-[#E2E8F0]'
r.done ? 'bg-[#0838E0]' : 'bg-white border-2 border-[#E2E8F0]'
}`}
/>
<span
className={`text-[15px] ${
r.isTarget
? 'font-semibold text-[#0A39DF]'
? 'font-semibold text-[#0838E0]'
: r.done
? 'text-[#0F172A] font-medium'
: 'text-[#64748B]'

View File

@@ -6,7 +6,7 @@ function Stage({ label, value, sub, accent, to }) {
const body = (
<div className="text-center sm:text-left">
<div className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8]">{label}</div>
<div className={`text-[20px] sm:text-[26px] font-semibold mt-1 tracking-tight ${accent ? 'text-[#0A39DF]' : 'text-[#0F172A]'}`}>
<div className={`text-[20px] sm:text-[26px] font-semibold mt-1 tracking-tight ${accent ? 'text-[#0838E0]' : 'text-[#0F172A]'}`}>
{value}
</div>
{sub && <div className="text-[13px] text-[#64748B] mt-0.5">{sub}</div>}
@@ -22,7 +22,7 @@ export default function CareerTrajectory({ profile, nextPath, nextReady, futureR
const next = nextPath?.target_role || nextPath?.name || 'Next Role';
return (
<section className="text-center pt-4 pb-12 sm:pt-8 sm:pb-20">
<span className="text-[12px] font-medium tracking-wide text-[#0A39DF] uppercase">Your Career</span>
<span className="text-[12px] font-medium tracking-wide text-[#0838E0] uppercase">Your Career</span>
<h1 className="text-[32px] sm:text-[44px] font-bold tracking-tight text-[#0F172A] mt-4 leading-[1.05]">
Everything revolves around your future.
</h1>

View File

@@ -38,7 +38,7 @@ export default function CertificationManager() {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button className="inline-flex items-center gap-1.5 bg-white border border-[#E5E7EB] hover:border-[#D1D5DB] text-[#374151] text-[13px] font-medium px-4 h-10 rounded-lg transition-colors">
<ShieldCheck className="w-4 h-4 text-[#0A39DF]" />
<ShieldCheck className="w-4 h-4 text-[#0838E0]" />
<span className="hidden sm:inline">Manage Certifications</span>
<span className="sm:hidden">Certs</span>
</button>
@@ -64,7 +64,7 @@ export default function CertificationManager() {
type="button"
onClick={handleAdd}
disabled={!newCert.trim() || createCert.isPending}
className="bg-[#0A39DF] hover:bg-[#082EB4]"
className="bg-[#0838E0] hover:bg-[#062BAF]"
>
{createCert.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
Add

View File

@@ -1,7 +1,7 @@
import React, { useMemo } from 'react';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts';
const TIER_COLORS = { Skilled: '#0A39DF', 'Cross-Trained': '#333F48', Beginner: '#D1E0D7' };
const TIER_COLORS = { Skilled: '#0838E0', 'Cross-Trained': '#333F48', Beginner: '#D1E0D7' };
export default function HiringAnalytics({ applications, staff }) {
const byTier = useMemo(() => {
@@ -44,7 +44,7 @@ export default function HiringAnalytics({ applications, staff }) {
<div className="grid grid-cols-3 gap-3 mb-6">
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
<div className="text-2xl font-heading font-bold text-[#0A39DF]">{hireRate}%</div>
<div className="text-2xl font-heading font-bold text-[#0838E0]">{hireRate}%</div>
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">HIRE RATE</div>
</div>
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
@@ -52,7 +52,7 @@ export default function HiringAnalytics({ applications, staff }) {
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">AVG SCORE</div>
</div>
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
<div className="text-2xl font-heading font-bold text-[#0A39DF]">{recentHires}</div>
<div className="text-2xl font-heading font-bold text-[#0838E0]">{recentHires}</div>
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">LAST 7 DAYS</div>
</div>
</div>
@@ -83,7 +83,7 @@ export default function HiringAnalytics({ applications, staff }) {
<XAxis type="number" tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
<YAxis type="category" dataKey="role" tick={{ fontSize: 9 }} stroke="#9CA3AF" width={100} />
<Tooltip contentStyle={{ borderRadius: 8, border: '1px solid #E5E7EB', fontSize: 12 }} />
<Bar dataKey="count" fill="#0A39DF" radius={[0, 4, 4, 0]} />
<Bar dataKey="count" fill="#0838E0" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>

View File

@@ -46,8 +46,8 @@ export default function ImpactMetrics({ applications, interviews, staff }) {
label: 'Time-to-Hire',
value: tthDisplay,
sub: hired.length ? `${hired.length} hires tracked` : 'AI screens instantly',
color: '#0A39DF',
bg: '#EFF6FF',
color: '#0838E0',
bg: '#EEF3FE',
},
{
icon: DollarSign,
@@ -62,7 +62,7 @@ export default function ImpactMetrics({ applications, interviews, staff }) {
label: 'Quality of Hire',
value: qualityOfHire ? `${qualityOfHire}/100` : '—',
sub: hired.length ? `${hired.length} hired candidates` : 'Awaiting first hire',
color: '#0A39DF',
color: '#0838E0',
bg: '#FEFCE8',
},
{
@@ -78,8 +78,8 @@ export default function ImpactMetrics({ applications, interviews, staff }) {
label: 'Decisions Standardized',
value: `${standardizedPct}%`,
sub: `${screened.length} of ${applications.length} applicants scored`,
color: '#0A39DF',
bg: '#EFF6FF',
color: '#0838E0',
bg: '#EEF3FE',
},
{
icon: Heart,

View File

@@ -2,7 +2,7 @@ import React, { useMemo } from 'react';
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
import { Mic, CheckCircle2, Clock, AlertTriangle } from 'lucide-react';
const VERDICT_COLORS = { hire: '#0A39DF', maybe: '#F9E547', no: '#333F48' };
const VERDICT_COLORS = { hire: '#0838E0', maybe: '#F9E547', no: '#333F48' };
export default function InterviewAnalytics({ applications, interviews }) {
const stats = useMemo(() => {
@@ -38,9 +38,9 @@ export default function InterviewAnalytics({ applications, interviews }) {
}, [interviews]);
const cards = [
{ label: 'Started', value: stats.started, icon: Mic, color: '#0A39DF', bg: '#EFF6FF' },
{ label: 'Started', value: stats.started, icon: Mic, color: '#0838E0', bg: '#EEF3FE' },
{ label: 'Completed', value: stats.completed, icon: CheckCircle2, color: '#333F48', bg: '#F3F4F6' },
{ label: 'Completion Rate', value: `${stats.completionRate}%`, icon: Clock, color: '#0A39DF', bg: '#FEFCE8' },
{ label: 'Completion Rate', value: `${stats.completionRate}%`, icon: Clock, color: '#0838E0', bg: '#FEFCE8' },
{ label: 'Avg Interview Score', value: stats.avgScore, icon: AlertTriangle, color: '#333F48', bg: '#D1E0D7' },
];

View File

@@ -26,7 +26,7 @@ const CATEGORY_LABELS = {
};
function getCategoryColor(val) {
if (val >= 80) return '#0A39DF';
if (val >= 80) return '#0838E0';
if (val >= 60) return '#333F48';
if (val >= 40) return '#6B7280';
return '#EF4444';
@@ -59,7 +59,7 @@ export default function InterviewResults({ application }) {
{/* Header */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-[#0A39DF] flex items-center justify-center">
<div className="w-7 h-7 rounded-lg bg-[#0838E0] flex items-center justify-center">
<MessageSquare className="w-3.5 h-3.5 text-white" />
</div>
<h4 className="text-[12px] font-bold text-[#111827] tracking-wide">AI INTERVIEW RESULTS</h4>
@@ -68,7 +68,7 @@ export default function InterviewResults({ application }) {
<span className={`px-2.5 py-1 rounded-full text-[11px] font-semibold ${verdictStyle.bg} ${verdictStyle.text}`}>
{verdictStyle.label}
</span>
<span className="text-[18px] font-bold text-[#0A39DF]">{interview.overall_interview_score || 0}<span className="text-[11px] text-[#9CA3AF]">/100</span></span>
<span className="text-[18px] font-bold text-[#0838E0]">{interview.overall_interview_score || 0}<span className="text-[11px] text-[#9CA3AF]">/100</span></span>
</div>
</div>
@@ -101,15 +101,15 @@ export default function InterviewResults({ application }) {
{/* Strengths & Concerns */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{interview.strengths?.length > 0 && (
<div className="bg-[#EFF6FF] rounded-xl p-4">
<div className="bg-[#EEF3FE] rounded-xl p-4">
<div className="flex items-center gap-1.5 mb-2">
<CheckCircle2 className="w-3.5 h-3.5 text-[#0A39DF]" strokeWidth={2.5} />
<h4 className="text-[11px] font-bold text-[#0A39DF] tracking-wide">STRENGTHS</h4>
<CheckCircle2 className="w-3.5 h-3.5 text-[#0838E0]" strokeWidth={2.5} />
<h4 className="text-[11px] font-bold text-[#0838E0] tracking-wide">STRENGTHS</h4>
</div>
<ul className="space-y-1.5">
{interview.strengths.map((s, i) => (
<li key={i} className="text-[12px] text-[#1E40AF] flex gap-2">
<span className="text-[#0A39DF] font-bold">+</span>
<span className="text-[#0838E0] font-bold">+</span>
<span>{s}</span>
</li>
))}
@@ -153,7 +153,7 @@ export default function InterviewResults({ application }) {
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5 flex items-center gap-1"><Star className="w-3 h-3" /> BEST FIT ROLES</h4>
<div className="flex items-center gap-2 flex-wrap">
{interview.best_fit_roles.map((role, i) => (
<span key={i} className="px-2.5 py-1 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium border border-[#DBEAFE]">
<span key={i} className="px-2.5 py-1 rounded-full bg-[#EEF3FE] text-[#0838E0] text-[11px] font-medium border border-[#DBEAFE]">
{role}
</span>
))}
@@ -170,7 +170,7 @@ export default function InterviewResults({ application }) {
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] rounded-2xl px-3.5 py-2 text-[13px] ${
msg.role === 'user'
? 'bg-[#0A39DF] text-white rounded-br-sm'
? 'bg-[#0838E0] text-white rounded-br-sm'
: 'bg-[#F3F4F6] text-[#111827] rounded-bl-sm'
}`}>
<p className="whitespace-pre-wrap">{msg.content}</p>

View File

@@ -61,7 +61,7 @@ export default function InviteApplicantsModal({ open, onClose, job }) {
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="w-5 h-5 text-[#0A39DF]" />
<Users className="w-5 h-5 text-[#0838E0]" />
Invite Applicants
</DialogTitle>
</DialogHeader>
@@ -72,7 +72,7 @@ export default function InviteApplicantsModal({ open, onClose, job }) {
<p className="text-[13px] font-semibold text-[#111827]">{jobTitle}</p>
{job?.location && <p className="text-[12px] text-[#6B7280]">{job.location}</p>}
{job?.pay_range_min != null && (
<p className="text-[12px] text-[#0A39DF] font-medium">${job.pay_range_min}–${job.pay_range_max}/hr</p>
<p className="text-[12px] text-[#0838E0] font-medium">${job.pay_range_min}–${job.pay_range_max}/hr</p>
)}
</div>
@@ -89,7 +89,7 @@ export default function InviteApplicantsModal({ open, onClose, job }) {
/>
<button
onClick={handleCopyLink}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-[#0A39DF] text-white text-[12px] font-medium hover:bg-[#082EB4] transition-colors"
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-[#0838E0] text-white text-[12px] font-medium hover:bg-[#062BAF] transition-colors"
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? 'Copied' : 'Copy'}
@@ -115,12 +115,12 @@ export default function InviteApplicantsModal({ open, onClose, job }) {
value={emailRecipients}
onChange={(e) => setEmailRecipients(e.target.value)}
placeholder="applicant1@email.com, applicant2@email.com"
className="w-full min-h-[48px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
className="w-full min-h-[48px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none"
/>
<button
onClick={handleEmailInvite}
disabled={!emailRecipients.trim() || sending}
className="w-full mt-2 inline-flex items-center justify-center gap-2 py-2.5 rounded-lg bg-[#0A39DF] text-white text-[13px] font-medium hover:bg-[#082EB4] disabled:opacity-50 transition-colors"
className="w-full mt-2 inline-flex items-center justify-center gap-2 py-2.5 rounded-lg bg-[#0838E0] text-white text-[13px] font-medium hover:bg-[#062BAF] disabled:opacity-50 transition-colors"
>
<Mail className="w-4 h-4" />
Send Email Invitations
@@ -128,8 +128,8 @@ export default function InviteApplicantsModal({ open, onClose, job }) {
</div>
{/* Mobile app note */}
<div className="flex items-start gap-2 p-3 bg-[#EFF6FF] rounded-lg">
<Smartphone className="w-4 h-4 text-[#0A39DF] mt-0.5 shrink-0" />
<div className="flex items-start gap-2 p-3 bg-[#EEF3FE] rounded-lg">
<Smartphone className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
<p className="text-[11px] text-[#374151] leading-relaxed">
Applicants open the link on any phone — no app download needed. They see open positions and complete the AI interview right in their mobile browser.
</p>

View File

@@ -52,7 +52,7 @@ export default function KROWCandidateLeaderboard({ applications, maxBars = 4 })
{sorted.slice(0, 5).map((app, idx) => (
<div key={app.id} className="flex items-center gap-3 py-2">
<span className="text-[12px] font-bold text-[#9CA3AF] w-6">#{idx + 1}</span>
<div className="w-7 h-7 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-[10px] font-semibold shrink-0">
<div className="w-7 h-7 rounded-full bg-[#0838E0] flex items-center justify-center text-white text-[10px] font-semibold shrink-0">
{app.applicant_name?.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase()}
</div>
<div className="flex-1 min-w-0">

View File

@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { cn } from '@/lib/utils';
const STATUS_STYLES = {
active: 'bg-[#0A39DF] text-white',
active: 'bg-[#0838E0] text-white',
draft: 'bg-[#F3F4F6] text-[#6B7280]',
paused: 'bg-[#FEF3C7] text-[#92400E]',
closed: 'bg-[#FEE2E2] text-[#991B1B]',
@@ -31,7 +31,7 @@ export default function KROWJobPostingCard({ posting, stats }) {
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-4">
<div className="flex items-start gap-3 min-w-0">
<div className="w-10 h-10 rounded-lg bg-[#0A39DF] flex items-center justify-center text-white text-[13px] font-bold shrink-0">
<div className="w-10 h-10 rounded-lg bg-[#0838E0] flex items-center justify-center text-white text-[13px] font-bold shrink-0">
{getCategoryAbbr(posting.role_category)}
</div>
<div className="min-w-0">
@@ -71,7 +71,7 @@ export default function KROWJobPostingCard({ posting, stats }) {
{/* Footer */}
<div className="flex items-center justify-between pt-3 border-t border-[#F3F4F6]">
{posting.ai_generated ? (
<span className="px-2 py-0.5 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium">
<span className="px-2 py-0.5 rounded-full bg-[#EEF3FE] text-[#0838E0] text-[11px] font-medium">
AI Built
</span>
) : (

View File

@@ -73,15 +73,15 @@ export default function KrowIdPreview() {
<div className="relative p-5">
{/* header row */}
<div className="flex items-center gap-3">
<div className="w-14 h-14 rounded-full overflow-hidden border-2 border-white shadow bg-[#EFF6FF] flex items-center justify-center shrink-0">
<div className="w-14 h-14 rounded-full overflow-hidden border-2 border-white shadow bg-[#EEF3FE] flex items-center justify-center shrink-0">
{profile.selfie_url ? (
<Image src={profile.selfie_url} alt={name} className="w-full h-full object-cover" fittingType="fill" />
) : (
<span className="text-[18px] font-bold text-[#0A39DF]">{initials}</span>
<span className="text-[18px] font-bold text-[#0838E0]">{initials}</span>
)}
</div>
<div className="min-w-0 text-left">
<p className="text-[9px] font-semibold uppercase tracking-[0.12em] text-[#0A39DF]">KROW ID · {shortId}</p>
<p className="text-[9px] font-semibold uppercase tracking-[0.12em] text-[#0838E0]">KROW ID · {shortId}</p>
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight leading-tight truncate">{name}</h2>
<p className="text-[11px] text-[#6B7280] truncate">{title}</p>
</div>
@@ -107,7 +107,7 @@ export default function KrowIdPreview() {
].map(([label, value]) => (
<span key={label} className="inline-flex items-center justify-between gap-1 rounded-lg bg-white/70 border border-[#E6ECF5] px-2 py-1 text-[10px]">
<span className="text-[#9CA3AF]">{label}</span>
<span className="font-semibold text-[#0A39DF]">{value}</span>
<span className="font-semibold text-[#0838E0]">{value}</span>
</span>
))}
</div>
@@ -119,11 +119,11 @@ export default function KrowIdPreview() {
<img src={qrSrc} alt="KROW Identity QR" width={48} height={48} className="block" />
</div>
<div className="flex-1 grid grid-cols-2 gap-1.5">
<button onClick={addToContacts} className="flex items-center justify-center gap-1 h-8 rounded-lg bg-[#0A39DF] text-white text-[10px] font-semibold hover:bg-[#082EB4] transition-colors">
<button onClick={addToContacts} className="flex items-center justify-center gap-1 h-8 rounded-lg bg-[#0838E0] text-white text-[10px] font-semibold hover:bg-[#062BAF] transition-colors">
<Smartphone className="w-3 h-3" /> Contacts
</button>
<button onClick={() => toast('Open My KROW ID page to save the full pass.', { icon: 'ℹ️' })} className="flex items-center justify-center gap-1 h-8 rounded-lg bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0A39DF] transition-colors">
<ImageIcon className="w-3 h-3 text-[#0A39DF]" /> Save
<button onClick={() => toast('Open My KROW ID page to save the full pass.', { icon: 'ℹ️' })} className="flex items-center justify-center gap-1 h-8 rounded-lg bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0838E0] transition-colors">
<ImageIcon className="w-3 h-3 text-[#0838E0]" /> Save
</button>
</div>
</div>

View File

@@ -16,7 +16,7 @@ export default function KrowIdentityCard({ form }) {
const pct = Math.round((done / parts.length) * 100);
return (
<div className="rounded-2xl bg-[#0A39DF] p-5 sm:p-6 text-white overflow-hidden relative">
<div className="rounded-2xl bg-[#0838E0] p-5 sm:p-6 text-white overflow-hidden relative">
<div className="absolute -top-10 -right-10 w-40 h-40 rounded-full bg-[#F9E547]/10" />
<div className="relative flex items-center gap-4">
<div className="shrink-0">

View File

@@ -4,7 +4,7 @@ export default function KrowScoreRing({ score = 0, size = 64, label = 'KROW Scor
const radius = (size - 8) / 2;
const circ = 2 * Math.PI * radius;
const offset = circ - (Math.min(100, score) / 100) * circ;
const color = score >= 80 ? '#0A39DF' : score >= 60 ? '#333F48' : score >= 40 ? '#6B7280' : '#EF4444';
const color = score >= 80 ? '#0838E0' : score >= 60 ? '#333F48' : score >= 40 ? '#6B7280' : '#EF4444';
return (
<div className="flex flex-col items-center">
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}>

View File

@@ -7,9 +7,9 @@ export default function LearningPathCard({ path, completedIds = [] }) {
const done = steps.filter(s => completedIds.has(s.course_id)).length;
const pct = steps.length ? Math.round((done / steps.length) * 100) : 0;
return (
<Link to="/university" className="block bg-white border border-[#E5E7EB] rounded-xl p-5 hover:border-[#0A39DF] hover:shadow-sm transition-all">
<Link to="/university" className="block bg-white border border-[#E5E7EB] rounded-xl p-5 hover:border-[#0838E0] hover:shadow-sm transition-all">
<div className="flex items-center gap-2 mb-1">
<div className="w-8 h-8 rounded-lg bg-[#0A39DF] flex items-center justify-center shrink-0"><Route className="w-4 h-4 text-white" /></div>
<div className="w-8 h-8 rounded-lg bg-[#0838E0] flex items-center justify-center shrink-0"><Route className="w-4 h-4 text-white" /></div>
<h3 className="font-semibold text-[#111827] text-[15px]">{path.name}</h3>
</div>
<p className="text-[12px] text-[#6B7280] mb-3">{path.description || `Career track to ${path.target_role || path.name}`}</p>
@@ -20,7 +20,7 @@ export default function LearningPathCard({ path, completedIds = [] }) {
])}
</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 rounded-full bg-[#F3F4F6] overflow-hidden"><div className="h-full bg-[#0A39DF]" style={{ width: `${pct}%` }} /></div>
<div className="flex-1 h-2 rounded-full bg-[#F3F4F6] overflow-hidden"><div className="h-full bg-[#0838E0]" style={{ width: `${pct}%` }} /></div>
<span className="text-[11px] font-medium text-[#6B7280]">{done}/{steps.length}</span>
</div>
</Link>

View File

@@ -0,0 +1,311 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowRight, Check, GraduationCap, Minus, Sparkles, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { LEVEL_LABEL, matchPosition, rankWorkforce, skillName } from '@/lib/skillGraph';
import { usePageSkills } from '@/lib/skills/usePageSkills';
/**
* Who is qualified for this position, and who is one module away.
*
* This is the admin half of the skill system, and it exists to answer one
* question without interpretation: *which people can do this work.* Every row is
* a person scored against this position's own skill requirements, and every
* score can be opened to show the arithmetic behind it — requirement by
* requirement, weight by weight. Nothing here is a model's opinion.
*
* Applicants and eligible workers are one list rather than two. A recruiter
* comparing candidates does not care whether the person arrived by applying or
* by qualifying, and splitting the list means the best match for a role can sit
* in the section nobody opened.
*
* Internal only: match scores, gaps, counts and comparisons never appear on the
* candidate-facing Positions page.
*/
const TONE = {
success: { chip: 'bg-[#ECFDF5] text-[#047857] border-[#A7F3D0]', bar: 'bg-[#059669]' },
brand: { chip: 'bg-[#EEF3FE] text-[#0838E0] border-[#BFDBFE]', bar: 'bg-[#0838E0]' },
warning: { chip: 'bg-[#FFFBEB] text-[#B45309] border-[#FDE68A]', bar: 'bg-[#D97706]' },
risk: { chip: 'bg-[#FEF2F2] text-[#B91C1C] border-[#FECACA]', bar: 'bg-[#DC2626]' },
};
/** One requirement, met or not, with the level on each side of the comparison. */
function RequirementRow({ line }) {
const Icon = line.met ? Check : line.close ? Minus : X;
const tone = line.met ? 'text-[#059669]' : line.close ? 'text-[#D97706]' : 'text-[#DC2626]';
return (
<li className="flex items-center gap-2 text-[12px]">
<Icon className={cn('h-3.5 w-3.5 shrink-0', tone)} aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-[#374151]">
{line.name} — {line.heldLabel}
{!line.met && <span className="text-[#9CA3AF]"> · needs {line.requiredLabel}</span>}
</span>
<span className="shrink-0 tabular-nums text-[#9CA3AF]">
{line.earned}/{line.weight}
</span>
</li>
);
}
function CandidateRow({ row, job, onView, onInterview, busy, attaches }) {
const [open, setOpen] = useState(false);
const { profile, match, application } = row;
const tone = TONE[match.tone] || TONE.warning;
/* The training prompt is shown only when Forge attached that path to
Positions. The score, the requirement rows and the gaps are unaffected —
what a position requires is the position's business, and attachment governs
only whether this page may point at the training behind a gap. */
const recommendation = row.recommendation && attaches(row.recommendation.skillId)
? row.recommendation
: null;
const applied = application
? new Date(application.created_date).toDateString() === new Date().toDateString()
? 'Applied today'
: `Applied ${new Date(application.created_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`
: 'Not applied — eligible from verified skills';
return (
<div className="rounded-xl border border-[#E5E7EB] p-4 transition-colors hover:border-[#0838E0]/40">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
{profile.selfie_url
? <img src={profile.selfie_url} alt="" className="h-10 w-10 shrink-0 rounded-full object-cover" />
: <div className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[#EEF3FE] text-[13px] font-semibold text-[#0838E0]">{profile.full_name?.[0]}</div>}
<div className="min-w-0">
<p className="truncate text-[14px] font-semibold text-[#111827]">{profile.full_name}</p>
<p className="truncate text-[12px] text-[#6B7280]">
{profile.current_position || profile.desired_position || 'Worker'} · {applied}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className={cn('rounded-full border px-2.5 py-1 text-[12px] font-semibold', tone.chip)}>
{match.score}% · {match.band}
</span>
</div>
</div>
{/* The score, as the requirements that produced it. */}
<ul className="mt-3 space-y-1.5">
{match.lines.map((line) => <RequirementRow key={line.skillId} line={line} />)}
</ul>
{/* The gap and the training that closes it — the Forge loop, stated on the
recruiter's page so "not ready" comes with what to do about it. */}
{recommendation?.improves && (
<div className="mt-3 flex flex-wrap items-center gap-2 rounded-lg border border-[#DBEAFE] bg-[#EEF3FE] px-3 py-2">
<GraduationCap className="h-3.5 w-3.5 shrink-0 text-[#0838E0]" aria-hidden="true" />
<p className="min-w-0 flex-1 text-[12px] text-[#1E3A8A]">
<span className="font-semibold">{recommendation.module.title}</span> would take{' '}
{profile.full_name.split(' ')[0]} to {recommendation.unlocks} — match{' '}
<span className="font-semibold tabular-nums">{recommendation.currentScore}% → {recommendation.projectedScore}%</span>
</p>
</div>
)}
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-[#F3F4F6] pt-3">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="text-[12px] font-medium text-[#6B7280] hover:text-[#111827]"
>
{open ? 'Hide scoring' : 'How this score is calculated'}
</button>
<div className="flex flex-wrap items-center gap-2">
{application && application.status !== 'interview' && application.status !== 'hired' && (
<button
type="button"
disabled={busy}
onClick={() => onInterview(application)}
className="rounded-lg border border-[#E5E7EB] px-3 py-1.5 text-[12px] font-medium text-[#374151] transition-colors hover:border-[#0838E0] hover:text-[#0838E0] disabled:opacity-50"
>
Move to Interview
</button>
)}
<button
type="button"
onClick={() => onView(row)}
className="inline-flex items-center gap-1 rounded-lg bg-[#0838E0] px-3 py-1.5 text-[12px] font-medium text-white transition-colors hover:bg-[#062BAF]"
>
View Candidate <ArrowRight className="h-3 w-3" />
</button>
</div>
</div>
{open && (
<div className="mt-3 rounded-lg bg-[#F9FAFB] p-3">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-[#9CA3AF]">
{job.title} — weighted requirements
</p>
<ul className="space-y-1 text-[12px] text-[#374151]">
{match.lines.map((line) => (
<li key={line.skillId} className="flex items-baseline justify-between gap-3">
<span className="min-w-0 truncate">
{line.name} — {LEVEL_LABEL[line.required]} required, holds {line.heldLabel}
</span>
<span className="shrink-0 tabular-nums font-medium">
{line.earned} of {line.weight}
</span>
</li>
))}
</ul>
<p className="mt-2 border-t border-[#E5E7EB] pt-2 text-[12px] font-semibold text-[#111827]">
Total {match.score}% — {match.band}
</p>
{match.gaps.length > 0 && (
<p className="mt-1 text-[12px] text-[#B45309]">{match.gaps[0].note}</p>
)}
<p className="mt-1 text-[11px] text-[#9CA3AF]">
A skill one level below its requirement earns half its weight; two or more levels below earns none.
</p>
</div>
)}
</div>
);
}
export function MatchedCandidates({ job, profiles = [], courses = [], applications = [], onInterview, busy }) {
const navigate = useNavigate();
const [showAll, setShowAll] = useState(false);
const positionSkills = usePageSkills('positions');
const ranked = useMemo(
() => rankWorkforce(job, profiles, courses, applications),
[job, profiles, courses, applications]
);
/* Position-level counts, from the same scores the rows show. Internal: these
are hiring metrics, and they stay on this page. */
const counts = useMemo(() => {
const jobApps = applications.filter((a) => a.job_posting_id === job?.id);
const scoredApplicants = ranked.filter((r) => r.application);
return {
applicants: jobApps.length,
strong: scoredApplicants.filter((r) => r.match.score >= 75).length,
potential: scoredApplicants.filter((r) => r.match.score >= 50 && r.match.score < 75).length,
interview: jobApps.filter((a) => a.status === 'interview').length,
hired: jobApps.filter((a) => a.status === 'hired').length,
eligible: ranked.filter((r) => r.match.score >= 75).length,
};
}, [ranked, applications, job]);
if (!job?.skill_requirements?.length) {
return (
<div className="rounded-2xl border border-dashed border-[#E5E7EB] bg-white p-6">
<h3 className="text-[15px] font-semibold text-[#111827]">Skill matching not configured</h3>
<p className="mt-1 max-w-lg text-[13px] text-[#6B7280]">
This position has no required skills, so candidates cannot be scored against verified
training. Add them by editing the position — every requirement is a skill, a level and a
weight.
</p>
</div>
);
}
const shown = showAll ? ranked : ranked.slice(0, 5);
return (
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-[15px] font-semibold text-[#111827]">Matched candidates</h3>
<p className="mt-0.5 text-[13px] text-[#6B7280]">
{counts.eligible} {counts.eligible === 1 ? 'person meets' : 'people meet'} this position at
75% or better, scored against verified skills. Best match first.
</p>
</div>
<ul className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px] text-[#6B7280]">
{[
['Applicants', counts.applicants],
['Strong matches', counts.strong],
['Potential', counts.potential],
['Interview', counts.interview],
['Hired', counts.hired],
].map(([label, value]) => (
<li key={label} className="whitespace-nowrap">
<span className="font-semibold tabular-nums text-[#111827]">{value}</span> {label}
</li>
))}
</ul>
</div>
{/* What the position asks for, so the scores below have a stated basis. */}
<div className="mt-4 flex flex-wrap items-center gap-2 rounded-lg bg-[#F9FAFB] px-3 py-2">
<Sparkles className="h-3.5 w-3.5 shrink-0 text-[#0838E0]" aria-hidden="true" />
<span className="text-[12px] text-[#6B7280]">Requires</span>
{job.skill_requirements.map((req) => (
<span key={req.skill_id} className="rounded-full border border-[#E5E7EB] bg-white px-2 py-0.5 text-[12px] text-[#374151]">
{skillName(req.skill_id)} — {LEVEL_LABEL[req.level]}
<span className="ml-1 text-[#9CA3AF]">{req.weight}%</span>
</span>
))}
</div>
<div className="mt-4 space-y-3">
{shown.map((row) => (
<CandidateRow
key={row.profile.id}
row={row}
job={job}
busy={busy}
onView={(r) => navigate(
r.application ? `/admin/candidates/${r.application.id}` : `/admin/talent-pool?search=${encodeURIComponent(r.profile.full_name)}`
)}
onInterview={onInterview}
attaches={positionSkills.attaches}
/>
))}
</div>
{ranked.length > 5 && (
<button
type="button"
onClick={() => setShowAll((v) => !v)}
className="mt-3 text-[13px] font-medium text-[#0838E0] hover:underline"
>
{showAll ? 'Show top 5' : `Show all ${ranked.length} scored`}
</button>
)}
</div>
);
}
/**
* The candidate's own fit, for a candidate audience.
*
* Same arithmetic, one person, and nothing about anyone else — no ranking, no
* counts, no other applicants. That separation is the reason this lives beside
* `MatchedCandidates` rather than inside it: two audiences, two components, one
* scoring function that cannot drift between them.
*/
export function YourSkillFit({ job, held, className }) {
const match = matchPosition(job, held);
if (!match) return null;
const tone = TONE[match.tone] || TONE.warning;
return (
<div className={cn('rounded-2xl border border-[#E5E7EB] bg-white p-6', className)}>
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-[15px] font-semibold text-[#111827]">Your skill fit</h3>
<span className={cn('rounded-full border px-2.5 py-1 text-[12px] font-semibold', tone.chip)}>
{match.score}% · {match.band}
</span>
</div>
<ul className="mt-3 space-y-1.5">
{match.lines.map((line) => <RequirementRow key={line.skillId} line={line} />)}
</ul>
{match.gaps.length > 0 && (
<p className="mt-3 text-[12px] text-[#B45309]">{match.gaps[0].note}</p>
)}
</div>
);
}
export default MatchedCandidates;

View File

@@ -37,7 +37,7 @@ export default function MessageCandidateModal({ open, onClose, application }) {
<button
onClick={() => setChannel('message')}
className={`flex items-center justify-center gap-2 py-2.5 rounded-lg border text-[13px] font-medium transition-all ${
channel === 'message' ? 'border-[#0A39DF] bg-[#EFF6FF] text-[#0A39DF]' : 'border-[#E5E7EB] text-[#6B7280]'
channel === 'message' ? 'border-[#0838E0] bg-[#EEF3FE] text-[#0838E0]' : 'border-[#E5E7EB] text-[#6B7280]'
}`}
>
<Mail className="w-4 h-4" /> Message
@@ -45,7 +45,7 @@ export default function MessageCandidateModal({ open, onClose, application }) {
<button
onClick={() => setChannel('call')}
className={`flex items-center justify-center gap-2 py-2.5 rounded-lg border text-[13px] font-medium transition-all ${
channel === 'call' ? 'border-[#0A39DF] bg-[#EFF6FF] text-[#0A39DF]' : 'border-[#E5E7EB] text-[#6B7280]'
channel === 'call' ? 'border-[#0838E0] bg-[#EEF3FE] text-[#0838E0]' : 'border-[#E5E7EB] text-[#6B7280]'
}`}
>
<Phone className="w-4 h-4" /> Call
@@ -54,7 +54,7 @@ export default function MessageCandidateModal({ open, onClose, application }) {
{channel === 'call' ? (
<div className="py-6 text-center text-[13px] text-[#6B7280]">
<Phone className="w-8 h-8 text-[#0A39DF] mx-auto mb-2" />
<Phone className="w-8 h-8 text-[#0838E0] mx-auto mb-2" />
Calling {application?.phone || 'candidate'}...
</div>
) : (
@@ -62,13 +62,13 @@ export default function MessageCandidateModal({ open, onClose, application }) {
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={`Hi ${application?.applicant_name?.split(' ')[0]}, ...`}
className="w-full min-h-[100px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
className="w-full min-h-[100px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none"
/>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={handleSend} disabled={channel === 'message' && !message.trim()} className="bg-[#0A39DF] hover:bg-[#082EB4]">
<Button onClick={handleSend} disabled={channel === 'message' && !message.trim()} className="bg-[#0838E0] hover:bg-[#062BAF]">
<Send className="w-4 h-4 mr-2" /> Send
</Button>
</DialogFooter>

View File

@@ -30,11 +30,11 @@ export default function OwliverChatBubble({ open: externalOpen, onOpenChange, sh
shadow-[0_10px_30px_rgba(10,57,223,0.22),0_2px_6px_rgba(51,63,72,0.08)]
ring-1 ring-black/5 transition-transform duration-300 hover:scale-[1.01]"
>
<Sparkles className="w-4 h-4 text-[#0A39DF] shrink-0" />
<span className="text-[15px] text-[#B5B8D3] font-medium flex-1 truncate text-left group-hover:text-[#0A39DF] transition-colors">
<Sparkles className="w-4 h-4 text-[#0838E0] shrink-0" />
<span className="text-[15px] text-[#B5B8D3] font-medium flex-1 truncate text-left group-hover:text-[#0838E0] transition-colors">
Build your profile
</span>
<span className="relative block w-12 h-12 rounded-full overflow-hidden ring-2 ring-[#0A39DF]/15 shadow-[0_4px_12px_rgba(10,57,223,0.35)] shrink-0">
<span className="relative block w-12 h-12 rounded-full overflow-hidden ring-2 ring-[#0838E0]/15 shadow-[0_4px_12px_rgba(10,57,223,0.35)] shrink-0">
<Image src={OWLIVER_URL} alt="Owliver" fittingType="fill" className="w-full h-full" />
</span>
</motion.button>
@@ -48,7 +48,7 @@ export default function OwliverChatBubble({ open: externalOpen, onOpenChange, sh
className="flex flex-col w-[min(94vw,400px)] h-[min(82vh,620px)] rounded-2xl bg-white overflow-hidden
shadow-[0_18px_50px_rgba(10,57,223,0.28),0_4px_12px_rgba(51,63,72,0.12)] ring-1 ring-black/5"
>
<div className="flex items-center gap-2.5 px-4 py-3 bg-[#0A39DF] text-white shrink-0">
<div className="flex items-center gap-2.5 px-4 py-3 bg-[#0838E0] text-white shrink-0">
<span className="block w-8 h-8 rounded-full overflow-hidden ring-2 ring-white/40">
<Image src={OWLIVER_URL} alt="Owliver" fittingType="fill" className="w-full h-full" />
</span>

View File

@@ -12,7 +12,7 @@ export default function OwliverCoachCard({ nextCourse, matchCount, avgPay }) {
<p className="text-[18px] sm:text-[22px] font-medium text-[#0F172A] mt-3 leading-snug">
{nextCourse ? (
<>
Complete <span className="text-[#0A39DF]">{nextCourse.title}</span> to unlock stronger matches.
Complete <span className="text-[#0838E0]">{nextCourse.title}</span> to unlock stronger matches.
</>
) : (
<>Keep building your profile and I'll map your fastest path forward.</>
@@ -26,7 +26,7 @@ export default function OwliverCoachCard({ nextCourse, matchCount, avgPay }) {
{nextCourse && (
<Link
to={`/university/${nextCourse.id}`}
className="mt-7 inline-flex items-center gap-1.5 bg-[#0A39DF] text-white text-[14px] font-medium px-6 py-3 rounded-full hover:bg-[#082EB4] transition-colors"
className="mt-7 inline-flex items-center gap-1.5 bg-[#0838E0] text-white text-[14px] font-medium px-6 py-3 rounded-full hover:bg-[#062BAF] transition-colors"
>
Start Learning <ArrowRight className="w-4 h-4" />
</Link>

View File

@@ -4,9 +4,9 @@ import { cn } from '@/lib/utils';
const STAGES = [
{ key: 'applied', label: 'Applied', color: 'bg-[#D1E0D7]', text: 'text-[#333F48]' },
{ key: 'ai_screened', label: 'AI Screened', color: 'bg-[#F8E08E]', text: 'text-[#333F48]' },
{ key: 'shortlisted', label: 'Shortlisted', color: 'bg-[#F9E547]', text: 'text-[#0A39DF]' },
{ key: 'shortlisted', label: 'Shortlisted', color: 'bg-[#F9E547]', text: 'text-[#0838E0]' },
{ key: 'interview', label: 'Interviewed', color: 'bg-[#333F48]', text: 'text-white' },
{ key: 'hired', label: 'Hired', color: 'bg-[#0A39DF]', text: 'text-white' },
{ key: 'hired', label: 'Hired', color: 'bg-[#0838E0]', text: 'text-white' },
];
export default function PipelineFunnel({ applications }) {

View File

@@ -102,7 +102,7 @@ export default function RateEndorseModal({ open, onClose, staff, reviewerName })
onChange={(e) => setText(e.target.value)}
rows={3}
placeholder="Share what made them great to work with..."
className="w-full rounded-lg border border-[#E5E7EB] bg-white px-3 py-2 text-[13px] focus:border-[#0A39DF] focus:outline-none resize-none"
className="w-full rounded-lg border border-[#E5E7EB] bg-white px-3 py-2 text-[13px] focus:border-[#0838E0] focus:outline-none resize-none"
/>
</div>
@@ -141,7 +141,7 @@ export default function RateEndorseModal({ open, onClose, staff, reviewerName })
<button
onClick={submit}
disabled={update.isPending}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-[#0A39DF] hover:bg-[#082EB4] disabled:opacity-50 text-white text-[13px] font-medium"
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-[#0838E0] hover:bg-[#062BAF] disabled:opacity-50 text-white text-[13px] font-medium"
>
<Award className="w-4 h-4" />
{update.isPending ? 'Saving...' : 'Save Rating'}

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Quote, BadgeCheck, Star, ChevronDown } from 'lucide-react';
const COMPANY_COLORS = ['bg-[#0A39DF]', 'bg-[#333F48]', 'bg-[#082EB4]'];
const COMPANY_COLORS = ['bg-[#0838E0]', 'bg-[#333F48]', 'bg-[#062BAF]'];
const colorFor = (name) => {
let hash = 0;
@@ -86,10 +86,10 @@ export default function ReferencesEndorsements({ application }) {
</div>
<p className="text-[12px] text-[#374151] mt-2 leading-relaxed">"{v.quote}"</p>
<div className="flex items-center gap-2 mt-2.5">
<button className="inline-flex items-center gap-1 px-3 py-1 rounded-full border border-[#0A39DF] text-[#0A39DF] text-[11px] font-semibold hover:bg-[#EFF6FF] transition-colors">
<button className="inline-flex items-center gap-1 px-3 py-1 rounded-full border border-[#0838E0] text-[#0838E0] text-[11px] font-semibold hover:bg-[#EEF3FE] transition-colors">
Request Reference
</button>
<button className="inline-flex items-center gap-1 px-3 py-1 rounded-full border border-[#0A39DF] text-[#0A39DF] text-[11px] font-semibold hover:bg-[#EFF6FF] transition-colors">
<button className="inline-flex items-center gap-1 px-3 py-1 rounded-full border border-[#0838E0] text-[#0838E0] text-[11px] font-semibold hover:bg-[#EEF3FE] transition-colors">
Endorse Skill
</button>
</div>
@@ -105,7 +105,7 @@ export default function ReferencesEndorsements({ application }) {
</p>
<div className="flex flex-wrap gap-2">
{strengths.map((s, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium border border-[#DBEAFE]">
<span key={i} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#EEF3FE] text-[#0838E0] text-[11px] font-medium border border-[#DBEAFE]">
<BadgeCheck className="w-3 h-3" />
{typeof s === 'string' ? s : s.skill || s}
</span>

View File

@@ -20,10 +20,10 @@ export default function RoleSelector({ value, onChange }) {
onClick={() => onChange(r.value)}
className={cn(
'p-4 rounded-xl border-2 text-left transition-all',
active ? 'border-[#0A39DF] bg-[#EFF6FF]' : 'border-[#E5E7EB] hover:border-[#D1D5DB]'
active ? 'border-[#0838E0] bg-[#EEF3FE]' : 'border-[#E5E7EB] hover:border-[#D1D5DB]'
)}
>
<Icon className={cn('w-5 h-5 mb-2', active ? 'text-[#0A39DF]' : 'text-[#9CA3AF]')} />
<Icon className={cn('w-5 h-5 mb-2', active ? 'text-[#0838E0]' : 'text-[#9CA3AF]')} />
<p className={cn('text-[13px] font-semibold', active ? 'text-[#1E40AF]' : 'text-[#374151]')}>{r.label}</p>
<p className="text-[11px] text-[#6B7280] mt-0.5">{r.desc}</p>
</button>

View File

@@ -49,7 +49,7 @@ export default function ScheduleInterviewModal({ open, onClose, application }) {
key={opt.value}
onClick={() => setType(opt.value)}
className={`flex flex-col items-center gap-1 py-2.5 rounded-lg border text-[12px] font-medium transition-all ${
type === opt.value ? 'border-[#0A39DF] bg-[#EFF6FF] text-[#0A39DF]' : 'border-[#E5E7EB] text-[#6B7280] hover:bg-[#F9FAFB]'
type === opt.value ? 'border-[#0838E0] bg-[#EEF3FE] text-[#0838E0]' : 'border-[#E5E7EB] text-[#6B7280] hover:bg-[#F9FAFB]'
}`}
>
<opt.icon className="w-4 h-4" />
@@ -76,13 +76,13 @@ export default function ScheduleInterviewModal({ open, onClose, application }) {
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add any prep notes..."
className="w-full mt-1.5 min-h-[60px] p-2.5 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
className="w-full mt-1.5 min-h-[60px] p-2.5 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={handleSchedule} disabled={!date || !time} className="bg-[#0A39DF] hover:bg-[#082EB4]">
<Button onClick={handleSchedule} disabled={!date || !time} className="bg-[#0838E0] hover:bg-[#062BAF]">
<Clock className="w-4 h-4 mr-2" /> Schedule
</Button>
</DialogFooter>

View File

@@ -5,7 +5,7 @@ const TIERS = [
{ label: '0-39', range: [0, 39], color: '#333F48' },
{ label: '40-59', range: [40, 59], color: '#D1E0D7' },
{ label: '60-79', range: [60, 79], color: '#F8E08E' },
{ label: '80-100', range: [80, 100], color: '#0A39DF' },
{ label: '80-100', range: [80, 100], color: '#0838E0' },
];
export default function ScoreDistribution({ applications }) {

View File

@@ -4,7 +4,7 @@ import React from 'react';
const LEVELS = [
{ name: 'Claimed', fill: 35, bar: 'bg-[#CBD5E1]', text: 'text-[#94A3B8]' },
{ name: 'Practiced', fill: 62, bar: 'bg-[#93C5FD]', text: 'text-[#3B82F6]' },
{ name: 'Verified', fill: 84, bar: 'bg-[#0A39DF]', text: 'text-[#0A39DF]' },
{ name: 'Verified', fill: 84, bar: 'bg-[#0838E0]', text: 'text-[#0838E0]' },
{ name: 'Expert', fill: 94, bar: 'bg-[#6366F1]', text: 'text-[#6366F1]' },
{ name: 'Elite', fill: 100, bar: 'bg-[#F9E547]', text: 'text-[#92400E]' },
];

View File

@@ -0,0 +1,132 @@
import React from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { LEVEL_LABEL, SKILLS, skillById } from '@/lib/skillGraph';
/**
* Required skills on a position — the recruiter's half of the matching engine.
*
* A requirement is three things and no more: which skill, the level the work
* actually needs, and how much of the role that skill carries. Everything the
* candidate list later shows — the percentage, the band, the gap, the training
* recommendation — is computed from these rows, so this is the only place a
* position says what it needs.
*
* Weights are shown as a running total but not enforced to 100: they are
* normalised when scoring, so a position with 40/25/20/15 and one with 4/2/2/1
* produce the same match. Reporting the total is still worth it — it is how a
* recruiter sees which skill they have made the deciding one.
*
* Styled to match the surrounding Create Position form rather than the Admin
* design system, because that is the form this lives in.
*/
export function SkillRequirementsField({ value = [], onChange }) {
const rows = Array.isArray(value) ? value : [];
const used = new Set(rows.map((r) => r.skill_id));
const available = SKILLS.filter((s) => !used.has(s.id));
const total = rows.reduce((sum, r) => sum + (Number(r.weight) || 0), 0);
const update = (index, patch) =>
onChange(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
const add = () => {
const skill = available[0];
if (!skill) return;
/* A flat 25 rather than "whatever is left of 100": weights are normalised
when scoring, so the total need not land on 100, and a predictable default
beats one that changes depending on what was added before it. */
onChange([...rows, { skill_id: skill.id, level: skill.levels[0], weight: 25 }]);
};
return (
<div>
<div className="flex items-center justify-between gap-3">
<label className="text-[13px] font-medium text-[#374151]">
Required Skills
<span className="ml-1.5 font-normal text-[#6B7280]">— what a candidate must have verified in KROW Forge</span>
</label>
{rows.length > 0 && (
<span className={`text-[12px] font-medium ${total === 100 ? 'text-[#059669]' : 'text-[#6B7280]'}`}>
{total}% weighted
</span>
)}
</div>
{rows.length === 0 ? (
<p className="mt-1.5 text-[12px] text-[#6B7280]">
No skill requirements yet. Without them this position cannot be matched against verified skills.
</p>
) : (
<div className="mt-2 space-y-2">
{rows.map((row, i) => {
const skill = skillById(row.skill_id);
const levels = skill?.levels || [];
return (
<div key={`${row.skill_id}-${i}`} className="flex flex-wrap items-center gap-2">
<select
value={row.skill_id}
onChange={(e) => {
const nextSkill = skillById(e.target.value);
update(i, {
skill_id: e.target.value,
/* A skill with a shorter ladder cannot hold the old level. */
level: nextSkill?.levels.includes(row.level) ? row.level : nextSkill?.levels[0],
});
}}
className="h-10 min-w-[9.5rem] flex-1 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none"
>
{[skill, ...available].filter(Boolean).map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<select
value={row.level}
onChange={(e) => update(i, { level: e.target.value })}
aria-label="Required level"
className="h-10 min-w-[8rem] rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none"
>
{levels.map((l) => <option key={l} value={l}>{LEVEL_LABEL[l]}</option>)}
</select>
<div className="flex items-center gap-1.5">
<input
type="number"
min="0"
max="100"
value={row.weight}
onChange={(e) => update(i, { weight: Number(e.target.value) || 0 })}
aria-label={`${skill?.name || 'Skill'} weight`}
className="h-10 w-20 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0838E0] focus:outline-none"
/>
<span className="text-[13px] text-[#6B7280]">%</span>
</div>
<button
type="button"
onClick={() => onChange(rows.filter((_, n) => n !== i))}
aria-label={`Remove ${skill?.name || 'requirement'}`}
className="grid h-10 w-10 place-items-center rounded-lg border border-[#E5E7EB] text-[#6B7280] transition-colors hover:border-[#FCA5A5] hover:text-[#DC2626]"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
);
})}
</div>
)}
{available.length > 0 && (
<button
type="button"
onClick={add}
className="mt-2 inline-flex items-center gap-1.5 rounded-lg border border-[#E5E7EB] px-3 py-2 text-[13px] font-medium text-[#374151] transition-colors hover:border-[#0838E0] hover:text-[#0838E0]"
>
<Plus className="h-3.5 w-3.5" /> Add required skill
</button>
)}
</div>
);
}
export default SkillRequirementsField;

View File

@@ -25,7 +25,7 @@ function getPortraitSeed(name) {
}
const MATCH_LABEL_STYLES = {
'Strong Match': 'bg-[#EFF6FF] text-[#0A39DF]',
'Strong Match': 'bg-[#EEF3FE] text-[#0838E0]',
'Good Match': 'bg-[#F0FDF4] text-[#16A34A]',
'Possible Fit': 'bg-[#FFFBEB] text-[#D97706]',
'Weak Fit': 'bg-[#F3F4F6] text-[#6B7280]',
@@ -33,7 +33,7 @@ const MATCH_LABEL_STYLES = {
const RECO_STYLES = {
Hire: 'bg-[#16A34A] text-white',
Interview: 'bg-[#0A39DF] text-white',
Interview: 'bg-[#0838E0] text-white',
Maybe: 'bg-[#D97706] text-white',
Pass: 'bg-[#9CA3AF] text-white',
};
@@ -76,13 +76,13 @@ export default function SuggestedTalent({ job, onContact, onSchedule, onHire })
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center">
<Sparkles className="w-4 h-4 text-[#0A39DF]" />
<div className="w-7 h-7 rounded-lg bg-[#EEF3FE] flex items-center justify-center">
<Sparkles className="w-4 h-4 text-[#0838E0]" />
</div>
<h3 className="text-[15px] font-semibold text-[#111827]">Suggested Talent</h3>
</div>
{matches.length > 0 && (
<button onClick={runMatch} disabled={matchTalent.isPending} className="text-[12px] font-medium text-[#0A39DF] hover:underline disabled:opacity-50">
<button onClick={runMatch} disabled={matchTalent.isPending} className="text-[12px] font-medium text-[#0838E0] hover:underline disabled:opacity-50">
{matchTalent.isPending ? 'Re-matching…' : 'Re-run match'}
</button>
)}
@@ -104,7 +104,7 @@ export default function SuggestedTalent({ job, onContact, onSchedule, onHire })
const p = m.profile;
const band = getScoreBand(p.krow_score || 0);
return (
<div key={m.profile_id} className="flex items-start gap-3 p-3 rounded-xl border border-[#E5E7EB] hover:border-[#0A39DF] hover:bg-[#FAFBFF] transition-colors cursor-pointer" onClick={() => setSelected(p)}>
<div key={m.profile_id} className="flex items-start gap-3 p-3 rounded-xl border border-[#E5E7EB] hover:border-[#0838E0] hover:bg-[#FAFBFF] transition-colors cursor-pointer" onClick={() => setSelected(p)}>
<div className="w-11 h-11 rounded-full bg-[#E5E7EB] overflow-hidden shrink-0">
<Image
src={p.selfie_url || `https://images.unsplash.com/${getPortraitSeed(p.full_name)}?w=120&h=120&fit=crop&crop=faces`}
@@ -121,7 +121,7 @@ export default function SuggestedTalent({ job, onContact, onSchedule, onHire })
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] text-[#6B7280] flex-wrap">
{(p.desired_position || p.current_position) && (
<span className="inline-flex items-center gap-1 font-medium text-[#0A39DF]">
<span className="inline-flex items-center gap-1 font-medium text-[#0838E0]">
<Briefcase className="w-3 h-3" />{p.desired_position || p.current_position}
</span>
)}
@@ -137,7 +137,7 @@ export default function SuggestedTalent({ job, onContact, onSchedule, onHire })
)}
</div>
<div className="flex flex-col items-center justify-center shrink-0 px-2">
<span className="text-[20px] font-bold text-[#0A39DF] leading-none">{Math.round(m.match_score)}</span>
<span className="text-[20px] font-bold text-[#0838E0] leading-none">{Math.round(m.match_score)}</span>
<span className="text-[9px] text-[#9CA3AF] tracking-wide">MATCH</span>
</div>
</div>

View File

@@ -50,7 +50,7 @@ const BADGE_LEVEL_STYLES = {
bronze: 'bg-[#FEF3C7] text-[#92400E] border-[#FCD34D]',
silver: 'bg-[#F3F4F6] text-[#4B5563] border-[#D1D5DB]',
gold: 'bg-[#FEF9E7] text-[#92400E] border-[#F9E547]',
platinum: 'bg-[#EFF6FF] text-[#1E40AF] border-[#BFDBFE]',
platinum: 'bg-[#EEF3FE] text-[#1E40AF] border-[#BFDBFE]',
};
function Section({ label, children }) {
@@ -104,7 +104,7 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
const identityVerified = capabilities.length > 0 || badges.length > 0;
const identityTag = identityVerified ? 'Verified' : 'Pending';
const identityClass = identityVerified ? 'bg-[#EFF6FF] text-[#1E40AF]' : 'bg-[#FFF7ED] text-[#B45309]';
const identityClass = identityVerified ? 'bg-[#EEF3FE] text-[#1E40AF]' : 'bg-[#FFF7ED] text-[#B45309]';
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto p-0">
@@ -169,10 +169,10 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
{/* Match banner */}
{matchScore != null && (
<div className="rounded-xl border border-[#DBEAFE] bg-[#EFF6FF] p-4">
<div className="rounded-xl border border-[#DBEAFE] bg-[#EEF3FE] p-4">
<div className="flex items-center gap-2 mb-1.5">
<Sparkles className="w-4 h-4 text-[#0A39DF]" />
<span className="text-[13px] font-bold text-[#0A39DF]">Match Score: {matchScore}/100</span>
<Sparkles className="w-4 h-4 text-[#0838E0]" />
<span className="text-[13px] font-bold text-[#0838E0]">Match Score: {matchScore}/100</span>
</div>
{matchReasons?.length > 0 && (
<ul className="text-[12px] text-[#374151] list-disc list-inside space-y-0.5">
@@ -250,8 +250,8 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{profile.personality && (
<div className="flex gap-2.5">
<span className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center shrink-0">
<Brain className="w-3.5 h-3.5 text-[#0A39DF]" />
<span className="w-7 h-7 rounded-lg bg-[#EEF3FE] flex items-center justify-center shrink-0">
<Brain className="w-3.5 h-3.5 text-[#0838E0]" />
</span>
<div className="min-w-0">
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1">PERSONALITY</p>
@@ -261,8 +261,8 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
)}
{profile.communication_style && (
<div className="flex gap-2.5">
<span className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center shrink-0">
<MessageSquare className="w-3.5 h-3.5 text-[#0A39DF]" />
<span className="w-7 h-7 rounded-lg bg-[#EEF3FE] flex items-center justify-center shrink-0">
<MessageSquare className="w-3.5 h-3.5 text-[#0838E0]" />
</span>
<div className="min-w-0">
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1">COMMUNICATION STYLE</p>
@@ -274,7 +274,7 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
{strengths.length > 0 && (
<div className="pt-3 border-t border-[#F3F4F6]">
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2">STRENGTHS</p>
<PillList items={strengths} className="bg-[#EFF6FF] text-[#0A39DF]" />
<PillList items={strengths} className="bg-[#EEF3FE] text-[#0838E0]" />
</div>
)}
</div>
@@ -288,7 +288,7 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
</div>
<div className="flex items-center gap-2 flex-wrap">
{onContact && (
<button onClick={() => onContact(profile)} className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[13px] font-medium px-4 py-2 rounded-lg transition-colors">
<button onClick={() => onContact(profile)} className="inline-flex items-center gap-1.5 bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-medium px-4 py-2 rounded-lg transition-colors">
<Mail className="w-4 h-4" /> Contact
</button>
)}

View File

@@ -52,7 +52,7 @@ export default function TalentLoop({ profile, applications = [] }) {
<section className="glass-card border border-white/60 rounded-2xl p-6 sm:p-8 shadow-sm">
<div className="flex items-end justify-between flex-wrap gap-3 mb-1">
<div>
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-[#0A39DF]">The Talent Loop</span>
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-[#0838E0]">The Talent Loop</span>
<h2 className="text-[22px] font-heading font-bold text-[#0F172A] mt-1 tracking-tight">Build your reputation. Not just your resume.</h2>
</div>
<span className="text-[12px] text-[#64748B]">{reached}/{STEPS.length - 1} stages</span>
@@ -63,7 +63,7 @@ export default function TalentLoop({ profile, applications = [] }) {
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.8, ease: 'easeOut' }}
className="h-full bg-[#0A39DF] rounded-full"
className="h-full bg-[#0838E0] rounded-full"
/>
</div>
@@ -78,18 +78,18 @@ export default function TalentLoop({ profile, applications = [] }) {
to={s.to}
className={`group relative flex flex-col items-start gap-2 rounded-xl border p-3 transition-all ${
isDone
? 'bg-[#0A39DF] border-[#0A39DF] text-white'
? 'bg-[#0838E0] border-[#0838E0] text-white'
: isCurrent
? 'bg-white border-[#0A39DF] ring-2 ring-[#0A39DF]/20'
: 'bg-white border-[#E5E7EB] hover:border-[#0A39DF]/50'
? 'bg-white border-[#0838E0] ring-2 ring-[#0838E0]/20'
: 'bg-white border-[#E5E7EB] hover:border-[#0838E0]/50'
}`}
>
<div className="flex items-center justify-between w-full">
<span className={`text-[10px] font-semibold ${isDone ? 'text-white/70' : 'text-[#94A3B8]'}`}>{String(i + 1).padStart(2, '0')}</span>
{s.loop && <Repeat className={`w-3.5 h-3.5 ${isDone ? 'text-white/70' : 'text-[#94A3B8]'}`} />}
</div>
<Icon className={`w-5 h-5 ${isDone ? 'text-white' : isCurrent ? 'text-[#0A39DF]' : 'text-[#64748B]'}`} />
<span className={`text-[12px] font-medium leading-tight ${isDone ? 'text-white' : isCurrent ? 'text-[#0A39DF]' : 'text-[#334155]'}`}>
<Icon className={`w-5 h-5 ${isDone ? 'text-white' : isCurrent ? 'text-[#0838E0]' : 'text-[#64748B]'}`} />
<span className={`text-[12px] font-medium leading-tight ${isDone ? 'text-white' : isCurrent ? 'text-[#0838E0]' : 'text-[#334155]'}`}>
{s.label}
</span>
</Link>
@@ -104,7 +104,7 @@ export default function TalentLoop({ profile, applications = [] }) {
</div>
<Link
to={current.to}
className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-[#0A39DF] hover:gap-2.5 transition-all"
className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-[#0838E0] hover:gap-2.5 transition-all"
>
Continue <ArrowRight className="w-4 h-4" />
</Link>

View File

@@ -35,7 +35,7 @@ function CareerGauge({ fico }) {
const theta = (180 - frac * 180) * (Math.PI / 180);
const xv = cx + r * Math.cos(theta);
const yv = cy - r * Math.sin(theta);
const color = value > MIN ? '#0A39DF' : '#CBD5E1';
const color = value > MIN ? '#0838E0' : '#CBD5E1';
return (
<div className="flex flex-col items-center shrink-0">
<span className="text-[8px] font-semibold text-[#8A94A4] tracking-[0.12em] mb-0.5">CAREER SCORE</span>
@@ -47,7 +47,7 @@ function CareerGauge({ fico }) {
<circle cx={xv} cy={yv} r="5.5" fill={color} stroke="#fff" strokeWidth="1.5" />
</>
)}
<text x={cx} y={cy - 6} textAnchor="middle" className="fill-[#0A39DF]" style={{ fontSize: 20, fontWeight: 700 }}>{value}</text>
<text x={cx} y={cy - 6} textAnchor="middle" className="fill-[#0838E0]" style={{ fontSize: 20, fontWeight: 700 }}>{value}</text>
<text x={cx} y={cy + 9} textAnchor="middle" fill="#8A94A4" style={{ fontSize: 8, fontWeight: 600 }}>300-850</text>
</svg>
</div>
@@ -58,7 +58,7 @@ const BADGE_LEVEL_STYLES = {
bronze: 'bg-[#FEF3C7] text-[#92400E]',
silver: 'bg-[#F3F4F6] text-[#4B5563]',
gold: 'bg-[#FEF9E7] text-[#92400E]',
platinum: 'bg-[#EFF6FF] text-[#1E40AF]',
platinum: 'bg-[#EEF3FE] text-[#1E40AF]',
};
export default function TalentPoolCard({ profile, onContact, onView }) {
@@ -85,7 +85,7 @@ export default function TalentPoolCard({ profile, onContact, onView }) {
<div
className={cn(
'bg-white border border-[#E5E7EB] rounded-xl transition-all overflow-hidden',
expanded ? 'shadow-sm border-[#0A39DF]' : 'hover:shadow-sm hover:border-[#0A39DF]'
expanded ? 'shadow-sm border-[#0838E0]' : 'hover:shadow-sm hover:border-[#0838E0]'
)}
>
{/* Clickable header → opens full profile modal */}
@@ -130,7 +130,7 @@ export default function TalentPoolCard({ profile, onContact, onView }) {
{/* Quick meta line */}
<div className="flex items-center gap-3 text-[11px] text-[#6B7280] flex-wrap">
{profile.desired_position && (
<span className="inline-flex items-center gap-1 font-medium text-[#0A39DF]">
<span className="inline-flex items-center gap-1 font-medium text-[#0838E0]">
<Briefcase className="w-3 h-3" />
{profile.desired_position}
</span>
@@ -155,7 +155,7 @@ export default function TalentPoolCard({ profile, onContact, onView }) {
{skills.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{skills.map((s) => (
<span key={s} className="px-2 py-0.5 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[10px] font-medium">{s}</span>
<span key={s} className="px-2 py-0.5 rounded-full bg-[#EEF3FE] text-[#0838E0] text-[10px] font-medium">{s}</span>
))}
{extraSkills > 0 && (
<span className="px-2 py-0.5 rounded-full bg-[#F3F4F6] text-[#6B7280] text-[10px] font-medium">+{extraSkills}</span>
@@ -200,7 +200,7 @@ export default function TalentPoolCard({ profile, onContact, onView }) {
</p>
<div className="flex items-center gap-1.5 flex-wrap">
{endorsements.map((e, i) => (
<span key={i} className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border', e.status === 'verified' ? 'bg-[#EFF6FF] text-[#0A39DF] border-[#DBEAFE]' : 'bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]')}>
<span key={i} className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border', e.status === 'verified' ? 'bg-[#EEF3FE] text-[#0838E0] border-[#DBEAFE]' : 'bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]')}>
<BadgeCheck className="w-2.5 h-2.5" />
{e.skill}
{e.level && <span className="opacity-70">· {e.level}</span>}
@@ -226,7 +226,7 @@ export default function TalentPoolCard({ profile, onContact, onView }) {
{onContact && (
<button
onClick={(e) => { e.stopPropagation(); onContact(profile); }}
className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[12px] font-medium px-3 py-1.5 rounded-lg transition-colors whitespace-nowrap"
className="inline-flex items-center gap-1.5 bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium px-3 py-1.5 rounded-lg transition-colors whitespace-nowrap"
>
<Mail className="w-3.5 h-3.5" />
Contact

View File

@@ -41,7 +41,7 @@ export default function TrustLayer({ profile }) {
<section className="glass-card border border-white/60 rounded-2xl p-6 sm:p-8 shadow-sm">
<div className="flex items-start justify-between gap-4 flex-wrap mb-6">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-[#0A39DF] flex items-center justify-center shrink-0">
<div className="w-10 h-10 rounded-xl bg-[#0838E0] flex items-center justify-center shrink-0">
<ShieldCheck className="w-5 h-5 text-white" />
</div>
<div>
@@ -51,7 +51,7 @@ export default function TrustLayer({ profile }) {
</div>
</div>
<div className="text-right">
<div className="text-[28px] font-heading font-bold text-[#0A39DF] leading-none">{verified}<span className="text-[#94A3B8] text-[18px]">/{ITEMS.length}</span></div>
<div className="text-[28px] font-heading font-bold text-[#0838E0] leading-none">{verified}<span className="text-[#94A3B8] text-[18px]">/{ITEMS.length}</span></div>
<div className="text-[11px] text-[#94A3B8] font-medium mt-1">verified</div>
</div>
</div>
@@ -62,7 +62,7 @@ export default function TrustLayer({ profile }) {
return (
<div key={item.key} className="flex items-center gap-2.5 py-1.5 border-b border-[#F1F5F9]">
{st === 'verified' ? (
<span className="w-5 h-5 rounded-full bg-[#0A39DF] flex items-center justify-center shrink-0">
<span className="w-5 h-5 rounded-full bg-[#0838E0] flex items-center justify-center shrink-0">
<Check className="w-3 h-3 text-white" />
</span>
) : (

View File

@@ -6,7 +6,7 @@ import { isUnlocked } from '@/lib/provingGround';
const DIFF_STYLES = {
beginner: { label: 'Beginner', bg: 'bg-[#F0FDF4]', text: 'text-[#16A34A]', border: 'border-[#DCFCE7]' },
intermediate: { label: 'Intermediate', bg: 'bg-[#FEF3C7]', text: 'text-[#D97706]', border: 'border-[#FDE68A]' },
advanced: { label: 'Advanced', bg: 'bg-[#EFF6FF]', text: 'text-[#0A39DF]', border: 'border-[#DBEAFE]' },
advanced: { label: 'Advanced', bg: 'bg-[#EEF3FE]', text: 'text-[#0838E0]', border: 'border-[#DBEAFE]' },
};
export default function UniversityCourseCard({ course, completed = false, profile }) {
@@ -17,13 +17,13 @@ export default function UniversityCourseCard({ course, completed = false, profil
return (
<Link
to={`/university/${course.id}`}
className="group relative flex flex-col justify-between rounded-xl border border-[#E5E7EB] bg-white p-4 hover:border-[#0A39DF] hover:shadow-md transition-all overflow-hidden font-sans"
className="group relative flex flex-col justify-between rounded-xl border border-[#E5E7EB] bg-white p-4 hover:border-[#0838E0] hover:shadow-md transition-all overflow-hidden font-sans"
>
<div>
{/* Top Meta Header */}
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-1.5 min-w-0">
<span className="w-2 h-2 rounded-full bg-[#0A39DF] shrink-0" />
<span className="w-2 h-2 rounded-full bg-[#0838E0] shrink-0" />
<span className="text-[11px] font-bold text-[#475569] truncate tracking-wide uppercase">
{proofSkill}
</span>
@@ -47,7 +47,7 @@ export default function UniversityCourseCard({ course, completed = false, profil
</div>
{/* Title & Description */}
<h3 className="text-[14px] font-bold text-[#0F172A] group-hover:text-[#0A39DF] tracking-tight leading-snug">
<h3 className="text-[14px] font-bold text-[#0F172A] group-hover:text-[#0838E0] tracking-tight leading-snug">
{course.title}
</h3>
@@ -78,7 +78,7 @@ export default function UniversityCourseCard({ course, completed = false, profil
)}
</div>
<div className="inline-flex items-center gap-1 text-[12px] font-bold text-[#0A39DF] group-hover:underline">
<div className="inline-flex items-center gap-1 text-[12px] font-bold text-[#0838E0] group-hover:underline">
<span>{completed ? 'Review' : lock.unlocked ? 'Start challenge' : 'Details'}</span>
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" />
</div>

View File

@@ -0,0 +1,226 @@
import React, { useMemo } from 'react';
import { ArrowRight, GraduationCap, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
levelLabel, matchingPositions, profileForEmail,
} from '@/lib/skillGraph';
import { skillStatesForPage } from '@/lib/skills/pageSkills';
/**
* What a person can actually do, according to the training they completed.
*
* Three things, in the order they matter to whoever is reading: the skills and
* the level each is verified at, what was earned most recently, and the one
* piece of training that would move them next. Every line is derived from
* completed modules — there is no self-reported skill on this panel, which is
* the whole point of it sitting next to the self-reported one.
*
* Rendered on the recruiter's candidate profile and on a worker's own profile:
* the facts are the same either way, so the component is.
*
* **Which skills appear is not this component's decision.** `pageId` is passed
* in and the Forge definitions attached to that page decide the list, so the
* candidate view and the profile view can legitimately show different skills for
* the same person without either being special-cased here. A page with nothing
* attached renders nothing at all — see the null return below.
*/
const STATUS_STYLE = {
verified: 'bg-success-muted text-success',
advancing: 'bg-krow-blue-tint text-krow-blue',
in_progress: 'bg-warning-muted text-warning',
not_started: 'bg-surface-sunken text-ink-4',
};
const STATUS_LABEL = {
verified: 'Verified',
advancing: 'Advancing',
in_progress: 'In progress',
not_started: 'Not started',
};
export function VerifiedSkills({
pageId, profile, profiles = [], courses = [], positions = [], email, customSources = [],
onOpenTraining, className,
}) {
/* Accepts a profile or the email of one, so a candidate record — which is an
application, not a worker profile — can be matched to the person behind it. */
const worker = profile || profileForEmail(profiles, email);
/**
* The page's attachments decide the list. Nothing below names a skill.
*
* Computed whether or not a worker is linked, because "is this page allowed to
* talk about skills" and "do we know who this person is" are different
* questions and answering them together produces the wrong empty state for
* one of them.
*/
const attached = useMemo(
() => skillStatesForPage(pageId, courses, worker, { customSources }),
[pageId, courses, worker, customSources]
);
const states = useMemo(() => attached.map((row) => row.state), [attached]);
/* skill id → the Forge definition governing it, so a row can name its own
training path without looking one up by name. */
const byId = useMemo(
() => new Map(attached.map((row) => [row.state.skillId, row.definition])),
[attached]
);
/** Most recent completions, newest first — what they just earned. */
const recent = useMemo(() => {
if (!worker) return [];
const byId = new Map(courses.map((c) => [c.id, c]));
return [...(worker.completed_courses || [])]
.sort((a, b) => new Date(b.completed_date) - new Date(a.completed_date))
.slice(0, 3)
.map((entry) => {
const course = byId.get(entry.course_id);
return {
id: entry.course_id,
title: entry.title,
unlocks: course?.skill_id
? `${states.find((s) => s.skillId === course.skill_id)?.name} ${levelLabel(course.target_level)}`
: null,
date: entry.completed_date,
};
});
}, [worker, courses, states]);
/** The training to take next, and the role it opens. */
const nextUp = useMemo(() => {
const candidates = states.filter((s) => s.nextRequirement);
if (!candidates.length) return null;
/* Prefer the skill closest to its next level — finishing something beats
starting something. */
const best = [...candidates].sort((a, b) => b.progressToNext - a.progressToNext)[0];
const opens = matchingPositions(worker, positions, courses)
.find((row) => row.match.gaps.some((g) => g.skillId === best.skillId));
return {
state: best,
module: best.nextRequirement,
opens: opens?.position || null,
opensScore: opens?.match.score ?? null,
};
}, [states, worker, positions, courses]);
/**
* Nothing attached to this page means no section — not an empty one.
*
* A heading over a "no skills found" card teaches the reader that the feature
* is broken. A page an author has attached nothing to should look exactly like
* a page the feature was never added to.
*/
if (!attached.length) return null;
if (!worker) {
return (
<section className={className}>
<p className="text-body-sm text-ink-3">
No worker profile is linked to this person, so there is no verified training to show.
</p>
</section>
);
}
const held = states.filter((s) => s.verifiedLevel || s.status !== 'not_started');
return (
<section className={cn('space-y-5', className)}>
<div>
<ul className="divide-y divide-border rounded-xl border border-border">
{(held.length ? held : states).map((state) => (
<li key={state.skillId} className="flex items-center gap-3 px-4 py-2.5">
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-ink-1">
{state.name}
{/* The training path behind this level, named from the Forge
definition attached to this page. The pathway wording is
the level being worked towards, so a reader can see both
where the person is and what they are on. */}
{byId.get(state.skillId) && state.nextLevel && (
<span className="font-normal text-ink-4">
{' · '}{byId.get(state.skillId).name}
{' · '}{levelLabel(state.nextLevel)} pathway
</span>
)}
</p>
<p className="truncate text-caption text-ink-3">
{state.verifiedLabel}
{state.nextLevel && state.requiredForNext > 0 && (
<span className="text-ink-4">
{' · '}{state.completedInNext}/{state.requiredForNext} towards {levelLabel(state.nextLevel)}
{' · '}{state.progressToNext}%
</span>
)}
</p>
</div>
<span
className={cn(
'inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold',
STATUS_STYLE[state.status]
)}
>
{state.verifiedLevel && <ShieldCheck className="h-3 w-3" aria-hidden="true" />}
{STATUS_LABEL[state.status]}
</span>
</li>
))}
</ul>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<div>
<h4 className="mb-2 font-heading text-caption font-bold uppercase tracking-wide text-ink-4">
Recently earned
</h4>
{recent.length ? (
<ul className="space-y-1.5">
{recent.map((r) => (
<li key={r.id} className="text-body-sm text-ink-2">
<span className="font-medium text-ink-1">{r.unlocks || r.title}</span>
{r.unlocks && <span className="text-ink-4"> · {r.title}</span>}
</li>
))}
</ul>
) : (
<p className="text-body-sm text-ink-3">No training completed yet.</p>
)}
</div>
<div>
<h4 className="mb-2 font-heading text-caption font-bold uppercase tracking-wide text-ink-4">
Next recommended training
</h4>
{nextUp ? (
<div className="rounded-xl border border-krow-blue/30 bg-krow-blue-tint/40 p-3">
<p className="flex items-center gap-1.5 text-body-sm font-semibold text-ink-1">
<GraduationCap className="h-3.5 w-3.5 shrink-0 text-krow-blue" aria-hidden="true" />
{nextUp.module.title}
</p>
<p className="mt-0.5 text-caption text-ink-2">
Unlocks {nextUp.state.name} — {levelLabel(nextUp.module.target_level)}
{nextUp.opens && <> · would improve the match for {nextUp.opens.title}</>}
</p>
{onOpenTraining && (
<button
type="button"
onClick={() => onOpenTraining(nextUp.module, worker)}
className="mt-2 inline-flex items-center gap-1 text-caption font-semibold text-krow-blue hover:underline"
>
Start training <ArrowRight className="h-3 w-3" aria-hidden="true" />
</button>
)}
</div>
) : (
<p className="text-body-sm text-ink-3">Nothing outstanding — every ladder is complete.</p>
)}
</div>
</div>
</section>
);
}
export default VerifiedSkills;

View File

@@ -73,7 +73,7 @@ function CareerScorePrimary({ application }) {
/* Brand blue for the score it earned, a hairline-weight neutral
for the rest of the range — the same pairing the rest of the
product uses for "achieved against available". */
[`& .${gaugeClasses.valueArc}`]: { fill: '#0A39DF' },
[`& .${gaugeClasses.valueArc}`]: { fill: '#0838E0' },
[`& .${gaugeClasses.referenceArc}`]: { fill: 'hsl(var(--border))' },
}}
/>
@@ -147,9 +147,9 @@ const commLabel = (score) => {
};
const COMPANY_COLORS = [
'bg-[#0A39DF]',
'bg-[#0838E0]',
'bg-[#333F48]',
'bg-[#082EB4]',
'bg-[#062BAF]',
];
const colorFor = (name) => {

View File

@@ -17,12 +17,12 @@ export default function CareerGrowth({ profile }) {
return (
<div key={step} className="flex gap-3">
<div className="flex flex-col items-center">
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-[12px] font-bold shrink-0 ${current ? 'bg-[#0A39DF] text-white' : done ? 'bg-[#ECFDF5] text-[#16A34A] border border-[#A7F3D0]' : 'bg-[#F3F4F6] text-[#9CA3AF]'}`}>
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-[12px] font-bold shrink-0 ${current ? 'bg-[#0838E0] text-white' : done ? 'bg-[#ECFDF5] text-[#16A34A] border border-[#A7F3D0]' : 'bg-[#F3F4F6] text-[#9CA3AF]'}`}>
{done ? '✓' : i + 1}
</div>
{!last && <div className="w-0.5 flex-1 bg-[#E5E7EB] my-1 min-h-[16px]" />}
</div>
<div className={`pb-4 pt-1.5 text-[14px] font-medium ${current ? 'text-[#0A39DF]' : done ? 'text-[#0F172A]' : 'text-[#9CA3AF]'}`}>
<div className={`pb-4 pt-1.5 text-[14px] font-medium ${current ? 'text-[#0838E0]' : done ? 'text-[#0F172A]' : 'text-[#9CA3AF]'}`}>
{step}
{current && <span className="ml-2 text-[11px] font-normal text-[#6B7280]">You are here</span>}
{last && <span className="ml-2 text-[11px] font-normal text-[#6B7280]">Target</span>}

View File

@@ -47,7 +47,7 @@ export default function CareerPassport({ profile }) {
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
<div className="flex items-center justify-between">
<h2 className="text-[15px] font-semibold text-[#0F172A]">Career Passport</h2>
<span className="text-[12px] font-semibold text-[#0A39DF]">{pct}% Complete</span>
<span className="text-[12px] font-semibold text-[#0838E0]">{pct}% Complete</span>
</div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
{ITEMS.map((label) => {

View File

@@ -5,7 +5,7 @@ import { getTalentScore, toFICO, getScoreBand } from '@/lib/talentHome';
const FICO_MIN = 300;
const FICO_MAX = 850;
const BLUE = '#0A39DF';
const BLUE = '#0838E0';
export default function CareerScoreGauge({ profile }) {
const raw = getTalentScore(profile);
@@ -43,7 +43,7 @@ export default function CareerScoreGauge({ profile }) {
aria-label={`Career Score: ${score}, Range: ${FICO_MIN}-${FICO_MAX}`}
>
<span className="text-[9px] uppercase tracking-wider text-[#94A3B8] font-semibold">CAREER SCORE</span>
<span className="text-[24px] font-extrabold text-[#0A39DF] leading-none mt-0.5 tabular-nums">{score}</span>
<span className="text-[24px] font-extrabold text-[#0838E0] leading-none mt-0.5 tabular-nums">{score}</span>
<span className="text-[9px] text-[#8690A1] font-medium mt-0.5">{FICO_MIN}–{FICO_MAX}</span>
</div>
</div>

View File

@@ -8,7 +8,7 @@ function WalletTile({ icon: Icon, label, value, tone = 'neutral' }) {
neutral: 'text-[#0F172A]',
}[tone];
return (
<div className="rounded-xl bg-white border border-[#E6E8EB] p-4 transition-colors hover:border-[#0A39DF]">
<div className="rounded-xl bg-white border border-[#E6E8EB] p-4 transition-colors hover:border-[#0838E0]">
<div className="flex items-center gap-1.5 text-[#7C818A] mb-2">
<Icon className="w-3.5 h-3.5" />
<span className="text-[10px] font-semibold uppercase tracking-[0.06em]">{label}</span>
@@ -52,7 +52,7 @@ export default function CareerWallet({ profile }) {
<div className="flex flex-wrap gap-2 mt-5">
{certs.map((c, i) => (
<span key={i} className="inline-flex items-center gap-1.5 rounded-full bg-white border border-[#E6E8EB] text-[#1A1D21] text-[12px] font-medium px-3 py-1.5">
<Award className="w-3.5 h-3.5 text-[#0A39DF]" />{c}
<Award className="w-3.5 h-3.5 text-[#0838E0]" />{c}
</span>
))}
</div>

View File

@@ -4,7 +4,7 @@ import { Building2, MapPin, Check, X, ArrowLeftRight, Sparkles, Hand } from 'luc
const STATUS = {
accepted: { txt: 'Accepted', cls: 'bg-[#ECFDF5] text-[#16A34A]', icon: Check },
declined: { txt: 'Declined', cls: 'bg-[#F3F4F6] text-[#6B7280]', icon: X },
countered: { txt: 'Counter sent', cls: 'bg-[#EFF6FF] text-[#0A39DF]', icon: ArrowLeftRight },
countered: { txt: 'Counter sent', cls: 'bg-[#EEF3FE] text-[#0838E0]', icon: ArrowLeftRight },
};
function DemandCard({ rec, decision, counterAsk, onAccept, onDecline, onCounter, onSend }) {
@@ -16,18 +16,18 @@ function DemandCard({ rec, decision, counterAsk, onAccept, onDecline, onCounter,
return (
<div className="rounded-2xl bg-white border border-[#E5E7EB] p-5">
<div className="flex items-start gap-3">
<div className="w-11 h-11 rounded-xl bg-[#EFF6FF] text-[#0A39DF] flex items-center justify-center shrink-0">
<div className="w-11 h-11 rounded-xl bg-[#EEF3FE] text-[#0838E0] flex items-center justify-center shrink-0">
<Building2 className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-[15px] font-bold text-[#0F172A] truncate">{job.title}</h3>
<span className="inline-flex items-center gap-0.5 text-[11px] font-bold text-[#0A39DF] bg-[#EFF6FF] px-2 py-0.5 rounded-full">{match}% match</span>
<span className="inline-flex items-center gap-0.5 text-[11px] font-bold text-[#0838E0] bg-[#EEF3FE] px-2 py-0.5 rounded-full">{match}% match</span>
</div>
<p className="text-[13px] text-[#6B7280] mt-0.5 flex items-center gap-1.5 flex-wrap">
<span className="inline-flex items-center gap-0.5"><MapPin className="w-3 h-3" />{job.location || 'Remote'}</span>
{pay && <span>· {pay}</span>}
<span className="text-[#0A39DF] font-medium">wants to interview you</span>
<span className="text-[#0838E0] font-medium">wants to interview you</span>
</p>
</div>
</div>
@@ -43,13 +43,13 @@ function DemandCard({ rec, decision, counterAsk, onAccept, onDecline, onCounter,
) : (
<>
<div className="flex items-center gap-2 mt-4">
<button onClick={() => onAccept(rec)} className="flex-1 inline-flex items-center justify-center gap-1.5 h-9 rounded-xl bg-[#0A39DF] text-white text-[13px] font-semibold hover:bg-[#082EB4] transition-colors">
<button onClick={() => onAccept(rec)} className="flex-1 inline-flex items-center justify-center gap-1.5 h-9 rounded-xl bg-[#0838E0] text-white text-[13px] font-semibold hover:bg-[#062BAF] transition-colors">
<Check className="w-4 h-4" /> Accept
</button>
<button onClick={() => onDecline(rec)} className="flex-1 inline-flex items-center justify-center gap-1.5 h-9 rounded-xl border border-[#E5E7EB] text-[#6B7280] text-[13px] font-semibold hover:bg-[#F9FAFB] transition-colors">
<X className="w-4 h-4" /> Decline
</button>
<button onClick={() => setCountering(v => !v)} className="flex-1 inline-flex items-center justify-center gap-1.5 h-9 rounded-xl border border-[#0A39DF] text-[#0A39DF] text-[13px] font-semibold hover:bg-[#EFF6FF] transition-colors">
<button onClick={() => setCountering(v => !v)} className="flex-1 inline-flex items-center justify-center gap-1.5 h-9 rounded-xl border border-[#0838E0] text-[#0838E0] text-[13px] font-semibold hover:bg-[#EEF3FE] transition-colors">
<ArrowLeftRight className="w-4 h-4" /> Counter
</button>
</div>
@@ -57,9 +57,9 @@ function DemandCard({ rec, decision, counterAsk, onAccept, onDecline, onCounter,
{countering && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-[#F3F4F6]">
<span className="text-[12px] text-[#6B7280]">Counter at</span>
<input type="number" value={ask} onChange={(e) => setAsk(e.target.value)} className="w-24 h-8 px-2 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none" />
<input type="number" value={ask} onChange={(e) => setAsk(e.target.value)} className="w-24 h-8 px-2 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none" />
<span className="text-[12px] text-[#6B7280]">/hr</span>
<button onClick={() => onSend(rec, ask)} className="ml-auto h-8 px-3 rounded-lg bg-[#0A39DF] text-white text-[12px] font-semibold hover:bg-[#082EB4]">Send</button>
<button onClick={() => onSend(rec, ask)} className="ml-auto h-8 px-3 rounded-lg bg-[#0838E0] text-white text-[12px] font-semibold hover:bg-[#062BAF]">Send</button>
</div>
)}
</>
@@ -84,7 +84,7 @@ export default function CompaniesCompeting({ jobRecs = [] }) {
<div className="flex items-center justify-between mb-5">
<div>
<h2 className="text-[18px] font-bold text-[#0F172A] flex items-center gap-2">
<Sparkles className="w-4 h-4 text-[#0A39DF]" /> Companies are competing for you
<Sparkles className="w-4 h-4 text-[#0838E0]" /> Companies are competing for you
</h2>
<p className="text-[13px] text-[#6B7280] mt-0.5">You're in demand. Accept, decline, or counter.</p>
</div>

View File

@@ -21,8 +21,8 @@ export default function CompetingOffers({ jobRecs = [] }) {
<div>
<p className="text-[15px] font-semibold text-[#0F172A]">{title}</p>
<div className="mt-1 flex flex-wrap gap-x-3 text-[12px]">
<span className="inline-flex items-center gap-1 text-[#0A39DF] font-semibold">
<span className="w-1.5 h-1.5 rounded-full bg-[#0A39DF]" /> {state}
<span className="inline-flex items-center gap-1 text-[#0838E0] font-semibold">
<span className="w-1.5 h-1.5 rounded-full bg-[#0838E0]" /> {state}
</span>
<span className="text-[#0F172A] font-medium">${pay}/hr</span>
<span className="text-[#6B7280]">{shift}</span>
@@ -31,10 +31,10 @@ export default function CompetingOffers({ jobRecs = [] }) {
{i === 1 && <span className="text-[11px] text-[#94A3B8]">Tomorrow</span>}
</div>
<div className="mt-3 flex gap-2">
<button className="flex-1 text-[12px] font-semibold text-white bg-[#0A39DF] rounded-full py-1.5 hover:bg-[#082EB4] transition-colors">
<button className="flex-1 text-[12px] font-semibold text-white bg-[#0838E0] rounded-full py-1.5 hover:bg-[#062BAF] transition-colors">
Accept
</button>
<button className="flex-1 text-[12px] font-semibold text-[#0A39DF] border border-[#0A39DF] rounded-full py-1.5 hover:bg-[#EFF6FF] transition-colors">
<button className="flex-1 text-[12px] font-semibold text-[#0838E0] border border-[#0838E0] rounded-full py-1.5 hover:bg-[#EEF3FE] transition-colors">
Counter
</button>
<button className="text-[12px] font-semibold text-[#94A3B8] border border-[#E5E7EB] rounded-full px-3 py-1.5 hover:text-[#EF4444] hover:border-[#FECACA] transition-colors">

View File

@@ -50,7 +50,7 @@ export default function ExperienceEndorsements({ profile, onReference }) {
<Stars count={x.stars} />
<button
onClick={() => onReference?.(x)}
className="hidden sm:inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-[#0A39DF] text-[#0A39DF] text-[11px] font-semibold hover:bg-[#EFF6FF] transition-colors"
className="hidden sm:inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-[#0838E0] text-[#0838E0] text-[11px] font-semibold hover:bg-[#EEF3FE] transition-colors"
>
Request Reference
</button>

View File

@@ -36,7 +36,7 @@ export default function ExperienceNetwork({ profile }) {
<Star key={s} className="w-3.5 h-3.5 text-[#F59E0B] fill-[#F59E0B]" />
))}
</div>
<button className="text-[12px] font-semibold text-[#0A39DF] border border-[#0A39DF] rounded-full px-3 py-1.5 hover:bg-[#EFF6FF] transition-colors whitespace-nowrap">
<button className="text-[12px] font-semibold text-[#0838E0] border border-[#0838E0] rounded-full px-3 py-1.5 hover:bg-[#EEF3FE] transition-colors whitespace-nowrap">
{x.action}
</button>
</div>

View File

@@ -48,7 +48,7 @@ function download(filename, content, type) {
URL.revokeObjectURL(url);
}
const BLUE = '#0A39DF';
const BLUE = '#0838E0';
function Gauge({ fico }) {
return (
@@ -80,7 +80,7 @@ function Gauge({ fico }) {
aria-label={`Career Score: ${fico}, Range: 300-850`}
>
<span className="text-[9px] uppercase tracking-wider text-[#94A3B8] font-semibold">CAREER SCORE</span>
<span className="text-[24px] font-extrabold text-[#0A39DF] leading-none mt-0.5 tabular-nums">{fico}</span>
<span className="text-[24px] font-extrabold text-[#0838E0] leading-none mt-0.5 tabular-nums">{fico}</span>
<span className="text-[9px] text-[#8690A1] font-medium mt-0.5">300–850</span>
</div>
</div>
@@ -91,7 +91,7 @@ function FactorPill({ label, value }) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/70 border border-[#E6ECF5] px-3 py-1.5 text-[11px] font-medium text-[#374151] backdrop-blur-sm">
<span className="text-[#9CA3AF]">{label}</span>
<span className="font-semibold text-[#0A39DF]">{value}</span>
<span className="font-semibold text-[#0838E0]">{value}</span>
</span>
);
}
@@ -170,14 +170,14 @@ export default function IdentityCareerCard({ profile }) {
<div className="relative p-5 text-center">
{/* Identity header */}
<div className="mx-auto w-16 h-16 rounded-full overflow-hidden border-4 border-white shadow-md bg-[#EFF6FF] flex items-center justify-center mb-2">
<div className="mx-auto w-16 h-16 rounded-full overflow-hidden border-4 border-white shadow-md bg-[#EEF3FE] flex items-center justify-center mb-2">
{profile.selfie_url ? (
<Image src={profile.selfie_url} alt={name} className="w-full h-full object-cover" fittingType="fill" />
) : (
<span className="text-[20px] font-bold text-[#0A39DF]">{initials}</span>
<span className="text-[20px] font-bold text-[#0838E0]">{initials}</span>
)}
</div>
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#0A39DF]">KROW ID · {shortId}</p>
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#0838E0]">KROW ID · {shortId}</p>
<h2 className="text-[20px] font-bold text-[#0F172A] tracking-tight mt-1">{name}</h2>
<p className="text-[12px] text-[#6B7280]">{title}</p>
<div className="mt-1.5 inline-flex items-center gap-1 text-[10px] text-[#9CA3AF]">
@@ -227,7 +227,7 @@ export default function IdentityCareerCard({ profile }) {
<p className="text-[12px] font-medium text-[#0F172A] truncate">{job.role || 'Role'}</p>
<p className="text-[10px] text-[#9CA3AF]">{job.company || 'Company'}</p>
</div>
<span className="text-[10px] font-semibold text-[#0A39DF] whitespace-nowrap">{job.years ? `${job.years} yr${job.years === 1 ? '' : 's'}` : '—'}</span>
<span className="text-[10px] font-semibold text-[#0838E0] whitespace-nowrap">{job.years ? `${job.years} yr${job.years === 1 ? '' : 's'}` : '—'}</span>
</div>
))}
</div>
@@ -242,12 +242,12 @@ export default function IdentityCareerCard({ profile }) {
</div>
<div className="flex-1 flex flex-col gap-1.5">
<div className="grid grid-cols-2 gap-1.5">
<button onClick={walletNotice} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-[#0A39DF] text-white text-[10px] font-semibold hover:bg-[#082EB4] transition-colors"><Wallet className="w-3 h-3" /> Apple</button>
<button onClick={walletNotice} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0A39DF] transition-colors"><Wallet className="w-3 h-3 text-[#0A39DF]" /> Google</button>
<button onClick={walletNotice} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-[#0838E0] text-white text-[10px] font-semibold hover:bg-[#062BAF] transition-colors"><Wallet className="w-3 h-3" /> Apple</button>
<button onClick={walletNotice} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0838E0] transition-colors"><Wallet className="w-3 h-3 text-[#0838E0]" /> Google</button>
</div>
<div className="grid grid-cols-2 gap-1.5">
<button onClick={addToContacts} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0A39DF] transition-colors"><Smartphone className="w-3 h-3 text-[#0A39DF]" /> Contacts</button>
<button onClick={saveReport} disabled={busy} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0A39DF] transition-colors disabled:opacity-60"><ImageIcon className="w-3 h-3 text-[#0A39DF]" /> {busy ? '…' : 'Save'}</button>
<button onClick={addToContacts} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0838E0] transition-colors"><Smartphone className="w-3 h-3 text-[#0838E0]" /> Contacts</button>
<button onClick={saveReport} disabled={busy} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0838E0] transition-colors disabled:opacity-60"><ImageIcon className="w-3 h-3 text-[#0838E0]" /> {busy ? '…' : 'Save'}</button>
</div>
</div>
</div>

View File

@@ -23,7 +23,7 @@ export default function IncreaseMarketValue({ courses = [] }) {
<Link
key={c.id}
to={`/university/${c.id}`}
className="block rounded-xl border border-[#E5E7EB] p-4 hover:border-[#0A39DF] transition-colors"
className="block rounded-xl border border-[#E5E7EB] p-4 hover:border-[#0838E0] transition-colors"
>
<div className="flex items-center justify-between">
<p className="text-[14px] font-semibold text-[#0F172A]">{c.title}</p>
@@ -31,7 +31,7 @@ export default function IncreaseMarketValue({ courses = [] }) {
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[12px]">
<span className="text-[#16A34A] font-semibold">+{score} Score</span>
<span className="text-[#0A39DF] font-semibold">+${pay.toFixed(2)}/hr</span>
<span className="text-[#0838E0] font-semibold">+${pay.toFixed(2)}/hr</span>
<span className="inline-flex items-center gap-1 text-[#6B7280]">
<Clock className="w-3 h-3" /> {c.estimated_minutes || 15} min
</span>

View File

@@ -15,13 +15,13 @@ export default function LearnToday({ courses = [] }) {
{recs.map((c) => (
<Link key={c.id} to={`/university/${c.id}`} className="flex items-center justify-between gap-3 p-3 rounded-xl hover:bg-[#F9FAFB] border border-transparent hover:border-[#E5E7EB] transition-colors">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-[#EFF6FF] text-[#0A39DF] flex items-center justify-center"><BookOpen className="w-4 h-4" /></div>
<div className="w-9 h-9 rounded-lg bg-[#EEF3FE] text-[#0838E0] flex items-center justify-center"><BookOpen className="w-4 h-4" /></div>
<div>
<div className="text-[14px] font-medium text-[#0F172A]">{c.title}</div>
<div className="text-[11px] text-[#6B7280]">{c.estimated_minutes} min · {c.category}</div>
</div>
</div>
<span className="text-[12px] font-bold text-[#0A39DF] whitespace-nowrap">+{Math.max(1, Math.round((c.xp || 100) / 20))} Score</span>
<span className="text-[12px] font-bold text-[#0838E0] whitespace-nowrap">+{Math.max(1, Math.round((c.xp || 100) / 20))} Score</span>
</Link>
))}
</div>

View File

@@ -63,8 +63,8 @@ export default function MarketOffers({ profile, jobRecs = [] }) {
<div>
<p className="text-[15px] font-semibold text-[#0F172A]">{title}</p>
<div className="mt-1 flex flex-wrap gap-x-3 text-[12px]">
<span className="inline-flex items-center gap-1 text-[#0A39DF] font-semibold">
<span className="w-1.5 h-1.5 rounded-full bg-[#0A39DF]" /> {state}
<span className="inline-flex items-center gap-1 text-[#0838E0] font-semibold">
<span className="w-1.5 h-1.5 rounded-full bg-[#0838E0]" /> {state}
</span>
<span className="text-[#0F172A] font-medium">${pay}/hr</span>
<span className="text-[#6B7280]">{shift}</span>
@@ -73,10 +73,10 @@ export default function MarketOffers({ profile, jobRecs = [] }) {
{i === 1 && <span className="text-[11px] text-[#94A3B8]">Tomorrow</span>}
</div>
<div className="mt-3 flex gap-2">
<button className="flex-1 text-[12px] font-semibold text-white bg-[#0A39DF] rounded-full py-1.5 hover:bg-[#082EB4] transition-colors">
<button className="flex-1 text-[12px] font-semibold text-white bg-[#0838E0] rounded-full py-1.5 hover:bg-[#062BAF] transition-colors">
Accept
</button>
<button className="flex-1 text-[12px] font-semibold text-[#0A39DF] border border-[#0A39DF] rounded-full py-1.5 hover:bg-[#EFF6FF] transition-colors">
<button className="flex-1 text-[12px] font-semibold text-[#0838E0] border border-[#0838E0] rounded-full py-1.5 hover:bg-[#EEF3FE] transition-colors">
Counter
</button>
<button className="text-[12px] font-semibold text-[#94A3B8] border border-[#E5E7EB] rounded-full px-3 py-1.5 hover:text-[#EF4444] hover:border-[#FECACA] transition-colors">

View File

@@ -23,9 +23,9 @@ export default function ReputationHistory({ profile }) {
</span>
</div>
<svg viewBox={`0 0 ${w} ${h}`} className="mt-3 w-full">
<path d={path} fill="none" stroke="#0A39DF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
<path d={path} fill="none" stroke="#0838E0" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
{pts.map((v, i) => (
<circle key={i} cx={x(i)} cy={y(v)} r={3} fill="#0A39DF" />
<circle key={i} cx={x(i)} cy={y(v)} r={3} fill="#0838E0" />
))}
</svg>
<div className="flex justify-between text-[11px] text-[#94A3B8] mt-1">

View File

@@ -3,7 +3,7 @@ import { getFactors } from '@/lib/talentHome';
function statusFor(v) {
if (v >= 80) return { txt: 'Excellent', color: 'text-[#16A34A]', dot: 'bg-[#16A34A]' };
if (v >= 60) return { txt: 'Good', color: 'text-[#0A39DF]', dot: 'bg-[#0A39DF]' };
if (v >= 60) return { txt: 'Good', color: 'text-[#0838E0]', dot: 'bg-[#0838E0]' };
if (v >= 40) return { txt: 'Fair', color: 'text-[#D97706]', dot: 'bg-[#D97706]' };
if (v > 0) return { txt: 'Poor', color: 'text-[#DC2626]', dot: 'bg-[#DC2626]' };
return { txt: 'No data', color: 'text-[#9CA3AF]', dot: 'bg-[#D1D5DB]' };

View File

@@ -35,7 +35,7 @@ function Field({ label, value, sub, accent }) {
<p className="text-[11px] uppercase tracking-wide text-[#6B7280]">{label}</p>
<div className="flex items-baseline gap-2">
<p className={`text-[18px] font-semibold ${accent || 'text-[#0F172A]'}`}>{value}</p>
{sub && <span className="text-[12px] font-medium text-[#0A39DF]">{sub}</span>}
{sub && <span className="text-[12px] font-medium text-[#0838E0]">{sub}</span>}
</div>
</div>
);
@@ -43,7 +43,7 @@ function Field({ label, value, sub, accent }) {
function statusFor(v) {
if (v >= 80) return { txt: 'Excellent', color: 'text-[#16A34A]', dot: 'bg-[#16A34A]' };
if (v >= 60) return { txt: 'Good', color: 'text-[#0A39DF]', dot: 'bg-[#0A39DF]' };
if (v >= 60) return { txt: 'Good', color: 'text-[#0838E0]', dot: 'bg-[#0838E0]' };
if (v >= 40) return { txt: 'Fair', color: 'text-[#D97706]', dot: 'bg-[#D97706]' };
if (v > 0) return { txt: 'Poor', color: 'text-[#DC2626]', dot: 'bg-[#DC2626]' };
return { txt: 'No data', color: 'text-[#9CA3AF]', dot: 'bg-[#D1D5DB]' };
@@ -82,11 +82,11 @@ export default function TalentHero({ profile, jobRecs = [], nextCourse, openOwli
: { label: 'Browse missions', sub: 'Keep leveling up', to: '/university' };
const StartButton = mission.to ? (
<Link to={mission.to} className="inline-flex items-center gap-2 bg-[#0A39DF] text-white text-[13px] font-bold px-5 py-2.5 rounded-full hover:bg-[#082EB4] transition-colors">
<Link to={mission.to} className="inline-flex items-center gap-2 bg-[#0838E0] text-white text-[13px] font-bold px-5 py-2.5 rounded-full hover:bg-[#062BAF] transition-colors">
START NOW <ArrowRight className="w-4 h-4" />
</Link>
) : (
<button onClick={mission.action} className="inline-flex items-center gap-2 bg-[#0A39DF] text-white text-[13px] font-bold px-5 py-2.5 rounded-full hover:bg-[#082EB4] transition-colors">
<button onClick={mission.action} className="inline-flex items-center gap-2 bg-[#0838E0] text-white text-[13px] font-bold px-5 py-2.5 rounded-full hover:bg-[#062BAF] transition-colors">
START NOW <ArrowRight className="w-4 h-4" />
</button>
);

View File

@@ -79,7 +79,7 @@ export default function VerifiedExperienceList({ profile, onReference, onEndorse
<Stars count={x.stars} />
<button
onClick={() => onReference?.(x)}
className="hidden sm:inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-[#0A39DF] text-[#0A39DF] text-[11px] font-semibold hover:bg-[#EFF6FF] transition-colors"
className="hidden sm:inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-[#0838E0] text-[#0838E0] text-[11px] font-semibold hover:bg-[#EEF3FE] transition-colors"
>
Request Reference
</button>

View File

@@ -32,7 +32,7 @@ export default function YourNetwork({ profile }) {
))}
</div>
</div>
<button className="text-[12px] font-semibold text-[#0A39DF] border border-[#0A39DF] rounded-full px-3 py-1.5 hover:bg-[#EFF6FF] transition-colors">
<button className="text-[12px] font-semibold text-[#0838E0] border border-[#0838E0] rounded-full px-3 py-1.5 hover:bg-[#EEF3FE] transition-colors">
{n.action}
</button>
</div>

View File

@@ -16,7 +16,7 @@ const buttonVariants = cva(
[
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium",
"transition-all duration-base ease-out",
"outline-none focus-visible:ring-2 focus-visible:ring-[#0A39DF]/60 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/60 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
],

View File

@@ -50,7 +50,7 @@ const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...pr
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"z-[100] min-w-[8rem] overflow-hidden rounded-xl border border-[#E5E7EB] bg-white p-1 text-popover-foreground shadow-lg",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}

View File

@@ -13,33 +13,56 @@
@layer base {
:root {
/* ── KROW ambient theme ─────────────────────────────────────────────
The named tokens the theme is specified in, kept as literal hex so the
source values are readable at a glance. Everything below them is the same
palette expressed as HSL triplets, which is the form Tailwind consumes —
so components keep using `bg-surface`, `text-ink-1`, `border-border` and
pick the theme up without a single component being edited.
Two vocabularies, one palette. Nothing in the app should reach for a raw
hex; these exist so the theme has a stated source, not so components can
bypass the semantic tokens. */
--krow-bg: #F8F8F8;
--krow-bg-blue: #E8F0F8;
--krow-bg-warm: #F8F8E8;
--krow-primary: #0838E0;
--krow-text: #101820;
--krow-text-secondary: #687386;
--krow-text-muted: #8B94A5;
--krow-border: #E0E4EA;
--krow-border-strong: #D8DDE5;
--krow-success: #16A765;
--krow-warning: #E7B900;
--krow-error: #D64545;
/* KROW Official Brand Colors */
/* Primary: KROW Blue #0A39DF (Pantone 286CP) */
/* Primary: KROW Blue #0838E0 */
/* Accent: KROW Yellow #F9E547 (Pantone 106CP) */
/* Pale Yellow: #F8E08E (Pantone 1205CP) */
/* Dark Navy: #333F48 (Pantone 432CP) */
/* Mint: #D1E0D7 (Pantone 621CP) */
--background: 150 10% 96%;
--foreground: 210 17% 24%;
--background: 0 0% 97%; /* #F8F8F8 — the neutral base */
--foreground: 210 33% 9%; /* #101820 */
--card: 0 0% 100%;
--card-foreground: 210 17% 24%;
--card-foreground: 210 33% 9%;
--popover: 0 0% 100%;
--popover-foreground: 210 17% 24%;
--primary: 229 89% 45%;
--popover-foreground: 210 33% 9%;
--primary: 227 93% 45%; /* #0838E0 */
--primary-foreground: 0 0% 100%;
--secondary: 224 89% 96%;
--secondary-foreground: 224 89% 30%;
--muted: 150 10% 93%;
--muted-foreground: 210 10% 46%;
--accent: 224 89% 94%;
--accent-foreground: 224 89% 25%;
--destructive: 0 84% 60%;
--secondary: 227 89% 96%;
--secondary-foreground: 227 89% 30%;
--muted: 210 16% 95%;
--muted-foreground: 218 13% 47%; /* #687386 */
--accent: 227 89% 94%;
--accent-foreground: 227 89% 25%;
--destructive: 0 64% 55%; /* #D64545 */
--destructive-foreground: 0 0% 100%;
--border: 150 8% 87%;
--input: 150 8% 87%;
--ring: 229 89% 45%;
--chart-1: 229 89% 45%;
--border: 216 19% 90%; /* #E0E4EA */
--input: 215 21% 89%; /* #DDE2E9 */
--ring: 227 93% 45%;
--chart-1: 227 93% 45%;
--chart-2: 52 91% 63%;
--chart-3: 210 17% 24%;
--chart-4: 42 73% 74%;
@@ -63,35 +86,43 @@
rather than referenced as raw CSS in components. */
/* Semantic status — used by StatusBadge, Alert, KpiCard trends */
--success: 142 71% 36%;
--success: 153 77% 37%; /* #16A765 */
--success-foreground: 0 0% 100%;
--success-muted: 142 60% 95%;
--warning: 32 95% 44%;
--success-muted: 146 50% 95%; /* #EAF8F0 */
--warning: 48 100% 40%; /* #E7B900, darkened for text contrast */
--warning-foreground: 0 0% 100%;
--warning-muted: 40 96% 94%;
--info: 229 89% 45%;
--warning-muted: 48 90% 94%;
--info: 227 93% 45%;
--info-foreground: 0 0% 100%;
--info-muted: 224 89% 96%;
--neutral: 210 17% 24%;
--info-muted: 227 89% 96%;
--neutral: 210 33% 9%;
--neutral-foreground: 0 0% 100%;
--neutral-muted: 210 12% 94%;
--destructive-muted: 0 86% 97%;
--neutral-muted: 216 14% 94%;
--destructive-muted: 0 70% 97%;
/* Ink scale — the text hierarchy every component uses */
--ink-1: 222 47% 11%; /* headings */
--ink-2: 215 25% 27%; /* body */
--ink-3: 220 9% 46%; /* secondary */
--ink-4: 220 9% 64%; /* placeholder / meta */
--ink-1: 210 33% 9%; /* #101820 — headings */
--ink-2: 216 17% 30%; /* body, one step above secondary */
--ink-3: 218 13% 47%; /* #687386 — secondary */
--ink-4: 219 13% 60%; /* #8B94A5 — placeholder / meta */
/* Surfaces */
/* Surfaces.
Cards stay opaque white by default: the ambient tint is supplied by the
canvas behind them, and making every card translucent would cost
readability for a difference nobody can name. The `.surface-translucent`
utility below exists for the few places a surface genuinely wants the
tint to read through it. */
--surface: 0 0% 100%;
--surface-subtle: 210 20% 98%;
--surface-sunken: 220 14% 96%;
--surface-sunken: 216 16% 96%;
/* Elevation — a 4-step scale, tinted with brand blue not neutral black */
--shadow-xs: 0 1px 2px 0 rgb(16 24 40 / 0.04);
--shadow-sm: 0 1px 3px 0 rgb(16 24 40 / 0.06), 0 1px 2px -1px rgb(16 24 40 / 0.04);
--shadow-md: 0 4px 12px -2px rgb(16 24 40 / 0.08), 0 2px 4px -2px rgb(16 24 40 / 0.04);
/* Softened for the ambient canvas: with a tinted background providing the
separation, a card only needs to lift off it — not cast a shadow that
announces itself. `sm` is the reference's own `0 2px 10px / 0.04`. */
--shadow-xs: 0 1px 2px 0 rgb(16 24 32 / 0.03);
--shadow-sm: 0 2px 10px 0 rgb(16 24 32 / 0.04);
--shadow-md: 0 4px 16px -2px rgb(16 24 32 / 0.06), 0 2px 6px -3px rgb(16 24 32 / 0.04);
--shadow-lg: 0 12px 28px -6px rgb(16 24 40 / 0.12), 0 4px 10px -4px rgb(16 24 40 / 0.06);
--shadow-xl: 0 24px 56px -12px rgb(16 24 40 / 0.18), 0 8px 20px -8px rgb(16 24 40 / 0.08);
--shadow-brand: 0 10px 30px -6px rgb(10 57 223 / 0.24), 0 2px 6px -2px rgb(51 63 72 / 0.08);
@@ -165,9 +196,64 @@
body {
@apply bg-background text-foreground font-body;
}
/**
* The ambient canvas.
*
* Cool blue on the left, neutral through the middle, the faintest warm cream
* on the right — the atmosphere the whole product sits in. Three decisions
* worth stating:
*
* A fixed pseudo-element rather than a background on `body`. The gradient runs
* left to right, so it must be sized to the *viewport*, not to the document —
* on a long page a body background stretches and the tint stops landing where
* it was drawn. Fixed also means it paints once and never repaints on scroll.
*
* `z-index: -1` puts it behind everything without entering the layout at all,
* so no shell had to grow a wrapper to hold it.
*
* The stops are close together in lightness on purpose. The reference reads as
* "softer", not as "there is a gradient" — at this spread the eye registers
* warmth and coolness without resolving a band.
*/
body::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background: linear-gradient(
90deg,
var(--krow-bg-blue) 0%,
#F1F4F7 28%,
#F5F6F7 45%,
var(--krow-bg) 65%,
#F8F8F1 86%,
var(--krow-bg-warm) 100%
);
}
/* The dark palette is configured (`darkMode: 'class'`) but nothing in the app
applies the class today. This keeps the canvas from being a trap the day
something does: a fixed light gradient behind a dark theme would wash out
every page at once. */
.dark body::before {
background: hsl(var(--background));
}
}
@layer utilities {
/**
* A surface that lets the ambient canvas tint read through it.
*
* Opt-in rather than the default for cards: at 0.82 the tint is present
* without body text losing contrast, but applied everywhere it turns the page
* into glassmorphism, which is the thing the theme is explicitly not.
*/
.surface-translucent {
background-color: rgb(255 255 255 / 0.82);
}
.glass {
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(24px) saturate(180%);
@@ -186,7 +272,7 @@
radial-gradient(at 10% 90%, rgba(209, 224, 215, 0.20) 0px, transparent 50%);
}
.gradient-text {
color: #0A39DF;
color: #0838E0;
}
.glow-blue {
box-shadow: 0 0 40px rgba(10, 57, 223, 0.15);
@@ -220,10 +306,10 @@
identical everywhere and never relies on the browser default. */
.focus-ring {
@apply outline-none ring-offset-2 ring-offset-background
focus-visible:ring-2 focus-visible:ring-[#0A39DF]/60;
focus-visible:ring-2 focus-visible:ring-[#0838E0]/60;
}
.focus-ring-inset {
@apply outline-none focus-visible:ring-2 focus-visible:ring-[#0A39DF]/60;
@apply outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/60;
}
/* Skeleton shimmer — a moving highlight reads as loading far better than a
@@ -264,9 +350,9 @@
*/
.krow-astryx {
/* The selected indicator rule. KROW blue is the active state. */
--color-accent: #0A39DF;
--color-accent: #0838E0;
/* Tab labels: selected picks up brand, the rest the app's secondary ink. */
--color-text-primary: #0A39DF;
--color-text-primary: #0838E0;
--color-text-secondary: hsl(var(--ink-3));
--color-border: hsl(var(--border));
/* The hover wash sits *over* the label, so it has to stay translucent — an

View File

@@ -179,7 +179,11 @@ export default function AdminLayout() {
return (
<AssistantPanelProvider role="admin" pathname={location.pathname}>
<div className="min-h-screen bg-surface-subtle">
{/* Transparent so the ambient canvas painted behind the app reads through.
An opaque shell here would cover it and every Admin page would lose the
tint at once — which is exactly why the canvas is one layer and not a
background repeated on each page. */}
<div className="min-h-screen">
{/* Single bar header in the new Admin design system */}
<header className="sticky top-0 z-40 border-b border-border bg-white/85 backdrop-blur-xl">
<div className="flex h-14 items-center justify-between gap-4 px-4 lg:px-6">

View File

@@ -117,8 +117,11 @@ export default function Layout() {
navigate(`/positions${param}`);
};
/* The shell is transparent so the ambient canvas reads through. The coloured
mesh and hard-coded slate that used to sit here were this product's own
background, which is the thing the theme pass exists to unify. */
return (
<div className="min-h-screen gradient-mesh bg-[#F8FAFC]">
<div className="min-h-screen">
{/* Header */}
<header className="sticky top-0 z-40 glass border-b border-white/40">
<div className="mx-auto max-w-container-wide px-4 sm:px-6 h-16 flex items-center justify-between gap-4">
@@ -144,7 +147,7 @@ export default function Layout() {
{isActive(item.path) && (
<motion.div
layoutId="navPill"
className="absolute inset-0 rounded-full bg-[#0A39DF]"
className="absolute inset-0 rounded-full bg-[#0838E0]"
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
/>
)}
@@ -157,7 +160,7 @@ export default function Layout() {
{/* Role switcher */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="hidden md:inline-flex items-center gap-1.5 h-9 px-3 rounded-full border border-white/60 bg-white/70 backdrop-blur-sm text-[12px] font-semibold text-[#0A39DF] hover:bg-white transition-colors">
<button className="hidden md:inline-flex items-center gap-1.5 h-9 px-3 rounded-full border border-white/60 bg-white/70 backdrop-blur-sm text-[12px] font-semibold text-[#0838E0] hover:bg-white transition-colors">
<Users className="w-3.5 h-3.5" />
{ROLE_LABEL[activeRole]}
<ChevronDown className="w-3 h-3" />
@@ -168,7 +171,7 @@ export default function Layout() {
<DropdownMenuItem
key={r}
onClick={() => handleRoleChange(r)}
className={cn('cursor-pointer', r === activeRole && 'text-[#0A39DF] font-semibold')}
className={cn('cursor-pointer', r === activeRole && 'text-[#0838E0] font-semibold')}
>
{ROLE_LABEL[r]}
</DropdownMenuItem>
@@ -179,7 +182,7 @@ export default function Layout() {
{/* User menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="hidden md:inline-flex items-center justify-center w-9 h-9 rounded-full bg-[#0A39DF] text-white text-[13px] font-semibold shadow-lg shadow-blue-500/20 hover:scale-105 transition-transform">
<button className="hidden md:inline-flex items-center justify-center w-9 h-9 rounded-full bg-[#0838E0] text-white text-[13px] font-semibold shadow-lg shadow-blue-500/20 hover:scale-105 transition-transform">
{user?.full_name?.[0]?.toUpperCase() || user?.email?.[0]?.toUpperCase() || <UserIcon className="w-4 h-4" />}
</button>
</DropdownMenuTrigger>
@@ -254,7 +257,7 @@ export default function Layout() {
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search positions..."
type="text"
className="w-full pl-9 pr-4 h-9 text-[13px] rounded-full border border-white/60 bg-white/70 backdrop-blur-sm focus:bg-white focus:border-[#0A39DF] focus:outline-none transition-all"
className="w-full pl-9 pr-4 h-9 text-[13px] rounded-full border border-white/60 bg-white/70 backdrop-blur-sm focus:bg-white focus:border-[#0838E0] focus:outline-none transition-all"
/>
</form>
@@ -263,7 +266,7 @@ export default function Layout() {
<select
value={statusFilter}
onChange={(e) => handleStatusChange(e.target.value)}
className="appearance-none w-full sm:w-auto h-9 pl-3 pr-8 text-[13px] font-medium rounded-full border border-white/60 bg-white/70 backdrop-blur-sm focus:border-[#0A39DF] focus:outline-none cursor-pointer"
className="appearance-none w-full sm:w-auto h-9 pl-3 pr-8 text-[13px] font-medium rounded-full border border-white/60 bg-white/70 backdrop-blur-sm focus:border-[#0838E0] focus:outline-none cursor-pointer"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option>
@@ -273,7 +276,7 @@ export default function Layout() {
</div>
<button
onClick={() => navigate('/positions/new')}
className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[13px] font-semibold px-4 py-2 rounded-full transition-all shrink-0 hover:scale-[1.02] shadow-sm"
className="inline-flex items-center gap-1.5 bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-semibold px-4 py-2 rounded-full transition-all shrink-0 hover:scale-[1.02] shadow-sm"
>
<Plus className="w-4 h-4" />
{/* The second row leaves room for the label on mobile too */}
@@ -299,7 +302,7 @@ export default function Layout() {
onClick={() => { handleRoleChange(r); setMenuOpen(false); }}
className={cn(
'flex-1 py-2 rounded-xl text-[12px] font-semibold transition-all',
r === activeRole ? 'bg-[#0A39DF] text-white' : 'bg-white/60 text-[#374151]'
r === activeRole ? 'bg-[#0838E0] text-white' : 'bg-white/60 text-[#374151]'
)}
>
{ROLE_LABEL[r]}
@@ -314,7 +317,7 @@ export default function Layout() {
className={cn(
'px-4 py-2.5 rounded-xl text-[14px] font-medium transition-all',
isActive(item.path)
? 'bg-[#EFF6FF] text-[#0A39DF]'
? 'bg-[#EEF3FE] text-[#0838E0]'
: 'text-[#374151] hover:bg-white/60'
)}
>
@@ -325,7 +328,7 @@ export default function Layout() {
onClick={() => { setMenuOpen(false); isTalent ? window.dispatchEvent(new CustomEvent('krow:open-owliver')) : navigate('/identity'); }}
className={cn(
'px-4 py-2.5 rounded-xl text-[14px] font-medium transition-all text-left w-full',
isActive('/identity') ? 'bg-[#EFF6FF] text-[#0A39DF]' : 'text-[#374151] hover:bg-white/60'
isActive('/identity') ? 'bg-[#EEF3FE] text-[#0838E0]' : 'text-[#374151] hover:bg-white/60'
)}
>
{isTalent ? 'Build Profile with Owliver' : 'Create KROW Identity'}
@@ -335,7 +338,7 @@ export default function Layout() {
onClick={() => setMenuOpen(false)}
className={cn(
'px-4 py-2.5 rounded-xl text-[14px] font-medium transition-all',
isActive('/me') ? 'bg-[#EFF6FF] text-[#0A39DF]' : 'text-[#374151] hover:bg-white/60'
isActive('/me') ? 'bg-[#EEF3FE] text-[#0838E0]' : 'text-[#374151] hover:bg-white/60'
)}
>
My KROW ID
@@ -345,7 +348,7 @@ export default function Layout() {
onClick={() => setMenuOpen(false)}
className={cn(
'px-4 py-2.5 rounded-xl text-[14px] font-medium transition-all',
isActive('/profile') ? 'bg-[#EFF6FF] text-[#0A39DF]' : 'text-[#374151] hover:bg-white/60'
isActive('/profile') ? 'bg-[#EEF3FE] text-[#0838E0]' : 'text-[#374151] hover:bg-white/60'
)}
>
Profile
@@ -360,7 +363,7 @@ export default function Layout() {
{!isTalent && (
<button
onClick={() => { setMenuOpen(false); navigate('/positions/new'); }}
className="mt-2 inline-flex items-center justify-center gap-1.5 bg-[#0A39DF] text-white text-[14px] font-semibold px-4 py-2.5 rounded-xl transition-all"
className="mt-2 inline-flex items-center justify-center gap-1.5 bg-[#0838E0] text-white text-[14px] font-semibold px-4 py-2.5 rounded-xl transition-all"
>
<Plus className="w-4 h-4" />
New Position

View File

@@ -359,6 +359,154 @@ export function useUpdateCourse() {
});
}
/* ── Workforce assignment ──────────────────────────────────────────────── */
/** Every assignment on file. The workforce side of the source of truth. */
export function useAssignments() {
return useQuery({
queryKey: ['assignments'],
queryFn: () => base44.entities.Assignment.list('-created_date', 500),
});
}
/**
* Put people on a position.
*
* Deliberately a bulk mutation taking an already-decided list: the deciding
* happens in `lib/workforce.js` and the confirming happens in the UI, so by the
* time anything reaches here the admin has seen exactly who and how many. There
* is no code path that assigns somebody without that preview having been shown.
*
* Writes three things per person, because an assignment that updated only one
* of them would leave the system disagreeing with itself: the assignment
* record, the application's status where the person applied, and an activity
* event carrying every id involved.
*/
export function useAssignWorkers() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ position, workers = [], applications = [] }) => {
const startsAt = position.start_date
? new Date(position.start_date).toISOString()
: new Date().toISOString();
const endsAt = position.duration_months == null
? null
: new Date(new Date(startsAt).getTime() + position.duration_months * 30 * 86400000).toISOString();
const created = [];
for (const worker of workers) {
const record = await base44.entities.Assignment.create({
job_posting_id: position.id,
worker_email: worker.email,
worker_name: worker.name,
starts_at: startsAt,
ends_at: endsAt,
status: 'active',
source: 'owliver',
match_score: worker.score ?? null,
});
created.push(record);
/**
* The application is what puts a person *in the pipeline for this role*,
* and it is what every downstream step keys on — the candidate profile
* is addressed by it, and the AI interview takes one as its subject.
*
* So assigning somebody from the existing workforce creates one where
* none exists, exactly as the Position page's own "admit talent" path
* does. Without it an assigned worker would be unreachable: no record to
* open, and no way to interview them.
*/
let application = applications.find(
(a) => a.job_posting_id === position.id
&& String(a.email || '').toLowerCase() === String(worker.email || '').toLowerCase()
);
if (!application) {
application = await base44.entities.JobApplication.create({
applicant_name: worker.name,
email: worker.email || '',
phone: worker.profile?.phone || '',
years_experience: worker.profile?.experience_years || 0,
skills: worker.profile?.skills || [],
availability: worker.profile?.availability || [],
certifications: worker.profile?.certifications || [],
selfie_url: worker.profile?.selfie_url || '',
professional_summary: worker.profile?.career_goals || '',
cover_letter: '',
job_posting_id: position.id,
job_title: position.title,
status: 'assigned',
ai_score: worker.score ?? 0,
});
} else {
await base44.entities.JobApplication.update(application.id, { status: 'assigned' });
}
record.application_id = application.id;
await logActivity('assign_employee', {
details: `${worker.name} assigned to ${position.company ? `${position.company} — ` : ''}${position.title}`,
position_id: position.id,
application_id: application?.id || null,
worker_email: worker.email,
});
}
return created;
},
onSuccess: () => {
/* Everything that reads workforce state refreshes together, so the
position card, the detail page and Owliver's next answer cannot be
looking at three different moments. */
queryClient.invalidateQueries({ queryKey: ['assignments'] });
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
queryClient.invalidateQueries({ queryKey: ['userActivity'] });
},
});
}
/**
* Put an assigned candidate in front of the AI interview.
*
* Deliberately *not* a call to `useCreateInterview`. `AIInterviewModal` — the AI
* Interview Owliver — writes its own `AIInterview` record when the interview
* actually finishes, carrying the transcript, the scores and the verdict.
* Creating one here would produce a second, empty interview record for the same
* person: a duplicate system, and a row claiming an interview happened when
* nobody has spoken yet.
*
* What is missing before an interview can run is the application being *at*
* interview stage. That is what this writes — the same status transition the
* Position and Candidates pages already perform — which is what makes the
* candidate appear as interview-ready everywhere and lets the existing modal
* open on them.
*/
export function useMarkInterviewReady() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ application, position }) => {
if (!application?.id) throw new Error('No application to schedule against');
const updated = await base44.entities.JobApplication.update(application.id, {
status: 'interview',
});
await logActivity('interview_ready', {
details: `${application.applicant_name} moved to interview for ${position?.title || application.job_title}`,
position_id: position?.id || application.job_posting_id,
application_id: application.id,
});
return updated;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: ['interviews'] });
queryClient.invalidateQueries({ queryKey: ['userActivity'] });
},
});
}
export function useLearningPaths() {
return useQuery({ queryKey: ['learningPaths'], queryFn: () => base44.entities.LearningPath.list('-created_date', 100) });
}
@@ -417,7 +565,10 @@ export function useUpdateWorkerProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }) => base44.entities.WorkerProfile.update(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['workerProfile'] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['workerProfile'] });
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
},
});
}
@@ -440,7 +591,13 @@ export function useCompleteCourse() {
const patch = { completed_courses, earned_badges, xp, ...recalcProfilePatch(merged) };
return base44.entities.WorkerProfile.update(profile.id, patch);
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['workerProfile'] }),
/* Completing training changes a verified skill level, and a level is read by
Forge, the profile and every position match — so the workforce list is
invalidated too, not just the one profile. */
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['workerProfile'] });
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
},
});
}
@@ -503,8 +660,12 @@ export function useSubmitChallenge() {
await base44.entities.WorkerProfile.update(profile.id, patch);
return { evidence, passed, result };
},
/* The whole loop lands here: evidence written, profile updated, and every
reader of a skill level — Forge, profiles, position matching — refreshed
from the same mutation that Owliver's verdict triggered. */
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['workerProfile'] });
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
queryClient.invalidateQueries({ queryKey: ['evidence'] });
},
});

View File

@@ -44,10 +44,43 @@ export const CRITERIA_LABELS = {
* A function rather than a constant, so two callers cannot end up sharing — and
* mutating — the same `vetting_criteria` or certifications array.
*/
/** How long a position needs people for, in months. `null` is open-ended. */
export const DURATION_OPTIONS = [
{ value: '0.5', label: '2 weeks' },
{ value: '1', label: '1 month' },
{ value: '3', label: '3 months' },
{ value: '6', label: '6 months' },
{ value: '12', label: '1 year' },
{ value: '', label: '1+ year / ongoing' },
];
/** Urgency, as the employer states it. Drives allocation priority, not display. */
export const PRIORITY_OPTIONS = [
{ value: 'urgent', label: 'Urgent' },
{ value: 'high', label: 'High' },
{ value: 'normal', label: 'Normal' },
];
export function defaultPosition() {
return {
title: '',
role_category: 'Server',
/* ── Workforce demand ────────────────────────────────────────────────
What the employer is actually asking for. Everything the allocation
engine reasons about — who needs people first, whether one person can
cover a role for its whole run, whether they are free when it starts —
comes from these five fields, so they live on the position record rather
than being inferred from prose. */
company: '',
/* How many people. One is the common case and the old implicit one, so a
position created before this existed still means what it meant. */
headcount: 1,
/* ISO date, or '' for "as soon as possible" — which is not the same as
today, and the engine treats it as an immediate start. */
start_date: '',
/* Months. '' means open-ended, which is a real answer, not a missing one. */
duration_months: '',
priority: 'normal',
custom_requirements: '',
physical_requirements: '',
leadership_expectations: '',
@@ -58,6 +91,11 @@ export function defaultPosition() {
pay_range_min: '',
pay_range_max: '',
certifications_required: [],
/* What the work requires in verified skill levels — the field the matching
engine reads (lib/skillGraph.js). Empty means "no skill requirements
defined", and a position with none shows no match score rather than an
invented one. */
skill_requirements: [],
vetting_criteria: { experience: 25, english: 20, reliability: 20, certifications: 20, availability: 15 },
};
}
@@ -77,9 +115,56 @@ export function toPositionPayload(draft = {}, { status = 'active' } = {}) {
pay_range_min: Number(record.pay_range_min) || 0,
pay_range_max: Number(record.pay_range_max) || 0,
min_experience_years: Number(record.min_experience_years) || 0,
/* At least one person is being asked for, whatever the field says. */
headcount: Math.max(1, Number(record.headcount) || 1),
/* Kept as a number or null rather than a string, because the allocation
engine compares it against assignment windows. Null is open-ended. */
duration_months: record.duration_months === '' || record.duration_months == null
? null
: Number(record.duration_months),
company: String(record.company || '').trim(),
};
}
/**
* "30 people · starts 4 Sep · 1 month" — the demand, in one line.
*
* Only the parts the record actually states. A position that declares no
* headcount, no start and no duration returns `null` and the caller renders
* nothing, rather than "1 person · starts immediately · ongoing" — which is
* three schema defaults wearing the appearance of an employer's answer.
*/
export function demandLabel(position = {}) {
const parts = [];
const count = Number(position.headcount);
if (Number.isFinite(count) && count > 0) {
parts.push(`${count} ${count === 1 ? 'person' : 'people'}`);
}
if (position.start_date) {
parts.push(`starts ${new Date(position.start_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`);
} else if (position.start_date === '') {
/* An explicit empty start means "as soon as possible" — a stated answer.
An absent field means the question was never asked. */
parts.push('starts immediately');
}
const months = position.duration_months;
if (months != null) {
parts.push(
months < 1 ? `${Math.round(months * 4)} weeks`
: months === 1 ? '1 month'
: months < 12 ? `${months} months`
: months === 12 ? '1 year' : `${Math.round(months / 12)}+ years`
);
} else if ('duration_months' in position) {
parts.push('ongoing');
}
return parts.length ? parts.join(' · ') : null;
}
/** `$30–$40/hr`, or a single rate, or nothing when no pay is set. */
export function payLabel({ pay_range_min: min, pay_range_max: max } = {}) {
const low = Number(min) || 0;

399
src/lib/skillGraph.js Normal file
View File

@@ -0,0 +1,399 @@
/**
* The skill graph: the one definition of how training becomes a verified level,
* and how a verified level becomes eligibility for a position.
*
* KROW has three things that used to know nothing about each other — Forge
* (training), worker profiles (what people can do) and positions (what the work
* needs). This module is the connection, and it is deliberately the only place
* the rules live:
*
* SKILL → LEVEL → TRAINING → COMPLETION → VERIFIED SKILL → MATCH
*
* Two decisions shape everything below.
*
* **Levels are derived, never stored.** A worker's level is computed from the
* training they have actually completed, so it cannot drift out of step with the
* evidence behind it, and completing a module updates the level everywhere at
* once without a single write. The exception is an explicit admin override,
* which is stored — because a manual verification is a claim by a person, and a
* claim has to be recorded somewhere to exist at all.
*
* **The match score is arithmetic, not judgement.** Every point is traceable to
* one requirement, one weight and one comparison of levels, so the reason for a
* score can always be shown next to it. Nothing here calls a model.
*
* Everything reads the existing entities — Course, WorkerProfile, JobPosting —
* through fields added to them, so swapping the demo store for a real API means
* changing nothing in this file.
*/
/* ── Levels ────────────────────────────────────────────────────────────── */
export const LEVELS = ['beginner', 'intermediate', 'advanced', 'expert'];
export const LEVEL_LABEL = {
beginner: 'Beginner',
intermediate: 'Intermediate',
advanced: 'Advanced',
expert: 'Expert',
};
/** Position in the ladder, or -1 for "no level held". */
export const levelIndex = (level) => LEVELS.indexOf(level);
export const levelLabel = (level) => LEVEL_LABEL[level] || 'Not started';
/**
* The skill catalogue.
*
* The ladders are different lengths on purpose. Food Safety and Customer Service
* top out at Advanced because there is no fourth thing to be good at — inventing
* an Expert tier for them would make "Expert" mean less everywhere else.
*/
export const SKILLS = [
{ id: 'server', name: 'Server', category: 'Service', levels: LEVELS },
{ id: 'bartending', name: 'Bartending', category: 'Bar', levels: LEVELS },
{ id: 'leadership', name: 'Leadership', category: 'Leadership', levels: LEVELS },
{ id: 'customer_service', name: 'Customer Service', category: 'Service', levels: LEVELS.slice(0, 3) },
{ id: 'food_safety', name: 'Food Safety', category: 'Kitchen', levels: LEVELS.slice(0, 3) },
];
export const skillById = (id) => SKILLS.find((s) => s.id === id) || null;
export const skillName = (id) => skillById(id)?.name || id;
/** The level above `level` in this skill's own ladder, or null at the top. */
export function nextLevelOf(skillId, level) {
const ladder = skillById(skillId)?.levels || LEVELS;
const at = level ? ladder.indexOf(level) : -1;
return ladder[at + 1] || null;
}
/* ── Training modules ──────────────────────────────────────────────────── */
/**
* A training module is a Course record carrying four extra fields:
*
* skill_id the skill it builds
* target_level the level it contributes to
* required_level the level a worker must already hold to start it
* completion_criteria what must be done
* verification_criteria what Owliver scores the evidence against
*
* Courses without `skill_id` are library material that no level depends on. They
* still appear in Forge; they just never move anyone's level.
*/
export const isModule = (course) => Boolean(course?.skill_id && course?.target_level);
export const modulesForSkill = (courses = [], skillId) =>
courses.filter((c) => c.skill_id === skillId);
/** Modules for one skill, grouped into its ladder — `{ beginner: [...], … }`. */
export function modulesByLevel(courses = [], skillId) {
const ladder = skillById(skillId)?.levels || LEVELS;
const grouped = Object.fromEntries(ladder.map((l) => [l, []]));
for (const course of modulesForSkill(courses, skillId)) {
if (grouped[course.target_level]) grouped[course.target_level].push(course);
}
return grouped;
}
/** What a module unlocks, as one line: "Server — Advanced". */
export const unlocksLabel = (course) =>
isModule(course) ? `${skillName(course.skill_id)} — ${levelLabel(course.target_level)}` : null;
/* ── What a worker has completed ───────────────────────────────────────── */
/**
* Completion is read off the profile the platform already writes.
*
* A course reaches `completed_courses` in exactly one way: the worker submitted
* evidence and Owliver passed it (`useSubmitChallenge`). So "completed" and
* "verified" are the same set here, and there is no second flag that could
* disagree with the evidence.
*/
export const completedCourseIds = (profile) =>
new Set((profile?.completed_courses || []).map((c) => c.course_id));
/* ── Level derivation ──────────────────────────────────────────────────── */
/**
* One skill, as this worker holds it.
*
* The progression rule, in one line: **a level is earned when every module at
* that level is verified and the level below it is earned.** That is what stops
* a worker who happens to pass one advanced module from being labelled Advanced
* while the ground under it is missing — the label has to mean the whole ladder.
*
* An admin override replaces the derived level and says so, so a manual
* verification is visible as a manual verification rather than passing itself
* off as earned training.
*/
export function skillStateFor(skillId, courses, profile) {
const skill = skillById(skillId);
const ladder = skill?.levels || LEVELS;
const done = completedCourseIds(profile);
const grouped = modulesByLevel(courses, skillId);
const levels = ladder.map((level) => {
const modules = grouped[level] || [];
const completed = modules.filter((m) => done.has(m.id));
return {
level,
label: levelLabel(level),
modules,
completed,
/* An empty level cannot be "complete" — a ladder rung with no training
behind it is an authoring gap, not an achievement. */
complete: modules.length > 0 && completed.length === modules.length,
progress: modules.length ? Math.round((completed.length / modules.length) * 100) : 0,
};
});
/* Earned levels stop at the first gap: the ladder is climbed, not sampled. */
let earnedIndex = -1;
for (let i = 0; i < levels.length; i += 1) {
if (!levels[i].complete) break;
earnedIndex = i;
levels[i].earned = true;
}
const override = (profile?.skill_overrides || {})[skillId] || null;
const overrideIndex = override ? ladder.indexOf(override.level) : -1;
const verifiedIndex = Math.max(earnedIndex, overrideIndex);
const verifiedLevel = verifiedIndex >= 0 ? ladder[verifiedIndex] : null;
const nextLevel = ladder[verifiedIndex + 1] || null;
const nextRung = nextLevel ? levels[verifiedIndex + 1] : null;
const nextRequirement = nextRung?.modules.find((m) => !done.has(m.id)) || null;
const totalCompleted = levels.reduce((n, l) => n + l.completed.length, 0);
const totalModules = levels.reduce((n, l) => n + l.modules.length, 0);
return {
skillId,
name: skill?.name || skillId,
category: skill?.category || '',
ladder,
levels,
verifiedLevel,
verifiedLabel: verifiedLevel ? levelLabel(verifiedLevel) : 'Not started',
verifiedByOverride: overrideIndex > earnedIndex ? override : null,
override,
nextLevel,
nextRequirement,
/* Progress towards the *next* level, which is the number a worker is
actually working against. Total-ladder progress is reported separately. */
progressToNext: nextRung ? nextRung.progress : 100,
completedInNext: nextRung?.completed.length || 0,
requiredForNext: nextRung?.modules.length || 0,
totalCompleted,
totalModules,
status: verifiedLevel
? (nextRung && nextRung.completed.length ? 'advancing' : 'verified')
: (nextRung && nextRung.completed.length ? 'in_progress' : 'not_started'),
/* "Next level ready" means the training is done and only the level label is
waiting — which never happens under the rule above, so it reads the rung
below: every module done except one. */
nearlyThere: Boolean(nextRung && nextRung.modules.length && nextRung.modules.length - nextRung.completed.length === 1),
};
}
/** Every skill in the catalogue, as this worker holds it. */
export const skillStates = (courses, profile) =>
SKILLS.map((s) => skillStateFor(s.id, courses, profile));
/** `{ server: 'advanced', food_safety: 'beginner' }` — the shape matching reads. */
export function verifiedLevels(courses, profile) {
const held = {};
for (const state of skillStates(courses, profile)) {
if (state.verifiedLevel) held[state.skillId] = state.verifiedLevel;
}
return held;
}
/**
* The profile as it would be with one more module completed.
*
* Used to answer "what would this training do for me" without writing anything.
* Same derivation runs over it, so the projected level and the real one can
* never be computed two different ways.
*/
export const withCourseCompleted = (profile, course) => ({
...profile,
completed_courses: [
...(profile?.completed_courses || []),
{ course_id: course.id, title: course.title, score: 100, completed_date: new Date().toISOString() },
],
});
/* ── Position requirements and matching ────────────────────────────────── */
/**
* Match bands. Four names, because a recruiter acts on a name, not a number —
* and a candidate should never have to interpret a percentage on their own.
*/
export const MATCH_BANDS = [
{ min: 90, label: 'Excellent Match', tone: 'success' },
{ min: 75, label: 'Strong Match', tone: 'brand' },
{ min: 50, label: 'Potential Match', tone: 'warning' },
{ min: 0, label: 'Not Ready', tone: 'risk' },
];
export const bandFor = (score) => MATCH_BANDS.find((b) => score >= b.min) || MATCH_BANDS[3];
/**
* Partial credit for being close.
*
* One level below the requirement earns half the weight: that candidate is a
* training module away from qualifying, and scoring them zero would hide exactly
* the people this system exists to find. Two levels below earns nothing —
* at that distance it is a different job.
*/
const creditFor = (gap) => (gap <= 0 ? 1 : gap === 1 ? 0.5 : 0);
/**
* Score one candidate against one position's skill requirements.
*
* Returns null when the position defines no skill requirements — a position that
* asks for nothing should show no score rather than an invented 100%.
*
* Weights are normalised, so requirements that do not add to 100 still produce a
* percentage that means what it says.
*/
export function matchPosition(position, held = {}) {
const requirements = position?.skill_requirements || [];
if (!requirements.length) return null;
const totalWeight = requirements.reduce((sum, r) => sum + (Number(r.weight) || 0), 0) || 1;
const lines = requirements.map((req) => {
const ladder = skillById(req.skill_id)?.levels || LEVELS;
const need = ladder.indexOf(req.level);
const have = held[req.skill_id] ? ladder.indexOf(held[req.skill_id]) : -1;
const gap = need - have;
const weight = (Number(req.weight) || 0) / totalWeight * 100;
const credit = creditFor(gap);
return {
skillId: req.skill_id,
name: skillName(req.skill_id),
required: req.level,
requiredLabel: levelLabel(req.level),
held: held[req.skill_id] || null,
heldLabel: held[req.skill_id] ? levelLabel(held[req.skill_id]) : 'Not started',
weight: Math.round(weight),
earned: Math.round(weight * credit),
met: gap <= 0,
close: gap === 1,
gap: Math.max(0, gap),
note: gap <= 0
? null
: gap === 1
? `One level below — ${levelLabel(req.level)} required`
: `Missing required ${skillName(req.skill_id)} level: ${levelLabel(req.level)}`,
};
});
const score = Math.round(lines.reduce((sum, l) => sum + (l.weight * creditFor(l.gap)), 0));
const band = bandFor(score);
return {
score,
band: band.label,
tone: band.tone,
lines,
met: lines.filter((l) => l.met),
gaps: lines.filter((l) => !l.met),
};
}
/**
* The training that closes the largest gap, and what completing it would do.
*
* This is the whole Forge↔Positions loop in one function: it names a module, the
* level that module unlocks, and the match score the candidate would hold after
* Owliver verifies it — computed by re-running the same derivation over a
* profile with that module added, so the promised number is the number they get.
*/
export function recommendTraining(position, profile, courses) {
const held = verifiedLevels(courses, profile);
const match = matchPosition(position, held);
if (!match || !match.gaps.length) return null;
/* Heaviest unmet requirement first — the one that moves the score most. */
const target = [...match.gaps].sort((a, b) => b.weight - a.weight)[0];
const state = skillStateFor(target.skillId, courses, profile);
/* The next module on the ladder, not the module at the required level: the
progression rule means levels are climbed in order, so the honest next step
is the first thing they have not done. */
const module = state.nextRequirement;
if (!module) return null;
const projectedHeld = verifiedLevels(courses, withCourseCompleted(profile, module));
const projected = matchPosition(position, projectedHeld);
return {
module,
skillId: target.skillId,
skillName: target.name,
fromLevel: state.verifiedLevel,
toLevel: module.target_level,
unlocks: unlocksLabel(module),
currentScore: match.score,
projectedScore: projected?.score ?? match.score,
requiredLabel: target.requiredLabel,
/* Whether this one module actually moves the match. It often does not —
a level takes every module on its rung, so someone three modules out gains
nothing measurable from the first. Callers that promise a number to a
recruiter should say nothing rather than promise "50% → 50%". */
improves: (projected?.score ?? match.score) > match.score,
};
}
/* ── Reading the workforce against a position ──────────────────────────── */
/** Links an application to the worker profile behind it, by email. */
export const profileForEmail = (profiles = [], email) =>
(email && profiles.find((p) => p.email?.toLowerCase() === String(email).toLowerCase())) || null;
/**
* Every worker scored against one position, best first.
*
* `applications` is optional: pass it and each row carries the application that
* connects that person to this role, which is what lets the admin Position page
* be one list — applicants and eligible workers — rather than two.
*/
export function rankWorkforce(position, profiles = [], courses = [], applications = []) {
const appliedBy = new Map(
applications
.filter((a) => a.job_posting_id === position?.id)
.map((a) => [String(a.email || '').toLowerCase(), a])
);
return profiles
.map((profile) => {
const held = verifiedLevels(courses, profile);
const match = matchPosition(position, held);
if (!match) return null;
return {
profile,
match,
held,
application: appliedBy.get(String(profile.email || '').toLowerCase()) || null,
recommendation: recommendTraining(position, profile, courses),
};
})
.filter(Boolean)
.sort((a, b) => b.match.score - a.match.score);
}
/** Positions this worker should be looking at, best match first. */
export function matchingPositions(profile, positions = [], courses = []) {
const held = verifiedLevels(courses, profile);
return positions
.map((position) => ({ position, match: matchPosition(position, held) }))
.filter((row) => row.match)
.sort((a, b) => b.match.score - a.match.score);
}

View File

@@ -0,0 +1,101 @@
import { allSkills, getSkillsForPage } from './registry';
import { skillStateFor } from '@/lib/skillGraph';
/**
* Page attachment, resolved against the skill graph.
*
* Two systems meet here, and keeping them apart is the point:
*
* the definition a Markdown file in Forge — what the skill is, the rungs of
* its ladder, and the `pages:` it may be surfaced on
* the graph `lib/skillGraph.js` — what a given worker has actually
* verified, and how that scores against a position
*
* A page asks this module what to show, gets back definitions already paired
* with the reader's state, and renders. It never asks for a skill by name, and
* it never decides for itself whether a skill belongs to it — which is what
* makes a skill authored tomorrow appear tonight without a page being touched.
*
* Three ideas that are easy to conflate and must not be:
*
* skill.pages where this skill may be *surfaced*
* position.skill_requirements what a particular job *requires*
* profile.completed_courses what a person has actually *verified*
*
* A skill attached to `positions` does not make every position require it. It
* makes the Positions experience allowed to talk about it when a position
* happens to require it.
*/
/** Workforce definitions attached to a page, in the order Forge lists them. */
export const workforceSkillsForPage = (pageId, options = {}) =>
getSkillsForPage(pageId, { ...options, kind: 'workforce' });
/**
* The graph skill ids a page may surface. Callers gating an existing panel on
* attachment want this rather than the definitions themselves.
*/
export const skillIdsForPage = (pageId, options = {}) =>
new Set(workforceSkillsForPage(pageId, options).map((s) => s.skillId).filter(Boolean));
/**
* Is this capability allowed to be surfaced here?
*
* The gate for contextual additions to a page that already exists — a training
* recommendation on a position, say. `false` means render nothing, not render an
* explanation of why nothing rendered.
*/
export const isSkillOnPage = (skillId, pageId, options = {}) =>
skillIdsForPage(pageId, options).has(skillId);
/**
* What a page should show for one reader: every skill attached to it, paired
* with that person's state in it.
*
* Definitions whose `skill:` binding matches nothing in the graph are dropped
* rather than rendered as an empty ladder — a definition pointing at a
* capability the platform does not model is an authoring error, and showing it
* as "Not started" would hide that.
*
* Returns `[]` when nothing is attached. Callers render nothing at all in that
* case: a page with no skills attached should look like a page that was never
* given any, not like one whose skill section is empty.
*/
export function skillStatesForPage(pageId, courses = [], profile = null, options = {}) {
return workforceSkillsForPage(pageId, options)
.map((definition) => {
if (!definition.skillId) return null;
const state = skillStateFor(definition.skillId, courses, profile);
/* `skillStateFor` answers for any id; a ladder with no rungs means the
graph does not carry this capability. */
return state.ladder?.length ? { definition, state } : null;
})
.filter(Boolean);
}
/**
* The definition governing one capability, or null.
*
* Lets a component that already holds a graph skill id — a position requirement
* line, a match gap — reach the training path behind it, and with it the pages
* that path may be surfaced on.
*/
export function definitionForSkillId(skillId, { customSources = [] } = {}) {
if (!skillId) return null;
return allSkills(customSources).find(
(s) => s.kind === 'workforce' && s.status === 'active' && s.skillId === skillId
) || null;
}
/**
* The definition behind one capability, but only if this page may surface it.
*
* The common case for a contextual addition: a position requirement names a
* capability, and the page wants the training path for it *if* the author
* attached that path here. One call, so no component has to remember to check
* attachment separately and none can forget.
*/
export function pageDefinitionForSkillId(skillId, pageId, options = {}) {
const definition = definitionForSkillId(skillId, options);
return definition && definition.pages.includes(pageId) ? definition : null;
}

View File

@@ -91,6 +91,43 @@ function sectionSteps(body, heading) {
.filter(Boolean);
}
/**
* The prose under a `## Heading`, with its bullets and blank lines stripped to
* one line of summary. Used for a level's description, which is a sentence
* rather than a list.
*/
function sectionText(body, heading) {
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
if (!section) return '';
return section[1]
.split(/\r?\n/)
.map((l) => l.replace(/^\s*[-*]\s+/, '').trim())
.filter(Boolean)
.join(' ')
.trim();
}
/**
* The rungs a workforce skill defines, read from its own body.
*
* A definition names its ladder as `## Beginner`, `## Intermediate` and so on,
* each followed by what a person must be able to do at that level. Only the
* headings that are actually present become rungs, so a skill that tops out at
* Advanced has a three-rung ladder rather than a fourth empty one — the ladder
* is what the author wrote, not a fixed shape they are padded into.
*/
const LEVEL_HEADINGS = ['Beginner', 'Intermediate', 'Advanced', 'Expert'];
function sectionLevels(body) {
return LEVEL_HEADINGS
.map((heading) => ({
level: heading.toLowerCase(),
label: heading,
summary: sectionText(body, heading),
}))
.filter((rung) => rung.summary);
}
/**
* The page key a route belongs to — `/admin/positions` → `positions`.
*
@@ -128,12 +165,44 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
{
const { data, body } = parseFrontmatter(raw);
const pages = Array.isArray(data.pages) ? data.pages : [];
const id = data.id || path.split('/').pop().replace(/\.md$/, '');
const levels = sectionLevels(body);
/**
* Two things wear the same format.
*
* An *assistant* skill teaches Owliver to do something on a page. A
* *workforce* skill defines a capability the workforce can hold, at levels,
* and says which pages may surface it. A definition that names a ladder is
* the second kind — nothing else distinguishes them, so an author declares a
* workforce skill by writing one, not by setting a flag.
*/
const kind = data.kind || (levels.length ? 'workforce' : 'assistant');
return {
id: data.id || path.split('/').pop().replace(/\.md$/, ''),
id,
kind,
name: data.name || 'Untitled skill',
description: data.description || '',
status: data.status === 'inactive' ? 'inactive' : 'active',
pages,
/**
* The capability in the skill graph this definition governs.
*
* Declared as `skill:`, or inferred by dropping a `-training` suffix and
* swapping dashes for underscores — so `server-training.md` governs
* `server` and `customer-service-training.md` governs `customer_service`
* without the author restating it. Only meaningful for workforce skills.
*/
skillId: kind === 'workforce'
? (data.skill || id.replace(/-training$/, '')).replace(/-/g, '_')
: null,
/* The ladder, in order, each rung carrying what it means to hold it. */
levels,
/* The definition as written. Forge edits this; every other page reads the
parsed form, so there is exactly one artefact behind all of them. */
markdown: raw,
source: custom ? 'account' : 'repository',
/* The page label Settings shows, taken from the context the page carries
so the two never disagree. */
actions: Array.isArray(data.actions) ? data.actions : [],
@@ -214,18 +283,42 @@ export function contextIdsForSkill(skill) {
return skill.pages.map((key) => ROUTE_BY_PAGE_KEY[key]?.contextId).filter(Boolean);
}
/**
* Every skill attached to a page — the one answer to "what belongs here".
*
* This is what `pages:` is for. A definition listing `positions` is not
* documenting itself; it is saying that the Positions experience may surface it,
* and this function is the only place that question is answered. Pages call it
* and render what comes back, which is what lets a skill authored tomorrow
* appear on the right pages tonight without any page component being edited.
*
* Nothing downstream may test a page name against a skill id. The moment a page
* asks "is this Server Training?" the attachment has stopped being data.
*/
export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind } = {}) {
if (!pageId) return [];
return allSkills(customSources).filter(
(s) => s.status === 'active'
&& !disabled.includes(s.id)
&& s.pages.includes(pageId)
&& (!kind || s.kind === kind)
);
}
/**
* Skills available on a page.
*
* `disabled` is the account's list of switched-off skill ids, so a skill can be
* turned off from Settings without being deleted from disk.
*
* Assistant skills only: a workforce skill defines a capability, not something
* Owliver can be asked to do, and offering one as a chat action would promise
* behaviour that does not exist.
*/
export function skillsForContext(contextId, disabled = [], customSources = []) {
const pageKey = PAGE_KEY_BY_CONTEXT[contextId];
if (!pageKey) return [];
return allSkills(customSources).filter(
(s) => s.status === 'active' && !disabled.includes(s.id) && s.pages.includes(pageKey)
);
return getSkillsForPage(pageKey, { disabled, customSources, kind: 'assistant' });
}
/**

View File

@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import { usePreferences } from '@/lib/krowHooks';
import {
isSkillOnPage, pageDefinitionForSkillId, skillStatesForPage, workforceSkillsForPage,
} from './pageSkills';
/**
* A page's skill attachments, resolved once.
*
* Every consuming page needs the same two things before it can ask anything
* else: the account's custom definitions (a skill added in Settings is as real
* as one on disk) and the ids it has switched off. Reading those in one hook
* means no page can forget either, and none has to know where they are stored.
*
* The hook is deliberately thin — it resolves attachment and hands back the
* helpers already bound to this page. The rules themselves stay in
* `pageSkills.js`, which is what keeps them testable without React.
*
* Usage is always the same shape:
*
* const { skills, statesFor } = usePageSkills('profile');
* if (!skills.length) return null; // nothing attached: no section
*
* `attaches(skillId)` is the gate for a contextual addition to something that
* already exists — a training prompt beside a position requirement, say — where
* the page is not listing skills but is about to mention one.
*/
export function usePageSkills(pageId) {
const preferences = usePreferences();
/* `usePreferences` rebuilds its object on every read, so the arrays inside it
are new identities each render. Keying the memo on their content is what
stops every consumer from recomputing — and re-rendering — continuously. */
const customKey = JSON.stringify(preferences.customSkills || []);
const disabledKey = JSON.stringify(preferences.disabledSkills || []);
return useMemo(() => {
const customSources = JSON.parse(customKey);
const disabled = JSON.parse(disabledKey);
const options = { customSources, disabled };
const skills = workforceSkillsForPage(pageId, options);
return {
/** The definitions attached here. Empty means render nothing. */
skills,
/** True when this page may surface that capability at all. */
attaches: (skillId) => isSkillOnPage(skillId, pageId, options),
/** The definition behind a capability, but only if attached here. */
definitionFor: (skillId) => pageDefinitionForSkillId(skillId, pageId, options),
/** Each attached definition paired with one person's state in it. */
statesFor: (courses, profile) => skillStatesForPage(pageId, courses, profile, options),
/** Passed through to components that resolve attachment themselves. */
customSources,
};
}, [pageId, customKey, disabledKey]);
}

View File

@@ -0,0 +1,728 @@
import { doc, heading, insights, list, note, table, text } from '@/components/ai-assistant/blocks';
import {
demandFor, poolFor, prepareAssignment, prioritise, startLabel, workforceStatusFor,
} from '@/lib/workforce';
import { levelLabel, skillName } from '@/lib/skillGraph';
/**
* Owliver's workforce layer: the conversation, not the reasoning.
*
* Every number, ranking, availability check and eligibility decision below comes
* from `lib/workforce.js`. This module's entire job is to work out which
* question was asked, which position it is about, and how to say the engine's
* answer — plus the one thing a chat can do that a page cannot, which is to
* propose an action and wait to be told to take it.
*
* The rule that shapes the file: **nothing here writes, and nothing here
* decides.** A preview is built by calling `prepareAssignment`, and executing it
* is a separate turn that re-derives the plan from live data before the existing
* mutation runs. That re-derivation is deliberate — between seeing a preview and
* confirming it, somebody else may have taken the person.
*/
/* ── Which question is this? ───────────────────────────────────────────── */
const has = (q, ...terms) => terms.some((t) => q.includes(t));
/**
* The workforce intent behind a question, or null to let the page's own
* responder answer it.
*
* Ordered by specificity: confirmation before proposal, proposal before
* enquiry. "Assign them" after a preview must not be read as a fresh request
* for recommendations.
*/
export function matchWorkforceIntent(question) {
const q = String(question).toLowerCase();
/* Inspection and record-opening are checked before assignment, so "show X"
and "open the full profile for X" never read as a request to assign. */
if (has(q, 'open the full profile', 'view full profile', 'full profile for')) return 'open_profile';
if (has(q, 'show ', "'s details", 'candidate details', 'tell me about ')) return 'candidate_detail';
if (has(q, 'can ', 'why not eligible', 'be assigned')) return 'eligibility';
if (has(q, 'confirm interview')) return 'confirm_interview';
if (has(q, 'set up an interview', 'set up interview', 'schedule an interview', 'schedule interview',
'interview for ', 'set up their interview')) return 'interview_setup';
if (has(q, 'confirm assignment', 'yes, assign', 'confirm the assignment')) return 'confirm_assign';
if (has(q, 'assign ', 'fill this position', 'fill the position', 'assign the best', 'assign them')) return 'assign';
if (has(q, 'ready for interview', 'who should i interview', 'interview ready')) return 'interview_ready';
if (has(q, 'applied today', "today's applicant", 'new applicant', 'new application')) return 'applied_today';
if (has(q, 'available', 'start earliest', 'can start', 'availability', 'free now')) return 'availability';
if (has(q, 'who matches', 'who can fill', 'find candidates', 'best match', 'strongest candidate',
'recommend candidate', 'suitable candidate', 'who is suitable', 'candidates for')) return 'matches';
if (has(q, 'needs people first', 'biggest gap', 'largest gap', 'most understaffed',
'which position should i fill', 'priority')) return 'priority';
return null;
}
/* ── Which position? ───────────────────────────────────────────────────── */
/**
* The position a question is about.
*
* Named titles win, longest match first so "Event Server – Fine Dining" is not
* resolved as "Event Server". Failing that, the position the reader currently
* has open. Failing that, nothing — and the caller asks rather than guessing,
* because assigning somebody to the wrong role is not a recoverable mistake.
*/
export function resolvePosition(question, positions = [], currentId = null) {
const q = String(question).toLowerCase();
let best = null;
for (const position of positions) {
const title = String(position.title || '').toLowerCase();
/* Titles use an en dash; people type a hyphen. */
const loose = title.replace(/[–—]/g, '-');
const asked = q.replace(/[–—]/g, '-');
if (title && (q.includes(title) || asked.includes(loose))
&& (!best || title.length > String(best.title).length)) {
best = position;
}
}
if (best) return best;
return positions.find((p) => p.id === currentId) || null;
}
/** The question Owliver asks when it cannot tell which role is meant. */
export const askWhichPosition = (positions = []) => ({
doc: doc(
text('Which position should I work on?'),
list(positions.filter((p) => p.status === 'active').slice(0, 6).map((p) => p.title)),
note('Name the role and I will read its requirements, demand and the people available for it.')
),
followUp: positions
.filter((p) => p.status === 'active')
.slice(0, 4)
.map((p) => ({ label: p.title, prompt: `Who matches ${p.title}?` })),
});
/**
* The person a question names, matched against the pool the engine scored.
*
* Full name first, then a unique first name — "assign Maria" is how people
* actually speak, but only when exactly one Maria is in play. An ambiguous first
* name resolves to nothing and the caller asks, because assigning the wrong
* person is not a recoverable mistake either.
*/
export function resolveCandidate(question, pool = []) {
const q = String(question).toLowerCase();
const byFull = pool.filter((row) => row.name && q.includes(String(row.name).toLowerCase()));
if (byFull.length === 1) return { row: byFull[0] };
if (byFull.length > 1) {
/* Longest name wins: "Arun Kumar" over a hypothetical "Arun". */
return { row: [...byFull].sort((a, b) => b.name.length - a.name.length)[0] };
}
const byFirst = pool.filter((row) => {
const first = String(row.name || '').split(' ')[0].toLowerCase();
return first.length > 2 && new RegExp(`\\b${first}\\b`).test(q);
});
if (byFirst.length === 1) return { row: byFirst[0] };
if (byFirst.length > 1) return { ambiguous: byFirst };
return {};
}
/* ── Saying what the engine found ──────────────────────────────────────── */
/**
* Why this person fits, in the engine's own terms.
*
* Requirement lines come from the match; availability and duration come from the
* commitment check. A gap is stated as a gap — a candidate one level short is
* useful to see, and calling them a strong match would be the single most
* damaging thing this assistant could do.
*/
function reasonsFor(row) {
const out = [];
for (const line of row.match.met) {
out.push(`✓ ${line.name} — ${line.heldLabel}`);
}
for (const line of row.match.gaps) {
out.push(`⚠ ${line.name} — ${line.heldLabel}, needs ${line.requiredLabel}`);
}
out.push(row.availability.available ? '✓ Available when this starts' : `✕ ${row.availability.reason}`);
if (row.duration.note) out.push(`⚠ ${row.duration.note}`);
return out;
}
/**
* The record to open for a recommended person.
*
* Matching reads worker profiles, because that is where verified skill levels
* live. The candidate profile is addressed by *application* id, because that is
* what the page was built to show. So the link is only real when this person has
* an application on file — and when they do not, the card says so rather than
* pointing somewhere that is not their record.
*
* The position travels with the link so the profile knows what the person was
* being considered for.
*/
export function candidateRoute(row, applications = [], position = null) {
const email = String(row.email || '').toLowerCase();
if (!email) return null;
const mine = applications.filter((a) => String(a.email || '').toLowerCase() === email);
if (!mine.length) return null;
/* An application for *this* role is the right record to open; failing that,
their most recent one, which is still their profile. */
const forThis = position && mine.find((a) => a.job_posting_id === position.id);
const chosen = forThis
|| [...mine].sort((a, b) => new Date(b.created_date) - new Date(a.created_date))[0];
return position
? `/admin/candidates/${chosen.id}?for=${encodeURIComponent(position.id)}`
: `/admin/candidates/${chosen.id}`;
}
/**
* One candidate, as a card.
*
* Clickable when there is a record behind the name. The reasons are the engine's
* — met requirements, gaps, and the availability verdict — so a card can never
* read as a stronger endorsement than the match actually is.
*/
const candidateBlock = (row, index, { position = null, inspectable = true } = {}) => insights([{
tone: row.strong ? 'success' : row.availability.available ? 'info' : 'warning',
title: `${index}. ${row.name} — ${row.score}% match`,
body: reasonsFor(row).join(' · '),
/* Clicking inspects the person *here*. Opening their full record is offered
separately, from the detail view, because leaving the panel loses the
position the admin is working on. */
prompt: inspectable && position ? `Show ${row.name} for ${position.title}` : null,
}]);
/**
* The people who could do this work, ranked.
*
* Shows the unavailable and the under-qualified too, marked as such: "nobody is
* free" is something an admin needs to see, and filtering it away leaves them
* asking the same question again tomorrow.
*/
export function candidateMatches(position, context) {
const pool = poolFor(position, context);
const status = workforceStatusFor(position, context);
if (!pool.length) {
return {
doc: doc(
text(`No worker profiles can be scored against **${position.title}** — it defines no skill requirements, or no profiles carry the skills it asks for.`),
note('Matching reads verified skill levels from worker profiles. Applicants without a profile appear in the pipeline but cannot be scored.')
),
};
}
const eligible = pool.filter((r) => r.availability.available);
const blocked = pool.filter((r) => !r.availability.available);
const shown = eligible.slice(0, 5);
return {
doc: doc(
heading(`Best matches for ${position.title}`,
`${status.strong.length} strong · ${eligible.length} available · ${pool.length} scored`),
...(shown.length
? shown.map((row, i) => candidateBlock(row, i + 1, { applications: context.applications, position }))
: [text('Nobody is currently free for this role.')]),
blocked.length
? note(`${blocked.length} other ${blocked.length === 1 ? 'person is' : 'people are'} qualified but committed elsewhere — ask who is available next to see when they free up.`)
: null,
status.demand.declared
? text(`This position needs **${status.demand.remaining}** more of **${status.demand.required}**.`)
: null
),
followUp: shown.length
? [
{ label: 'Assign the best', prompt: `Assign the best candidates to ${position.title}` },
{ label: 'Who is available next?', prompt: `Who is available next for ${position.title}?` },
]
: [{ label: 'Who is available next?', prompt: `Who is available next for ${position.title}?` }],
};
}
/** Who is free, who is not, and when the committed ones come back. */
export function availability(position, context) {
const pool = poolFor(position, context);
if (!pool.length) {
return { doc: doc(text(`No worker profiles can be assessed for **${position.title}**.`)) };
}
return {
doc: doc(
heading(`Availability for ${position.title}`, `starts ${startLabel(position)}`),
table(
[
{ key: 'name', label: 'Person' },
{ key: 'state', label: 'Availability' },
{ key: 'match', label: 'Match', align: 'right' },
],
pool.slice(0, 8).map((row) => ({
name: row.name,
state: row.availability.available ? 'Available' : row.availability.reason,
match: `${row.score}%`,
}))
),
note('Availability is measured against this position’s start date — somebody finishing a role before it begins counts as free.')
),
};
}
/** Applications created today, across a position or the whole board. */
export function appliedToday(position, context, positions = []) {
const scope = position ? [position] : positions;
const rows = scope.flatMap((p) =>
workforceStatusFor(p, context).newToday.map((row) => ({
name: row.name,
position: p.title,
time: new Date(row.application.created_date).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }),
status: row.application.status,
match: row.match ? `${row.match.score}%` : '—',
}))
);
if (!rows.length) {
return {
doc: doc(text(position
? `No applications for **${position.title}** today.`
: 'No applications arrived today.')),
};
}
return {
doc: doc(
heading(`${rows.length} new ${rows.length === 1 ? 'application' : 'applications'} today`),
table(
[
{ key: 'name', label: 'Candidate' },
{ key: 'position', label: 'Position' },
{ key: 'time', label: 'Applied' },
{ key: 'status', label: 'Status' },
{ key: 'match', label: 'Match', align: 'right' },
],
rows
),
note('Match is shown only for people who carry a worker profile; an applicant without one cannot be scored.')
),
};
}
/** Candidates whose application has reached a stage where an interview is next. */
export function interviewReady(position, context, positions = []) {
const scope = position ? [position] : positions;
const rows = scope.flatMap((p) => {
const status = workforceStatusFor(p, context);
return status.applicants
.filter((a) => ['ai_screened', 'shortlisted'].includes(a.application.status))
.map((a) => ({
name: a.name,
position: p.title,
stage: a.application.status === 'ai_screened' ? 'Screened' : 'Shortlisted',
match: a.match ? `${a.match.score}%` : '—',
}));
});
if (!rows.length) {
return {
doc: doc(text(position
? `Nobody is waiting on an interview decision for **${position.title}**.`
: 'Nobody is currently screened or shortlisted and waiting on an interview.')),
};
}
return {
doc: doc(
heading(`${rows.length} ready for interview`),
table(
[
{ key: 'name', label: 'Candidate' },
{ key: 'position', label: 'Position' },
{ key: 'stage', label: 'Stage' },
{ key: 'match', label: 'Match', align: 'right' },
],
rows
),
note('Interviews are scheduled from the candidate’s record on the position page — I can tell you who is ready, but I do not create the booking.')
),
};
}
/** Which role gets people first, and the engine's stated reasons. */
export function positionPriority(positions, context) {
const ranked = prioritise(positions, context);
if (!ranked.length) {
return { doc: doc(text('No active position currently has an unfilled headcount.')) };
}
const top = ranked[0];
return {
doc: doc(
heading('Hiring priority', `${ranked.length} ${ranked.length === 1 ? 'position needs' : 'positions need'} people`),
insights(ranked.slice(0, 3).map((row) => ({
tone: row.band === 'High' ? 'warning' : 'info',
title: `${row.position.title} — ${row.band}`,
body: row.reasons.join(' · '),
}))),
text(`Start with **${top.position.title}**.`)
),
followUp: [{ label: `Who matches ${top.position.title}?`, prompt: `Who matches ${top.position.title}?` }],
};
}
/* ── Proposing an assignment ───────────────────────────────────────────── */
/**
* The preview. Nothing is written here.
*
* `prepareAssignment` picks the people; this states who, why, and what the
* position looks like afterwards. The confirmation is a follow-up carrying the
* position title, so accepting it goes back through the same resolution path a
* typed sentence would — and re-checks the data before writing.
*/
export function assignmentPreview(position, context) {
const plan = prepareAssignment(position, context);
const demand = demandFor(position, context);
if (!demand.declared) {
return {
doc: doc(
text(`**${position.title}** does not state how many people it needs, so there is no gap for me to fill.`),
note('Set a headcount on the position and I can propose an assignment against it.')
),
};
}
if (plan.blocked === 'full') {
return { doc: doc(text(`**${position.title}** is already fully staffed — ${demand.assigned} of ${demand.required}.`)) };
}
if (plan.blocked === 'no_available_matches') {
const blocked = poolFor(position, context).filter((r) => !r.availability.available);
return {
doc: doc(
text(`I cannot propose anyone for **${position.title}** — nobody is both qualified and free when it starts.`),
blocked.length
? insights(blocked.slice(0, 3).map((row) => ({
tone: 'warning',
title: row.name,
body: `${row.match.score}% on skills, but ${row.availability.reason.toLowerCase()}`,
})))
: null,
note(`${demand.remaining} of ${demand.required} still unfilled.`)
),
};
}
return {
doc: doc(
heading(`Assignment preview — ${position.title}`, `${plan.selected.length} proposed`),
...plan.selected.map((row, i) => candidateBlock(row, i + 1, { applications: context.applications, position })),
text(
`**${demand.assigned} of ${demand.required}** assigned now · `
+ `**${plan.after} of ${plan.required}** after · `
+ `**${plan.remainingAfter}** remaining`
),
note('Nothing is written until you confirm. I re-check availability at that moment, so anyone taken in the meantime drops out.')
),
followUp: [
{ label: 'Confirm assignment', prompt: `Confirm assignment for ${position.title}` },
{ label: 'Cancel', prompt: `Who matches ${position.title}?` },
],
};
}
/**
* One candidate, examined — inside the panel.
*
* Deliberately not a copy of the Candidate Profile page. It carries only what a
* hiring decision needs: how they match, whether they are free, what they have
* done, and where their application stands. The full record stays one explicit
* click away, and remains the source of truth.
*
* The actions offered depend on the person's real state, so the panel never
* offers an assignment it would then have to refuse.
*/
export function candidateDetail(position, row, context) {
const profile = row.profile || {};
const applications = context.applications || [];
const application = applications.find(
(a) => String(a.email || '').toLowerCase() === String(row.email || '').toLowerCase()
&& a.job_posting_id === position.id
) || applications.find(
(a) => String(a.email || '').toLowerCase() === String(row.email || '').toLowerCase()
) || null;
const facts = [
profile.experience_years ? `${profile.experience_years} years experience` : null,
profile.current_position || profile.desired_position || null,
profile.address || null,
(profile.certifications || []).length ? (profile.certifications || []).join(', ') : null,
].filter(Boolean);
const assignable = row.availability.available && !row.match.gaps.length;
/* Actions follow the state. An unavailable person is not offered an
assignment; somebody with no record is not offered a record to open. */
const followUp = [
assignable
? { label: 'Assign to position', prompt: `Assign ${row.name} to ${position.title}` }
: { label: 'Why not eligible?', prompt: `Can ${row.name} be assigned to ${position.title}?` },
{ label: '← Back to matches', prompt: `Who matches ${position.title}?` },
application
? { label: 'View full profile', prompt: `Open the full profile for ${row.name}` }
: null,
].filter(Boolean);
return {
doc: doc(
heading(`${row.name} — ${row.score}% match`, `Evaluating for ${position.title}`),
insights([{
tone: row.strong ? 'success' : row.availability.available ? 'info' : 'warning',
title: 'Match',
body: reasonsFor(row).join(' · '),
}]),
facts.length ? insights([{ tone: 'neutral', title: 'Background', body: facts.join(' · ') }]) : null,
insights([{
tone: row.availability.available ? 'success' : 'warning',
title: 'Availability',
body: row.availability.available
? `Free when this starts (${startLabel(position)})`
: row.availability.reason,
}]),
insights([{
tone: application ? 'info' : 'neutral',
title: 'Application',
body: application
? `${application.status.replace(/_/g, ' ')} · applied ${new Date(application.created_date).toLocaleDateString()}`
: 'No application on file for this role — assigning them creates one.',
}]),
assignable
? null
: note('I will not offer an assignment the engine would refuse. The reason is above.')
),
followUp,
};
}
/**
* A preview for one named person.
*
* Separate from the ranked preview because the question is different: the admin
* has already chosen, and what they need is whether that choice holds. So this
* validates rather than recommends — and refuses, with the engine's reason, when
* the person is committed elsewhere or short of a required level.
*/
export function namedAssignmentPreview(position, row, context) {
const demand = demandFor(position, context);
if (!row.availability.available) {
return {
doc: doc(
heading(`${row.name} cannot take ${position.title}`),
insights([{ tone: 'warning', title: row.availability.reason, body: reasonsFor(row).join(' · ') }]),
note('Availability is measured against this position’s start date. Ask who is available if you want the people who can take it.')
),
followUp: [{ label: 'Who is available?', prompt: `Who is available for ${position.title}?` }],
};
}
if (row.match.gaps.length) {
return {
doc: doc(
heading(`${row.name} is short of ${position.title}`, `${row.score}% match`),
insights([{
tone: 'warning',
title: row.match.gaps.map((g) => `${g.name}: ${g.heldLabel} → needs ${g.requiredLabel}`).join(' · '),
body: reasonsFor(row).join(' · '),
}]),
note('I will not put somebody forward as qualified when the engine says they are not. Assign them anyway by naming the gap you are willing to accept, or ask who does meet it.')
),
followUp: [{ label: 'Who fully matches?', prompt: `Who matches ${position.title}?` }],
};
}
return {
doc: doc(
heading('Assignment ready', `${row.name} → ${position.title}`),
candidateBlock(row, 1, { applications: context.applications, position }),
demand.declared
? text(`**${demand.assigned} of ${demand.required}** assigned now · **${demand.assigned + 1}** after · **${Math.max(0, demand.remaining - 1)}** remaining`)
: text('This position states no headcount, so there is no coverage figure to move.'),
note('Nothing is written until you confirm.')
),
followUp: [
{ label: 'Confirm assignment', prompt: `Confirm assignment of ${row.name} to ${position.title}` },
{ label: 'Cancel', prompt: `Who matches ${position.title}?` },
],
};
}
/** The single-person plan, re-derived at confirmation. */
export function namedAssignmentToExecute(position, row, context) {
if (!row || !row.availability.available || row.match.gaps.length) return null;
const demand = demandFor(position, context);
return {
position,
workers: [{ email: row.email, name: row.name, score: row.score }],
before: demand.assigned,
after: demand.assigned + 1,
required: demand.required,
remainingAfter: Math.max(0, demand.remaining - 1),
};
}
/**
* The plan, re-derived at the moment of confirmation.
*
* Returned to the caller to execute through the existing mutation. Re-deriving
* rather than replaying a stored preview is what makes the confirmation honest:
* the people written are the people who are still free.
*/
export function assignmentToExecute(position, context) {
const plan = prepareAssignment(position, context);
if (plan.blocked || !plan.selected.length) return null;
return {
position,
workers: plan.selected.map((row) => ({ email: row.email, name: row.name, score: row.score })),
before: plan.before,
after: plan.after,
required: plan.required,
remainingAfter: plan.remainingAfter,
};
}
/* ── Interview ─────────────────────────────────────────────────────────── */
/**
* The application a person holds for this role, or null.
*
* Everything about interviewing keys on this record: it is the subject the AI
* interview takes, and the thing a status transition moves.
*/
export function applicationFor(row, position, context) {
const email = String(row?.email || '').toLowerCase();
if (!email) return null;
return (context.applications || []).find(
(a) => a.job_posting_id === position?.id && String(a.email || '').toLowerCase() === email
) || null;
}
/**
* Interview setup, previewed.
*
* The blockers are real states, not guesses: somebody with no application for
* this role has not entered its pipeline, and somebody already at interview
* stage does not need moving there again. Both are reported rather than
* papered over with a second record.
*/
export function interviewPreview(position, row, context) {
const application = applicationFor(row, position, context);
if (!application) {
return {
doc: doc(
text(`**${row.name}** has no application for **${position.title}**, so there is nothing to interview against.`),
note('Assigning them creates the application, and the interview can be set up straight after.')
),
followUp: [{ label: 'Assign to position', prompt: `Assign ${row.name} to ${position.title}` }],
};
}
if (application.status === 'interview') {
return {
doc: doc(
heading('Already at interview stage', `${row.name} · ${position.title}`),
text('The AI interview can be started from their candidate record.'),
insights([{
tone: 'info',
title: 'Open the AI interview',
body: `${row.name} — ${position.title}`,
to: `/admin/candidates/${application.id}`,
}])
),
followUp: [{ label: '← Back to candidate', prompt: `Show ${row.name} for ${position.title}` }],
};
}
const requirements = (position.skill_requirements || [])
.map((r) => `${skillName(r.skill_id)} — ${levelLabel(r.level)}`)
.join(' · ');
return {
doc: doc(
heading('Interview setup', `${row.name} → ${position.title}`),
insights([{
tone: 'info',
title: 'AI Interview',
body: requirements
? `Scored against this role's own requirements: ${requirements}`
: 'This position defines no skill requirements, so the interview covers the role generally.',
}]),
insights([{
tone: 'neutral',
title: 'Application',
body: `Currently ${String(application.status).replace(/_/g, ' ')} · moving to interview`,
}]),
note('This moves the application to interview stage. The interview itself is conducted by the AI interviewer on the candidate’s record, which writes the result.')
),
followUp: [
{ label: 'Confirm interview', prompt: `Confirm interview for ${row.name} at ${position.title}` },
{ label: '← Back to candidate', prompt: `Show ${row.name} for ${position.title}` },
],
};
}
/** The interview step to execute, or null when it cannot proceed. */
export function interviewToExecute(position, row, context) {
const application = applicationFor(row, position, context);
if (!application || application.status === 'interview') return null;
return { position, row, application };
}
/** What to say once the application has actually moved. */
export const interviewDone = (plan) => doc(
heading('Interview ready', `${plan.row.name} · ${plan.position.title}`),
text(`**${plan.row.name}** is now at interview stage for **${plan.position.title}**.`),
insights([{
tone: 'success',
title: 'Open the AI interview',
body: 'The AI interviewer scores against this role’s requirements and writes the result to their record.',
to: `/admin/candidates/${plan.application.id}`,
}]),
note('Candidates, the position pipeline and the activity log all reflect this now.')
);
export const interviewFailed = () => doc(
text('I could not move that application to interview stage.'),
note('Nothing was changed. Open the candidate’s record and try from there.')
);
/** What to say once the write has actually happened. */
export const assignmentDone = (plan) => doc(
heading('Assignment complete', `${plan.workers.length} assigned to ${plan.position.title}`),
list(plan.workers.map((w) => `${w.name} — ${w.score}% match`)),
text(
`**${plan.position.title}** is now **${plan.after} of ${plan.required}** assigned · `
+ `**${plan.remainingAfter}** remaining.`
),
note('The position, the applications and the activity log have been updated.')
);
/** The next step to offer once an assignment lands. */
export const assignmentFollowUp = (plan) => [
plan.workers.length === 1
? { label: 'Set up interview', prompt: `Set up an interview for ${plan.workers[0].name} at ${plan.position.title}` }
: { label: 'Who is ready for interview?', prompt: 'Who is ready for interview?' },
{ label: '← Back to matches', prompt: `Who matches ${plan.position.title}?` },
];
/** What to say when the write could not proceed. */
export const assignmentFailed = () => doc(
text('I could not complete that assignment.'),
note('Nothing was written. The people I proposed may have been taken since the preview — ask again and I will re-check.')
);

View File

@@ -58,7 +58,7 @@ export const FACTOR_WEIGHTS = {
// Score band — the "credit rating" tier for the talent
export function getScoreBand(score = 0) {
if (score >= 90) return { label: 'Elite', color: '#0A39DF' };
if (score >= 90) return { label: 'Elite', color: '#0838E0' };
if (score >= 75) return { label: 'Excellent', color: '#16A34A' };
if (score >= 60) return { label: 'Solid', color: '#2563EB' };
if (score >= 40) return { label: 'Building', color: '#D97706' };

View File

@@ -24,6 +24,23 @@ export async function logActivity(eventType, extra = {}) {
user_name: me?.full_name || extra.name || '',
account_type: extra.account_type || me?.account_type || 'unknown',
details: extra.details || '',
/**
* The records this event is about.
*
* An event that says only "a candidate was contacted" cannot be read back
* against the position it was about, so the ids travel with it. They are
* written only when supplied — an event with no application does not get
* an `application_id: null` that later reads as a missing link.
*
* Kept as flat fields rather than a metadata blob so the store's own
* `filter({ position_id })` can find them without a custom query path.
*/
...(extra.position_id ? { position_id: extra.position_id } : {}),
...(extra.application_id ? { application_id: extra.application_id } : {}),
...(extra.candidate_id ? { candidate_id: extra.candidate_id } : {}),
...(extra.interview_id ? { interview_id: extra.interview_id } : {}),
...(extra.worker_email ? { worker_email: extra.worker_email } : {}),
...(extra.metadata ? { metadata: extra.metadata } : {}),
};
await base44.entities.UserActivity.create(record);
base44.analytics.track({

432
src/lib/workforce.js Normal file
View File

@@ -0,0 +1,432 @@
/**
* Workforce allocation: who is needed where, who is actually free, and which
* demand gets the people first.
*
* `skillGraph.js` answers "can this person do this work". This module answers
* the questions that come after it, and they are the ones that decide a real
* roster:
*
* demand how many people a position needs, has, and still lacks
* availability whether a person is free *when the work starts*, and free
* long enough to see it out
* priority which position gets the scarce people first, and why
*
* Two rules shape everything here.
*
* **Availability is a window, not a flag.** A person committed to a six-month
* role is not "unavailable" — they are unavailable *until a date*. Treating it
* as a boolean is what makes a system recommend someone who is already on a
* floor somewhere else, so every check below is against a start date.
*
* **Every score carries its reasons.** An allocation moves a person's work and a
* company's staffing; a number nobody can interrogate is not usable for that.
* Every function that ranks returns the reasons alongside the rank.
*
* Reads the existing entities only — JobPosting, JobApplication, WorkerProfile,
* Staff and the Assignment records the store now carries. Nothing here writes.
*/
import { matchPosition, recommendTraining, verifiedLevels } from './skillGraph';
const DAY_MS = 86400000;
const MONTH_MS = DAY_MS * 30;
/* ── Dates ─────────────────────────────────────────────────────────────── */
/** A position's start, as a date. No start date means "as soon as possible". */
export function startsAt(position, today = new Date()) {
return position?.start_date ? new Date(position.start_date) : new Date(today);
}
/** When the work ends, or null when it is open-ended. */
export function endsAt(position, today = new Date()) {
const months = position?.duration_months;
if (months == null) return null;
return new Date(startsAt(position, today).getTime() + months * MONTH_MS);
}
/** Days from now until the work starts. Negative or zero means it is live. */
export const daysUntilStart = (position, today = new Date()) =>
Math.round((startsAt(position, today) - today) / DAY_MS);
/** "Immediately", "in 6 days", "in 3 weeks" — the phrasing answers use. */
export function startLabel(position, today = new Date()) {
const days = daysUntilStart(position, today);
if (days <= 0) return 'immediately';
if (days === 1) return 'tomorrow';
if (days < 14) return `in ${days} days`;
if (days < 60) return `in ${Math.round(days / 7)} weeks`;
return `in ${Math.round(days / 30)} months`;
}
/* ── Demand ────────────────────────────────────────────────────────────── */
/**
* What a position needs, has and still lacks.
*
* "Assigned" counts live assignment records plus anyone already hired onto the
* role, because both are people who are actually on it — a system that counted
* only one of them would report a gap the floor does not have.
*/
export function demandFor(position, { assignments = [], staff = [] } = {}) {
/**
* Whether this position states how many people it wants.
*
* Most records predate the field, and a position that never declared a
* headcount has no demand figure — it has an unknown one. Callers read
* `declared` and omit the demand entirely rather than presenting the fallback
* of 1 as though the employer had asked for one person.
*/
const declared = Number.isFinite(Number(position?.headcount)) && Number(position.headcount) > 0;
const required = declared ? Number(position.headcount) : 1;
const assigned = assignments.filter(
(a) => a.job_posting_id === position?.id && a.status === 'active'
);
/* Hired staff who never got an explicit assignment record still occupy a
slot. Counted by email so the two sources cannot double-count one person. */
const assignedEmails = new Set(assigned.map((a) => String(a.worker_email || '').toLowerCase()));
const hired = staff.filter(
(s) => s.job_posting_id === position?.id
&& !assignedEmails.has(String(s.email || '').toLowerCase())
);
const filled = assigned.length + hired.length;
return {
declared,
required,
assigned: filled,
remaining: Math.max(0, required - filled),
filledPct: required ? Math.round((filled / required) * 100) : 0,
assignments: assigned,
isFull: filled >= required,
};
}
/* ── Availability ──────────────────────────────────────────────────────── */
/**
* Is this person free for this work, and if not, when do they come free?
*
* The commitment that matters is the one covering the position's start date.
* Someone finishing a role next Tuesday is available for work starting next
* month — reporting them as "committed" would hide a person the roster needs.
*/
export function availabilityOf(profile, position, { assignments = [], today = new Date() } = {}) {
const email = String(profile?.email || '').toLowerCase();
const start = startsAt(position, today);
const commitments = assignments.filter(
(a) => String(a.worker_email || '').toLowerCase() === email && a.status === 'active'
);
/* A commitment blocks this work when it is still running when the work
starts. Open-ended commitments (`ends_at: null`) block everything. */
const blocking = commitments.filter((a) => !a.ends_at || new Date(a.ends_at) > start);
if (!blocking.length) {
return { available: true, committedUntil: null, blockedBy: null, reason: 'Free when this starts' };
}
/* The one that clears last decides when they are actually free. */
const openEnded = blocking.find((a) => !a.ends_at);
const until = openEnded
? null
: new Date(Math.max(...blocking.map((a) => new Date(a.ends_at).getTime())));
return {
available: false,
committedUntil: until,
blockedBy: blocking[0],
reason: until
? `Committed until ${until.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`
: 'Committed to an open-ended assignment',
};
}
/**
* Can this person cover the whole run?
*
* A two-week availability against a one-year role is a real mismatch, and the
* spec is explicit that duration must matter. Someone free open-endedly covers
* anything; the penalty only applies when their own availability actually ends
* before the work does.
*/
export function durationFitOf(profile, position, { today = new Date() } = {}) {
const end = endsAt(position, today);
if (!end) {
/* Open-ended work. Anyone with a stated end to their availability is a
partial fit for it, and anyone without is a full one. */
const free = profile?.available_until ? new Date(profile.available_until) : null;
if (!free) return { covers: true, ratio: 1, note: null };
const months = Math.max(0, (free - startsAt(position, today)) / MONTH_MS);
return {
covers: false,
ratio: Math.min(1, months / 12),
note: `Available for about ${Math.round(months)} more months on open-ended work`,
};
}
const free = profile?.available_until ? new Date(profile.available_until) : null;
if (!free || free >= end) return { covers: true, ratio: 1, note: null };
const needed = end - startsAt(position, today);
const have = Math.max(0, free - startsAt(position, today));
const ratio = needed ? Math.max(0, Math.min(1, have / needed)) : 1;
return {
covers: false,
ratio,
note: `Available for ${Math.round(have / DAY_MS)} of the ${Math.round(needed / DAY_MS)} days this runs`,
};
}
/* ── Candidate and workforce pool ──────────────────────────────────────── */
/**
* Everyone who could do this work, ranked, with the reasoning attached.
*
* Skill match is the base score; timing adjusts it. A perfect skill match who
* cannot start until after the work begins is not a better answer than a
* slightly weaker one who can, and the ranking says so — but the person is
* still returned, with the reason, because "nobody is free" is something the
* admin needs to see rather than have filtered away.
*/
export function poolFor(position, {
profiles = [], applications = [], assignments = [], courses = [], staff = [], today = new Date(),
} = {}) {
const appliedBy = new Map(
applications
.filter((a) => a.job_posting_id === position?.id)
.map((a) => [String(a.email || '').toLowerCase(), a])
);
return profiles
.map((profile) => {
const held = verifiedLevels(courses, profile);
const match = matchPosition(position, held);
if (!match) return null;
const availability = availabilityOf(profile, position, { assignments, today });
const duration = durationFitOf(profile, position, { today });
const application = appliedBy.get(String(profile.email || '').toLowerCase()) || null;
/* Timing multiplies the skill score rather than replacing it: the skill
match is still the thing being measured, and the reader can see both
numbers. A person who cannot start loses most of the score but keeps a
visible one, so "strong but unavailable" stays legible. */
const timingFactor = (availability.available ? 1 : 0.25) * (0.6 + 0.4 * duration.ratio);
const score = Math.round(match.score * timingFactor);
const reasons = [
`${match.score}% skill match against this role's requirements`,
availability.reason,
duration.note,
application ? `Applied ${new Date(application.created_date).toLocaleDateString()}` : null,
].filter(Boolean);
return {
profile,
email: profile.email,
name: profile.full_name,
match,
skillScore: match.score,
score,
availability,
duration,
application,
applied: Boolean(application),
/* Ready to be put forward: qualified enough, and actually free. */
strong: match.score >= 75 && availability.available,
reasons,
recommendation: recommendTraining(position, profile, courses),
};
})
.filter(Boolean)
/**
* Equally qualified people are common and the score says so honestly —
* everyone who meets every requirement scores the same. Rather than
* manufacture variance to break the tie, the order falls through to things
* that are also true and also matter: who can cover more of the run, who
* has done this longer, and who is more reliable on the record.
*/
.sort((a, b) =>
b.score - a.score
|| b.duration.ratio - a.duration.ratio
|| (b.profile.experience_years || 0) - (a.profile.experience_years || 0)
|| (b.profile.reliability_score || 0) - (a.profile.reliability_score || 0)
|| String(a.name).localeCompare(String(b.name)));
}
/**
* The workforce picture for one position — the figures the detail page and
* Owliver both report, computed once so they cannot disagree.
*/
export function workforceStatusFor(position, context = {}) {
const demand = demandFor(position, context);
const pool = poolFor(position, context);
const today = context.today || new Date();
/**
* Applicants are counted from the application records, not from the matched
* pool.
*
* These are two different populations and conflating them under-reports the
* real one. Matching needs a worker profile — that is where verified skill
* levels live — but most applicants are external people who have applied
* without one. Counting "applied" off the pool would report a position with
* four applications as having none, because none of the four happened to
* carry a profile.
*
* So: applications answer "who applied", the pool answers "who can do the
* work", and each row is paired with its pool entry when one exists.
*/
const byEmail = new Map(pool.map((row) => [String(row.email || '').toLowerCase(), row]));
const applicants = (context.applications || [])
.filter((a) => a.job_posting_id === position?.id)
.map((application) => ({
application,
email: application.email,
name: application.applicant_name,
/* The engine's assessment, when this person is someone we can assess. */
match: byEmail.get(String(application.email || '').toLowerCase()) || null,
}));
const newToday = applicants.filter(
(a) => new Date(a.application.created_date).toDateString() === today.toDateString()
);
const strong = pool.filter((p) => p.strong);
return {
position,
demand,
pool,
/* Existing workforce free for this work and not already on it. */
existingAvailable: pool.filter((p) => p.availability.available && !p.applied),
applicants,
newToday,
strong,
interviewReady: applicants.filter((a) => a.application.status === 'interview'),
/**
* Hired onto this role, from the application record.
*
* Reported separately from `demand.assigned` because they are different
* facts: hiring is a decision about a person, assignment is their presence
* on the roster. Someone can be hired and not yet assigned, and a card that
* conflated the two would report coverage the floor does not have.
*/
hired: applicants.filter((a) => a.application.status === 'hired'),
/* What is still missing after everyone free and qualified is counted. */
gap: Math.max(0, demand.remaining - strong.length),
};
}
/* ── Priority across companies ─────────────────────────────────────────── */
/**
* Which position gets the scarce people first.
*
* Four things decide it, and each contributes a stated number of points so the
* ranking can always be read back as a sentence:
*
* urgency what the employer declared
* timing work starting today outranks work starting next month
* shortage how many people are still missing, and what share of the role
* supply whether anyone is actually available to fill it
*
* Supply counts *against* priority when it is plentiful: a position with more
* strong matches than open slots does not need to be first in the queue, it
* needs somebody to press the button. What ranks highest is a role that starts
* soon, is badly short, and has few people who can fill it.
*/
export function prioritise(positions = [], context = {}) {
const today = context.today || new Date();
return positions
.filter((p) => p.status === 'active')
.map((position) => {
const status = workforceStatusFor(position, { ...context, today });
const { demand } = status;
if (!demand.remaining) return null;
const days = daysUntilStart(position, today);
const reasons = [];
let score = 0;
const urgency = { urgent: 30, high: 18, normal: 6 }[position.priority] ?? 6;
score += urgency;
if (position.priority === 'urgent') reasons.push('Flagged urgent by the employer');
/* Timing. Live work is the strongest signal in the model — every day it
runs short is a day the client is under-staffed. */
const timing = days <= 0 ? 35 : days <= 7 ? 26 : days <= 30 ? 14 : 5;
score += timing;
reasons.push(days <= 0 ? 'Starts immediately' : `Starts ${startLabel(position, today)}`);
/* Shortage, as both a count and a proportion: 18 of 30 missing is worse
than 2 of 30, and worse again than 18 of 200. */
const shortfall = demand.remaining / demand.required;
const shortage = Math.round(shortfall * 20) + Math.min(15, demand.remaining);
score += shortage;
reasons.push(`${demand.remaining} of ${demand.required} still unfilled`);
/* Supply. Scarcity raises priority; a full bench lowers it. */
const coverage = demand.remaining ? status.strong.length / demand.remaining : 1;
const supply = coverage >= 1 ? -10 : Math.round((1 - coverage) * 20);
score += supply;
reasons.push(
status.strong.length
? `${status.strong.length} strong ${status.strong.length === 1 ? 'match' : 'matches'} available now`
: 'No strong matches available'
);
return {
position,
status,
score: Math.max(0, score),
band: score >= 70 ? 'High' : score >= 45 ? 'Medium' : 'Low',
reasons,
breakdown: { urgency, timing, shortage, supply },
};
})
.filter(Boolean)
.sort((a, b) => b.score - a.score);
}
/* ── Preparing an assignment ───────────────────────────────────────────── */
/**
* The assignment Owliver would make, as a proposal — never as a write.
*
* Returns the people, the counts before and after, and nothing else: executing
* it is a separate, explicitly confirmed step (see `useAssignWorkers`). This
* function existing separately from the mutation is what makes "show me first"
* the only possible path rather than a convention someone has to remember.
*/
export function prepareAssignment(position, { count, ...context } = {}) {
const status = workforceStatusFor(position, context);
const take = Math.min(
count ?? status.demand.remaining,
status.demand.remaining,
status.strong.length
);
const selected = status.strong.slice(0, Math.max(0, take));
return {
position,
status,
selected,
before: status.demand.assigned,
after: status.demand.assigned + selected.length,
required: status.demand.required,
remainingAfter: Math.max(0, status.demand.required - status.demand.assigned - selected.length),
/* Why this is not simply "the top N": either nobody is free, or the role is
already full. Callers say which rather than showing an empty preview. */
blocked: selected.length === 0
? (status.demand.remaining === 0 ? 'full' : 'no_available_matches')
: null,
};
}

View File

@@ -8,13 +8,13 @@ import { Briefcase, Users, Brain, ClipboardCheck, UserCheck, BadgeCheck, Gauge,
import { AssistantTrigger } from '@/components/ai-assistant';
const METRICS = [
{ icon: Briefcase, label: 'Open Positions', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
{ icon: Briefcase, label: 'Open Positions', bg: 'bg-[#0838E0]', glow: 'shadow-blue-600/20' },
{ icon: Users, label: 'Applicants', bg: 'bg-[#333F48]', glow: 'shadow-slate-600/20' },
{ icon: Brain, label: 'AI Screened', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
{ icon: Brain, label: 'AI Screened', bg: 'bg-[#0838E0]', glow: 'shadow-blue-600/20' },
{ icon: ClipboardCheck, label: 'Shortlisted', bg: 'bg-[#F9E547]', glow: 'shadow-yellow-400/30' },
{ icon: UserCheck, label: 'Hired', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
{ icon: UserCheck, label: 'Hired', bg: 'bg-[#0838E0]', glow: 'shadow-blue-600/20' },
{ icon: BadgeCheck, label: 'Active Staff', bg: 'bg-[#333F48]', glow: 'shadow-slate-600/20' },
{ icon: Gauge, label: 'Avg KROW Score', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
{ icon: Gauge, label: 'Avg KROW Score', bg: 'bg-[#0838E0]', glow: 'shadow-blue-600/20' },
{ icon: Sparkles, label: 'AI Interviews', bg: 'bg-[#F9E547]', glow: 'shadow-yellow-400/30' },
];
@@ -29,7 +29,7 @@ function MetricCard({ icon: Icon, value, label, bg, glow, index }) {
className="glass-card border border-white/60 rounded-2xl p-4 shadow-sm hover:shadow-lg transition-shadow"
>
<div className={`w-10 h-10 rounded-xl ${bg} flex items-center justify-center shadow-lg ${glow} mb-3`}>
<Icon className={`w-5 h-5 ${isYellow ? 'text-[#0A39DF]' : 'text-white'}`} />
<Icon className={`w-5 h-5 ${isYellow ? 'text-[#0838E0]' : 'text-white'}`} />
</div>
<div className="text-3xl font-heading font-bold gradient-text">{value}</div>
<div className="text-[11px] text-[#9CA3AF] font-medium tracking-wide mt-0.5">{label}</div>
@@ -75,7 +75,7 @@ export default function AdminDashboard() {
{/* Hero */}
<motion.div initial={{ opacity: 0, y: -12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} className="flex items-center justify-between flex-wrap gap-4">
<div>
<span className="text-[11px] font-medium tracking-wide text-[#0A39DF] bg-[#EFF6FF] px-2.5 py-1 rounded-full">Admin · Employer</span>
<span className="text-[11px] font-medium tracking-wide text-[#0838E0] bg-[#EEF3FE] px-2.5 py-1 rounded-full">Admin · Employer</span>
<h1 className="text-3xl font-heading font-bold text-[#111827] mt-2">Workforce <span className="gradient-text">Control Center</span></h1>
<p className="text-[14px] text-[#6B7280] mt-1">Full hiring pipeline, staffing & talent intelligence in one view</p>
</div>
@@ -98,7 +98,7 @@ export default function AdminDashboard() {
<motion.div initial={{ opacity: 0, x: 16 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.3 }} className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
<div className="flex items-center justify-between mb-5">
<h3 className="font-heading font-semibold text-[#111827] text-[16px]">Active Positions</h3>
<button onClick={() => navigate('/positions')} className="text-[12px] text-[#0A39DF] hover:underline inline-flex items-center gap-1 font-medium">View all <ChevronRight className="w-3 h-3" /></button>
<button onClick={() => navigate('/positions')} className="text-[12px] text-[#0838E0] hover:underline inline-flex items-center gap-1 font-medium">View all <ChevronRight className="w-3 h-3" /></button>
</div>
<div className="space-y-1">
{openPositions.length === 0 && (
@@ -111,12 +111,12 @@ export default function AdminDashboard() {
const s = statsForPosting(p.id);
return (
<motion.div key={p.id} initial={{ opacity: 0, x: 8 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.4 + idx * 0.05 }} onClick={() => navigate(`/positions/${p.id}`)} className="flex items-center gap-3 p-2.5 rounded-xl hover:bg-white/50 transition-colors cursor-pointer group">
<div className="w-1 h-9 rounded-full bg-[#0A39DF] shrink-0" />
<div className="w-1 h-9 rounded-full bg-[#0838E0] shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-[#111827] truncate">{p.title}</div>
<div className="text-[11px] text-[#6B7280]">{s.applied} applied · {s.screened} screened · {s.hired} hired</div>
</div>
<ChevronRight className="w-4 h-4 text-[#D1D5DB] group-hover:text-[#0A39DF] transition-colors" />
<ChevronRight className="w-4 h-4 text-[#D1D5DB] group-hover:text-[#0838E0] transition-colors" />
</motion.div>
);
})}
@@ -147,7 +147,7 @@ export default function AdminDashboard() {
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-[#111827] truncate">{s.name}</div>
<div className="text-[11px] text-[#6B7280] truncate">{s.role} · <span className="text-[#0A39DF] font-medium">{s.profile_tier}</span></div>
<div className="text-[11px] text-[#6B7280] truncate">{s.role} · <span className="text-[#0838E0] font-medium">{s.profile_tier}</span></div>
</div>
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium ${s.status === 'active' ? 'bg-[#ECFDF5] text-[#065F46]' : 'bg-[#FFFBEB] text-[#92400E]'}`}>{s.status}</span>
</div>

View File

@@ -31,7 +31,7 @@ export default function Analytics() {
transition={{ duration: 0.4 }}
className="flex items-center gap-3"
>
<div className="w-10 h-10 rounded-xl bg-[#0A39DF] flex items-center justify-center shadow-lg shadow-blue-500/20">
<div className="w-10 h-10 rounded-xl bg-[#0838E0] flex items-center justify-center shadow-lg shadow-blue-500/20">
<BarChart3 className="w-5 h-5 text-white" />
</div>
<div>

View File

@@ -1,7 +1,9 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ChevronRight, Clock, Mic, Gauge, ArrowLeft } from 'lucide-react';
import { useJobPostings, useCreateApplication } from '@/lib/krowHooks';
import { useJobPostings, useCreateApplication, useCourses } from '@/lib/krowHooks';
import { YourSkillFit } from '@/components/krow/MatchedCandidates';
import { verifiedLevels } from '@/lib/skillGraph';
import ApplyIdentityCard from '@/components/krow/ApplyIdentityCard';
import AIInterviewModal from '@/components/krow/AIInterviewModal';
import { useWorkerProfile } from '@/lib/krowHooks';
@@ -14,6 +16,7 @@ export default function Apply() {
const { data: postings = [], isLoading } = useJobPostings();
const createApplication = useCreateApplication();
const { data: profile, isLoading: profileLoading } = useWorkerProfile();
const { data: courses = [] } = useCourses();
const [selectedJob, setSelectedJob] = useState(null);
const [showForm, setShowForm] = useState(false);
const [interviewApp, setInterviewApp] = useState(null);
@@ -96,7 +99,7 @@ export default function Apply() {
</span>
))}
</div>
<button onClick={() => navigate('/owliver')} className="mt-5 inline-flex items-center gap-2 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[13px] font-semibold px-5 py-2.5 rounded-full transition-all">
<button onClick={() => navigate('/owliver')} className="mt-5 inline-flex items-center gap-2 bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-semibold px-5 py-2.5 rounded-full transition-all">
Create your KROW Identity <ChevronRight className="w-4 h-4" />
</button>
</div>
@@ -111,6 +114,12 @@ export default function Apply() {
onStart={handleStartInterview}
onBuildIdentity={() => navigate((profile?.profile_completion || 0) < 40 ? '/owliver' : '/identity')}
/>
{/* The candidate's own fit against this role — their skills, this
position's requirements, and nothing about anybody else. The
recruiter's ranked comparison of every candidate is the same
arithmetic and stays on the admin position page. */}
<YourSkillFit job={selectedJob} held={verifiedLevels(courses, profile || {})} />
<button onClick={() => { setShowForm(false); setSelectedJob(null); navigate('/apply', { replace: true }); }} className="text-[13px] text-[#6B7280] hover:text-[#111827] font-medium flex items-center gap-1">
<ArrowLeft className="w-3.5 h-3.5" /> Back to positions
</button>

View File

@@ -95,7 +95,7 @@ export default function Candidates() {
<button
onClick={handleScreenAll}
disabled={screenAll.isPending}
className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] disabled:opacity-50 text-white text-[13px] font-medium px-3 py-2 rounded-lg transition-colors"
className="inline-flex items-center gap-1.5 bg-[#0838E0] hover:bg-[#062BAF] disabled:opacity-50 text-white text-[13px] font-medium px-3 py-2 rounded-lg transition-colors"
>
{screenAll.isPending ? 'Screening...' : 'AI Screen All'}
</button>
@@ -109,21 +109,21 @@ export default function Candidates() {
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search candidates..."
className="w-full h-10 pl-4 pr-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0A39DF] focus:outline-none"
className="w-full h-10 pl-4 pr-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none"
/>
</div>
<select value={stageFilter} onChange={(e) => setStageFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0A39DF] focus:outline-none">
<select value={stageFilter} onChange={(e) => setStageFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none">
<option value="all">All Stages</option>
<option value="applied">Applied</option>
<option value="ai_screened">AI Screened</option>
<option value="interview">Interviewing</option>
<option value="hired">Hired</option>
</select>
<select value={positionFilter} onChange={(e) => setPositionFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0A39DF] focus:outline-none max-w-[200px] truncate">
<select value={positionFilter} onChange={(e) => setPositionFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none max-w-[200px] truncate">
<option value="all">All Positions</option>
{positionOptions.map(p => <option key={p} value={p}>{p}</option>)}
</select>
<select value={scoreFilter} onChange={(e) => setScoreFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0A39DF] focus:outline-none">
<select value={scoreFilter} onChange={(e) => setScoreFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none">
<option value="all">All Scores</option>
<option value="high">80+ (Top Talent)</option>
<option value="mid">60-79 (Strong Fit)</option>

Some files were not shown because too many files have changed in this diff Show More