update position page design
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) };
|
||||
|
||||
|
||||
@@ -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) ?? [];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(), []);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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%)',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
285
src/components/forge/SkillProgression.jsx
Normal file
285
src/components/forge/SkillProgression.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]',
|
||||
};
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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' }) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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]'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
311
src/components/krow/MatchedCandidates.jsx
Normal file
311
src/components/krow/MatchedCandidates.jsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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]' },
|
||||
];
|
||||
|
||||
132
src/components/krow/SkillRequirementsField.jsx
Normal file
132
src/components/krow/SkillRequirementsField.jsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
226
src/components/krow/VerifiedSkills.jsx
Normal file
226
src/components/krow/VerifiedSkills.jsx
Normal 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;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]' };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user