update owliver skill

This commit is contained in:
2026-08-14 17:33:16 +05:30
parent cccada9bd2
commit 4fdfb90326
58 changed files with 8015 additions and 1339 deletions

View File

@@ -0,0 +1,401 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight, ChevronRight, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ProgressBar } from '@/components/ds';
/**
* The components a skill definition may ask for, and nothing else.
*
* `type: flow` in a Markdown file resolves to `FlowSection` through the table
* at the bottom of this file. That table is the only bridge between a
* definition and a React component: a definition names a key, the renderer
* looks it up, and a key that is not here was already refused by validation.
* There is no dynamic import, no component name evaluated from a string, and
* no markup carried from the file.
*
* Every section is built from the existing KROW vocabulary — the same borders,
* blue, pills and type as the rest of Admin — so an extension looks like part
* of the product rather than like an embedded widget.
*/
/** What a section shows when its data has nothing in it. Never a fake figure. */
function EmptyNote({ children }) {
return (
<p className="rounded-lg border border-dashed border-border px-3 py-3 text-body-sm text-ink-3">
{children}
</p>
);
}
/**
* A sequence — periods, or the stages of a funnel.
*
* Built from flex and borders rather than a graph library: the shape is a row
* of steps with connectors, and a dependency for that would be heavier than the
* feature. Wraps to a column on narrow screens, where a horizontal flow would
* either overflow or become unreadable.
*/
function FlowSection({ data }) {
if (data.empty || !data.steps?.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
const peak = Math.max(...data.steps.map((s) => s.value || 0), 1);
return (
<ol className="flex flex-col gap-2 sm:flex-row sm:items-stretch">
{data.steps.map((step, i) => (
<React.Fragment key={step.id}>
{i > 0 && (
<li aria-hidden="true" className="flex shrink-0 items-center justify-center text-ink-4">
{/* The connector turns with the layout: down when stacked. */}
<ChevronRight className="hidden h-4 w-4 sm:block" />
<span className="text-[11px] sm:hidden">↓</span>
</li>
)}
<li className="min-w-0 flex-1 rounded-xl border border-border bg-surface px-3 py-2.5">
<p className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">{step.label}</p>
<p className="mt-0.5 font-heading text-title font-bold tabular-nums text-ink-1">{step.value}</p>
{step.detail && <p className="text-caption text-ink-3">{step.detail}</p>}
<div className="mt-1.5 h-1 overflow-hidden rounded-full bg-surface-sunken">
<div
className="h-full rounded-full bg-krow-blue transition-all"
style={{ width: `${Math.round(((step.value || 0) / peak) * 100)}%` }}
/>
</div>
</li>
</React.Fragment>
))}
</ol>
);
}
/** Counted figures, side by side. */
function StatsSection({ data }) {
const items = data.steps || data.items || [];
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
return (
<dl className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{items.map((item) => (
<div key={item.id} className="rounded-xl border border-border bg-surface px-3 py-2.5">
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">
{item.label || item.title}
</dt>
<dd className="mt-0.5 font-heading text-title font-bold tabular-nums text-ink-1">
{item.value ?? '—'}
</dd>
</div>
))}
</dl>
);
}
/** Records as rows, each optionally linking to the record it names. */
function ListSection({ data }) {
const items = data.items || [];
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
return (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border">
{items.map((item) => {
const body = (
<>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">{item.title}</span>
{item.detail && <span className="block truncate text-caption text-ink-3">{item.detail}</span>}
</span>
{item.value != null && (
<span className="shrink-0 font-heading text-body font-bold tabular-nums text-ink-1">
{item.value}
</span>
)}
{item.to && <ArrowRight className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />}
</>
);
return (
<li key={item.id}>
{item.to ? (
<Link to={item.to} className="flex items-center gap-3 px-3 py-2.5 transition-colors hover:bg-surface-subtle">
{body}
</Link>
) : (
<div className="flex items-center gap-3 px-3 py-2.5">{body}</div>
)}
</li>
);
})}
</ul>
);
}
/** Dated events, most recent first. */
function TimelineSection({ data }) {
const items = [...(data.items || [])]
.filter((item) => item.at)
.sort((a, b) => new Date(b.at) - new Date(a.at));
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
return (
<ol className="space-y-2">
{items.map((item) => (
<li key={item.id} className="flex items-baseline justify-between gap-3 text-body-sm">
<span className="min-w-0">
<span className="font-medium text-ink-1">{item.title}</span>
{item.detail && <span className="text-ink-3"> — {item.detail}</span>}
</span>
<span className="shrink-0 text-caption text-ink-4">
{new Date(item.at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
</li>
))}
</ol>
);
}
/** Rows and columns, from whatever the source declared. */
function TableSection({ data }) {
const items = data.items || data.steps || [];
const columns = data.columns || [
{ key: 'label', label: 'Item' },
{ key: 'value', label: 'Value', align: 'right' },
];
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
return (
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full border-collapse text-body-sm">
<thead>
<tr className="border-b border-border bg-surface-subtle">
{columns.map((c) => (
<th
key={c.key}
scope="col"
className={cn(
'whitespace-nowrap px-3 py-2 text-[10px] font-bold uppercase tracking-wide text-ink-4',
c.align === 'right' ? 'text-right' : 'text-left'
)}
>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b border-border last:border-0">
{columns.map((c) => (
<td
key={c.key}
className={cn(
'px-3 py-2 text-ink-2',
c.align === 'right' ? 'text-right tabular-nums' : 'text-left'
)}
>
{item[c.key] ?? '—'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
/** Bars against a total. */
function ProgressSection({ data }) {
const items = data.items || data.steps || [];
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
const peak = Math.max(...items.map((i) => i.max || i.value || 0), 1);
return (
<ul className="space-y-2.5">
{items.map((item) => {
const max = item.max || peak;
const percent = max ? Math.round(((item.value || 0) / max) * 100) : 0;
return (
<li key={item.id}>
<div className="mb-1 flex items-baseline justify-between gap-3">
<span className="min-w-0 truncate text-body-sm capitalize text-ink-2">
{item.title || item.label}
</span>
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-1">
{item.value ?? 0}{item.max ? `/${item.max}` : ''}
</span>
</div>
<ProgressBar value={percent} tone="brand" size="sm" />
</li>
);
})}
</ul>
);
}
/** One finding, stated plainly. */
function InsightSection({ data }) {
const items = data.items || data.steps || [];
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
const lead = items.reduce((best, item) => ((item.value || 0) > (best?.value || 0) ? item : best), items[0]);
return (
<div className="flex items-start gap-2.5 rounded-xl border border-krow-blue/30 bg-krow-blue-tint/50 px-3.5 py-3">
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-krow-blue" aria-hidden="true" />
<p className="text-body-sm leading-relaxed text-ink-2">
<span className="font-semibold text-ink-1">{lead.label || lead.title}</span>
{lead.value != null && <> — <span className="tabular-nums">{lead.value}</span></>}
{lead.detail && <span className="text-ink-3"> · {lead.detail}</span>}
</p>
</div>
);
}
/** A plain panel: whatever figures the source gives, as a sentence and a row. */
function CardSection({ data }) {
const items = data.steps || data.items || [];
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
return (
<div className="rounded-xl border border-border bg-surface-subtle px-3.5 py-3">
<dl className="flex flex-wrap gap-x-6 gap-y-2">
{items.slice(0, 6).map((item) => (
<div key={item.id} className="min-w-0">
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">
{item.label || item.title}
</dt>
<dd className="text-body-sm font-medium tabular-nums text-ink-1">{item.value ?? item.detail ?? '—'}</dd>
</div>
))}
</dl>
</div>
);
}
/**
* Weighted criteria that share one budget.
*
* Distinct from `ProgressSection`, which draws bars against independent
* maximums. Here the rows are shares of a single total, so the total is stated
* once for the set and flagged when it does not come to 100 — which is the only
* thing that makes a set of weights wrong rather than merely different.
*
* The same component draws the page card and Owliver's compact answer; `compact`
* only tightens spacing. Two renderings of one declaration, not two components.
*
* **Editing.** Controls appear only when the definition asked for them *and* the
* surface passed an `onApply` — which a page only supplies for a source it owns.
* Adjustments are held locally until Apply, so a half-dragged slider never
* writes a total of 87% into the form; Apply hands the whole set over at once
* and the page's own state is the only place it lands.
*/
function WeightsSection({ data, section, onApply, compact = false }) {
const rows = React.useMemo(() => data?.steps || data?.items || [], [data]);
const editable = Boolean(section?.editable && onApply);
/* Local only while editing, and re-seeded whenever the page's own values
change — so a change made on the form shows up here even mid-edit, and
there is never a second copy of the weights outliving the interaction. */
const [draft, setDraft] = React.useState(null);
const signature = React.useMemo(
() => rows.map((r) => `${r.id}:${r.value}`).join('|'),
[rows]
);
React.useEffect(() => { setDraft(null); }, [signature]);
if (data?.empty || !rows.length) return <EmptyNote>{data?.emptyNote}</EmptyNote>;
const current = draft || Object.fromEntries(rows.map((r) => [r.id, Number(r.value) || 0]));
const total = Object.values(current).reduce((a, b) => a + b, 0);
const balanced = total === 100;
const dirty = draft !== null && rows.some((r) => (Number(r.value) || 0) !== current[r.id]);
return (
<div className={compact ? 'space-y-2' : 'space-y-2.5'}>
<ul className={compact ? 'space-y-1.5' : 'space-y-2.5'}>
{rows.map((row) => {
const value = current[row.id] ?? 0;
return (
<li key={row.id}>
<div className="flex items-baseline justify-between gap-3">
<span className="min-w-0 truncate text-body-sm capitalize text-ink-2">
{row.title || row.label}
</span>
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-1">
{value}%
</span>
</div>
{editable ? (
<input
type="range"
min="0"
max="100"
value={value}
aria-label={`${row.title || row.label} weight`}
onChange={(e) => setDraft({ ...current, [row.id]: Number(e.target.value) })}
className="mt-1 w-full accent-krow-blue"
/>
) : (
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-surface-sunken">
<div
className="h-full rounded-full bg-krow-blue transition-all"
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
)}
</li>
);
})}
</ul>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-2">
<span className="text-caption text-ink-3">
Total{' '}
<span
className={cn(
'font-semibold tabular-nums',
balanced ? 'text-success' : 'text-warning'
)}
>
{total}%
</span>
{!balanced && <span className="text-ink-4"> — must total 100%</span>}
</span>
{editable && (
<button
type="button"
disabled={!dirty || !balanced}
onClick={() => { onApply(current); setDraft(null); }}
className="inline-flex items-center rounded-lg bg-krow-blue px-2.5 py-1 text-caption font-semibold text-white
transition-colors hover:bg-krow-blue-strong disabled:cursor-not-allowed disabled:opacity-40
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
Apply to position
</button>
)}
</div>
</div>
);
}
/**
* The registry: declared type → component.
*
* The only mapping from a definition to rendering. Adding a type means adding a
* component here and a name in `surfaces.js` — never a branch on a skill id.
*/
export const SECTION_COMPONENTS = {
card: CardSection,
stats: StatsSection,
list: ListSection,
timeline: TimelineSection,
flow: FlowSection,
table: TableSection,
progress: ProgressSection,
insight: InsightSection,
weights: WeightsSection,
};

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