diff --git a/apps/ui/src/components/PageSectionNav.tsx b/apps/ui/src/components/PageSectionNav.tsx index cd1b168..64e6234 100644 --- a/apps/ui/src/components/PageSectionNav.tsx +++ b/apps/ui/src/components/PageSectionNav.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; export type SectionNavItem = { id: string; @@ -6,9 +6,9 @@ export type SectionNavItem = { }; /** - * Sticky “On this page” links with active-section highlighting. - * Scroll tracking avoids layout thrashing: use IntersectionObserver entry - * geometry only, coalesce updates to animation frames, and skip no-op sets. + * Sticky “On this page” links. Active state is click/hash driven only — + * no IntersectionObserver scroll-spy (that was a major source of scroll jank + * on long pages, especially in WebView). */ export function PageSectionNav({ items, @@ -19,89 +19,35 @@ export function PageSectionNav({ }) { const [activeId, setActiveId] = useState(items[0]?.id ?? ""); const sectionKey = items.map((item) => item.id).join("|"); - const clickLockUntil = useRef(0); - const itemsRef = useRef(items); - itemsRef.current = items; useEffect(() => { - const first = sectionKey.split("|")[0]; - if (first) setActiveId(first); + const ids = sectionKey.split("|").filter(Boolean); + const fromHash = window.location.hash.replace(/^#/, ""); + if (fromHash && ids.includes(fromHash)) { + setActiveId(fromHash); + return; + } + if (ids[0]) setActiveId(ids[0]); }, [sectionKey]); useEffect(() => { - const ids = sectionKey.split("|").filter(Boolean); - const nodes = ids - .map((id) => document.getElementById(id)) - .filter((el): el is HTMLElement => Boolean(el)); - if (nodes.length === 0) return; - - const ratios = new Map(); - const tops = new Map(); - let raf = 0; - - const commit = () => { - raf = 0; - if (Date.now() < clickLockUntil.current) return; - - const ordered = itemsRef.current; - let bestId = ""; - let bestRatio = 0; - for (const item of ordered) { - const ratio = ratios.get(item.id) ?? 0; - if (ratio > bestRatio) { - bestRatio = ratio; - bestId = item.id; - } - } - if (bestId && bestRatio > 0.02) { - setActiveId((prev) => (prev === bestId ? prev : bestId)); - return; - } - - let aboveId = ""; - let aboveTop = -Infinity; - for (const item of ordered) { - const top = tops.get(item.id); - if (top == null) continue; - if (top <= 140 && top >= aboveTop) { - aboveTop = top; - aboveId = item.id; - } - } - if (aboveId) setActiveId((prev) => (prev === aboveId ? prev : aboveId)); - }; - - const observer = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - const id = entry.target.id; - ratios.set(id, entry.isIntersecting ? entry.intersectionRatio : 0); - // Prefer observer-provided rect — do not call getBoundingClientRect (forces layout). - tops.set(id, entry.boundingClientRect.top); - } - if (!raf) raf = requestAnimationFrame(commit); - }, - { - root: null, - rootMargin: "-10% 0px -55% 0px", - // Fewer thresholds → fewer callbacks while scrolling. - threshold: [0, 0.25, 0.6, 1], - }, - ); - - for (const node of nodes) observer.observe(node); - return () => { - if (raf) cancelAnimationFrame(raf); - observer.disconnect(); + const ids = new Set(sectionKey.split("|").filter(Boolean)); + const onHash = () => { + const id = window.location.hash.replace(/^#/, ""); + if (id && ids.has(id)) setActiveId(id); }; + window.addEventListener("hashchange", onHash); + return () => window.removeEventListener("hashchange", onHash); }, [sectionKey]); function goTo(id: string) { const el = document.getElementById(id); if (!el) return; - clickLockUntil.current = Date.now() + 700; setActiveId(id); const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + // Update hash without fighting smooth scroll (replaceState avoids hashchange jump). + const url = `${window.location.pathname}${window.location.search}#${id}`; + window.history.replaceState(null, "", url); el.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" }); } diff --git a/apps/ui/src/pages/RolesPage.tsx b/apps/ui/src/pages/RolesPage.tsx index 181f550..d5f11a1 100644 --- a/apps/ui/src/pages/RolesPage.tsx +++ b/apps/ui/src/pages/RolesPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type FormEvent } from "react"; import { Link } from "react-router-dom"; import type { PersonDTO, @@ -12,7 +12,9 @@ import { PageSectionsLayout } from "../components/PageSectionsLayout"; type ExpectationEdit = { id?: string; kind: "responsibility" | "expectation"; text: string }; -const LEVEL_PRESETS = ["IC3", "IC4", "IC5", "IC6", "M1", "M2", "M3"]; +const LEVEL_PRESETS = ["IC1", "IC2", "IC3", "IC4", "IC5", "IC6", "L1", "M1", "M2", "M3"]; + +type AssignDraft = { roleId: string; nextId: string }; function emptyEditor(): { id?: string; @@ -35,13 +37,23 @@ function emptyEditor(): { }; } +function draftsFromPeople(people: PersonDTO[]): Record { + const out: Record = {}; + for (const p of people) { + out[p.id] = { + roleId: p.roleDefinitionId ?? "", + nextId: p.targetNextRoleDefinitionId ?? "", + }; + } + return out; +} + export function RolesPage() { const [roles, setRoles] = useState([]); const [people, setPeople] = useState([]); const [framework, setFramework] = useState(null); - const [personId, setPersonId] = useState(""); + const [assignDrafts, setAssignDrafts] = useState>({}); const [roleId, setRoleId] = useState(""); - const [nextId, setNextId] = useState(""); const [saved, setSaved] = useState(null); const [msg, setMsg] = useState(null); const [editor, setEditor] = useState(emptyEditor()); @@ -51,11 +63,13 @@ export function RolesPage() { const [drafts, setDrafts] = useState([]); const [extractMeta, setExtractMeta] = useState(null); const [busy, setBusy] = useState(false); + const [rowBusy, setRowBusy] = useState(null); const [fwOwner, setFwOwner] = useState(""); const [fwFrom, setFwFrom] = useState(""); const [fwTo, setFwTo] = useState(""); const [migrateLabel, setMigrateLabel] = useState(""); const [keyMapText, setKeyMapText] = useState("{}"); + const [showAdvanced, setShowAdvanced] = useState(false); async function load() { const [r, p, fw] = await Promise.all([ @@ -63,9 +77,11 @@ export function RolesPage() { api("/api/people"), api>("/api/roles/framework"), ]); - setRoles(r.filter(Boolean) as RoleDefinitionDTO[]); + const roleList = r.filter(Boolean) as RoleDefinitionDTO[]; + setRoles(roleList); const directs = p.filter((x) => x.managerId); setPeople(directs); + setAssignDrafts(draftsFromPeople(directs)); if (fw && "versionId" in fw && fw.versionId) { const info = fw as RoleFrameworkInfo; setFramework(info); @@ -73,9 +89,7 @@ export function RolesPage() { setFwFrom(info.effectiveFrom ?? ""); setFwTo(info.effectiveTo ?? ""); } else setFramework(null); - const unassigned = directs.find((x) => !x.roleDefinitionId); - if (!personId) setPersonId(unassigned?.id ?? directs[0]?.id ?? ""); - if (!roleId && r[0]) setRoleId(r[0]!.id); + if (!roleId && roleList[0]) setRoleId(roleList[0].id); } useEffect(() => { @@ -84,12 +98,12 @@ export function RolesPage() { const selected = roles.find((r) => r.id === roleId); const unassigned = useMemo(() => people.filter((p) => !p.roleDefinitionId), [people]); - const selectedPerson = people.find((p) => p.id === personId); function startCreate() { setEditor(emptyEditor()); setEditing(true); setMsg(null); + document.getElementById("roles-catalog")?.scrollIntoView({ block: "start" }); } function startEdit(role: RoleDefinitionDTO) { @@ -108,9 +122,34 @@ export function RolesPage() { setEditing(true); setRoleId(role.id); setMsg(null); + document.getElementById("roles-catalog")?.scrollIntoView({ block: "start" }); + } + + async function saveAssignment(personId: string) { + const draft = assignDrafts[personId]; + if (!draft?.roleId) return; + setRowBusy(personId); + setSaved(null); + try { + await api("/api/roles/assign", { + method: "POST", + body: JSON.stringify({ + personId, + roleDefinitionId: draft.roleId, + targetNextRoleDefinitionId: draft.nextId || null, + }), + }); + const name = people.find((p) => p.id === personId)?.name ?? "Person"; + setSaved(`Saved role for ${name}`); + await load(); + } catch (err) { + setMsg(err instanceof Error ? err.message : "Assignment failed"); + } finally { + setRowBusy(null); + } } - async function saveRole(e: React.FormEvent) { + async function saveRole(e: FormEvent) { e.preventDefault(); setBusy(true); setMsg(null); @@ -183,6 +222,7 @@ export function RolesPage() { ? `Stored ${json.filename}. ${json.extractError}` : `Stored ${json.filename}. Review text below, then extract.`, ); + setShowAdvanced(true); await load(); } catch (err) { setMsg(err instanceof Error ? err.message : "Upload failed"); @@ -200,15 +240,15 @@ export function RolesPage() { const result = await api<{ roles: RoleDraftDTO[]; mode: string; - model: string | null; - warning: string | null; + model?: string | null; + warning?: string | null; }>("/api/roles/extract", { method: "POST", body: JSON.stringify({ text: extractText, useAi }), }); setDrafts(result.roles); setExtractMeta( - `${result.mode}${result.model ? ` · ${result.model}` : ""}${result.warning ? ` · ${result.warning}` : ""}`, + `${result.mode}${result.model ? ` · ${result.model}` : ""}${result.warning ? ` — ${result.warning}` : ""}`, ); setMsg(`Extracted ${result.roles.length} role(s) — review, then apply.`); } catch (err) { @@ -226,7 +266,7 @@ export function RolesPage() { method: "POST", body: JSON.stringify({ roles: drafts, replace }), }); - setMsg(`Imported ${result.created.length} role(s)`); + setMsg(replace ? `Replaced catalog (${result.created.length} roles)` : `Added ${result.created.length} roles`); setDrafts([]); if (result.created[0]) setRoleId(result.created[0].id); await load(); @@ -237,484 +277,577 @@ export function RolesPage() { } } + function nextOptionsFor(roleDefinitionId: string) { + const role = roles.find((r) => r.id === roleDefinitionId); + if (!role?.nextRoleKeys.length) return roles; + const keyed = roles.filter((r) => role.nextRoleKeys.includes(r.key)); + return keyed.length ? keyed : roles; + } + return (
- Add role - + unassigned[0] ? ( + + Assign {unassigned[0].name} + + ) : ( + + Review assignments + + ) } secondary={View team} /> {framework && ( -
-

- {framework.frameworkName} · {framework.versionLabel} · {framework.roleCount} roles - {framework.sourceFilename ? ` · source ${framework.sourceFilename}` : ""} -

- {framework.insufficient?.length ? ( - - ) : ( - - )} -
-
- - setFwOwner(e.target.value)} placeholder="People team / EM" /> -
-
- - setFwFrom(e.target.value)} /> -
-
- - setFwTo(e.target.value)} /> -
- -
-

Publish new year (migrate)

-

- Clones the published ladder into a new version, remaps assignment keys, and marks the old version superseded. -

-
- - setMigrateLabel(e.target.value)} placeholder="2027.1" /> -
-
- -