Files
doormilxpress_astryx/src/components/skills/SkillSurface.jsx
2026-08-17 17:33:56 +05:30

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>
);
}