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. * * * * 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.title || skill.name}

{section.description && (

{section.description}

)}
{/* Attribution, so an admin can tell an extension from a built-in panel and knows which skill to switch off. */}
); } /** @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 (
{sections.map(({ skill, section }) => ( ))}
); }