This commit is contained in:
@@ -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} />
|
||||
|
||||
208
src/pages/OpportunityDetail.jsx
Normal file
208
src/pages/OpportunityDetail.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user