update owliver skill
This commit is contained in:
181
src/components/skills/SkillSurface.jsx
Normal file
181
src/components/skills/SkillSurface.jsx
Normal file
@@ -0,0 +1,181 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillSurface({ page, placement, context = null, className }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user