filegrc 0.14.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/model/index.js +2 -1
- package/model/v11.json +5 -5
- package/package.json +1 -1
- package/src/agent.js +1 -1
- package/src/audit-preparation.js +16 -4
- package/src/audit-selection.js +33 -0
- package/src/cli.js +6 -3
- package/src/collection-review.js +61 -2
- package/src/collection-revision.js +3 -1
- package/src/collection-scope.js +45 -1
- package/src/index.js +1 -1
- package/src/program-path.js +59 -14
- package/src/program-readiness.js +217 -30
- package/src/retention-schedule-approval.js +121 -0
- package/src/retention.js +87 -12
- package/src/state.js +19 -22
- package/src/validate.js +46 -0
- package/src/web.js +509 -139
- package/src/workflow-history-integrity.js +5 -1
- package/src/workflow.js +2 -0
package/src/web.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createResourceId } from "./id.js";
|
|
2
|
+
import { selectDefaultAudit } from "./audit-selection.js";
|
|
2
3
|
import { MODEL_CAPABILITY_VERSIONS } from "../model/index.js";
|
|
3
4
|
import {
|
|
4
5
|
calendarOccurrence,
|
|
@@ -9,17 +10,25 @@ import {
|
|
|
9
10
|
utcCalendarDate,
|
|
10
11
|
validCalendarRecurrence
|
|
11
12
|
} from "./recurrence.js";
|
|
12
|
-
import { PROGRAM_PATH, RESOURCE_INSTRUCTIONS, RESOURCE_PAGE_SUMMARIES } from "./program-path.js";
|
|
13
|
+
import { PROGRAM_PATH, programPathForModel, RESOURCE_INSTRUCTIONS, RESOURCE_PAGE_SUMMARIES } from "./program-path.js";
|
|
13
14
|
import { formatCalendarDate, formatLocalDateTime } from "./time.js";
|
|
14
15
|
|
|
15
16
|
export function dashboardProgramReadiness(programReadiness = {}) {
|
|
16
17
|
const progress = programReadiness.progress || {};
|
|
17
|
-
const lifecycle =
|
|
18
|
-
? {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
const lifecycle = progress.mode === "operating-window"
|
|
19
|
+
? {
|
|
20
|
+
status: progress.status || (programReadiness.operating ? "Operating" : "Needs attention"),
|
|
21
|
+
tone: programReadiness.operating ? "good" : "warn"
|
|
22
|
+
}
|
|
23
|
+
: programReadiness.operating
|
|
24
|
+
? { status: "Operating", tone: "good" }
|
|
25
|
+
: programReadiness.evidenceReady
|
|
26
|
+
? { status: progress.status || "Evidence ready", tone: "good" }
|
|
27
|
+
: { status: progress.status || "Needs work", tone: "warn" };
|
|
22
28
|
return {
|
|
29
|
+
mode: progress.mode || "setup-progress",
|
|
30
|
+
label: progress.label || "Program setup",
|
|
31
|
+
detail: progress.detail || `${progress.complete ?? 0} of ${progress.total ?? 0} required actions complete.`,
|
|
23
32
|
percent: progress.percent ?? 0,
|
|
24
33
|
complete: progress.complete ?? 0,
|
|
25
34
|
total: progress.total ?? 0,
|
|
@@ -27,6 +36,23 @@ export function dashboardProgramReadiness(programReadiness = {}) {
|
|
|
27
36
|
};
|
|
28
37
|
}
|
|
29
38
|
|
|
39
|
+
export function workflowHrefBelongsToPage(destination = {}, href = "") {
|
|
40
|
+
const route = String(href).split("?")[0];
|
|
41
|
+
if (!route) return false;
|
|
42
|
+
if (destination.href === "#/policies") {
|
|
43
|
+
return ["policy", "document", "training"].some((type) => route.startsWith(`#/resource/${type}/`));
|
|
44
|
+
}
|
|
45
|
+
if (destination.type) {
|
|
46
|
+
return route === `#/resources/${destination.type}` || route.startsWith(`#/resource/${destination.type}/`);
|
|
47
|
+
}
|
|
48
|
+
return route === String(destination.href || "").split("?")[0];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function pageActionIdentity(href = "", fallback = "") {
|
|
52
|
+
const route = String(href).split("?")[0];
|
|
53
|
+
return route.startsWith("#/resource/") ? route : fallback || href;
|
|
54
|
+
}
|
|
55
|
+
|
|
30
56
|
export function renderIndex(state = null) {
|
|
31
57
|
const snapshot = state
|
|
32
58
|
? `<script id="filegrc-data" type="application/json">${safeJson(state)}</script>`
|
|
@@ -64,15 +90,20 @@ let latestPacketResult = null;
|
|
|
64
90
|
let latestPacketState = null;
|
|
65
91
|
let policyEventFeedback = null;
|
|
66
92
|
const SHARED_PROGRAM_STAGES = ${JSON.stringify(PROGRAM_PATH)};
|
|
67
|
-
const
|
|
93
|
+
const LEGACY_PROGRAM_STAGES = ${JSON.stringify(programPathForModel({ modelVersion: "10" }))};
|
|
94
|
+
let READINESS_STAGES = SHARED_PROGRAM_STAGES.map((stage) => ({
|
|
68
95
|
...stage,
|
|
69
96
|
number: String(stage.number)
|
|
70
97
|
}));
|
|
71
98
|
const RESOURCE_GUIDE_INSTRUCTIONS = ${JSON.stringify(RESOURCE_INSTRUCTIONS)};
|
|
72
99
|
const dashboardProgramReadiness = ${dashboardProgramReadiness.toString()};
|
|
100
|
+
const workflowHrefBelongsToPage = ${workflowHrefBelongsToPage.toString()};
|
|
101
|
+
const pageActionIdentity = ${pageActionIdentity.toString()};
|
|
102
|
+
const selectDefaultAudit = ${selectDefaultAudit.toString()};
|
|
73
103
|
const STAGE_PAGE_SUMMARIES = ${JSON.stringify({
|
|
74
104
|
...RESOURCE_PAGE_SUMMARIES,
|
|
75
105
|
"utility:evidence-sources": "Check that each Control has an authoritative, retrievable evidence source.",
|
|
106
|
+
"utility:retention-schedule": "Approve the governed schedule document and its structured rows as one revision.",
|
|
76
107
|
"utility:audit-packet": "Review fieldwork readiness and build the indexed evidence packet."
|
|
77
108
|
})};
|
|
78
109
|
const RECORD_TEXT_FIELDS = new Set(["description", "statement", "activity", "purpose", "scope", "objective", "applicabilityRationale", "summary", "rationale", "businessPurpose", "changeSummary", "decisionSummary", "decisionRationale", "recommendation", "remediationPlan", "auditorNotes", "notPerformedReason"]);
|
|
@@ -91,6 +122,7 @@ let onboardingPendingDraft = false;
|
|
|
91
122
|
const resourceDetailRequests = new Map();
|
|
92
123
|
const stateSectionRequests = new Map();
|
|
93
124
|
let resourceGuideCleanup = null;
|
|
125
|
+
let recordOverflowCleanup = null;
|
|
94
126
|
let repositorySyncPollTimer = null;
|
|
95
127
|
let repositorySyncPollInFlight = false;
|
|
96
128
|
let mutationStateRefreshInFlight = false;
|
|
@@ -163,6 +195,8 @@ function handleRouteChange() {
|
|
|
163
195
|
function render() {
|
|
164
196
|
resourceGuideCleanup?.();
|
|
165
197
|
resourceGuideCleanup = null;
|
|
198
|
+
recordOverflowCleanup?.();
|
|
199
|
+
recordOverflowCleanup = null;
|
|
166
200
|
const previousNavigation = root.querySelector(".sidebar-nav");
|
|
167
201
|
if (previousNavigation) navigationScrollTop = previousNavigation.scrollTop;
|
|
168
202
|
const route = parseRoute();
|
|
@@ -176,6 +210,9 @@ function render() {
|
|
|
176
210
|
else if (waitingFor.length) renderStateLoading(main, route, waitingFor);
|
|
177
211
|
else if (route.name === "home") renderHome(main);
|
|
178
212
|
else if (route.name === "stage") renderStageOverview(main, route.stageId, route.params);
|
|
213
|
+
else if (route.name === "policies") renderPoliciesPage(main, route.params);
|
|
214
|
+
else if (route.name === "retention-schedule" && modelSupports("retention-schedule-approval")) renderRetentionSchedulePage(main, route.params);
|
|
215
|
+
else if (route.name === "retention-schedule") renderList(main, "retention-schedule-item", route.params);
|
|
179
216
|
else if (route.name === "evidence-sources") renderEvidenceSourcesPage(main);
|
|
180
217
|
else if (route.name === "obligations") renderObligations(main, route.params);
|
|
181
218
|
else if (route.name === "audit-packet") renderAuditPacket(main, route.params);
|
|
@@ -188,6 +225,10 @@ function render() {
|
|
|
188
225
|
}
|
|
189
226
|
|
|
190
227
|
function normalizeAppState(next) {
|
|
228
|
+
const stages = Number(next?.model?.modelVersion || 0) >= MODEL_CAPABILITY_VERSIONS["retention-schedule-approval"]
|
|
229
|
+
? SHARED_PROGRAM_STAGES
|
|
230
|
+
: LEGACY_PROGRAM_STAGES;
|
|
231
|
+
READINESS_STAGES = stages.map((stage) => ({ ...stage, number: String(stage.number) }));
|
|
191
232
|
if (next.sections) return next;
|
|
192
233
|
return {
|
|
193
234
|
...next,
|
|
@@ -208,6 +249,8 @@ function blockingStateSections(route) {
|
|
|
208
249
|
if (route.name === "obligations" || route.name === "stage" && route.stageId === "run") return ["program", "obligations"];
|
|
209
250
|
if (route.name === "audit-packet") return ["repository", "program", "obligations", "audits"];
|
|
210
251
|
if (route.name === "evidence-sources") return ["program", "workflow"];
|
|
252
|
+
if (route.name === "policies") return ["program", "workflow"];
|
|
253
|
+
if (route.name === "retention-schedule") return ["program", "workflow"];
|
|
211
254
|
if (route.name === "stage" && route.stageId === "audit") return ["program", "workflow", "audits"];
|
|
212
255
|
if (route.name === "stage") return ["program", "workflow"];
|
|
213
256
|
return [];
|
|
@@ -286,6 +329,8 @@ function parseRoute() {
|
|
|
286
329
|
if (parts.length === 2 && parts[0] === "stage" && parts[1]) return { name: "stage", stageId: parts[1], params: new URLSearchParams(query) };
|
|
287
330
|
if (parts.length === 1 && parts[0] === "obligations") return { name: "obligations", params: new URLSearchParams(query) };
|
|
288
331
|
if (parts.length === 1 && parts[0] === "evidence-sources") return { name: "evidence-sources", params: new URLSearchParams(query) };
|
|
332
|
+
if (parts.length === 1 && parts[0] === "policies") return { name: "policies", params: new URLSearchParams(query) };
|
|
333
|
+
if (parts.length === 1 && parts[0] === "retention-schedule") return { name: "retention-schedule", params: new URLSearchParams(query) };
|
|
289
334
|
if (parts.length === 1 && parts[0] === "audit-packet") return { name: "audit-packet", params: new URLSearchParams(query) };
|
|
290
335
|
if (parts.length === 2 && parts[0] === "resources" && parts[1]) return { name: "list", type: parts[1], params: new URLSearchParams(query) };
|
|
291
336
|
if (parts.length === 3 && parts[0] === "resource" && parts[1] && parts[2]) return { name: "detail", type: parts[1], id: parts[2], params: new URLSearchParams(query) };
|
|
@@ -307,7 +352,9 @@ function buildNavigation(route) {
|
|
|
307
352
|
|| (section.relatedLinks || []).some((link) => route.type === link.type && contextualStageId === stage.id)
|
|
308
353
|
|| (section.utility === "obligation-board" && route.name === "obligations")
|
|
309
354
|
|| (section.utility === "audit-packet" && route.name === "audit-packet")
|
|
310
|
-
|| (section.utility === "evidence-sources" && route.name === "evidence-sources")
|
|
355
|
+
|| (section.utility === "evidence-sources" && route.name === "evidence-sources")
|
|
356
|
+
|| (section.relatedLinks || []).some((link) => link.href === "#/" + route.name)
|
|
357
|
+
|| (section.utility === "retention-schedule" && route.name === "retention-schedule");
|
|
311
358
|
const sectionOpen = navigationGroupState[sectionKey] ?? (sectionCurrent || section.defaultOpen);
|
|
312
359
|
const resources = section.types
|
|
313
360
|
.map((type) => [type, state.model.resources[type]])
|
|
@@ -318,7 +365,8 @@ function buildNavigation(route) {
|
|
|
318
365
|
return '<a class="' + (direct ? "nav-direct " : "") + (current ? "current" : "") + '" href="#/resources/' + encodeURIComponent(type) + '"><span>' + esc(titleCase(definition.pluralTitle)) + '</span><span class="nav-control-slot" aria-hidden="true"></span></a>';
|
|
319
366
|
}).join("") + (section.relatedLinks || []).map((link) => {
|
|
320
367
|
const current = route.type === link.type && contextualStageId === stage.id
|
|
321
|
-
|| link.href === "#/stage/" + route.stageId
|
|
368
|
+
|| link.href === "#/stage/" + route.stageId
|
|
369
|
+
|| link.href === "#/" + route.name;
|
|
322
370
|
return '<a class="' + (direct ? "nav-direct " : "") + (current ? "current" : "") + '" href="' + esc(link.href) + '"><span>' + esc(link.label) + '</span><span class="nav-control-slot" aria-hidden="true"></span></a>';
|
|
323
371
|
}).join("") + renderSidebarUtility(section.utility, route, direct);
|
|
324
372
|
if (direct) return links;
|
|
@@ -334,6 +382,7 @@ function buildNavigation(route) {
|
|
|
334
382
|
|
|
335
383
|
function readinessStageForRoute(route) {
|
|
336
384
|
if (route.name === "stage") return READINESS_STAGES.find((stage) => stage.id === route.stageId);
|
|
385
|
+
if (route.name === "policies") return READINESS_STAGES.find((stage) => stage.id === "policies");
|
|
337
386
|
const contextualStageId = route.params?.get("stage");
|
|
338
387
|
const contextualStage = contextualStageId && READINESS_STAGES.find((stage) => (
|
|
339
388
|
stage.id === contextualStageId
|
|
@@ -345,7 +394,8 @@ function readinessStageForRoute(route) {
|
|
|
345
394
|
|| stage.sections.some((section) => section.types.includes(route.type)
|
|
346
395
|
|| (section.utility === "obligation-board" && route.name === "obligations")
|
|
347
396
|
|| (section.utility === "audit-packet" && route.name === "audit-packet")
|
|
348
|
-
|| (section.utility === "evidence-sources" && route.name === "evidence-sources")
|
|
397
|
+
|| (section.utility === "evidence-sources" && route.name === "evidence-sources")
|
|
398
|
+
|| (section.utility === "retention-schedule" && route.name === "retention-schedule"))
|
|
349
399
|
));
|
|
350
400
|
}
|
|
351
401
|
|
|
@@ -375,6 +425,9 @@ function renderSidebarUtility(utility, route, direct = false) {
|
|
|
375
425
|
if (utility === "evidence-sources") {
|
|
376
426
|
return '<a class="' + directClass + (route.name === "evidence-sources" ? "current" : "") + '" href="#/evidence-sources"><span>Evidence Sources</span><span class="nav-control-slot" aria-hidden="true"></span></a>';
|
|
377
427
|
}
|
|
428
|
+
if (utility === "retention-schedule") {
|
|
429
|
+
return '<a class="' + directClass + (route.name === "retention-schedule" ? "current" : "") + '" href="#/retention-schedule"><span>Data Retention Schedule</span><span class="nav-control-slot" aria-hidden="true"></span></a>';
|
|
430
|
+
}
|
|
378
431
|
if (utility === "audit-packet") {
|
|
379
432
|
return '<a class="' + directClass + 'audit-packet-link ' + (route.name === "audit-packet" ? "current" : "") + '" href="#/audit-packet"><span>Audit Evidence & Packet</span><span class="nav-control-slot" aria-hidden="true"></span></a>';
|
|
380
433
|
}
|
|
@@ -396,6 +449,10 @@ function topbar(route) {
|
|
|
396
449
|
? "Audit Readiness"
|
|
397
450
|
: route.name === "evidence-sources"
|
|
398
451
|
? "Evidence Sources"
|
|
452
|
+
: route.name === "policies"
|
|
453
|
+
? "Policies"
|
|
454
|
+
: route.name === "retention-schedule"
|
|
455
|
+
? "Data Retention Schedule"
|
|
399
456
|
: route.name === "list" && route.type === "document"
|
|
400
457
|
? documentListTitle(route.params)
|
|
401
458
|
: state.model.resources[route.type]?.pluralTitle || "filegrc";
|
|
@@ -435,8 +492,16 @@ function topbarProgramReadiness() {
|
|
|
435
492
|
return '<a class="topbar-readiness' + (loading ? ' is-loading' : '') + '" href="#/"' + (loading ? ' aria-busy="true"' : '') + '><span class="topbar-readiness-copy"><span>Program readiness</span><strong>' + label + '</strong></span><span class="progress" aria-hidden="true"><span style="width:0%"></span></span></a>';
|
|
436
493
|
}
|
|
437
494
|
const progress = dashboardProgramReadiness(state.programReadiness);
|
|
438
|
-
const
|
|
439
|
-
|
|
495
|
+
const setupRemaining = state.programReadiness.setupProgress?.remaining || 0;
|
|
496
|
+
const setupRegression = progress.mode === "operating-window" && setupRemaining > 0;
|
|
497
|
+
const href = progress.mode === "operating-window" && !setupRegression ? "#/stage/run" : nextProgramStageHref();
|
|
498
|
+
const label = setupRegression
|
|
499
|
+
? progress.label + " · " + setupRemaining + " setup " + (setupRemaining === 1 ? "action" : "actions")
|
|
500
|
+
: progress.label;
|
|
501
|
+
const detail = setupRegression
|
|
502
|
+
? progress.detail + " " + setupRemaining + " required setup " + (setupRemaining === 1 ? "action also needs" : "actions also need") + " attention."
|
|
503
|
+
: progress.detail;
|
|
504
|
+
return '<a class="topbar-readiness" href="' + href + '" aria-label="' + esc(label + ": " + progress.percent + "%. " + detail + " " + progress.status + ".") + '"><span class="topbar-readiness-copy"><span>' + esc(label) + '</span><strong>' + progress.percent + '%</strong></span><span class="progress" aria-hidden="true"><span style="width:' + progress.percent + '%"></span></span></a>';
|
|
440
505
|
}
|
|
441
506
|
|
|
442
507
|
function repositoryStatusTone(status) {
|
|
@@ -535,11 +600,12 @@ function nextProgramStageHref() {
|
|
|
535
600
|
function renderStageOverview(main, stageId, params = new URLSearchParams()) {
|
|
536
601
|
const stage = READINESS_STAGES.find((candidate) => candidate.id === stageId);
|
|
537
602
|
if (!stage) return renderNotFound(main);
|
|
603
|
+
if (stage.id === "policies" && !modelSupports("retention-schedule-approval")) return renderPoliciesPage(main, params);
|
|
538
604
|
if (stage.id === "run") return renderObligations(main, params);
|
|
539
605
|
const progress = stageProgress(stage);
|
|
540
606
|
main.innerHTML = '<div class="page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
|
|
541
607
|
'<section class="stage-overview-hero"><div><p class="kicker">Step ' + esc(stage.number) + ' of 5</p><h2>' + esc(stage.title) + '</h2><p>' + esc(stage.summary) + '</p></div>' + stageProgressCard(progress) + '</section>' +
|
|
542
|
-
|
|
608
|
+
renderStagePageIndex(stage) + (stage.id === "controls" ? renderFinishStepThree() : "") + (stage.id === "audit" ? renderAuditDocumentActivationAssessments() : "") + '</div>';
|
|
543
609
|
main.querySelector("[data-show-evidence-families]")?.addEventListener("click", (event) => {
|
|
544
610
|
main.querySelectorAll("[data-evidence-family-extra]").forEach((card) => { card.hidden = false; });
|
|
545
611
|
event.currentTarget.remove();
|
|
@@ -551,6 +617,173 @@ function renderStageOverview(main, stageId, params = new URLSearchParams()) {
|
|
|
551
617
|
});
|
|
552
618
|
}
|
|
553
619
|
|
|
620
|
+
function renderPoliciesPage(main, params = new URLSearchParams()) {
|
|
621
|
+
const guideTrigger = '<button class="guide-trigger" id="resource-guide-trigger" type="button" aria-label="About Policies" aria-haspopup="dialog" aria-controls="resource-guide" aria-expanded="false"><svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="8"></circle><path d="M7.8 7.5a2.4 2.4 0 1 1 3.25 2.25c-.7.31-1.05.72-1.05 1.5v.25M10 14.5v.1"></path></svg></button>';
|
|
622
|
+
main.innerHTML = '<div class="page policies-page"><nav class="breadcrumbs"><a href="#/stage/policies">Step 2</a><span>/</span><span>Policies</span></nav>' +
|
|
623
|
+
'<div class="page-intro"><div><p class="kicker">Step 2</p><div class="page-title-line"><h2>Policies</h2>' + guideTrigger + '</div><p>Review and approve the Policies, program Documents, and Training content that govern this program.</p></div></div>' + policyContentGuide() +
|
|
624
|
+
renderPolicyApprovalGuidance() + renderPoliciesTable(params) + '</div>';
|
|
625
|
+
resourceGuideCleanup = setupResourceGuide(main);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function policyContentGuide() {
|
|
629
|
+
return '<section class="page-guide resource-guide-popover" id="resource-guide" role="dialog" aria-label="How to use Policies" hidden><div><span>Instructions</span><p>Open each Policy, program Document, and Training record. Replace starter placeholders, review its Markdown, and confirm its owner, separate approver, relationships, and intended values.</p></div><div><span>Approval</span><p>Bind approval to the exact Markdown revision. Keep approved content inactive until Step 3 confirms implementation and records the activation cutover.</p></div><div><span>Included here</span><p>Use this page for governed program content. The Data Retention Schedule has its own Step 2 page because its document and structured rows are approved together.</p></div></section>';
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
|
|
633
|
+
const documentEntries = state.resources.filter(({ record }) => (
|
|
634
|
+
record.type === "document"
|
|
635
|
+
&& record.documentKind === "schedule"
|
|
636
|
+
&& record.workflowScope === "program"
|
|
637
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
638
|
+
));
|
|
639
|
+
const documentEntry = documentEntries.length === 1 ? documentEntries[0] : null;
|
|
640
|
+
const rows = resourcesOfType("retention-schedule-item")
|
|
641
|
+
.sort((left, right) => left.record.title.localeCompare(right.record.title));
|
|
642
|
+
const activeRows = rows.filter(({ record }) => record.status === "active").length;
|
|
643
|
+
const documentReadiness = documentEntry ? retentionScheduleDocumentReadiness(documentEntry.record.id) : null;
|
|
644
|
+
const documentReady = documentEntries.length === 1 && documentReadiness?.status === "complete";
|
|
645
|
+
const documentAction = documentEntry
|
|
646
|
+
? '<a class="button" href="#/resource/document/' + encodeURIComponent(documentEntry.record.id) + '?stage=policies&documentScope=program">Review document</a>'
|
|
647
|
+
: !documentEntries.length && !state.readOnly ? '<button class="button" type="button" data-add-retention-document>Add document</button>' : "";
|
|
648
|
+
const documentSummary = documentEntries.length
|
|
649
|
+
? documentEntries.map((entry) => '<a href="#/resource/document/' + encodeURIComponent(entry.record.id) + '?stage=policies&documentScope=program"><strong>' + esc(entry.record.title) + '</strong><small>' + (documentEntries.length > 1 ? 'Resolve this duplicate before approval' : documentReadiness?.message || 'Policy, legal holds, exceptions, and disposal requirements') + '</small></a><span class="badge ' + (documentReady ? "good" : "warn") + '">' + (documentEntries.length > 1 ? "Duplicate" : documentReady ? "Ready" : "Needs work") + '</span>').join("")
|
|
650
|
+
: '<span><strong>No governing document</strong><small>Add the schedule document before approving its rows.</small></span><span class="badge warn">Required</span>';
|
|
651
|
+
const rowHtml = rows.length
|
|
652
|
+
? rows.map((entry) => {
|
|
653
|
+
const { record } = entry;
|
|
654
|
+
const informationTypes = (record.informationTypeIds || []).map(referenceTitle).join(", ") || "Not selected";
|
|
655
|
+
const scope = (record.scopeResourceIds || []).map(referenceTitle).join(", ") || "Not selected";
|
|
656
|
+
const cutoff = retentionCutoffLabel(record.cutoff);
|
|
657
|
+
const period = retentionPeriodLabel(record.retentionPeriod);
|
|
658
|
+
const action = record.dispositionAction ? properCase(record.dispositionAction) : "Not selected";
|
|
659
|
+
return '<tr data-retention-row data-history="' + (["superseded", "retired"].includes(record.status) ? "true" : "false") + '"><td data-label="Rule" data-primary-field><a class="record-title" href="#/resource/retention-schedule-item/' + encodeURIComponent(record.id) + '?stage=policies">' + esc(record.title) + '</a><small>' + esc(record.description || record.id) + '</small></td><td data-label="Information types">' + esc(informationTypes) + '</td><td data-label="Scope">' + esc(scope) + '</td><td data-label="Retention"><strong>' + esc(period) + '</strong><small>From ' + esc(cutoff) + '</small></td><td data-label="Disposition">' + esc(action) + '</td><td data-label="Status">' + formatValue(displayStatus(record), "status", record.type, true) + '</td><td data-label="Next action">' + recordWorkflowCell("retention-schedule-item", entry, "?stage=policies") + '</td></tr>';
|
|
660
|
+
}).join("")
|
|
661
|
+
: '<tr><td colspan="7">' + empty("No retention rules are needed until an actual information use requires one.") + '</td></tr>';
|
|
662
|
+
const add = state.readOnly ? "" : '<button class="button primary" type="button" data-add-retention-row>Add schedule row</button>';
|
|
663
|
+
const guideTrigger = '<button class="guide-trigger" id="resource-guide-trigger" type="button" aria-label="About Data Retention Schedule" aria-haspopup="dialog" aria-controls="resource-guide" aria-expanded="false"><svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="8"></circle><path d="M7.8 7.5a2.4 2.4 0 1 1 3.25 2.25c-.7.31-1.05.72-1.05 1.5v.25M10 14.5v.1"></path></svg></button>';
|
|
664
|
+
const initialFilter = params.get("q") || "";
|
|
665
|
+
const showHistory = params.get("history") === "1";
|
|
666
|
+
const tableTools = '<div class="retention-table-tools"><label><span class="sr-only">Filter schedule rows</span><input type="search" data-retention-search placeholder="Filter schedule rows" value="' + esc(initialFilter) + '"></label><label class="history-toggle"><input type="checkbox" data-retention-history ' + (showHistory ? "checked" : "") + '> Show retired</label></div>';
|
|
667
|
+
main.innerHTML = '<div class="page retention-schedule-page"><nav class="breadcrumbs"><a href="#/stage/policies">Step 2</a><span>/</span><span>Data Retention Schedule</span></nav>' +
|
|
668
|
+
'<div class="page-intro"><div><p class="kicker">Step 2</p><div class="page-title-line"><h2>Data Retention Schedule</h2>' + guideTrigger + '</div><p>Define the retention rules management has approved. Step 3 checks whether implemented systems and evidence sources follow them.</p></div><div class="actions">' + documentAction + add + '</div></div>' + resourceGuide("retention-schedule-item") +
|
|
669
|
+
'<div class="retention-summary"><div><span class="retention-summary-label">Governing document</span>' + documentSummary + '</div><div><span class="retention-summary-label">Schedule revision</span><span><strong>' + activeRows + ' active ' + pluralize("row", activeRows) + '</strong><small>Document and rows are approved together</small></span><span class="badge ' + (state.collectionReviews?.["retention-schedule-item"]?.status === "current" ? "good" : "warn") + '">' + (state.collectionReviews?.["retention-schedule-item"]?.status === "current" ? "Approved" : "Approval needed") + '</span></div></div>' +
|
|
670
|
+
'<section class="retention-rows"><div class="section-head"><div><p class="kicker">Structured schedule</p><h2>Schedule rows</h2><p>Add a row when an Information Type and its operational scope need a distinct cutoff, period, or disposition rule.</p></div>' + tableTools + '</div><div class="record-table-wrap"><table class="record-table"><thead><tr><th>Rule</th><th>Information types</th><th>Scope</th><th>Retention</th><th>Disposition</th><th>Status</th><th>Next action</th></tr></thead><tbody>' + rowHtml + '</tbody></table></div><p class="retention-filter-empty" data-retention-empty hidden>No schedule rows match this filter.</p><p class="retention-row-count" data-retention-count aria-live="polite"></p></section>' +
|
|
671
|
+
renderRetentionScheduleIssues() + collectionReviewPanel("retention-schedule-item", true) + '</div>';
|
|
672
|
+
resourceGuideCleanup = setupResourceGuide(main);
|
|
673
|
+
wireRetentionScheduleTable(rows.length);
|
|
674
|
+
main.querySelector("[data-review-collection]")?.addEventListener("click", () => openCollectionReviewDialog("retention-schedule-item"));
|
|
675
|
+
main.querySelector("[data-add-retention-document]")?.addEventListener("click", () => openEditor("document", null, {
|
|
676
|
+
seed: {
|
|
677
|
+
documentKind: "schedule",
|
|
678
|
+
workflowScope: "program",
|
|
679
|
+
programRole: "required"
|
|
680
|
+
},
|
|
681
|
+
description: "Write the governing Data Retention Schedule, link its Controls, and assign a separate approver before approving the document and schedule rows.",
|
|
682
|
+
saveLabel: "Save schedule document"
|
|
683
|
+
}));
|
|
684
|
+
main.querySelector("[data-add-retention-row]")?.addEventListener("click", () => openRetentionScheduleItemEditor());
|
|
685
|
+
main.querySelector(".retention-rows tbody")?.addEventListener("click", (event) => {
|
|
686
|
+
if (event.target.closest("[data-load-workflow]")) loadStateSection("workflow");
|
|
687
|
+
});
|
|
688
|
+
if (params.get("new") === "1" && !state.readOnly) {
|
|
689
|
+
const seed = {
|
|
690
|
+
...(params.get("title") ? { title: params.get("title") } : {}),
|
|
691
|
+
...(params.get("informationTypeId") ? { informationTypeIds: [params.get("informationTypeId")] } : {}),
|
|
692
|
+
...(params.getAll("scopeResourceId").length ? { scopeResourceIds: params.getAll("scopeResourceId") } : {}),
|
|
693
|
+
...(params.get("scheduleDocumentId") ? { scheduleDocumentId: params.get("scheduleDocumentId") } : {})
|
|
694
|
+
};
|
|
695
|
+
history.replaceState(null, "", "#/retention-schedule");
|
|
696
|
+
queueMicrotask(() => openRetentionScheduleItemEditor(seed));
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function openRetentionScheduleItemEditor(seed = {}) {
|
|
701
|
+
const scheduleDocuments = resourcesOfType("document").filter(({ record }) => (
|
|
702
|
+
record.documentKind === "schedule"
|
|
703
|
+
&& record.workflowScope !== "engagement"
|
|
704
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
705
|
+
));
|
|
706
|
+
openEditor("retention-schedule-item", null, {
|
|
707
|
+
seed: {
|
|
708
|
+
...(scheduleDocuments.length === 1 ? { scheduleDocumentId: scheduleDocuments[0].record.id } : {}),
|
|
709
|
+
...seed
|
|
710
|
+
},
|
|
711
|
+
description: "Define one retention decision. Keep it planned until its scope, cutoff, period, disposition, sources, and approval are complete.",
|
|
712
|
+
saveLabel: "Save schedule row"
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function wireRetentionScheduleTable(total) {
|
|
717
|
+
const table = root.querySelector(".retention-rows");
|
|
718
|
+
if (!table) return;
|
|
719
|
+
const search = table.querySelector("[data-retention-search]");
|
|
720
|
+
const historyToggle = table.querySelector("[data-retention-history]");
|
|
721
|
+
const count = table.querySelector("[data-retention-count]");
|
|
722
|
+
const noResults = table.querySelector("[data-retention-empty]");
|
|
723
|
+
const filter = () => {
|
|
724
|
+
const query = search.value.trim().toLowerCase();
|
|
725
|
+
let visible = 0;
|
|
726
|
+
table.querySelectorAll("[data-retention-row]").forEach((row) => {
|
|
727
|
+
const show = (historyToggle.checked || row.dataset.history !== "true")
|
|
728
|
+
&& (!query || row.textContent.toLowerCase().includes(query));
|
|
729
|
+
row.hidden = !show;
|
|
730
|
+
if (show) visible += 1;
|
|
731
|
+
});
|
|
732
|
+
noResults.hidden = visible !== 0 || total === 0;
|
|
733
|
+
count.textContent = visible + " of " + total + " " + pluralize("row", total);
|
|
734
|
+
};
|
|
735
|
+
const updateLocation = () => {
|
|
736
|
+
const params = new URLSearchParams();
|
|
737
|
+
if (search.value.trim()) params.set("q", search.value.trim());
|
|
738
|
+
if (historyToggle.checked) params.set("history", "1");
|
|
739
|
+
history.replaceState(null, "", "#/retention-schedule" + (params.size ? "?" + params : ""));
|
|
740
|
+
};
|
|
741
|
+
search.addEventListener("input", () => { filter(); updateLocation(); });
|
|
742
|
+
historyToggle.addEventListener("change", () => { filter(); updateLocation(); });
|
|
743
|
+
filter();
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function renderRetentionScheduleIssues() {
|
|
747
|
+
const actions = retentionSchedulePageItems().filter((item) => (
|
|
748
|
+
item.id !== "collection-review-retention-schedule-item"
|
|
749
|
+
));
|
|
750
|
+
if (!actions.length) return "";
|
|
751
|
+
return '<section class="retention-issues"><div class="section-head"><div><p class="kicker">Before approval</p><h2>Items to resolve</h2></div></div><div class="retention-issue-list">' + actions.map((item) => {
|
|
752
|
+
const href = item.href || (item.id?.startsWith("retention-use-")
|
|
753
|
+
? retentionScheduleItemHref(item)
|
|
754
|
+
: item.resourceId && item.resourceType
|
|
755
|
+
? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
|
|
756
|
+
: '#/resources/' + encodeURIComponent(item.resourceType || "retention-schedule-item"));
|
|
757
|
+
return '<a href="' + href + '"><span><strong>' + esc(item.title) + '</strong><small>' + esc(item.message) + '</small></span><b>Review</b></a>';
|
|
758
|
+
}).join("") + '</div></section>';
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function retentionScheduleDocumentReadiness(documentId) {
|
|
762
|
+
return state.programReadiness?.stages
|
|
763
|
+
?.find(({ id }) => id === "policies")
|
|
764
|
+
?.items.find(({ id }) => id === "document-approval-" + documentId) || null;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function referenceTitle(id) {
|
|
768
|
+
return state.resources.find(({ record }) => record.id === id)?.record.title || id;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function retentionPeriodLabel(period) {
|
|
772
|
+
if (!period) return "Not selected";
|
|
773
|
+
if (period.basis === "permanent") return "Permanent";
|
|
774
|
+
if (period.basis === "until-event") return "Until " + (period.event || "specified event");
|
|
775
|
+
if (period.basis === "fixed" && Number.isFinite(period.amount) && period.unit) {
|
|
776
|
+
return period.amount + " " + period.unit + (period.amount === 1 ? "" : "s");
|
|
777
|
+
}
|
|
778
|
+
return properCase(period.basis || "Configured");
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function retentionCutoffLabel(cutoff) {
|
|
782
|
+
if (!cutoff) return "cutoff not selected";
|
|
783
|
+
if (cutoff.basis === "event") return cutoff.event || "specified event";
|
|
784
|
+
return properCase(cutoff.basis || "configured cutoff");
|
|
785
|
+
}
|
|
786
|
+
|
|
554
787
|
function renderEvidenceSourcesPage(main) {
|
|
555
788
|
main.innerHTML = '<div class="page"><nav class="breadcrumbs"><a href="#/stage/controls">Step 3</a><span>/</span><span>Evidence Sources</span></nav><section class="stage-overview-hero"><div><p class="kicker">Step 3.3</p><h2>Evidence Sources</h2><p>Check that each Control points to an authoritative Component that people can use to retrieve its expected evidence.</p></div></section>' + renderEvidenceReadiness() + '</div>';
|
|
556
789
|
main.querySelector("[data-show-evidence-families]")?.addEventListener("click", (event) => {
|
|
@@ -571,11 +804,11 @@ function renderPolicyApprovalGuidance() {
|
|
|
571
804
|
return '<section class="policy-lifecycle-note panel ' + (proposalRows ? "" : "single") + '"><div><p class="kicker">Step 2 approval</p><h3>Approve the governed content</h3><p>Approval means your company reviewed and accepted the requirements and intended values in each Policy, program Document, and Training record. It does not mean the linked Controls are implemented yet.</p><p>Bind each approval to the exact Markdown revision here. Implement Controls and configure Obligations in Step 3, then activate each unchanged approved revision at implementation cutover.</p></div>' + (proposalRows ? '<div class="policy-library-proposals">' + proposalRows + '</div>' : "") + '</section>';
|
|
572
805
|
}
|
|
573
806
|
|
|
574
|
-
function renderPoliciesTable() {
|
|
807
|
+
function renderPoliciesTable(params = new URLSearchParams()) {
|
|
575
808
|
const entries = state.resources.filter(({ record }) => (
|
|
576
809
|
record.type === "policy"
|
|
577
810
|
|| record.type === "training"
|
|
578
|
-
|| record.type === "document" && !auditSpecificDocument(record)
|
|
811
|
+
|| record.type === "document" && !auditSpecificDocument(record) && record.documentKind !== "schedule"
|
|
579
812
|
)).sort((left, right) => (
|
|
580
813
|
["policy", "document", "training"].indexOf(left.record.type) - ["policy", "document", "training"].indexOf(right.record.type)
|
|
581
814
|
|| left.record.title.localeCompare(right.record.title)
|
|
@@ -604,8 +837,10 @@ function renderPoliciesTable() {
|
|
|
604
837
|
const add = !state.readOnly
|
|
605
838
|
? '<details class="policy-content-add"><summary class="button primary">Add</summary><div><button type="button" data-add-policy-content="policy">Policy</button><button type="button" data-add-policy-content="document">Document</button><button type="button" data-add-policy-content="training">Training</button></div></details>'
|
|
606
839
|
: '';
|
|
840
|
+
const initialFilter = params.get("q") || "";
|
|
841
|
+
const showHistory = params.get("history") === "1";
|
|
607
842
|
const emptyRow = '<tr><td colspan="6">' + empty('No governed content has been created.') + '</td></tr>';
|
|
608
|
-
const html = '<section class="policy-content-table"><div class="section-head"><div><p class="kicker">
|
|
843
|
+
const html = '<section class="policy-content-table"><div class="section-head"><div><p class="kicker">Policies, Documents, and Training</p><h2>Governed content</h2><p>Review every program artifact in one place. Each row keeps its own type-specific fields and Git file.</p></div><div class="policy-content-tools"><label><span class="sr-only">Filter policies</span><input type="search" data-policy-content-search placeholder="Filter policies" value="' + esc(initialFilter) + '"></label><label class="history-toggle"><input type="checkbox" data-policy-content-history ' + (showHistory ? "checked" : "") + '> Show retired</label>' + add + '</div></div><div class="record-table-wrap"><table class="record-table"><thead><tr><th>Name</th><th>Type</th><th>Owner</th><th>Status</th><th>Approval</th><th>Next action</th></tr></thead><tbody data-policy-content-body>' + (entries.length ? entries.map(row).join('') : emptyRow) + '</tbody></table></div><p class="policy-content-filter-empty" data-policy-content-empty hidden>No governed content matches this filter.</p><p class="policy-content-count" data-policy-content-count aria-live="polite"></p></section>';
|
|
609
844
|
queueMicrotask(() => wirePoliciesTable(entries.length));
|
|
610
845
|
return html;
|
|
611
846
|
}
|
|
@@ -614,21 +849,29 @@ function wirePoliciesTable(total) {
|
|
|
614
849
|
const table = root.querySelector('.policy-content-table');
|
|
615
850
|
if (!table) return;
|
|
616
851
|
const search = table.querySelector('[data-policy-content-search]');
|
|
617
|
-
const
|
|
852
|
+
const historyToggle = table.querySelector('[data-policy-content-history]');
|
|
618
853
|
const count = table.querySelector('[data-policy-content-count]');
|
|
854
|
+
const noResults = table.querySelector('[data-policy-content-empty]');
|
|
619
855
|
const filter = () => {
|
|
620
856
|
const query = search.value.trim().toLowerCase();
|
|
621
857
|
let visible = 0;
|
|
622
858
|
table.querySelectorAll('[data-policy-content-row]').forEach((row) => {
|
|
623
|
-
const show = (
|
|
859
|
+
const show = (historyToggle.checked || row.dataset.history !== 'true')
|
|
624
860
|
&& (!query || row.textContent.toLowerCase().includes(query));
|
|
625
861
|
row.hidden = !show;
|
|
626
862
|
if (show) visible += 1;
|
|
627
863
|
});
|
|
864
|
+
noResults.hidden = visible !== 0 || total === 0;
|
|
628
865
|
count.textContent = visible + ' of ' + total + ' ' + pluralize('artifact', total);
|
|
629
866
|
};
|
|
630
|
-
|
|
631
|
-
|
|
867
|
+
const updateLocation = () => {
|
|
868
|
+
const params = new URLSearchParams();
|
|
869
|
+
if (search.value.trim()) params.set("q", search.value.trim());
|
|
870
|
+
if (historyToggle.checked) params.set("history", "1");
|
|
871
|
+
history.replaceState(null, "", "#/policies" + (params.size ? "?" + params : ""));
|
|
872
|
+
};
|
|
873
|
+
search.addEventListener('input', () => { filter(); updateLocation(); });
|
|
874
|
+
historyToggle.addEventListener('change', () => { filter(); updateLocation(); });
|
|
632
875
|
table.querySelectorAll('[data-add-policy-content]').forEach((button) => {
|
|
633
876
|
button.addEventListener('click', () => openEditor(button.dataset.addPolicyContent));
|
|
634
877
|
});
|
|
@@ -640,7 +883,8 @@ function policyContentWorkflowCell(entry) {
|
|
|
640
883
|
const items = recordWorkflowItems(record.type, record.id);
|
|
641
884
|
if (!items.length) return '<span class="record-workflow-clear">No calculated action</span>';
|
|
642
885
|
const item = items[0];
|
|
643
|
-
|
|
886
|
+
let href = workflowItemHref(item) || '#/resource/' + encodeURIComponent(record.type) + '/' + encodeURIComponent(record.id) + '?stage=policies';
|
|
887
|
+
if (href.startsWith('#/resource/' + encodeURIComponent(record.type) + '/') && !href.includes('?')) href += '?stage=policies';
|
|
644
888
|
const title = ["draft", "in-review"].includes(record.status)
|
|
645
889
|
? "Complete and approve"
|
|
646
890
|
: record.status === "approved"
|
|
@@ -675,7 +919,7 @@ function renderDocumentActivationAssessments() {
|
|
|
675
919
|
|
|
676
920
|
function renderAuditDocumentActivationAssessments() {
|
|
677
921
|
const audits = resourcesOfType("audit").map(({ record }) => record);
|
|
678
|
-
const audit = audits.
|
|
922
|
+
const audit = selectDefaultAudit(audits, state.programReadiness?.asOf || currentDate());
|
|
679
923
|
const preparation = audit ? state.auditPreparations?.[audit.id] : null;
|
|
680
924
|
const assessments = preparation?.documentActivations || [];
|
|
681
925
|
if (!audit || !assessments.length) return "";
|
|
@@ -916,32 +1160,62 @@ function collectionReviewPanel(type, force = false) {
|
|
|
916
1160
|
if (!assessment || (!force && !collectionReviewVisible(type))) return "";
|
|
917
1161
|
const configuration = assessment.configuration;
|
|
918
1162
|
const current = assessment.status === "current";
|
|
1163
|
+
const scheduleReview = type === "retention-schedule-item" && modelSupports("retention-schedule-approval");
|
|
1164
|
+
const scheduleBlocker = scheduleReview ? retentionScheduleApprovalBlocker() : null;
|
|
919
1165
|
const needsFirstRecord = collectionNeedsFirstRecord(type);
|
|
920
1166
|
const reviewerNames = (assessment.review?.reviewedByIds || [])
|
|
921
1167
|
.map((id) => state.resources.find(({ record }) => record.id === id)?.record.title || id);
|
|
922
1168
|
const reviewNote = assessment.review?.rationale
|
|
923
1169
|
? " Note: " + esc(assessment.review.rationale)
|
|
924
1170
|
: "";
|
|
925
|
-
const reviewSummary = current
|
|
1171
|
+
const reviewSummary = current && !scheduleBlocker
|
|
926
1172
|
? '<p class="collection-review-result"><strong>' + esc(properCase(assessment.review.decision)) + '</strong><span>Reviewed ' + esc(formatCalendarDate(assessment.review.reviewedOn)) + (reviewerNames.length ? " by " + esc(reviewerNames.join(", ")) : "") + "." + reviewNote + '</span></p>'
|
|
927
|
-
: '<p class="collection-review-result"><strong>' + (needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(assessment.message) + '</span></p>';
|
|
928
|
-
const compactReviewSummary = current
|
|
1173
|
+
: '<p class="collection-review-result"><strong>' + (scheduleBlocker ? "Complete schedule first" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(scheduleBlocker || assessment.message) + '</span></p>';
|
|
1174
|
+
const compactReviewSummary = current && !scheduleBlocker
|
|
929
1175
|
? '<p class="collection-review-result"><strong>' + esc(properCase(assessment.review.decision)) + '</strong><span>Reviewed ' + esc(formatCalendarDate(assessment.review.reviewedOn)) + (reviewerNames.length ? " by " + esc(reviewerNames.join(", ")) : "") + ".</span></p>"
|
|
930
1176
|
: reviewSummary;
|
|
931
1177
|
const action = state.readOnly
|
|
932
1178
|
? ""
|
|
1179
|
+
: scheduleBlocker
|
|
1180
|
+
? '<button class="button" type="button" disabled title="' + esc(scheduleBlocker) + '">Review and approve schedule</button>'
|
|
933
1181
|
: needsFirstRecord
|
|
934
1182
|
? '<a class="button primary" href="#/resources/' + encodeURIComponent(type) + '?new=1">Add first ' + esc(state.model.resources[type].title.toLowerCase()) + '</a>'
|
|
935
|
-
: '<button class="button ' + (current ? "" : "primary") + '" type="button" data-review-collection="' + esc(type) + '">' + (current ? "Review again" : "Review and confirm") + '</button>';
|
|
936
|
-
const status = current ? "Reviewed" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Stale" : "Review required";
|
|
937
|
-
const details = '<details ' + (current ? 'class="collection-review-details"' : "open") + '><summary>' + (current ? "Show scope confirmation" : "What to review") + '</summary><div class="collection-review-detail-content"><div class="collection-review-head"><div><p class="kicker">Scope confirmation</p><h3>' + esc(configuration.title) + '</h3><p>' + esc(configuration.description) + '</p></div><span class="badge ' + (current ? "good" : "warn") + '">' + status + '</span></div><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul>' + (current ? reviewSummary : "") + '</div></details>';
|
|
1183
|
+
: '<button class="button ' + (current ? "" : "primary") + '" type="button" data-review-collection="' + esc(type) + '">' + (current ? scheduleReview ? "Approve a new revision" : "Review again" : scheduleReview ? "Review and approve schedule" : "Review and confirm") + '</button>';
|
|
1184
|
+
const status = scheduleBlocker ? "Not ready" : current ? "Reviewed" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Stale" : "Review required";
|
|
1185
|
+
const details = '<details ' + (current ? 'class="collection-review-details"' : "open") + '><summary>' + (current ? scheduleReview ? "Show approved revision" : "Show scope confirmation" : "What to review") + '</summary><div class="collection-review-detail-content"><div class="collection-review-head"><div><p class="kicker">' + (scheduleReview ? "Schedule approval" : "Scope confirmation") + '</p><h3>' + esc(configuration.title) + '</h3><p>' + esc(configuration.description) + '</p></div><span class="badge ' + (current ? "good" : "warn") + '">' + status + '</span></div><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul>' + (current ? reviewSummary : "") + '</div></details>';
|
|
938
1186
|
const summary = current
|
|
939
|
-
? '<div class="collection-review-current-row"><div class="collection-review-complete-summary"><span class="kicker">Scope confirmation</span>' + compactReviewSummary + '</div>' + details + action + '</div>'
|
|
1187
|
+
? '<div class="collection-review-current-row"><div class="collection-review-complete-summary"><span class="kicker">' + (scheduleReview ? "Approved schedule" : "Scope confirmation") + '</span>' + compactReviewSummary + '</div>' + details + action + '</div>'
|
|
940
1188
|
: details;
|
|
941
1189
|
return '<section class="collection-review-panel panel ' + (current ? "current" : "required") + '">' + summary +
|
|
942
1190
|
(current ? "" : '<div class="collection-review-foot">' + reviewSummary + action + '</div>') + '</section>';
|
|
943
1191
|
}
|
|
944
1192
|
|
|
1193
|
+
function retentionScheduleApprovalBlocker() {
|
|
1194
|
+
if (!modelSupports("retention-schedule-approval")) return null;
|
|
1195
|
+
const documents = state.resources.filter(({ record }) => (
|
|
1196
|
+
record.type === "document"
|
|
1197
|
+
&& record.documentKind === "schedule"
|
|
1198
|
+
&& record.workflowScope === "program"
|
|
1199
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
1200
|
+
)).map(({ record }) => record);
|
|
1201
|
+
const document = documents.length === 1 ? documents[0] : null;
|
|
1202
|
+
if (documents.length > 1) return "Keep exactly one current program Data Retention Schedule document before approval.";
|
|
1203
|
+
if (!document) return "Add the governing Data Retention Schedule document first.";
|
|
1204
|
+
if (!["approved", "active"].includes(document.status) || !document.approvedOn || !document.approvedContentRevisions) {
|
|
1205
|
+
return "Approve the governing document and bind its exact Markdown revision first.";
|
|
1206
|
+
}
|
|
1207
|
+
const documentReadiness = retentionScheduleDocumentReadiness(document.id);
|
|
1208
|
+
if (documentReadiness?.status !== "complete") {
|
|
1209
|
+
return documentReadiness?.message || "Complete the governing document before approving the schedule.";
|
|
1210
|
+
}
|
|
1211
|
+
const planned = resourcesOfType("retention-schedule-item").find(({ record }) => record.status === "planned")?.record;
|
|
1212
|
+
if (planned) return "Complete or retire the planned row: " + planned.title + ".";
|
|
1213
|
+
const incomplete = retentionReviewItems("policies", false).find((item) => (
|
|
1214
|
+
item.status === "action" && item.id !== "collection-review-retention-schedule-item"
|
|
1215
|
+
));
|
|
1216
|
+
return incomplete?.message || null;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
945
1219
|
function collectionReviewVisible(type) {
|
|
946
1220
|
return type !== "control";
|
|
947
1221
|
}
|
|
@@ -1033,8 +1307,7 @@ function recordWorkflowItems(type, id) {
|
|
|
1033
1307
|
const activeStates = new Set(["blocked", "due", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
|
|
1034
1308
|
return [
|
|
1035
1309
|
...(state.workflow?.findings || []),
|
|
1036
|
-
...(state.workflow?.workItems || [])
|
|
1037
|
-
...programReadinessWorkflowItems()
|
|
1310
|
+
...(state.workflow?.workItems || [])
|
|
1038
1311
|
].filter((item) => (
|
|
1039
1312
|
activeStates.has(item.state)
|
|
1040
1313
|
&& (
|
|
@@ -1048,14 +1321,15 @@ function recordWorkflowItems(type, id) {
|
|
|
1048
1321
|
));
|
|
1049
1322
|
}
|
|
1050
1323
|
|
|
1051
|
-
function recordWorkflowCell(type, entry) {
|
|
1324
|
+
function recordWorkflowCell(type, entry, detailContext = "") {
|
|
1052
1325
|
if (state.sections?.workflow !== "complete") {
|
|
1053
1326
|
return '<button class="text-button record-workflow-clear" type="button" data-load-workflow>Calculate action</button>';
|
|
1054
1327
|
}
|
|
1055
1328
|
const items = recordWorkflowItems(type, entry.record.id);
|
|
1056
1329
|
if (!items.length) return '<span class="record-workflow-clear">No calculated action</span>';
|
|
1057
1330
|
const item = items[0];
|
|
1058
|
-
|
|
1331
|
+
let href = workflowItemHref(item) || "#/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(entry.record.id);
|
|
1332
|
+
if (detailContext && href.startsWith("#/resource/" + encodeURIComponent(type) + "/") && !href.includes("?")) href += detailContext;
|
|
1059
1333
|
const title = type === "control" ? "Finish implementation" : item.title;
|
|
1060
1334
|
return '<a class="record-workflow-action" href="' + href + '"><span class="workflow-finding-status ' + esc(item.state) + '">' + esc(properCase(item.state)) + '</span><span><strong>' + esc(title) + '</strong><small>' + esc(stagePageItemDetail(item)) + (items.length > 1 ? " +" + (items.length - 1) + " more" : "") + '</small></span></a>';
|
|
1061
1335
|
}
|
|
@@ -1064,7 +1338,10 @@ function openCollectionReviewDialog(type) {
|
|
|
1064
1338
|
const assessment = state.collectionReviews?.[type];
|
|
1065
1339
|
if (!assessment) return;
|
|
1066
1340
|
const configuration = assessment.configuration;
|
|
1067
|
-
const
|
|
1341
|
+
const scheduleReview = type === "retention-schedule-item" && modelSupports("retention-schedule-approval");
|
|
1342
|
+
const eligibleReviewerIds = assessment.eligibleReviewerIds
|
|
1343
|
+
? new Set(assessment.eligibleReviewerIds)
|
|
1344
|
+
: null;
|
|
1068
1345
|
const people = resourcesOfType("person").filter(({ record }) => (
|
|
1069
1346
|
record.status === "active" && (!eligibleReviewerIds || eligibleReviewerIds.has(record.id))
|
|
1070
1347
|
));
|
|
@@ -1091,10 +1368,18 @@ function openCollectionReviewDialog(type) {
|
|
|
1091
1368
|
const dialog = document.createElement("dialog");
|
|
1092
1369
|
dialog.className = "commit-dialog event-dialog collection-review-dialog";
|
|
1093
1370
|
dialog.setAttribute("aria-labelledby", "collection-review-dialog-title");
|
|
1094
|
-
const reviewerHelp =
|
|
1095
|
-
? '<p class="policy-activation-warning">No eligible reviewer is configured. Add an active
|
|
1371
|
+
const reviewerHelp = eligibleReviewerIds && !people.length
|
|
1372
|
+
? '<p class="policy-activation-warning">No eligible reviewer is configured. Add an active Person who does not own ' + (scheduleReview ? 'the governing document or any included schedule row.' : 'these Controls or their enabled Obligations.') + '</p>'
|
|
1096
1373
|
: "";
|
|
1097
|
-
|
|
1374
|
+
const dialogKicker = scheduleReview ? "Schedule approval" : "Scope confirmation";
|
|
1375
|
+
const dialogTitle = scheduleReview ? "Review and approve Data Retention Schedule" : "Confirm " + configuration.title.toLowerCase();
|
|
1376
|
+
const checklistLabel = scheduleReview ? "Before approving" : "Before confirming";
|
|
1377
|
+
const saveDescription = scheduleReview
|
|
1378
|
+
? 'Approve the governing document and ' + assessment.recordCount + ' current ' + pluralize("schedule row", assessment.recordCount) + ' as one revision. A later document, row, or material scope change requires another approval.'
|
|
1379
|
+
: 'Confirm ' + assessment.recordCount + ' current ' + pluralize("record", assessment.recordCount) + '. If the collection or material scope changes, FileGRC will ask for another review.';
|
|
1380
|
+
const confirmSubmit = '<button type="submit" class="button primary">Confirm and save</button>';
|
|
1381
|
+
const submit = scheduleReview ? '<button type="submit" class="button primary">Approve schedule</button>' : confirmSubmit;
|
|
1382
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + dialogKicker + '</p><h2 id="collection-review-dialog-title">' + esc(dialogTitle) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(configuration.description) + '</p><section class="event-dialog-steps collection-review-checks"><strong>' + checklistLabel + '</strong><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section>' + reviewerHelp + '<div class="form-grid"><label><span>Conclusion</span><select name="decision" required>' + decisions + '</select></label><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + ((assessment.review?.reviewedByIds || []).includes(record.id) ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label><label data-authoritative-system><span>Authoritative ' + (v4 ? "Component" : "System") + '</span><select name="authoritativeSourceId"><option value="">Select</option>' + systems.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (preservedAuthoritativeSourceId === record.id ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label class="full"><span>Review notes</span><textarea name="rationale" rows="3" required placeholder="Note what you confirmed and any scope decision that needs context.">' + esc(assessment.review?.rationale || "") + '</textarea></label></div><div class="workflow-preview"><strong>What this saves</strong><p>' + esc(saveDescription) + '</p></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status review-save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button>' + submit + '</div></form>';
|
|
1098
1383
|
document.body.append(dialog);
|
|
1099
1384
|
dialog.showModal();
|
|
1100
1385
|
const form = dialog.querySelector("form");
|
|
@@ -1126,7 +1411,8 @@ function openCollectionReviewDialog(type) {
|
|
|
1126
1411
|
reviewedOn: form.elements.reviewedOn.value,
|
|
1127
1412
|
scopeRevision: state.git?.commit || undefined,
|
|
1128
1413
|
[v4 ? "authoritativeComponentId" : "authoritativeSystemId"]: form.elements.authoritativeSourceId.value || undefined,
|
|
1129
|
-
expectedRevision: assessment.reviewRevision || undefined
|
|
1414
|
+
expectedRevision: assessment.reviewRevision || undefined,
|
|
1415
|
+
expectedCollectionRevision: assessment.collectionRevision
|
|
1130
1416
|
};
|
|
1131
1417
|
form.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = true; });
|
|
1132
1418
|
const submit = form.querySelector('button[type="submit"]');
|
|
@@ -1195,6 +1481,7 @@ function workflowItemHref(item) {
|
|
|
1195
1481
|
const collectionReviewCommand = commands.find((command) => command.includes(" review-collection "));
|
|
1196
1482
|
const collectionReviewType = collectionReviewCommand?.match(/review-collection\s+([a-z0-9-]+)/)?.[1];
|
|
1197
1483
|
if (collectionReviewType && state.model.collectionReviews?.[collectionReviewType]) {
|
|
1484
|
+
if (collectionReviewType === "retention-schedule-item" && modelSupports("retention-schedule-approval")) return "#/retention-schedule";
|
|
1198
1485
|
return "#/resources/" + encodeURIComponent(collectionReviewType) + "?review-collection=1";
|
|
1199
1486
|
}
|
|
1200
1487
|
if (
|
|
@@ -1242,7 +1529,10 @@ function workflowItemHref(item) {
|
|
|
1242
1529
|
function retentionScheduleItemHref(item) {
|
|
1243
1530
|
const params = new URLSearchParams({ new: "1" });
|
|
1244
1531
|
if (item.informationTypeId) params.set("informationTypeId", item.informationTypeId);
|
|
1245
|
-
|
|
1532
|
+
for (const id of item.retentionScopeResourceIds || []) params.append("scopeResourceId", id);
|
|
1533
|
+
if (!params.has("scopeResourceId") && (item.resourceId || item.subject?.id)) {
|
|
1534
|
+
params.set("scopeResourceId", item.resourceId || item.subject.id);
|
|
1535
|
+
}
|
|
1246
1536
|
const informationTypeTitle = String(item.title || "").replace(/^Decide retention for\s+/i, "").trim();
|
|
1247
1537
|
if (informationTypeTitle && informationTypeTitle !== item.title) params.set("title", "Retention for " + informationTypeTitle);
|
|
1248
1538
|
const scheduleDocuments = resourcesOfType("document").filter(({ record }) => (
|
|
@@ -1251,7 +1541,7 @@ function retentionScheduleItemHref(item) {
|
|
|
1251
1541
|
&& !["superseded", "retired"].includes(record.status)
|
|
1252
1542
|
));
|
|
1253
1543
|
if (scheduleDocuments.length === 1) params.set("scheduleDocumentId", scheduleDocuments[0].record.id);
|
|
1254
|
-
return "#/
|
|
1544
|
+
return "#/retention-schedule?" + params;
|
|
1255
1545
|
}
|
|
1256
1546
|
|
|
1257
1547
|
function workflowItemDetail(item) {
|
|
@@ -1328,57 +1618,76 @@ function renderEvidenceReadiness() {
|
|
|
1328
1618
|
return '<section class="evidence-map"><div class="evidence-map-head"><div><p class="kicker">Control implementation</p><h2>' + completeCount + ' of ' + items.length + ' evidence ' + (items.length === 1 ? "family" : "families") + ' ready</h2><p>Connect each Control to the ' + sourceLabel + ' that produce its evidence.</p></div><div class="evidence-map-actions"><a class="button" href="#/resources/' + sourceType + '">Review ' + sourceLabel + '</a><a class="button primary" href="#/resources/control">Review Controls</a></div></div>' + (cards || empty) + more + '</section>';
|
|
1329
1619
|
}
|
|
1330
1620
|
|
|
1331
|
-
function retentionReviewItems() {
|
|
1621
|
+
function retentionReviewItems(stageId = null, includeMappings = true) {
|
|
1622
|
+
const scheduleDocumentReviewIds = new Set(state.resources
|
|
1623
|
+
.filter(({ record }) => record.type === "document" && record.documentKind === "schedule" && record.workflowScope === "program")
|
|
1624
|
+
.map(({ record }) => "document-approval-" + record.id));
|
|
1332
1625
|
return (state.programReadiness?.stages || []).flatMap((stage) => (
|
|
1333
1626
|
(stage.items || []).filter((item) => (
|
|
1334
|
-
|
|
1335
|
-
|
|
1627
|
+
(!stageId || stage.id === stageId)
|
|
1628
|
+
&& (item.id?.startsWith("retention-")
|
|
1629
|
+
|| includeMappings && item.id?.startsWith("requirement-mapping-")
|
|
1336
1630
|
|| ["collection-review-information-type", "collection-review-retention-schedule-item"].includes(item.id)
|
|
1631
|
+
|| scheduleDocumentReviewIds.has(item.id)
|
|
1632
|
+
)
|
|
1337
1633
|
)).map((item) => ({ ...item, stage: stage.id }))
|
|
1338
1634
|
));
|
|
1339
1635
|
}
|
|
1340
1636
|
|
|
1341
|
-
function
|
|
1342
|
-
if (
|
|
1343
|
-
return
|
|
1344
|
-
|
|
1345
|
-
.
|
|
1346
|
-
|
|
1347
|
-
|
|
1637
|
+
function isRetentionScheduleWorkflowItem(item) {
|
|
1638
|
+
if (item.id?.startsWith("retention-") || item.id === "collection-review-retention-schedule-item") return true;
|
|
1639
|
+
return [item.subject, item.source].filter(Boolean).some((reference) => {
|
|
1640
|
+
if (reference.type === "retention-schedule-item") return true;
|
|
1641
|
+
if (reference.type !== "document" || !reference.id) return false;
|
|
1642
|
+
const record = state.resources.find(({ record }) => record.id === reference.id)?.record;
|
|
1643
|
+
return record?.documentKind === "schedule" && record.workflowScope !== "engagement";
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
function retentionSchedulePageItems() {
|
|
1648
|
+
const plannedRows = resourcesOfType("retention-schedule-item")
|
|
1649
|
+
.filter(({ record }) => record.status === "planned")
|
|
1650
|
+
.map(({ record }) => ({
|
|
1651
|
+
id: "planned-" + record.id,
|
|
1652
|
+
key: "planned-" + record.id,
|
|
1653
|
+
status: "action",
|
|
1348
1654
|
state: "ready",
|
|
1349
|
-
|
|
1655
|
+
title: "Complete or retire " + record.title,
|
|
1656
|
+
message: "Planned rows are excluded from the approved schedule. Finish this decision or retire the row before schedule approval.",
|
|
1657
|
+
resourceType: record.type,
|
|
1658
|
+
resourceId: record.id,
|
|
1659
|
+
href: '#/resource/retention-schedule-item/' + encodeURIComponent(record.id) + '?stage=policies'
|
|
1350
1660
|
}));
|
|
1661
|
+
return distinctPageActionItems([
|
|
1662
|
+
...plannedRows,
|
|
1663
|
+
...retentionReviewItems("policies", false)
|
|
1664
|
+
.filter(({ status }) => status === "action")
|
|
1665
|
+
.map((item) => ({
|
|
1666
|
+
...item,
|
|
1667
|
+
key: item.id,
|
|
1668
|
+
state: "ready",
|
|
1669
|
+
href: item.id === "collection-review-retention-schedule-item"
|
|
1670
|
+
? "#/retention-schedule"
|
|
1671
|
+
: item.id?.startsWith("retention-use-")
|
|
1672
|
+
? retentionScheduleItemHref(item)
|
|
1673
|
+
: item.resourceId && item.resourceType
|
|
1674
|
+
? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
|
|
1675
|
+
: workflowItemHref(item) || "#/retention-schedule",
|
|
1676
|
+
subject: item.resourceId && item.resourceType
|
|
1677
|
+
? { type: item.resourceType, id: item.resourceId }
|
|
1678
|
+
: item.subject
|
|
1679
|
+
}))
|
|
1680
|
+
]);
|
|
1351
1681
|
}
|
|
1352
1682
|
|
|
1353
|
-
function
|
|
1354
|
-
const
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
...(item.retentionScheduleItemIds || []),
|
|
1362
|
-
...(item.staleResourceIds || [])
|
|
1363
|
-
].filter(Boolean))];
|
|
1364
|
-
const references = ids.length
|
|
1365
|
-
? '<div class="evidence-map-references">' + ids.map((id) => formatReference(id)).join("") + '</div>'
|
|
1366
|
-
: "";
|
|
1367
|
-
const href = item.id?.startsWith("retention-use-")
|
|
1368
|
-
? retentionScheduleItemHref(item)
|
|
1369
|
-
: item.id?.startsWith("collection-review-")
|
|
1370
|
-
? '#/resources/' + encodeURIComponent(item.resourceType)
|
|
1371
|
-
: item.resourceId && item.resourceType
|
|
1372
|
-
? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
|
|
1373
|
-
: '#/resources/' + encodeURIComponent(item.resourceType || "retention-schedule-item");
|
|
1374
|
-
return '<article class="evidence-map-card"><div class="evidence-map-card-head"><div><span class="badge warn">Review</span><h3><a href="' + href + '">' + esc(item.title) + '</a></h3></div></div><p>' + esc(item.message) + '</p>' + references + '</article>';
|
|
1375
|
-
});
|
|
1376
|
-
const visibleCount = 6;
|
|
1377
|
-
const visibleCards = cards.slice(0, visibleCount).join("");
|
|
1378
|
-
const moreCards = cards.length > visibleCount
|
|
1379
|
-
? '<details class="workflow-guidance-more retention-readiness-more"><summary>Show ' + (cards.length - visibleCount) + ' more retention and mapping items</summary><div class="policy-activation-grid workflow-findings-more">' + cards.slice(visibleCount).join("") + '</div></details>'
|
|
1380
|
-
: "";
|
|
1381
|
-
return '<section class="evidence-map retention-readiness"><div class="evidence-map-head"><div><p class="kicker">Information lifecycle</p><h2>' + (items.length - actions.length) + ' of ' + items.length + ' retention and mapping checks current</h2><p>Review Information Types, schedule coverage, and mappings here when source records or processing uses change.</p></div><div class="evidence-map-actions"><a class="button" href="#/resources/requirement-mapping">Review mappings</a><a class="button primary" href="#/resources/retention-schedule-item">Review schedule</a></div></div>' + (cards.length ? '<div class="policy-activation-grid">' + visibleCards + '</div>' + moreCards : '<article class="evidence-map-card complete"><span class="badge good">Current</span><p>Every retention decision and Requirement Mapping is bound to its current sources.</p></article>') + '</section>';
|
|
1683
|
+
function distinctPageActionItems(items) {
|
|
1684
|
+
const distinct = new Map();
|
|
1685
|
+
for (const item of items) {
|
|
1686
|
+
const href = item.href || workflowItemHref(item);
|
|
1687
|
+
const key = pageActionIdentity(href, item.key || item.id);
|
|
1688
|
+
if (!distinct.has(key)) distinct.set(key, item);
|
|
1689
|
+
}
|
|
1690
|
+
return [...distinct.values()];
|
|
1382
1691
|
}
|
|
1383
1692
|
|
|
1384
1693
|
function evidenceSourceCheckLabel(name) {
|
|
@@ -1418,11 +1727,11 @@ function stagePageCard(stage, destination, index) {
|
|
|
1418
1727
|
const stepLabel = "Step " + stage.number + "." + String.fromCharCode(97 + index);
|
|
1419
1728
|
const derived = derivedStagePageState(stage, destination);
|
|
1420
1729
|
const complete = derived.complete;
|
|
1421
|
-
const items =
|
|
1730
|
+
const items = stagePageActionItems(stage, destination);
|
|
1422
1731
|
const completionState = '<span class="stage-page-completion-state ' + (complete ? "complete" : "") + '">' + esc(derived.label) + '</span>';
|
|
1423
1732
|
const taskPreview = items.length
|
|
1424
1733
|
? '<div class="stage-page-tasks">' + items.slice(0, 3).map((item) => {
|
|
1425
|
-
const href = destination.utility === "evidence-sources" ? destination.href : workflowItemHref(item) || destination.href;
|
|
1734
|
+
const href = destination.utility === "evidence-sources" ? destination.href : item.href || workflowItemHref(item) || destination.href;
|
|
1426
1735
|
return '<a href="' + href + '"><span class="workflow-finding-status ' + esc(item.state) + '">' + esc(properCase(item.state)) + '</span><span><strong>' + esc(item.title) + '</strong><small>' + esc(stagePageItemDetail(item)) + '</small></span></a>';
|
|
1427
1736
|
}).join("") + (items.length > 3 ? '<small class="stage-page-tasks-more">+' + (items.length - 3) + ' more on this page</small>' : "") + '</div>'
|
|
1428
1737
|
: "";
|
|
@@ -1457,6 +1766,11 @@ function stageProgress(stage) {
|
|
|
1457
1766
|
return progressFromCounts(complete, applicable.length, "page");
|
|
1458
1767
|
}
|
|
1459
1768
|
|
|
1769
|
+
function stagePageActionItems(stage, destination) {
|
|
1770
|
+
const deferredStates = new Set(["later", "scheduled", "upcoming", "waiting-external"]);
|
|
1771
|
+
return stagePageItems(stage, destination).filter(({ state }) => !deferredStates.has(state));
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1460
1774
|
function derivedStagePageState(stage, destination) {
|
|
1461
1775
|
if (
|
|
1462
1776
|
stage.id === "audit"
|
|
@@ -1473,9 +1787,15 @@ function derivedStagePageState(stage, destination) {
|
|
|
1473
1787
|
? { complete: false, label: incomplete + " " + pluralize("source", incomplete) + " need work" }
|
|
1474
1788
|
: { complete: evidenceItems.length > 0, label: evidenceItems.length ? "Ready" : "Not configured", countsTowardProgress: evidenceItems.length > 0 };
|
|
1475
1789
|
}
|
|
1790
|
+
if (destination.utility === "retention-schedule") {
|
|
1791
|
+
const incomplete = retentionSchedulePageItems().length;
|
|
1792
|
+
return !incomplete
|
|
1793
|
+
? { complete: true, label: "Approved" }
|
|
1794
|
+
: { complete: false, label: incomplete ? incomplete + " " + pluralize("item", incomplete) + " need work" : "Approval needed" };
|
|
1795
|
+
}
|
|
1476
1796
|
const items = stagePageItems(stage, destination);
|
|
1477
1797
|
const deferredStates = new Set(["later", "scheduled", "upcoming", "waiting-external"]);
|
|
1478
|
-
const blocking =
|
|
1798
|
+
const blocking = stagePageActionItems(stage, destination);
|
|
1479
1799
|
if (blocking.length) {
|
|
1480
1800
|
return { complete: false, label: blocking.length + " " + pluralize("item", blocking.length) + (blocking.length === 1 ? " needs work" : " need work") };
|
|
1481
1801
|
}
|
|
@@ -1500,8 +1820,7 @@ function stagePageItems(stage, destination) {
|
|
|
1500
1820
|
const activeStates = new Set(["blocked", "due", "later", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
|
|
1501
1821
|
const items = [
|
|
1502
1822
|
...(state.workflow?.findings || []),
|
|
1503
|
-
...(state.workflow?.workItems || [])
|
|
1504
|
-
...programReadinessWorkflowItems()
|
|
1823
|
+
...(state.workflow?.workItems || [])
|
|
1505
1824
|
].filter((item) => (
|
|
1506
1825
|
activeStates.has(item.state)
|
|
1507
1826
|
&& (
|
|
@@ -1516,14 +1835,10 @@ function stagePageItems(stage, destination) {
|
|
|
1516
1835
|
.filter(({ id, status }) => id.startsWith("source-family-") && status !== "complete")
|
|
1517
1836
|
.map((item) => ({ ...item, key: item.id, state: item.status, subject: { type: "source-coverage" } }));
|
|
1518
1837
|
}
|
|
1519
|
-
if (
|
|
1520
|
-
return
|
|
1521
|
-
workflowItemStatePriority(left) - workflowItemStatePriority(right)
|
|
1522
|
-
|| (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
|
|
1523
|
-
|| left.key.localeCompare(right.key)
|
|
1524
|
-
));
|
|
1838
|
+
if (destination.utility === "retention-schedule") {
|
|
1839
|
+
return retentionSchedulePageItems();
|
|
1525
1840
|
}
|
|
1526
|
-
return items.filter((item) => {
|
|
1841
|
+
return distinctPageActionItems(items.filter((item) => {
|
|
1527
1842
|
if (destination.type === "program" && item.key === "program.scope.criteria") return false;
|
|
1528
1843
|
if (
|
|
1529
1844
|
destination.type === "requirement"
|
|
@@ -1535,22 +1850,10 @@ function stagePageItems(stage, destination) {
|
|
|
1535
1850
|
&& ["source-coverage", "system"].includes(item.subject?.type)
|
|
1536
1851
|
&& item.key?.startsWith("evidence-source.")
|
|
1537
1852
|
) return false;
|
|
1538
|
-
if (
|
|
1539
|
-
stage.id === "policies"
|
|
1540
|
-
&& destination.type === "policy"
|
|
1541
|
-
&& item.code === "governance.appointment.independent-policy-reviewer"
|
|
1542
|
-
) return true;
|
|
1543
|
-
if (
|
|
1544
|
-
destination.type
|
|
1545
|
-
&& (item.subject?.type === destination.type || item.source?.type === destination.type)
|
|
1546
|
-
) return true;
|
|
1547
1853
|
const href = workflowItemHref(item);
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|| href.startsWith(destinationHref + "?")
|
|
1552
|
-
|| Boolean(destination.type && href.startsWith("#/resource/" + destination.type + "/"));
|
|
1553
|
-
}).sort((left, right) => (
|
|
1854
|
+
return workflowHrefBelongsToPage(destination, href)
|
|
1855
|
+
&& !(destination.href === "#/policies" && isRetentionScheduleWorkflowItem(item));
|
|
1856
|
+
})).sort((left, right) => (
|
|
1554
1857
|
workflowItemStatePriority(left) - workflowItemStatePriority(right)
|
|
1555
1858
|
|| (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
|
|
1556
1859
|
|| left.key.localeCompare(right.key)
|
|
@@ -1566,7 +1869,7 @@ function operationProgress() {
|
|
|
1566
1869
|
const goal = program?.target?.goal || activeProgram().assuranceGoal || "none";
|
|
1567
1870
|
const asOf = program?.asOf || currentDate();
|
|
1568
1871
|
const candidateStarted = goal === "soc-2-type-2"
|
|
1569
|
-
? Boolean(program?.
|
|
1872
|
+
? Boolean(program?.progress?.mode === "operating-window")
|
|
1570
1873
|
: goal === "soc-2-type-1"
|
|
1571
1874
|
? Boolean(program?.target?.candidateCoverage?.kind === "as-of")
|
|
1572
1875
|
: Boolean(program?.evidenceReady);
|
|
@@ -1661,6 +1964,7 @@ function sectionDestinations(section) {
|
|
|
1661
1964
|
}
|
|
1662
1965
|
if (section.utility === "obligation-board") destinations.push({ utility: section.utility, kind: "Working page", label: "Work Queue", href: "#/stage/run", description: "Complete recurring work, Policy Event tasks, and assigned follow-up with its due windows and linked proof." });
|
|
1663
1966
|
if (section.utility === "evidence-sources") destinations.push({ utility: section.utility, kind: "Coverage page", label: "Evidence Sources", href: "#/evidence-sources", description: section.description });
|
|
1967
|
+
if (section.utility === "retention-schedule") destinations.push({ utility: section.utility, kind: "Governed schedule", label: "Data Retention Schedule", href: "#/retention-schedule", description: section.description });
|
|
1664
1968
|
if (section.utility === "audit-packet") destinations.push({ utility: section.utility, kind: "Working page", label: "Audit Evidence & Packet", href: "#/audit-packet", description: "Review filegrc Evidence and Evidence Artifacts, prepare fieldwork, and build the indexed packet." });
|
|
1665
1969
|
return destinations;
|
|
1666
1970
|
}
|
|
@@ -2837,8 +3141,13 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
2837
3141
|
const seed = type === "retention-schedule-item" ? {
|
|
2838
3142
|
...(params.get("title") ? { title: params.get("title") } : {}),
|
|
2839
3143
|
...(params.get("informationTypeId") ? { informationTypeIds: [params.get("informationTypeId")] } : {}),
|
|
2840
|
-
...(params.
|
|
3144
|
+
...(params.getAll("scopeResourceId").length ? { scopeResourceIds: params.getAll("scopeResourceId") } : {}),
|
|
2841
3145
|
...(params.get("scheduleDocumentId") ? { scheduleDocumentId: params.get("scheduleDocumentId") } : {})
|
|
3146
|
+
} : type === "document" ? {
|
|
3147
|
+
...(params.get("title") ? { title: params.get("title") } : {}),
|
|
3148
|
+
...(params.get("documentKind") ? { documentKind: params.get("documentKind") } : {}),
|
|
3149
|
+
...(documentScope ? { workflowScope: documentScope } : {}),
|
|
3150
|
+
...relationshipSeed
|
|
2842
3151
|
} : {
|
|
2843
3152
|
...(params.get("title") ? { title: params.get("title") } : {}),
|
|
2844
3153
|
...relationshipSeed
|
|
@@ -2873,8 +3182,19 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2873
3182
|
const entry = resourcesOfType(type).find(({ record }) => record.id === id);
|
|
2874
3183
|
const definition = state.model.resources[type];
|
|
2875
3184
|
if (!entry || !definition) return renderNotFound(main);
|
|
3185
|
+
const scheduleContext = params.get("stage") === "policies" && (
|
|
3186
|
+
type === "retention-schedule-item"
|
|
3187
|
+
|| type === "document" && entry.record.documentKind === "schedule" && entry.record.workflowScope !== "engagement"
|
|
3188
|
+
);
|
|
3189
|
+
const policiesContext = params.get("stage") === "policies" && (
|
|
3190
|
+
type === "policy"
|
|
3191
|
+
|| type === "training"
|
|
3192
|
+
|| type === "document" && !auditSpecificDocument(entry.record) && entry.record.documentKind !== "schedule"
|
|
3193
|
+
);
|
|
3194
|
+
const collectionHref = scheduleContext ? "#/retention-schedule" : policiesContext ? "#/policies" : "#/resources/" + encodeURIComponent(type);
|
|
3195
|
+
const collectionLabel = scheduleContext ? "Data Retention Schedule" : policiesContext ? "Policies" : titleCase(definition.pluralTitle);
|
|
2876
3196
|
if (entry.detailsLoaded === false) {
|
|
2877
|
-
main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="
|
|
3197
|
+
main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="' + collectionHref + '">' + esc(collectionLabel) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div></div><section class="panel detail-loading is-loading" role="status" aria-live="polite" aria-busy="true">' + loadingIndicator("Loading record…") + '</section></div>';
|
|
2878
3198
|
loadResourceDetail(type, id);
|
|
2879
3199
|
return;
|
|
2880
3200
|
}
|
|
@@ -2884,12 +3204,11 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2884
3204
|
const narrative = recordNarrative(entry.record, fields);
|
|
2885
3205
|
const narrativeNames = new Set(narrative.map(([name]) => name));
|
|
2886
3206
|
const visible = Object.entries(entry.record).filter(([name]) => (
|
|
2887
|
-
!["id", "type", "title"].includes(name)
|
|
3207
|
+
!["id", "type", "title", "extensions"].includes(name)
|
|
2888
3208
|
&& !fields[name]?.content
|
|
2889
3209
|
&& !narrativeNames.has(name)
|
|
2890
3210
|
));
|
|
2891
3211
|
const content = Object.entries(entry.content);
|
|
2892
|
-
const sourceMetadata = '<div><dt>Source file</dt><dd><code>' + esc(entry.relativePath) + '</code></dd></div><div><dt>Workspace revision</dt><dd>' + (state.git.available ? '<code>' + esc(state.git.shortCommit) + '</code>' : "Unavailable until the workspace is committed.") + '</dd></div>';
|
|
2893
3212
|
const narrativeContent = narrative.length
|
|
2894
3213
|
? '<div class="content-label"><span>Record</span></div><div class="record-prose">' + narrative.map(([name, value]) => '<section><h3>' + esc(titleCase(fields[name]?.label || humanize(name))) + '</h3><p>' + esc(value) + '</p></section>').join("") + '</div>'
|
|
2895
3214
|
: "";
|
|
@@ -2942,26 +3261,41 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2942
3261
|
? '<section class="panel detail-main">' + narrativeContent + markdownContent + addRecordContent + '</section>'
|
|
2943
3262
|
: "";
|
|
2944
3263
|
const attachmentPanel = type === "evidence" ? evidenceAttachmentPanel(entry) : "";
|
|
2945
|
-
const
|
|
2946
|
-
? '<section class="panel detail-
|
|
3264
|
+
const recordFieldsPanel = visible.length
|
|
3265
|
+
? '<section class="panel detail-support-panel detail-metadata-panel"><div class="panel-head"><h3>Record details</h3></div><dl class="metadata">' + visible.map(([name, value]) => '<div><dt>' + esc(fields[name]?.label || humanize(name)) + '</dt><dd>' + formatValue(name === "status" ? displayStatus(entry.record) : value, name, type) + '</dd></div>').join("") + '</dl></section>'
|
|
2947
3266
|
: "";
|
|
2948
|
-
const participationPanel = entry.historyLoaded === false
|
|
2949
|
-
? '<section class="panel detail-support-panel" role="status" aria-live="polite" aria-busy="true"><div class="panel-head"><h3>Participation</h3></div><p class="muted is-loading">' + loadingIndicator("Loading participation and file history…") + '</p></section>'
|
|
2950
|
-
: entry.historyError
|
|
2951
|
-
? '<section class="panel detail-support-panel" role="status" aria-live="polite"><div class="panel-head"><h3>Participation</h3></div><p class="muted">Participation and file history are unavailable. Reload the record to try again.</p></section>'
|
|
2952
|
-
: personParticipation(entry);
|
|
2953
3267
|
const supportPanels = renderDetailSupport({
|
|
2954
3268
|
hasRecordBody,
|
|
2955
3269
|
workflowPanel: workflowGuidance({ type, id, title: "Next steps", workflow: entry.workflow }) || recordCompletionState(entry.record),
|
|
2956
|
-
metadataPanel:
|
|
2957
|
-
attachmentPanel
|
|
2958
|
-
participationPanel,
|
|
2959
|
-
connectionsPanel: resourceConnections(entry),
|
|
2960
|
-
historyPanel
|
|
3270
|
+
metadataPanel: recordFieldsPanel,
|
|
3271
|
+
attachmentPanel
|
|
2961
3272
|
});
|
|
2962
|
-
|
|
3273
|
+
const mayDelete = !state.readOnly && !routeSetLocked && !definition.singleton;
|
|
3274
|
+
const recordMenu = '<details class="record-overflow"><summary aria-label="More record actions" title="More record actions"><svg viewBox="0 0 18 4" aria-hidden="true"><circle cx="2" cy="2" r="2"></circle><circle cx="9" cy="2" r="2"></circle><circle cx="16" cy="2" r="2"></circle></svg></summary><div class="record-overflow-menu" aria-label="Record actions"><button type="button" data-record-modal="info">Record info</button><button type="button" data-record-modal="connections">Connections</button><button type="button" data-record-modal="history"' + (entry.historyLoaded === false ? ' disabled' : '') + '>File history' + (entry.historyLoaded === false ? ' (loading)' : '') + '</button>' + (mayDelete ? '<div class="record-overflow-separator"></div><button class="danger-text" type="button" data-delete-resource>Delete record</button>' : '') + '</div></details>';
|
|
3275
|
+
main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="' + collectionHref + '">' + esc(collectionLabel) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div><div class="actions">' + reportingRouteSetActions + auditCycleAction + auditPopulationCorrectionAction + (type === "audit" ? '<a class="button primary" href="#/audit-packet?auditId=' + encodeURIComponent(entry.record.id) + '">Audit Evidence & Packet</a>' : "") + governanceActions + lifecycleActions + issueActions + addRecordContentAction + (!state.readOnly && !routeSetLocked ? '<button class="button" id="edit-resource">Edit</button>' : "") + recordMenu + '</div></div><div class="detail-grid ' + (hasRecordBody ? "" : "detail-grid-structured") + '">' + detailMain + supportPanels + '</div></div>';
|
|
2963
3276
|
main.querySelectorAll("[data-completion-requirements]").forEach((button) => button.addEventListener("click", () => openCompletionRequirementsDialog(button.dataset.completionRequirements)));
|
|
2964
3277
|
main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry));
|
|
3278
|
+
main.querySelectorAll("[data-record-modal]").forEach((button) => button.addEventListener("click", () => {
|
|
3279
|
+
button.closest("details")?.removeAttribute("open");
|
|
3280
|
+
openRecordUtilityDialog(entry, button.dataset.recordModal);
|
|
3281
|
+
}));
|
|
3282
|
+
const overflowMenu = main.querySelector(".record-overflow");
|
|
3283
|
+
const stopOutsideDismissal = () => document.removeEventListener("click", dismissOnOutsideClick);
|
|
3284
|
+
const dismissOnOutsideClick = (event) => {
|
|
3285
|
+
if (overflowMenu.contains(event.target)) return;
|
|
3286
|
+
overflowMenu.removeAttribute("open");
|
|
3287
|
+
stopOutsideDismissal();
|
|
3288
|
+
};
|
|
3289
|
+
overflowMenu?.addEventListener("toggle", () => {
|
|
3290
|
+
stopOutsideDismissal();
|
|
3291
|
+
if (overflowMenu.open) queueMicrotask(() => document.addEventListener("click", dismissOnOutsideClick));
|
|
3292
|
+
});
|
|
3293
|
+
overflowMenu?.addEventListener("keydown", (event) => {
|
|
3294
|
+
if (event.key !== "Escape" || !overflowMenu.open) return;
|
|
3295
|
+
overflowMenu.removeAttribute("open");
|
|
3296
|
+
overflowMenu.querySelector("summary")?.focus();
|
|
3297
|
+
});
|
|
3298
|
+
recordOverflowCleanup = stopOutsideDismissal;
|
|
2965
3299
|
main.querySelector("[data-external-reviewer-governance]")?.addEventListener("click", openExternalReviewerGovernanceDialog);
|
|
2966
3300
|
main.querySelector("[data-next-audit-cycle]")?.addEventListener("click", () => openNextAuditCycleDialog(entry.record));
|
|
2967
3301
|
main.querySelector("[data-correct-audit-population]")?.addEventListener("click", async () => {
|
|
@@ -3072,7 +3406,8 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
3072
3406
|
}));
|
|
3073
3407
|
main.querySelector("#add-record-content")?.addEventListener("click", () => openEditor(type, entry, { addRecordContent: true }));
|
|
3074
3408
|
main.querySelectorAll("[data-edit-content]").forEach((button) => button.addEventListener("click", () => openContentEditor(entry, button.dataset.editContent)));
|
|
3075
|
-
main.querySelector("
|
|
3409
|
+
main.querySelector("[data-delete-resource]")?.addEventListener("click", async () => {
|
|
3410
|
+
main.querySelector(".record-overflow")?.removeAttribute("open");
|
|
3076
3411
|
const repositoryPrefetch = prefetchRepositoryForReview();
|
|
3077
3412
|
if (!await confirmAction({
|
|
3078
3413
|
kicker: "Delete record",
|
|
@@ -3098,17 +3433,50 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
3098
3433
|
}
|
|
3099
3434
|
}
|
|
3100
3435
|
|
|
3101
|
-
function renderDetailSupport({ hasRecordBody, workflowPanel, metadataPanel, attachmentPanel
|
|
3102
|
-
const panels = workflowPanel + metadataPanel + attachmentPanel
|
|
3436
|
+
function renderDetailSupport({ hasRecordBody, workflowPanel, metadataPanel, attachmentPanel }) {
|
|
3437
|
+
const panels = workflowPanel + metadataPanel + attachmentPanel;
|
|
3103
3438
|
if (hasRecordBody) return '<aside>' + panels + '</aside>';
|
|
3104
3439
|
const stacks = [
|
|
3105
3440
|
{ name: "guidance", content: workflowPanel },
|
|
3106
|
-
{ name: "record", content: metadataPanel + attachmentPanel
|
|
3107
|
-
{ name: "relationships", content: participationPanel + connectionsPanel }
|
|
3441
|
+
{ name: "record", content: metadataPanel + attachmentPanel }
|
|
3108
3442
|
].filter(({ content }) => content);
|
|
3109
3443
|
return '<aside class="detail-support-columns">' + stacks.map(({ name, content }) => '<div class="detail-support-stack ' + name + '">' + content + '</div>').join("") + '</aside>';
|
|
3110
3444
|
}
|
|
3111
3445
|
|
|
3446
|
+
function openRecordUtilityDialog(entry, view) {
|
|
3447
|
+
const dialog = document.createElement("dialog");
|
|
3448
|
+
dialog.className = "alert-dialog record-utility-dialog";
|
|
3449
|
+
const definitions = {
|
|
3450
|
+
info: {
|
|
3451
|
+
kicker: "Record info",
|
|
3452
|
+
title: entry.record.title,
|
|
3453
|
+
body: '<dl class="metadata record-info"><div><dt>Record ID</dt><dd><code>' + esc(entry.record.id) + '</code></dd></div><div><dt>Type</dt><dd>' + esc(state.model.resources[entry.record.type].title) + '</dd></div><div><dt>Source file</dt><dd><code>' + esc(entry.relativePath) + '</code></dd></div><div><dt>Workspace revision</dt><dd>' + (state.git.available ? '<code>' + esc(state.git.shortCommit) + '</code>' : "Unavailable until the workspace is committed.") + '</dd></div>' + (entry.record.extensions ? '<div><dt>Extensions</dt><dd>' + formatValue(entry.record.extensions, "extensions", entry.record.type) + '</dd></div>' : '') + '</dl><details class="record-json"><summary>View raw JSON</summary><pre class="compact-json">' + esc(JSON.stringify(entry.record, null, 2)) + '</pre></details>'
|
|
3454
|
+
},
|
|
3455
|
+
connections: {
|
|
3456
|
+
kicker: "Connections",
|
|
3457
|
+
title: entry.record.title,
|
|
3458
|
+
body: (personParticipation(entry, true) + resourceConnections(entry, true)) || '<p class="muted">This record has no connected records.</p>'
|
|
3459
|
+
},
|
|
3460
|
+
history: {
|
|
3461
|
+
kicker: "File history",
|
|
3462
|
+
title: entry.record.title,
|
|
3463
|
+
body: entry.historyError
|
|
3464
|
+
? '<p class="muted">File history is unavailable. Reload the record to try again.</p>'
|
|
3465
|
+
: entry.history?.length
|
|
3466
|
+
? '<div class="history">' + entry.history.map((commit) => '<div><code>' + esc(commit.shortCommit) + '</code><span><strong>' + esc(commit.subject) + '</strong><small>' + esc(commit.author) + ' · ' + esc(formatLocalDateTime(commit.timestamp)) + '</small></span></div>').join("") + '</div>'
|
|
3467
|
+
: '<p class="muted">No committed history is available for this record.</p>'
|
|
3468
|
+
}
|
|
3469
|
+
};
|
|
3470
|
+
const definition = definitions[view];
|
|
3471
|
+
if (!definition) return;
|
|
3472
|
+
dialog.setAttribute("aria-labelledby", "record-utility-title");
|
|
3473
|
+
dialog.innerHTML = '<div class="dialog-head"><div><p class="kicker">' + esc(definition.kicker) + '</p><h2 id="record-utility-title">' + esc(titleCase(definition.title)) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><div class="record-utility-body">' + definition.body + '</div><div class="dialog-actions"><button type="button" class="button primary">Close</button></div>';
|
|
3474
|
+
document.body.append(dialog);
|
|
3475
|
+
dialog.querySelectorAll("button").forEach((button) => button.addEventListener("click", () => dialog.close()));
|
|
3476
|
+
dialog.addEventListener("close", () => dialog.remove(), { once: true });
|
|
3477
|
+
dialog.showModal();
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3112
3480
|
function evidenceAttachmentPanel(entry) {
|
|
3113
3481
|
const attachments = (entry.record.filePaths || []).map((path) => ({
|
|
3114
3482
|
path,
|
|
@@ -3292,7 +3660,7 @@ function recordContentDefinition(type) {
|
|
|
3292
3660
|
};
|
|
3293
3661
|
}
|
|
3294
3662
|
|
|
3295
|
-
function personParticipation(entry) {
|
|
3663
|
+
function personParticipation(entry, expanded = false) {
|
|
3296
3664
|
if (entry.record.type !== "person") return "";
|
|
3297
3665
|
const personId = entry.record.id;
|
|
3298
3666
|
const appointments = resourcesOfType("appointment")
|
|
@@ -3332,8 +3700,8 @@ function personParticipation(entry) {
|
|
|
3332
3700
|
const count = affiliations.length + assignments.length;
|
|
3333
3701
|
if (!count) return "";
|
|
3334
3702
|
return '<section class="panel connections-panel"><div class="panel-head"><h3>Participation</h3><span>' + count + '</span></div>'
|
|
3335
|
-
+ personParticipationGroup("Appointments and teams", affiliations, 8, "more appointments or teams")
|
|
3336
|
-
+ personParticipationGroup("Assigned records", assignments, 10, "more assigned records")
|
|
3703
|
+
+ personParticipationGroup("Appointments and teams", affiliations, expanded ? affiliations.length : 8, "more appointments or teams")
|
|
3704
|
+
+ personParticipationGroup("Assigned records", assignments, expanded ? assignments.length : 10, "more assigned records")
|
|
3337
3705
|
+ '</section>';
|
|
3338
3706
|
}
|
|
3339
3707
|
|
|
@@ -3346,7 +3714,7 @@ function personParticipationGroup(title, rows, limit, moreLabel) {
|
|
|
3346
3714
|
+ '</div>';
|
|
3347
3715
|
}
|
|
3348
3716
|
|
|
3349
|
-
function resourceConnections(entry) {
|
|
3717
|
+
function resourceConnections(entry, expanded = false) {
|
|
3350
3718
|
const relatedPeopleOnly = entry.record.type === "person";
|
|
3351
3719
|
const connections = new Map();
|
|
3352
3720
|
const entriesById = new Map(state.resources.map((item) => [item.record.id, item]));
|
|
@@ -3381,7 +3749,7 @@ function resourceConnections(entry) {
|
|
|
3381
3749
|
return firstType - secondType || first.entry.record.title.localeCompare(second.entry.record.title);
|
|
3382
3750
|
});
|
|
3383
3751
|
if (!sorted.length) return "";
|
|
3384
|
-
const visible = sorted.slice(0, 14);
|
|
3752
|
+
const visible = expanded ? sorted : sorted.slice(0, 14);
|
|
3385
3753
|
return '<section class="panel connections-panel"><div class="panel-head"><h3>' + (relatedPeopleOnly ? "Related people" : "Connections") + '</h3><span>' + sorted.length + '</span></div><div class="connections">' + visible.map(({ entry: connected, reasons }) => '<a href="#/resource/' + encodeURIComponent(connected.record.type) + '/' + encodeURIComponent(connected.record.id) + '"><strong>' + esc(connected.record.title) + '</strong><small>' + esc([...reasons].join(" · ")) + '</small></a>').join("") + '</div>' + (sorted.length > visible.length ? '<p class="connections-more">' + (sorted.length - visible.length) + ' more connections are available through the linked records.</p>' : "") + '</section>';
|
|
3386
3754
|
}
|
|
3387
3755
|
|
|
@@ -6088,7 +6456,9 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
6088
6456
|
@media(max-width:520px){.collection-review-complete-summary{flex:1 1 180px;min-width:0;flex-wrap:wrap}.collection-review-complete-summary .collection-review-result{min-width:0;flex-wrap:wrap}.collection-review-complete-summary .collection-review-result span{overflow-wrap:anywhere}.collection-review-panel.current .button{margin-left:0}}
|
|
6089
6457
|
.evidence-attachments .panel-head{align-items:flex-start}.evidence-attachments .panel-head h3{margin:0}.evidence-attachments .panel-head p{margin:4px 0 0;color:var(--muted);font-size:10px}.evidence-attachments ul{list-style:none;margin:0;padding:0}.evidence-attachments li{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 0;border-top:1px solid var(--line)}.evidence-attachments li:first-child{border-top:0}.evidence-attachments strong,.evidence-attachments small{display:block}.evidence-attachments strong{font-size:11px}.evidence-attachments small{margin-top:2px;color:var(--muted);font-size:9px;overflow-wrap:anywhere}.evidence-attachments .attachment-empty{display:block;color:var(--muted);font-size:10px;line-height:1.45}.danger-text{color:var(--red)}
|
|
6090
6458
|
.policy-lifecycle-note.single{grid-template-columns:1fr}
|
|
6091
|
-
.policy-content-table{margin-top:22px}.policy-content-table>.section-head{align-items:end}.policy-content-table>.section-head h2{font:500 27.6px Georgia,serif;margin:5px 0 6px}.policy-content-table>.section-head p:not(.kicker){max-width:720px;margin:0;color:var(--muted);font-size:12px;line-height:1.5}.policy-content-tools{display:flex;align-items:center;justify-content:flex-end;gap:9px;flex-wrap:wrap}.policy-content-tools input[type="search"]{min-width:210px;border:1px solid var(--line);border-radius:7px;background:var(--field);padding:9px 11px}.history-toggle{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:11px}.policy-content-add{position:relative}.policy-content-add>summary{list-style:none}.policy-content-add>summary::-webkit-details-marker{display:none}.policy-content-add>div{position:absolute;right:0;z-index:5;display:grid;min-width:150px;margin-top:6px;padding:5px;border:1px solid var(--line);border-radius:8px;background:var(--panel);box-shadow:var(--shadow)}.policy-content-add button{border:0;border-radius:5px;background:transparent;padding:8px 10px;color:var(--ink);text-align:left;cursor:pointer}.policy-content-add button:hover{background:var(--accent-soft)}.policy-content-type span,.policy-content-approval span{display:block}.policy-content-type small,.policy-content-approval small{display:block;margin-top:3px;color:var(--muted);font-size:9.6px}.policy-content-count{margin:9px 2px 0;color:var(--muted);font-size:10.8px;text-align:right}
|
|
6459
|
+
.policy-content-table{margin-top:22px}.policy-content-table>.section-head{align-items:end}.policy-content-table>.section-head h2{font:500 27.6px Georgia,serif;margin:5px 0 6px}.policy-content-table>.section-head p:not(.kicker){max-width:720px;margin:0;color:var(--muted);font-size:12px;line-height:1.5}.policy-content-tools{display:flex;align-items:center;justify-content:flex-end;gap:9px;flex-wrap:wrap}.policy-content-tools input[type="search"]{min-width:210px;border:1px solid var(--line);border-radius:7px;background:var(--field);padding:9px 11px}.history-toggle{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:11px}.policy-content-add{position:relative}.policy-content-add>summary{list-style:none}.policy-content-add>summary::-webkit-details-marker{display:none}.policy-content-add>div{position:absolute;right:0;z-index:5;display:grid;min-width:150px;margin-top:6px;padding:5px;border:1px solid var(--line);border-radius:8px;background:var(--panel);box-shadow:var(--shadow)}.policy-content-add button{border:0;border-radius:5px;background:transparent;padding:8px 10px;color:var(--ink);text-align:left;cursor:pointer}.policy-content-add button:hover{background:var(--accent-soft)}.policy-content-type span,.policy-content-approval span{display:block}.policy-content-type small,.policy-content-approval small{display:block;margin-top:3px;color:var(--muted);font-size:9.6px}.policy-content-filter-empty{margin:10px 0 0;padding:16px;border:1px dashed var(--line);border-radius:8px;color:var(--muted);font-size:12px;text-align:center}.policy-content-count{margin:9px 2px 0;color:var(--muted);font-size:10.8px;text-align:right}
|
|
6460
|
+
.policy-stage-pages{margin-top:30px}.retention-schedule-page .page-intro{margin-bottom:18px}.retention-schedule-page .page-intro .actions{flex-wrap:wrap;justify-content:flex-end}.retention-summary{margin-bottom:28px;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.retention-summary>div{display:grid;grid-template-columns:150px minmax(0,1fr) auto;gap:16px;align-items:center;padding:13px 0}.retention-summary>div+div{border-top:1px solid var(--line)}.retention-summary-label{color:var(--muted);font-size:10.8px;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.retention-summary a{text-decoration:none}.retention-summary strong,.retention-summary small{display:block}.retention-summary strong{font-size:13.2px}.retention-summary small{margin-top:3px;color:var(--muted);font-size:10.8px}.retention-rows h2,.retention-issues h2{font:500 24px Georgia,serif;margin:5px 0 7px}.retention-rows .section-head{align-items:end}.retention-rows .section-head p:not(.kicker){max-width:760px;margin:0;color:var(--muted);font-size:12px;line-height:1.55}.retention-table-tools{display:flex;align-items:center;justify-content:flex-end;gap:9px;flex-wrap:wrap}.retention-table-tools input[type="search"]{min-width:210px;border:1px solid var(--line);border-radius:7px;background:var(--field);padding:9px 11px}.retention-rows .record-table-wrap{margin-top:12px}.retention-rows .record-title+small{display:block;max-width:380px;margin-top:4px;color:var(--muted);font-size:9.6px;line-height:1.4}.retention-filter-empty{margin:10px 0 0;padding:16px;border:1px dashed var(--line);border-radius:8px;color:var(--muted);font-size:12px;text-align:center}.retention-row-count{margin:9px 2px 0;color:var(--muted);font-size:10.8px;text-align:right}.retention-issues{margin-top:28px}.retention-issue-list{border-top:1px solid var(--line)}.retention-issue-list>a{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:12px 2px;border-bottom:1px solid var(--line);text-decoration:none}.retention-issue-list strong,.retention-issue-list small{display:block}.retention-issue-list strong{font-size:12px}.retention-issue-list small{margin-top:3px;color:var(--muted);font-size:10.4px;line-height:1.45}.retention-issue-list b{color:var(--accent);font-size:11px}.retention-schedule-page>.collection-review-panel{margin-top:28px;margin-bottom:0}
|
|
6461
|
+
@media(max-width:760px){.retention-summary>div{grid-template-columns:1fr;gap:7px}.retention-summary>div>.badge{justify-self:start}.retention-schedule-page .page-intro .actions{justify-content:flex-start}}
|
|
6092
6462
|
.policy-library-proposals{min-width:0}.policy-library-proposals article,.policy-library-proposals details{min-width:0}.policy-library-proposals pre{box-sizing:border-box;max-width:100%;overflow:auto;padding:9px;border:1px solid var(--line);border-radius:6px;background:var(--paper);font-size:9px;line-height:1.45}
|
|
6093
6463
|
.policy-activation-actions{display:flex;justify-content:flex-end;margin-top:14px;padding-top:12px;border-top:1px solid var(--line)}
|
|
6094
6464
|
.policy-activation-review-warning{margin-top:14px;padding:12px 14px;border:1px solid #d8bd78;border-radius:8px;background:#fff8e8}.policy-activation-review-warning>strong{font-size:12px}.policy-activation-review-warning ul{display:grid;gap:5px;margin:9px 0;padding-left:20px;font-size:10.8px}.policy-activation-review-warning p{margin:9px 0 0;color:#6d4917;font-size:11px;line-height:1.5}
|
|
@@ -6112,7 +6482,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
6112
6482
|
.reconciliation-panel>p{margin:0 0 12px;color:var(--muted);font-size:12px;line-height:1.5}.reconciliation-candidates{display:grid;gap:9px}.reconciliation-candidate{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,.8fr);gap:16px;padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.reconciliation-candidate strong{margin-left:8px;font-size:13.2px}.reconciliation-candidate p{margin:7px 0;color:var(--muted);font-size:12px;line-height:1.5}.reconciliation-candidate small{display:block;overflow-wrap:anywhere;color:var(--muted);font-size:9.6px}.reconciliation-actions{display:grid;gap:8px;justify-items:end;align-content:center}.reconciliation-actions>code{max-width:100%;overflow-wrap:anywhere;color:var(--muted);font-size:9.6px;text-align:right}
|
|
6113
6483
|
.onboarding-save-status{color:var(--muted);font-size:10.8px;line-height:1.35}.onboarding-save-status:empty{display:none}.onboarding-save-status:not(:empty){order:-1;flex-basis:100%;margin-bottom:4px}.onboarding-retry-sync{margin-top:9px}.detail-loading{color:var(--muted)}
|
|
6114
6484
|
.save-status{min-height:16px;color:var(--muted);font-size:10.8px;line-height:1.35}
|
|
6115
|
-
.page-intro,.detail-head{align-items:center;margin-bottom:12px}.actions{align-items:center}.detail-head>div:first-child{min-width:0}.detail-head h2{margin:7px 0}.detail-head .header-breadcrumbs{margin:0;font-size:10.8px;line-height:normal;min-height:11px;align-items:center}.header-breadcrumbs span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60ch}
|
|
6485
|
+
.page-intro,.detail-head{align-items:center;margin-bottom:12px}.actions{align-items:center;justify-content:flex-end;flex-wrap:wrap}.detail-head>div:first-child{min-width:0}.detail-head h2{margin:7px 0}.detail-head .header-breadcrumbs{margin:0;font-size:10.8px;line-height:normal;min-height:11px;align-items:center}.header-breadcrumbs span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60ch}.record-overflow{position:relative;flex:0 0 auto;margin-left:auto}.record-overflow>summary{display:grid;place-items:center;width:38px;height:38px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--ink);cursor:pointer;list-style:none}.record-overflow>summary::-webkit-details-marker{display:none}.record-overflow>summary svg{display:block;width:18px;height:4px;fill:currentColor}.record-overflow>summary:hover,.record-overflow[open]>summary{border-color:var(--accent-light);background:var(--accent-soft);color:var(--accent)}.record-overflow-menu{position:absolute;z-index:12;top:calc(100% + 6px);right:0;display:grid;width:190px;padding:6px;border:1px solid var(--line);border-radius:9px;background:var(--panel);box-shadow:0 14px 36px rgba(0,0,53,.16)}.record-overflow-menu button{width:100%;border:0;border-radius:6px;padding:9px 10px;background:none;color:var(--ink);font-size:13.2px;text-align:left;cursor:pointer}.record-overflow-menu button:hover{background:var(--surface-soft)}.record-overflow-menu button:disabled{cursor:wait;color:var(--muted)}.record-overflow-menu .danger-text{color:var(--red)}.record-overflow-separator{height:1px;margin:5px 4px;background:var(--line)}.record-utility-dialog{width:min(680px,calc(100vw - 30px));max-height:min(760px,calc(100vh - 30px));overflow:auto}.record-utility-body{display:grid;gap:12px;margin:19px 0}.record-utility-body>.panel{box-shadow:none}.record-info>div:first-child{border-top:0}.record-json{border-top:1px solid var(--line);padding-top:13px}.record-json summary{color:var(--accent);font-size:13.2px;font-weight:750;cursor:pointer}.record-json pre{max-height:360px;overflow:auto;margin:12px 0 0}.record-utility-dialog .history{max-height:none}
|
|
6116
6486
|
.not-found{max-width:620px;padding:28px}.not-found h2{font-family:Georgia,serif;font-size:37.2px;font-weight:500;margin:7px 0}.not-found>p:not(.kicker){margin:0;color:var(--muted);font-size:15.6px}.not-found .page-actions{justify-content:flex-start;margin-top:22px}
|
|
6117
6487
|
.button.danger-action{background:var(--red);border-color:var(--red);color:#fff}
|
|
6118
6488
|
@media(max-width:1200px){.readiness-flow{grid-template-columns:repeat(3,minmax(0,1fr))}.audit-engagement{grid-template-columns:1fr 1fr}.audit-engagement .button{grid-column:1/-1;justify-self:start}}
|
|
@@ -6123,12 +6493,12 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
6123
6493
|
.detail-grid aside .panel{width:100%;max-height:min(520px,65vh);overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable}.detail-grid aside .workflow-guidance,.detail-grid aside .resource-review-criteria{margin:0}.detail-grid aside .resource-review-criteria{padding:21px}.detail-grid aside .detail-workflow-panel{max-height:min(440px,60vh)}.detail-grid aside .detail-review-panel{max-height:min(300px,50vh)}.detail-grid aside .workflow-findings,.detail-grid aside .resource-review-criteria ul{grid-template-columns:1fr}.detail-support-stack{display:grid;min-width:0;gap:14px;align-content:start}
|
|
6124
6494
|
@media(max-width:760px){.detail-grid aside{order:-1}}
|
|
6125
6495
|
@media(max-width:760px){.reconciliation-candidate{grid-template-columns:1fr}.reconciliation-actions{justify-items:start}.reconciliation-actions>code{text-align:left}}
|
|
6126
|
-
@media(max-width:760px){.detail-grid-structured .detail-support-columns{display:flex;flex-direction:column}.detail-grid-structured .detail-support-stack{display:contents}
|
|
6496
|
+
@media(max-width:760px){.detail-grid-structured .detail-support-columns{display:flex;flex-direction:column}.detail-grid-structured .detail-support-stack{display:contents}}
|
|
6127
6497
|
@media(max-width:760px){.guide-review ul,.resource-review-criteria ul{grid-template-columns:1fr}.collection-review-head,.collection-review-foot{align-items:stretch;flex-direction:column}.record-workflow-action{min-width:0}}
|
|
6498
|
+
@media(max-width:760px){.policy-content-table>.section-head{display:block}.policy-content-tools{justify-content:flex-start;margin-top:15px}.policy-content-tools>label:first-child{flex:1 1 100%;width:100%}.policy-content-tools input[type="search"]{width:100%;min-width:0}}
|
|
6128
6499
|
@media(max-width:520px){.onboarding-form,.onboarding-sections,.setup-steps{grid-template-columns:1fr}.onboarding-form label.wide{grid-column:auto}.onboarding-actions{flex-wrap:wrap}.onboarding-skip{width:100%;order:3;margin:3px 0 0}.readiness-flow{grid-template-columns:1fr}.obligation-card-foot{align-items:flex-start;flex-direction:column}.obligation-action{align-self:flex-start}}
|
|
6129
6500
|
@media(min-width:761px){.detail-grid{grid-template-columns:minmax(270px,1fr) minmax(0,2fr)}.detail-grid aside{grid-column:1;grid-row:1}.detail-main{grid-column:2;grid-row:1}}
|
|
6130
6501
|
@media(min-width:761px){.detail-grid.detail-grid-structured{grid-template-columns:1fr}.detail-grid-structured aside{grid-column:1;grid-row:1}.detail-grid-structured .detail-support-columns{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
|
6131
|
-
@media(min-width:1300px){.detail-grid-structured .detail-support-columns{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
|
6132
6502
|
@media(max-width:760px){.sidebar{visibility:hidden;transition:transform .2s,visibility 0s .2s}.sidebar.shown{visibility:visible;transition-delay:0s}.nav-close{display:grid;place-items:center;position:absolute;top:25px;right:18px;width:34px;height:34px;border:1px solid #5966a4;border-radius:50%;background:#11174a;color:#eef1ff;font-size:24px;cursor:pointer}.nav-scrim{display:block;position:fixed;inset:0;border:0;background:rgba(0,0,24,.38);opacity:0;pointer-events:none;transition:opacity .2s;z-index:15}.sidebar.shown+.nav-scrim{opacity:1;pointer-events:auto}.pagination{justify-content:space-between;gap:8px}.page-status{min-width:0}}
|
|
6133
6503
|
@media(max-width:760px){.topbar{height:56px}.topbar>div:first-of-type{display:none}.topbar-readiness{flex:0 1 220px;min-width:120px}.nav-close{font-size:0}.nav-close:before,.nav-close:after{content:"";position:absolute;width:13px;height:2px;border-radius:2px;background:currentColor;transform:rotate(45deg)}.nav-close:after{transform:rotate(-45deg)}}
|
|
6134
6504
|
|