Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 52 additions & 50 deletions apps/ui/src/components/IntegrationsPanel.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -48,12 +49,48 @@ export function IntegrationsPanel() {
const jiraExtra = state.jira.extra ?? {};

return (
<div className="panel stack">
<h2>Integrations</h2>
<p className="muted">
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.
<Section
id="settings-integrations"
title="Integrations"
when="Connect trackers when you want cycle-scoped issues and merged PRs as citable dossier achievements."
how="Enable a provider, save credentials (encrypted with the workspace secret), test, then sync linked people."
action={
<button
type="button"
className="btn"
disabled={busy}
onClick={async () => {
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
</button>
}
>
<p className="muted" style={{ marginTop: 0 }}>
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).
</p>

<form
Expand Down Expand Up @@ -143,7 +180,7 @@ export function IntegrationsPanel() {
</div>
<div className="cta-primary-row">
<button className="btn" disabled={busy}>
Save Jira
Save Jira settings
</button>
<button
type="button"
Expand All @@ -164,7 +201,7 @@ export function IntegrationsPanel() {
}
}}
>
Test Jira
Test Jira connection
</button>
</div>
{state.jira.lastSyncAt && (
Expand Down Expand Up @@ -250,7 +287,7 @@ export function IntegrationsPanel() {
</div>
<div className="cta-primary-row">
<button className="btn" disabled={busy}>
Save Bitbucket
Save Bitbucket settings
</button>
<button
type="button"
Expand All @@ -275,7 +312,7 @@ export function IntegrationsPanel() {
}
}}
>
Test Bitbucket
Test Bitbucket connection
</button>
</div>
{state.bitbucket.lastSyncAt && (
Expand Down Expand Up @@ -357,7 +394,7 @@ export function IntegrationsPanel() {
</div>
<div className="cta-primary-row">
<button className="btn" disabled={busy}>
Save GitHub
Save GitHub settings
</button>
<button
type="button"
Expand All @@ -378,38 +415,7 @@ export function IntegrationsPanel() {
}
}}
>
Test GitHub
</button>
<button
type="button"
className="btn secondary"
disabled={busy}
onClick={async () => {
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
Test GitHub connection
</button>
</div>
{state.github.lastSyncAt && (
Expand Down Expand Up @@ -489,7 +495,7 @@ export function IntegrationsPanel() {
</div>
<div className="cta-primary-row">
<button className="btn" disabled={busy}>
Save Linear
Save Linear settings
</button>
<button
type="button"
Expand All @@ -510,17 +516,13 @@ export function IntegrationsPanel() {
}
}}
>
Test Linear
Test Linear connection
</button>
</div>
{state.linear.lastSyncAt && <p className="muted">Last sync: {state.linear.lastSyncAt}</p>}
</form>

<p className="muted">
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.
</p>
{msg && <p className="muted">{msg}</p>}
</div>
</Section>
);
}
57 changes: 54 additions & 3 deletions apps/ui/src/components/PageChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function PageHeader({
secondary?: ReactNode;
}) {
return (
<div className="page-header">
<header className="page-header">
<div className="page-header-copy">
<h1>{title}</h1>
<p className="page-job">{job}</p>
Expand All @@ -24,7 +24,58 @@ export function PageHeader({
{primary}
</div>
)}
</div>
</header>
);
}

/**
* 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 (
<section
id={id}
className={`panel stack page-section settings-section section-block ${className}`.trim()}
aria-labelledby={titleId}
>
<header className="section-header">
<div className="section-header-copy">
<h2 id={titleId}>{title}</h2>
<dl className="section-guide">
<div>
<dt>When</dt>
<dd>{when}</dd>
</div>
<div>
<dt>How</dt>
<dd>{how}</dd>
</div>
</dl>
</div>
{action != null ? <div className="section-header-actions">{action}</div> : null}
</header>
{children ? <div className="section-body">{children}</div> : null}
</section>
);
}

Expand All @@ -41,7 +92,7 @@ export function NextStep({
tone?: "brand" | "warn" | "ok";
}) {
return (
<div className={`next-step next-step-${tone}`}>
<div className={`next-step next-step-${tone}`} role="status" aria-live="polite">
<div className="next-step-copy">
<div className="next-step-label">{label}</div>
<p>{detail}</p>
Expand Down
72 changes: 38 additions & 34 deletions apps/ui/src/components/RetentionPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<WorkspaceSettingsDTO | null>(null);
Expand All @@ -18,14 +19,43 @@ export function RetentionPanel() {
if (!settings) return <p className="muted">Loading retention…</p>;

return (
<div className="panel stack">
<div className="panel-title">
<h2>Retention & legal hold</h2>
</div>
<Section
id="settings-retention"
title="Retention & legal hold"
when="Set policy when you start keeping multiple cycles, before a purge, or when legal hold is required."
how="Choose how many cycles to keep, toggle legal hold if needed, save, then purge older closed cycles only when hold is off."
action={
<button
type="button"
className="btn"
disabled={busy}
onClick={async () => {
setBusy(true);
try {
const next = await api<WorkspaceSettingsDTO>("/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
</button>
}
>
<p className="muted" style={{ marginTop: 0 }}>
Default policy keeps the <strong>current + previous</strong> 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 <strong>current + previous</strong> cycle. Purge marks cycles as purged
(audit trail kept) — it does not shred the vault.
</p>
<div className="field">
<label htmlFor="retention-select">Retention policy</label>
Expand Down Expand Up @@ -55,32 +85,6 @@ export function RetentionPanel() {
</label>
</div>
<div className="cta-primary-row">
<button
type="button"
className="btn"
disabled={busy}
onClick={async () => {
setBusy(true);
try {
const next = await api<WorkspaceSettingsDTO>("/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
</button>
<button
type="button"
className="btn secondary"
Expand Down Expand Up @@ -126,6 +130,6 @@ export function RetentionPanel() {
<p className="muted">No cycles eligible for purge under the current policy.</p>
)}
{msg && <p className="muted">{msg}</p>}
</div>
</Section>
);
}
Loading
Loading