diff --git a/apps/ui/src/components/IntegrationsPanel.tsx b/apps/ui/src/components/IntegrationsPanel.tsx index f7ab604..300a64c 100644 --- a/apps/ui/src/components/IntegrationsPanel.tsx +++ b/apps/ui/src/components/IntegrationsPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import type { IntegrationSettingsDTO, IntegrationSyncResult } from "@prm/shared"; import { api } from "../lib/api"; +import { Section } from "./PageChrome"; type IntegrationsState = { jira: IntegrationSettingsDTO; @@ -48,12 +49,48 @@ export function IntegrationsPanel() { const jiraExtra = state.jira.extra ?? {}; return ( -
-

Integrations

-

- Pull cycle-scoped Jira / Linear issues and merged Bitbucket / GitHub PRs into each person’s dossier as - achievements (citable on the writing desk). Tokens are encrypted with the workspace secret. - Sync is explicit egress — no leaderboards, LOC scores, or Slack mining. +

{ + setBusy(true); + try { + const res = await api<{ count: number; results: IntegrationSyncResult[] }>( + "/api/integrations/sync-all", + { method: "POST", body: "{}" }, + ); + const imported = res.results.reduce( + (n, r) => + n + + r.jira.imported + + r.bitbucket.imported + + r.github.imported + + (r.linear?.imported ?? 0), + 0, + ); + setMsg(`Synced ${res.count} linked people (${imported} new achievements)`); + await load(); + } catch (err) { + setMsg(err instanceof Error ? err.message : "Sync-all failed"); + } finally { + setBusy(false); + } + }} + > + Sync all linked people + + } + > +

+ Sync is explicit egress — no leaderboards, LOC scores, or Slack mining. Link each direct’s accounts + on their person page, then sync there or use Sync all above (active review cycle window).

{state.jira.lastSyncAt && ( @@ -250,7 +287,7 @@ export function IntegrationsPanel() {
{state.bitbucket.lastSyncAt && ( @@ -357,7 +394,7 @@ export function IntegrationsPanel() {
-
{state.github.lastSyncAt && ( @@ -489,7 +495,7 @@ export function IntegrationsPanel() {
{state.linear.lastSyncAt &&

Last sync: {state.linear.lastSyncAt}

} -

- Link each direct’s Jira / Bitbucket / GitHub / Linear account on their person page, then sync there or use Sync - all above. Sync uses the active review cycle window. -

{msg &&

{msg}

} - + ); } diff --git a/apps/ui/src/components/PageChrome.tsx b/apps/ui/src/components/PageChrome.tsx index 80cd54c..e2dc54d 100644 --- a/apps/ui/src/components/PageChrome.tsx +++ b/apps/ui/src/components/PageChrome.tsx @@ -13,7 +13,7 @@ export function PageHeader({ secondary?: ReactNode; }) { return ( -
+

{title}

{job}

@@ -24,7 +24,58 @@ export function PageHeader({ {primary}
)} -
+ + ); +} + +/** + * Scannable page section: clear heading, when/how copy, and an optional CTA. + * Keep `id` stable for sticky “On this page” anchors. + */ +export function Section({ + id, + title, + when, + how, + action, + children, + className = "", +}: { + id: string; + title: string; + /** When to open this section (one short sentence). */ + when: string; + /** How to use it (one short sentence). */ + how: string; + action?: ReactNode; + children?: ReactNode; + className?: string; +}) { + const titleId = `${id}-heading`; + return ( +
+
+
+

{title}

+
+
+
When
+
{when}
+
+
+
How
+
{how}
+
+
+
+ {action != null ?
{action}
: null} +
+ {children ?
{children}
: null} +
); } @@ -41,7 +92,7 @@ export function NextStep({ tone?: "brand" | "warn" | "ok"; }) { return ( -
+
{label}

{detail}

diff --git a/apps/ui/src/components/RetentionPanel.tsx b/apps/ui/src/components/RetentionPanel.tsx index 64cf3ad..360ada5 100644 --- a/apps/ui/src/components/RetentionPanel.tsx +++ b/apps/ui/src/components/RetentionPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import type { WorkspaceSettingsDTO } from "@prm/shared"; import { api } from "../lib/api"; +import { Section } from "./PageChrome"; export function RetentionPanel() { const [settings, setSettings] = useState(null); @@ -18,14 +19,43 @@ export function RetentionPanel() { if (!settings) return

Loading retention…

; return ( -
-
-

Retention & legal hold

-
+
{ + setBusy(true); + try { + const next = await api("/api/workspace/settings", { + method: "PUT", + body: JSON.stringify({ + retention: settings.retention, + legalHold: settings.legalHold, + }), + }); + setSettings({ ...settings, ...next }); + setMsg("Retention settings saved"); + await load(); + } catch (e) { + setMsg(e instanceof Error ? e.message : "Save failed"); + } finally { + setBusy(false); + } + }} + > + Save retention settings + + } + >

- Default policy keeps the current + previous cycle. Older closed cycles can be - purged when legal hold is off. Purge marks cycles as purged (audit trail kept) — it does not - shred the vault. + Default policy keeps the current + previous cycle. Purge marks cycles as purged + (audit trail kept) — it does not shred the vault.

@@ -55,32 +85,6 @@ export function RetentionPanel() {
-
+
); } diff --git a/apps/ui/src/pages/ConsistencyPage.tsx b/apps/ui/src/pages/ConsistencyPage.tsx index 3fb640b..f64e1e5 100644 --- a/apps/ui/src/pages/ConsistencyPage.tsx +++ b/apps/ui/src/pages/ConsistencyPage.tsx @@ -4,7 +4,7 @@ import type { CalibBundleDTO, ConsistencyDTO, CycleDTO } from "@prm/shared"; import { competencyLabel } from "@prm/shared"; import { api } from "../lib/api"; import { downloadBinaryExport, openHtmlExport } from "../lib/exports"; -import { NextStep, PageHeader } from "../components/PageChrome"; +import { NextStep, PageHeader, Section } from "../components/PageChrome"; import { PageSectionsLayout } from "../components/PageSectionsLayout"; export function ConsistencyPage() { @@ -106,12 +106,28 @@ export function ConsistencyPage() { { id: "cons-cards", label: "People" }, ]} > -
-

Calibration bundles

-

- Director round-trip: export cards, capture decisions in the form (or paste JSON), then import - back into draft manager reviews for the same cycle. -

+
{ + try { + await openHtmlExport(`/api/exports/calib-room/${data.cycleId}?format=html`); + setCalibMsg("Opened calibration room pack — Print → Save as PDF"); + } catch (e) { + setCalibMsg(e instanceof Error ? e.message : "Calib pack failed"); + } + }} + > + Print calib room pack + + } + >
-
@@ -205,7 +207,7 @@ export function ConsistencyPage() { } }} > - Encrypted read-only bundle + Download encrypted bundle
@@ -316,10 +318,10 @@ export function ConsistencyPage() { await load(data.cycleId); }} > - Import decisions + Import calibration decisions {calibMsg &&

{calibMsg}

} -
+ {thin.length > 0 ? ( )} -
-

Overall distribution

+
{["1", "2", "3", "4", "5", "unset"].map((bucket) => { const n = data.distribution[bucket] ?? 0; @@ -360,9 +366,22 @@ export function ConsistencyPage() { ); })}
-
+ -
+
+ Open first writing desk + + ) : undefined + } + > +
{data.cards.map((card) => (
@@ -396,11 +415,12 @@ export function ConsistencyPage() { ))} - Writing desk + Open writing desk
))} -
+
+
)}
diff --git a/apps/ui/src/pages/CyclesPage.tsx b/apps/ui/src/pages/CyclesPage.tsx index 36a7f47..7f683d9 100644 --- a/apps/ui/src/pages/CyclesPage.tsx +++ b/apps/ui/src/pages/CyclesPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import type { CycleDTO, PersonDTO } from "@prm/shared"; import { api } from "../lib/api"; -import { NextStep, PageHeader } from "../components/PageChrome"; +import { NextStep, PageHeader, Section } from "../components/PageChrome"; import { PageSectionsLayout } from "../components/PageSectionsLayout"; const PHASE_STATUSES = [ @@ -51,7 +51,7 @@ export function CyclesPage() { job="Export fillable JSON for self/peer reviews, send outside the app, then import completed files. ICs do not log in." primary={ cycles.length === 0 ? ( - + ) : ( ) @@ -66,7 +66,7 @@ export function CyclesPage() { tone="warn" label="Create your first cycle" detail="A cycle snapshots your directs as participants and unlocks self/peer bundle export." - action={} + action={} /> ) : ( -
-

Cycles

+
+ Create cycle with directs + + } + > {cycles.length === 0 ? (

No cycles yet.

) : ( @@ -159,17 +168,27 @@ export function CyclesPage() { setManagerDue(e.target.value)} />
- +
-
+ {selected && ( -
-

Phase wizard — {selected.name}

-

- Soft gates only: advance phases when ready. Waive missing self/peer from the writing desk with a reason. -

+
+ Update cycle window + + } + >
{PHASE_STATUSES.map((phase) => ( ))}
{ @@ -214,112 +234,133 @@ export function CyclesPage() {
- + -
+ )}
-
{ - e.preventDefault(); - if (!selectedCycle || !subjectId) return; - const bundle = await api>("/api/bundles/export", { - method: "POST", - body: JSON.stringify({ - kind: bundleKind, - cycleId: selectedCycle, - subjectPersonId: subjectId, - peerAuthorName: peerName || undefined, - }), - }); - const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${bundleKind}-${(bundle.subjectName as string)?.replace(/\s+/g, "-") ?? "bundle"}.json`; - a.click(); - setMessage( - `Bundle downloaded — ${bundleKind} pipeline marked exported. Send outside the app, then import the filled JSON below.`, - ); - }} +
+ Download JSON bundle + + } > -

1. Export bundle

-
- - -
-
- - -
-
- - -
- {bundleKind === "peer" && ( + { + e.preventDefault(); + if (!selectedCycle || !subjectId) return; + const bundle = await api>("/api/bundles/export", { + method: "POST", + body: JSON.stringify({ + kind: bundleKind, + cycleId: selectedCycle, + subjectPersonId: subjectId, + peerAuthorName: peerName || undefined, + }), + }); + const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${bundleKind}-${(bundle.subjectName as string)?.replace(/\s+/g, "-") ?? "bundle"}.json`; + a.click(); + setMessage( + `Bundle downloaded — ${bundleKind} pipeline marked exported. Send outside the app, then import the filled JSON below.`, + ); + }} + >
- - setPeerName(e.target.value)} placeholder="Jordan Lee" /> + +
- )} - - +
+ + +
+
+ + +
+ {bundleKind === "peer" && ( +
+ + setPeerName(e.target.value)} placeholder="Jordan Lee" /> +
+ )} + +
-
-

2. Import completed bundle

+
{ + const parsed = JSON.parse(importText) as Record; + const fields = (parsed.fields ?? {}) as Record; + if (parsed.kind === "self" && !fields.top_outcomes) { + fields.top_outcomes = "Shipped reliability work; mentored a new joiner; unblocked checkout migration."; + fields.growth_areas = "Take more cross-team design leadership."; + fields.proposed_goals = "Own Q3 platform migration milestones."; + parsed.fields = fields; + } + if (parsed.kind === "peer" && !fields.strengths) { + fields.context = "Paired on API design for 6 weeks."; + fields.strengths = "Clear written RFCs; calm under incident pressure."; + fields.growth = "Could socialize designs earlier with partner teams."; + parsed.fields = fields; + parsed.peerAuthorName = parsed.peerAuthorName || "Peer Reviewer"; + } + await api("/api/bundles/import", { + method: "POST", + body: JSON.stringify({ bundle: parsed, sourceFilename: "paste.json" }), + }); + const sid = String(parsed.subjectPersonId ?? subjectId); + const cid = String(parsed.cycleId ?? selectedCycle); + setMessage("Imported. Next: open the writing desk and draft the manager review."); + setImportText(""); + setSubjectId(sid); + setSelectedCycle(cid); + }} + > + Import completed JSON + + } + >