196 lines
7.8 KiB
JavaScript
196 lines
7.8 KiB
JavaScript
import React, { useMemo } from 'react';
|
|
import { Sparkles } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
useApplications, useAssignments, useCourses, useCurrentUser, useInterviews, useJobPostings,
|
|
usePreferences, useStaff, useUserActivity, useWorkerProfiles,
|
|
} from '@/lib/krowHooks';
|
|
import { allSkills } from '@/lib/skills/registry';
|
|
import { sectionsForPage } from '@/lib/skills/uiConfig';
|
|
import { resolveSkillData } from '@/lib/skills/dataResolver';
|
|
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
|
import { profileForEmail } from '@/lib/skillGraph';
|
|
import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
|
|
/* The page's current selection, from the one channel that carries it. Imported
|
|
from the module rather than the package index so a page section never pulls
|
|
the assistant panel in behind it. */
|
|
import { usePageAction, usePageContext } from '@/components/ai-assistant/PageContext';
|
|
|
|
/**
|
|
* The extension point: one controlled slot a page offers to skills.
|
|
*
|
|
* A page says where a skill *may* appear; a skill definition says where it
|
|
* *does*. Neither knows the other exists — the page names a surface and a
|
|
* placement, the definition names the same two things, and this component is
|
|
* what joins them. That is why a new skill needs no change to any page, and why
|
|
* a page can be redesigned without touching a skill.
|
|
*
|
|
* <SkillSurface page="positions" placement="after-position-summary"
|
|
* context={{ position }} />
|
|
*
|
|
* What it will not do is as important as what it does. It renders only sections
|
|
* that survived validation, only from skills that are active, only through the
|
|
* component table, and only with data the resolver produced from real records.
|
|
* Nothing from the Markdown reaches the DOM as markup.
|
|
*/
|
|
|
|
/** The skills contributing sections to this page right now. */
|
|
function useSkillSections(page, placement) {
|
|
const preferences = usePreferences();
|
|
const customKey = JSON.stringify(preferences.customSkills || []);
|
|
const disabledKey = JSON.stringify(preferences.disabledSkills || []);
|
|
|
|
return useMemo(() => {
|
|
const custom = JSON.parse(customKey);
|
|
const disabled = JSON.parse(disabledKey);
|
|
|
|
return allSkills(custom)
|
|
/* Inactive means registered but not offered — the same rule the assistant
|
|
follows, so switching a skill off removes its UI too. */
|
|
.filter((skill) => skill.status === 'active' && !disabled.includes(skill.id))
|
|
.flatMap((skill) => sectionsForPage(skill, page)
|
|
.filter((section) => !placement || section.placement === placement)
|
|
.map((section) => ({ skill, section })));
|
|
}, [page, placement, customKey, disabledKey]);
|
|
}
|
|
|
|
/**
|
|
* The records a section may be read from.
|
|
*
|
|
* Loaded once per surface rather than per section, and passed to the resolver —
|
|
* which is the only thing that touches them. A section never queries anything.
|
|
*
|
|
* Exported because the panel needs the identical reading: an answer drawn in the
|
|
* chat and a card drawn on the page must resolve from one set of collections, or
|
|
* the two surfaces of the same definition can disagree. Every hook inside is a
|
|
* cache read the app has already paid for.
|
|
*/
|
|
export function useSkillDataContext(context) {
|
|
/* What the page published about itself, for a surface that was not handed a
|
|
record directly. An explicit `context` prop still wins — a card knows which
|
|
position it is, and that is more specific than what the page says. */
|
|
const published = usePageContext();
|
|
const { data: applications = [] } = useApplications();
|
|
const { data: positions = [] } = useJobPostings();
|
|
const { data: interviews = [] } = useInterviews();
|
|
const { data: courses = [] } = useCourses();
|
|
const { data: workerProfiles = [] } = useWorkerProfiles();
|
|
/* The hires and the audit trail, for the sources that read outcomes and
|
|
events. Same caches the pages themselves render from, so a section and the
|
|
panel beside it can never disagree. */
|
|
const { data: staff = [] } = useStaff();
|
|
const { data: activity = [] } = useUserActivity();
|
|
/* Who is already committed, for the sources that score people against a role:
|
|
the matching engine reads availability from assignments, and without them
|
|
everybody would look free. */
|
|
const { data: assignments = [] } = useAssignments();
|
|
const { data: user } = useCurrentUser();
|
|
const { statesFor } = useWorkforcePaths();
|
|
|
|
const trainingPaths = useMemo(
|
|
() => statesFor(courses, profileForEmail(workerProfiles, user?.email)),
|
|
[statesFor, courses, workerProfiles, user?.email]
|
|
);
|
|
|
|
return useMemo(() => ({
|
|
...published,
|
|
...context,
|
|
applications,
|
|
positions,
|
|
interviews,
|
|
courses,
|
|
workerProfiles,
|
|
/* The workforce engine's own name for the same records. Both keys are
|
|
published so a resolver can be written against either without a rename
|
|
rippling through every existing source. */
|
|
profiles: workerProfiles,
|
|
assignments,
|
|
staff,
|
|
activity,
|
|
trainingPaths,
|
|
/* `published` belongs here: it is what changes when the reader edits the
|
|
form this panel is sitting beside, and leaving it out froze every
|
|
section on the first value the page ever published. */
|
|
}), [published, context, applications, positions, interviews, courses, workerProfiles, assignments,
|
|
staff, activity, trainingPaths]);
|
|
}
|
|
|
|
/** One declared section, resolved and drawn. */
|
|
function SkillSection({ skill, section, context }) {
|
|
const Component = SECTION_COMPONENTS[section.type];
|
|
const data = useMemo(
|
|
() => resolveSkillData(section, context),
|
|
[section, context]
|
|
);
|
|
|
|
/* How this section writes back, if the page is offering that write at all.
|
|
Null on every page that is not, which is what keeps a section declared
|
|
`editable` honest on a surface that only reads. */
|
|
const apply = usePageAction(section.editable ? section.source : null);
|
|
|
|
/* Validation refuses an unknown type long before this, so a missing component
|
|
means the registry and the vocabulary have drifted apart. Render nothing
|
|
rather than a broken panel. */
|
|
if (!Component) return null;
|
|
|
|
return (
|
|
<section
|
|
aria-label={section.title || skill.name}
|
|
className="rounded-2xl border border-border bg-surface p-5 shadow-xs"
|
|
>
|
|
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<h3 className="font-heading text-body font-semibold text-ink-1">
|
|
{section.title || skill.name}
|
|
</h3>
|
|
{section.description && (
|
|
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{section.description}</p>
|
|
)}
|
|
</div>
|
|
{/* Attribution, so an admin can tell an extension from a built-in panel
|
|
and knows which skill to switch off. */}
|
|
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-krow-blue-tint px-2 py-0.5 text-[10px] font-semibold text-krow-blue">
|
|
<Sparkles className="h-3 w-3" aria-hidden="true" />
|
|
{skill.name}
|
|
</span>
|
|
</div>
|
|
|
|
<Component data={data} section={section} onApply={apply} />
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/** @param {any} props */
|
|
export function SkillSurface({
|
|
page = '',
|
|
placement = '',
|
|
context = null,
|
|
className = '',
|
|
_pageId = '',
|
|
_profile = null,
|
|
_profiles = [],
|
|
_courses = [],
|
|
_positions = [],
|
|
_customSources = [],
|
|
_email = '',
|
|
_onOpenTraining = null,
|
|
}) {
|
|
const sections = useSkillSections(page, placement);
|
|
const resolved = useSkillDataContext(context);
|
|
|
|
if (!sections.length) return null;
|
|
|
|
return (
|
|
<div className={cn('space-y-4', className)}>
|
|
{sections.map(({ skill, section }) => (
|
|
<SkillSection
|
|
key={`${skill.id}:${section.id}`}
|
|
skill={skill}
|
|
section={section}
|
|
context={resolved}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|