archive issue fix
Some checks failed
CI / check (push) Failing after 5m5s

This commit is contained in:
2026-09-10 19:29:39 +05:30
parent 6249e00a3a
commit f522b6508e
20 changed files with 2444 additions and 207 deletions

View File

@@ -4,7 +4,7 @@ import { recommendJobs, recommendNextCourse } from '@/lib/krowScore';
import TalentHero from '@/components/krow/talent/TalentHero';
import IncreaseMarketValue from '@/components/krow/talent/IncreaseMarketValue';
import CareerGrowth from '@/components/krow/talent/CareerGrowth';
import MarketOffers from '@/components/krow/talent/MarketOffers';
import Opportunities from '@/components/krow/talent/Opportunities';
import ExperienceNetwork from '@/components/krow/talent/ExperienceNetwork';
import { Loader2 } from 'lucide-react';
@@ -25,7 +25,7 @@ export default function EmployeeDashboard() {
return (
<div className="space-y-6">
<TalentHero profile={profile} jobRecs={jobRecs} nextCourse={nextCourse} openOwliver={openOwliver} />
<MarketOffers profile={profile} jobRecs={jobRecs} />
<Opportunities jobRecs={jobRecs} />
<IncreaseMarketValue courses={courses} />
<CareerGrowth profile={profile} />
<ExperienceNetwork profile={profile} />

View File

@@ -0,0 +1,208 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
ArrowLeft, MapPin, Wallet, Sparkles, CheckCircle2, Award,
Briefcase, Clock, Loader2, Compass,
} from 'lucide-react';
import { useWorkerProfile, useJobPostings } from '@/lib/krowHooks';
import { computeJobMatch } from '@/lib/krowScore';
/**
* OpportunityDetail — the full-page Employee job detail.
*
* A dedicated route (/opportunities/:id), NOT a drawer/sheet/modal. It continues
* the Opportunities grid: the same title-first identity, the same match, and an
* Apply action that hands off to the existing /apply?job=<id> flow (which runs
* the AI interview). It reuses the existing matching — useWorkerProfile,
* useJobPostings and computeJobMatch — and changes none of it.
*
* COMPANY PRIVACY: never renders job.company. Only title, role_category,
* location, pay, status, description/responsibilities/qualifications and the
* match signals are shown.
*/
/* The skills the profile brings that this role asks for — the same
case-insensitive overlap computeJobMatch scores, shown here for the reader
rather than recomputed into a number. Read-only; the algorithm is untouched. */
function matchedSkills(profile, job) {
const have = (profile?.skills || []).map((s) => String(s).toLowerCase());
const want = job?.qualifications || [];
return want.filter((q) => {
const ql = String(q).toLowerCase();
return have.some((ps) => ql.includes(ps) || ps.includes(ql));
});
}
function matchedCerts(profile, job) {
const have = (profile?.earned_badges || []).map((b) => String(b?.name || '').toLowerCase()).filter(Boolean);
const want = job?.certifications_required || [];
return want.filter((c) => {
const cl = String(c).toLowerCase();
return have.some((pc) => cl.includes(pc) || pc.includes(cl));
});
}
function Section({ title, children }) {
return (
<div className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
<h2 className="text-[15px] font-bold text-[#0F172A] mb-3">{title}</h2>
{children}
</div>
);
}
export default function OpportunityDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { data: profile, isLoading: loadingProfile } = useWorkerProfile();
const { data: postings = [], isLoading: loadingJobs } = useJobPostings();
if (loadingProfile || loadingJobs) {
return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0838E0]" /></div>;
}
const job = postings.find((p) => p.id === id);
if (!job) {
return (
<div className="max-w-2xl mx-auto text-center py-20">
<div className="w-12 h-12 rounded-2xl bg-[#F9FAFB] text-[#9CA3AF] flex items-center justify-center mx-auto mb-3">
<Compass className="w-6 h-6" />
</div>
<p className="text-[15px] font-semibold text-[#0F172A]">This opportunity is no longer available.</p>
<button
onClick={() => navigate('/employee')}
className="mt-5 inline-flex items-center gap-1.5 text-[13px] font-semibold text-[#0838E0] hover:text-[#062BAF]"
>
<ArrowLeft className="w-4 h-4" /> Back to Opportunities
</button>
</div>
);
}
const match = computeJobMatch(profile, job);
const pay = job.pay_range_min && job.pay_range_max ? `$${job.pay_range_min}–$${job.pay_range_max}/hr` : null;
const isHiring = job.status === 'active';
const skills = matchedSkills(profile, job);
const certs = matchedCerts(profile, job);
const exp = profile?.experience_years || 0;
const reqExp = job.min_experience_years || 0;
const meetsExp = exp >= reqExp;
return (
<div className="max-w-3xl mx-auto space-y-5 pb-10">
<button
onClick={() => navigate('/employee')}
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#6B7280] hover:text-[#111827]"
>
<ArrowLeft className="w-4 h-4" /> Back to Opportunities
</button>
{/* Header — title-first identity, no company name. */}
<div className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-[#EEF3FE] text-[#0838E0] flex items-center justify-center shrink-0">
<Briefcase className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-[22px] font-bold text-[#0F172A] leading-tight">{job.title}</h1>
{isHiring && (
<span className="inline-flex items-center text-[11px] font-semibold text-white bg-[#0838E0] px-2.5 py-1 rounded-full">Hiring</span>
)}
</div>
{job.role_category && <p className="mt-1 text-[14px] text-[#6B7280] capitalize">{job.role_category}</p>}
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2">
<span className="inline-flex items-center gap-1.5 text-[14px] font-bold text-[#0838E0]">
<Sparkles className="w-4 h-4" /> {match}% match
</span>
<span className="inline-flex items-center gap-1.5 text-[13px] text-[#6B7280]">
<MapPin className="w-4 h-4 text-[#9CA3AF]" /> {job.location || 'Remote'}
</span>
{pay && (
<span className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#111827]">
<Wallet className="w-4 h-4 text-[#9CA3AF]" /> {pay}
</span>
)}
</div>
</div>
</div>
</div>
{/* About this opportunity */}
<Section title="About this opportunity">
{job.description ? (
<p className="text-[13px] text-[#6B7280] whitespace-pre-wrap leading-relaxed">{job.description}</p>
) : (
<p className="text-[13px] text-[#9CA3AF]">No description provided for this opportunity yet.</p>
)}
{job.responsibilities?.length > 0 && (
<div className="mt-4">
<p className="text-[13px] font-semibold text-[#0F172A] mb-1.5">What you'll do</p>
<ul className="list-disc pl-5 space-y-1 text-[13px] text-[#6B7280]">
{job.responsibilities.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
{job.qualifications?.length > 0 && (
<div className="mt-4">
<p className="text-[13px] font-semibold text-[#0F172A] mb-1.5">What they're looking for</p>
<ul className="list-disc pl-5 space-y-1 text-[13px] text-[#6B7280]">
{job.qualifications.map((q, i) => <li key={i}>{q}</li>)}
</ul>
</div>
)}
</Section>
{/* Why you're a match */}
<Section title="Why you're a match">
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-2 rounded-full bg-[#F3F4F6] overflow-hidden">
<div className="h-full rounded-full bg-[#0838E0]" style={{ width: `${match}%` }} />
</div>
<span className="text-[14px] font-bold text-[#0838E0]">{match}%</span>
</div>
<ul className="space-y-2.5">
<li className="flex items-start gap-2 text-[13px] text-[#374151]">
<Clock className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
<span>
{exp} yr{exp === 1 ? '' : 's'} of experience
{reqExp > 0 && <> · role asks for {reqExp}+ {meetsExp ? '— you qualify' : ''}</>}
</span>
</li>
{skills.length > 0 && (
<li className="flex items-start gap-2 text-[13px] text-[#374151]">
<CheckCircle2 className="w-4 h-4 text-[#16A34A] mt-0.5 shrink-0" />
<span>Your skills match: {skills.join(', ')}</span>
</li>
)}
{certs.length > 0 && (
<li className="flex items-start gap-2 text-[13px] text-[#374151]">
<Award className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
<span>Certifications you hold: {certs.join(', ')}</span>
</li>
)}
{skills.length === 0 && certs.length === 0 && (
<li className="flex items-start gap-2 text-[13px] text-[#6B7280]">
<Sparkles className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
<span>You showed up here because your profile aligns with this role. Add skills to strengthen the match.</span>
</li>
)}
</ul>
</Section>
{/* Apply */}
<div className="flex justify-end">
<button
onClick={() => navigate(`/apply?job=${job.id}`)}
className="inline-flex items-center justify-center gap-2 h-11 px-8 rounded-full bg-[#0838E0] text-white text-[14px] font-semibold hover:bg-[#062BAF] transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
>
Apply <Sparkles className="w-4 h-4" />
</button>
</div>
</div>
);
}

View File

@@ -126,7 +126,7 @@ function AgentCard({ agent, shipped, overridden, onOpen, onAction }) {
<DropdownMenuItem onClick={() => onAction('remove', agent)} className="cursor-pointer text-destructive focus:text-destructive">
{shipped
? <><RotateCcw className="mr-2 h-3.5 w-3.5" /> Revert to shipped</>
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Delete</>}
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Remove</>}
</DropdownMenuItem>
</>
)}
@@ -310,7 +310,7 @@ function AgentTableRow({ agent, shipped, overridden, onOpen, onAction }) {
<DropdownMenuItem onClick={() => onAction('remove', agent)} className="cursor-pointer text-destructive focus:text-destructive">
{shipped
? <><RotateCcw className="mr-2 h-3.5 w-3.5" /> Revert to shipped</>
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Delete</>}
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Remove</>}
</DropdownMenuItem>
</>
)}
@@ -330,7 +330,12 @@ export default function AdminWorkspaceAgents() {
} = useAgents();
const [query, setQuery] = useState('');
const [status, setStatus] = useState('all');
/* The default view is the agents in service. Removing an agent archives it,
and an archived agent that stayed in the default list would make Remove
look like it had not worked — while hiding it anywhere but a tab the
reader can open would make it look destroyed. Archived is one click away
and is where restoring happens. */
const [status, setStatus] = useState('active');
const [view, setView] = useState(() => {
try {
return localStorage.getItem('krow_agents_view') || 'list';
@@ -351,16 +356,35 @@ export default function AdminWorkspaceAgents() {
const visible = useMemo(() => {
const found = searchAgents(agents, query);
return status === 'all' ? found : found.filter((a) => a.status === status);
if (status === 'active') return found.filter((a) => a.status !== 'archived');
return found.filter((a) => a.status === status);
}, [agents, query, status]);
const activeCount = agents.filter((a) => a.status !== 'archived').length;
const publishedCount = agents.filter((a) => a.status === 'published').length;
const draftCount = agents.filter((a) => a.status === 'draft').length;
const archivedCount = agents.filter((a) => a.status === 'archived').length;
/** Runs a lifecycle action and reports honestly when it is refused. */
/**
* Runs a lifecycle action and reports honestly when it is refused.
*
* `remove` resolves to two different operations, and the difference is the
* whole of what makes removal safe:
*
* authored agent → archive. The definition is kept, under the same id,
* with its instructions, tools and skills untouched. It
* leaves the default list and can be restored.
* shipped agent → remove, which deletes THIS ACCOUNT'S override row and
* nothing else. The product's own definition takes over
* again — that is the "Revert to shipped" wording the
* menu already uses, and it destroys no shipped agent.
*
* Resolved here rather than in the menu so both the card and the table row
* cannot drift apart on what Remove means.
*/
const run = async (action, agent) => {
const fn = { publish, republish: publish, archive, restore, duplicate, remove }[action];
const resolved = action === 'remove' && !isShipped(agent.id) ? 'archive' : action;
const fn = { publish, republish: publish, archive, restore, duplicate, remove }[resolved];
const result = await fn(agent.id);
if (result?.conflict) toast.error(result.conflict.message);
else if (result?.error) toast.error(result.error);
@@ -368,7 +392,9 @@ export default function AdminWorkspaceAgents() {
};
const onAction = (action, agent) => {
/* Destructive and irreversible-looking actions confirm; the rest run. */
/* Anything that takes an agent out of service confirms first, even though
none of it is destructive — a reader should choose to stop an agent
answering, not discover it. The rest run. */
if (action === 'remove' || action === 'archive') {
setConfirming({ action, agent });
return;
@@ -410,15 +436,15 @@ export default function AdminWorkspaceAgents() {
<div className="inline-flex items-center rounded-xl border border-border/80 bg-surface-subtle/80 p-1 text-caption">
<button
type="button"
onClick={() => setStatus('all')}
onClick={() => setStatus('active')}
className={cn(
'rounded-lg px-3 py-1 font-medium transition-all',
status === 'all'
status === 'active'
? 'bg-surface text-ink-1 shadow-2xs font-semibold'
: 'text-ink-3 hover:text-ink-1'
)}
>
All <span className="text-ink-4 font-normal">({agents.length})</span>
Active <span className="text-ink-4 font-normal">({activeCount})</span>
</button>
<button
type="button"
@@ -446,20 +472,26 @@ export default function AdminWorkspaceAgents() {
Drafts <span className="text-ink-4 font-normal">({draftCount})</span>
</button>
)}
{archivedCount > 0 && (
<button
type="button"
onClick={() => setStatus('archived')}
className={cn(
'rounded-lg px-3 py-1 font-medium transition-all',
status === 'archived'
? 'bg-surface text-amber-700 shadow-2xs font-semibold'
: 'text-ink-3 hover:text-ink-1'
)}
>
Archived <span className="text-ink-4 font-normal">({archivedCount})</span>
</button>
)}
{/* Always rendered, empty or not. Archived is where removing an
agent puts it and where restoring it happens, and the remove
confirmation says so in as many words — a tab that appears
only once something is already in it tells a reader where
their agent went strictly after they needed to know. The
count answers "is anything in here?" without hiding the
answer. Drafts stays conditional: an empty Drafts tab
teaches nothing, because nothing is ever sent there. */}
<button
type="button"
onClick={() => setStatus('archived')}
className={cn(
'rounded-lg px-3 py-1 font-medium transition-all',
status === 'archived'
? 'bg-surface text-amber-700 shadow-2xs font-semibold'
: 'text-ink-3 hover:text-ink-1'
)}
>
Archived <span className="text-ink-4 font-normal">({archivedCount})</span>
</button>
</div>
</div>
@@ -546,16 +578,23 @@ export default function AdminWorkspaceAgents() {
title={
confirming?.action === 'archive'
? `Archive ${confirming?.agent.name}?`
: isShipped(confirming?.agent?.id) ? 'Revert to the shipped definition?' : `Delete ${confirming?.agent.name}?`
: isShipped(confirming?.agent?.id) ? 'Revert to the shipped definition?' : 'Remove agent?'
}
description={
confirming?.action === 'archive'
? 'It stops answering and disappears from the switcher. Its definition is kept, and it can be restored as a draft.'
: isShipped(confirming?.agent?.id)
? 'Your changes to this agent are discarded and the shipped definition takes over again.'
: 'This definition is removed. It cannot be recovered.'
: 'This agent will be removed from the active Agents list. Its configuration and '
+ 'skills will be preserved and it can be restored later from Archived.'
}
confirmLabel={
confirming?.action === 'archive'
? 'Archive'
/* Reverting a shipped agent is not a removal and must not read as
one: the shipped definition takes over, nothing is destroyed. */
: isShipped(confirming?.agent?.id) ? 'Confirm' : 'Remove Agent'
}
confirmLabel={confirming?.action === 'archive' ? 'Archive' : 'Confirm'}
busy={saving}
onConfirm={async () => {
const pending = confirming;

View File

@@ -366,6 +366,7 @@ function ActionQueue({ items }) {
/* ── Page ───────────────────────────────────────────────────────────────── */
import { registerNodeType } from '@/lib/ui/registry';
import { registerSeries } from '@/lib/ui/series';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
@@ -830,6 +831,57 @@ registerNodeType({
wrap: true,
});
/**
* The figures behind this page's hiring chart, named so something else can draw
* them.
*
* The page already computed this array — it is the same `activitySeries` the
* composed chart above reads, taken from the same context, unaggregated and
* unreordered. Declaring it here is what lets a person say "show this as a bar
* chart" and get *these* numbers rather than a new empty panel, and it is the
* only thing that had to be added to make a built-in chart changeable at all.
*
* `periodic` is the honest kind: these are days in order. It is also what
* refuses the pie.
*/
registerSeries({
id: 'control-center.hiring-activity',
label: 'Hiring activity',
kind: 'periodic',
measures: [
{ key: 'applications', label: 'Applications' },
{ key: 'screened', label: 'AI Screened' },
{ key: 'interviews', label: 'Interviews' },
{ key: 'hires', label: 'Hires' },
],
read: (context) => context?.activitySeries || [],
emptyNote: 'No applications yet.',
/**
* The range control, declared on the reading rather than on the component.
*
* It is the reason "show this as a bar chart" used to be a downgrade: the
* 7D/30D/90D toggle lived inside this page's own chart section, so replacing
* the section removed a control the person had been using — and the figures
* it governs are the very figures the replacement draws.
*
* Declaring it here moves it to where it actually belongs. The control does
* not change how the series is drawn; it changes which rows the series *is*,
* which is a property of the reading. Every visualization draws it because
* every visualization asks the binding for it, and none of them knows what a
* range is. `options` is the same `RANGES` table the built-in section reads,
* so the two cannot drift apart.
*/
control: {
id: 'range',
label: 'Hiring activity range',
options: Object.entries(RANGES).map(([value, r]) => ({ value, label: r.label })),
/* The page's own state, both ways. Nothing is stored here, and a page that
stops publishing `range` stops publishing the control with it. */
read: (context) => context?.range ?? null,
write: (context, value) => context?.setRange?.(value),
},
});
registerNodeType({
type: 'cc-activity',
/* This page's own section: it reads what this page publishes, so it belongs
@@ -837,7 +889,17 @@ registerNodeType({
page: 'control-center',
label: 'Hiring activity',
component: CCActivity,
capabilities: SECTION,
/**
* The one section on this page that can become something else.
*
* It draws a series, it says which series, and it says what that series
* means — which is the whole of what the engine needs to offer a bar, a line
* or an area in its place and to refuse a pie. Nothing about charts is
* written here: the candidates are computed by the registry from these three
* facts.
*/
capabilities: [...SECTION, 'replace'],
seriesKinds: ['periodic'],
wrap: true,
});
@@ -899,7 +961,7 @@ registerNodeType({
registerPageComposition('control-center', [
{ id: 'cc-extensions-top', type: 'skill-surface', props: { page: 'control-center', placement: 'after-header' } },
{ id: 'cc-snapshot', type: 'cc-snapshot' },
{ id: 'cc-activity', type: 'cc-activity' },
{ id: 'cc-activity', type: 'cc-activity', data: { series: 'control-center.hiring-activity' } },
{ id: 'cc-pipeline', type: 'cc-pipeline' },
{ id: 'cc-positions', type: 'cc-positions' },
{ id: 'cc-actions', type: 'cc-actions' },