filegrc 0.13.5 → 0.15.0
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/README.md +4 -4
- package/model/index.js +7 -5
- package/model/v11.json +12200 -0
- package/package.json +1 -1
- package/src/agent.js +1 -1
- package/src/cli.js +1 -1
- package/src/collection-review.js +118 -0
- package/src/collection-revision.js +3 -1
- package/src/collection-scope.js +150 -3
- package/src/document-activation.js +28 -4
- package/src/model-migration.js +70 -2
- package/src/policy-activation.js +16 -2
- package/src/program-path.js +64 -14
- package/src/program-readiness.js +37 -14
- package/src/retention-schedule-approval.js +121 -0
- package/src/retention.js +56 -1
- package/src/state.js +19 -22
- package/src/validate.js +70 -1
- package/src/web.js +425 -71
- package/src/workflow-history-integrity.js +5 -1
- package/src/workflow.js +1 -3
package/src/web.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
utcCalendarDate,
|
|
10
10
|
validCalendarRecurrence
|
|
11
11
|
} from "./recurrence.js";
|
|
12
|
-
import { PROGRAM_PATH, RESOURCE_INSTRUCTIONS, RESOURCE_PAGE_SUMMARIES } from "./program-path.js";
|
|
12
|
+
import { PROGRAM_PATH, programPathForModel, RESOURCE_INSTRUCTIONS, RESOURCE_PAGE_SUMMARIES } from "./program-path.js";
|
|
13
13
|
import { formatCalendarDate, formatLocalDateTime } from "./time.js";
|
|
14
14
|
|
|
15
15
|
export function dashboardProgramReadiness(programReadiness = {}) {
|
|
@@ -64,7 +64,8 @@ let latestPacketResult = null;
|
|
|
64
64
|
let latestPacketState = null;
|
|
65
65
|
let policyEventFeedback = null;
|
|
66
66
|
const SHARED_PROGRAM_STAGES = ${JSON.stringify(PROGRAM_PATH)};
|
|
67
|
-
const
|
|
67
|
+
const LEGACY_PROGRAM_STAGES = ${JSON.stringify(programPathForModel({ modelVersion: "10" }))};
|
|
68
|
+
let READINESS_STAGES = SHARED_PROGRAM_STAGES.map((stage) => ({
|
|
68
69
|
...stage,
|
|
69
70
|
number: String(stage.number)
|
|
70
71
|
}));
|
|
@@ -72,6 +73,8 @@ const RESOURCE_GUIDE_INSTRUCTIONS = ${JSON.stringify(RESOURCE_INSTRUCTIONS)};
|
|
|
72
73
|
const dashboardProgramReadiness = ${dashboardProgramReadiness.toString()};
|
|
73
74
|
const STAGE_PAGE_SUMMARIES = ${JSON.stringify({
|
|
74
75
|
...RESOURCE_PAGE_SUMMARIES,
|
|
76
|
+
"utility:evidence-sources": "Check that each Control has an authoritative, retrievable evidence source.",
|
|
77
|
+
"utility:retention-schedule": "Approve the governed schedule document and its structured rows as one revision.",
|
|
75
78
|
"utility:audit-packet": "Review fieldwork readiness and build the indexed evidence packet."
|
|
76
79
|
})};
|
|
77
80
|
const RECORD_TEXT_FIELDS = new Set(["description", "statement", "activity", "purpose", "scope", "objective", "applicabilityRationale", "summary", "rationale", "businessPurpose", "changeSummary", "decisionSummary", "decisionRationale", "recommendation", "remediationPlan", "auditorNotes", "notPerformedReason"]);
|
|
@@ -175,6 +178,10 @@ function render() {
|
|
|
175
178
|
else if (waitingFor.length) renderStateLoading(main, route, waitingFor);
|
|
176
179
|
else if (route.name === "home") renderHome(main);
|
|
177
180
|
else if (route.name === "stage") renderStageOverview(main, route.stageId, route.params);
|
|
181
|
+
else if (route.name === "policies") renderPoliciesPage(main, route.params);
|
|
182
|
+
else if (route.name === "retention-schedule" && modelSupports("retention-schedule-approval")) renderRetentionSchedulePage(main, route.params);
|
|
183
|
+
else if (route.name === "retention-schedule") renderList(main, "retention-schedule-item", route.params);
|
|
184
|
+
else if (route.name === "evidence-sources") renderEvidenceSourcesPage(main);
|
|
178
185
|
else if (route.name === "obligations") renderObligations(main, route.params);
|
|
179
186
|
else if (route.name === "audit-packet") renderAuditPacket(main, route.params);
|
|
180
187
|
else if (route.name === "list") renderList(main, route.type, route.params);
|
|
@@ -186,6 +193,10 @@ function render() {
|
|
|
186
193
|
}
|
|
187
194
|
|
|
188
195
|
function normalizeAppState(next) {
|
|
196
|
+
const stages = Number(next?.model?.modelVersion || 0) >= MODEL_CAPABILITY_VERSIONS["retention-schedule-approval"]
|
|
197
|
+
? SHARED_PROGRAM_STAGES
|
|
198
|
+
: LEGACY_PROGRAM_STAGES;
|
|
199
|
+
READINESS_STAGES = stages.map((stage) => ({ ...stage, number: String(stage.number) }));
|
|
189
200
|
if (next.sections) return next;
|
|
190
201
|
return {
|
|
191
202
|
...next,
|
|
@@ -205,6 +216,9 @@ function blockingStateSections(route) {
|
|
|
205
216
|
if (route.name === "repository") return ["repository"];
|
|
206
217
|
if (route.name === "obligations" || route.name === "stage" && route.stageId === "run") return ["program", "obligations"];
|
|
207
218
|
if (route.name === "audit-packet") return ["repository", "program", "obligations", "audits"];
|
|
219
|
+
if (route.name === "evidence-sources") return ["program", "workflow"];
|
|
220
|
+
if (route.name === "policies") return ["program", "workflow"];
|
|
221
|
+
if (route.name === "retention-schedule") return ["program", "workflow"];
|
|
208
222
|
if (route.name === "stage" && route.stageId === "audit") return ["program", "workflow", "audits"];
|
|
209
223
|
if (route.name === "stage") return ["program", "workflow"];
|
|
210
224
|
return [];
|
|
@@ -282,6 +296,9 @@ function parseRoute() {
|
|
|
282
296
|
if (!parts.length) return { name: "home" };
|
|
283
297
|
if (parts.length === 2 && parts[0] === "stage" && parts[1]) return { name: "stage", stageId: parts[1], params: new URLSearchParams(query) };
|
|
284
298
|
if (parts.length === 1 && parts[0] === "obligations") return { name: "obligations", params: new URLSearchParams(query) };
|
|
299
|
+
if (parts.length === 1 && parts[0] === "evidence-sources") return { name: "evidence-sources", params: new URLSearchParams(query) };
|
|
300
|
+
if (parts.length === 1 && parts[0] === "policies") return { name: "policies", params: new URLSearchParams(query) };
|
|
301
|
+
if (parts.length === 1 && parts[0] === "retention-schedule") return { name: "retention-schedule", params: new URLSearchParams(query) };
|
|
285
302
|
if (parts.length === 1 && parts[0] === "audit-packet") return { name: "audit-packet", params: new URLSearchParams(query) };
|
|
286
303
|
if (parts.length === 2 && parts[0] === "resources" && parts[1]) return { name: "list", type: parts[1], params: new URLSearchParams(query) };
|
|
287
304
|
if (parts.length === 3 && parts[0] === "resource" && parts[1] && parts[2]) return { name: "detail", type: parts[1], id: parts[2], params: new URLSearchParams(query) };
|
|
@@ -302,7 +319,10 @@ function buildNavigation(route) {
|
|
|
302
319
|
const sectionCurrent = (route.type && section.types.includes(route.type) && (!contextualStageId || contextualStageId === stage.id))
|
|
303
320
|
|| (section.relatedLinks || []).some((link) => route.type === link.type && contextualStageId === stage.id)
|
|
304
321
|
|| (section.utility === "obligation-board" && route.name === "obligations")
|
|
305
|
-
|| (section.utility === "audit-packet" && route.name === "audit-packet")
|
|
322
|
+
|| (section.utility === "audit-packet" && route.name === "audit-packet")
|
|
323
|
+
|| (section.utility === "evidence-sources" && route.name === "evidence-sources")
|
|
324
|
+
|| (section.relatedLinks || []).some((link) => link.href === "#/" + route.name)
|
|
325
|
+
|| (section.utility === "retention-schedule" && route.name === "retention-schedule");
|
|
306
326
|
const sectionOpen = navigationGroupState[sectionKey] ?? (sectionCurrent || section.defaultOpen);
|
|
307
327
|
const resources = section.types
|
|
308
328
|
.map((type) => [type, state.model.resources[type]])
|
|
@@ -313,7 +333,8 @@ function buildNavigation(route) {
|
|
|
313
333
|
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>';
|
|
314
334
|
}).join("") + (section.relatedLinks || []).map((link) => {
|
|
315
335
|
const current = route.type === link.type && contextualStageId === stage.id
|
|
316
|
-
|| link.href === "#/stage/" + route.stageId
|
|
336
|
+
|| link.href === "#/stage/" + route.stageId
|
|
337
|
+
|| link.href === "#/" + route.name;
|
|
317
338
|
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>';
|
|
318
339
|
}).join("") + renderSidebarUtility(section.utility, route, direct);
|
|
319
340
|
if (direct) return links;
|
|
@@ -329,6 +350,7 @@ function buildNavigation(route) {
|
|
|
329
350
|
|
|
330
351
|
function readinessStageForRoute(route) {
|
|
331
352
|
if (route.name === "stage") return READINESS_STAGES.find((stage) => stage.id === route.stageId);
|
|
353
|
+
if (route.name === "policies") return READINESS_STAGES.find((stage) => stage.id === "policies");
|
|
332
354
|
const contextualStageId = route.params?.get("stage");
|
|
333
355
|
const contextualStage = contextualStageId && READINESS_STAGES.find((stage) => (
|
|
334
356
|
stage.id === contextualStageId
|
|
@@ -339,7 +361,9 @@ function readinessStageForRoute(route) {
|
|
|
339
361
|
(stage.supportingResourceTypes || []).includes(route.type)
|
|
340
362
|
|| stage.sections.some((section) => section.types.includes(route.type)
|
|
341
363
|
|| (section.utility === "obligation-board" && route.name === "obligations")
|
|
342
|
-
|| (section.utility === "audit-packet" && route.name === "audit-packet")
|
|
364
|
+
|| (section.utility === "audit-packet" && route.name === "audit-packet")
|
|
365
|
+
|| (section.utility === "evidence-sources" && route.name === "evidence-sources")
|
|
366
|
+
|| (section.utility === "retention-schedule" && route.name === "retention-schedule"))
|
|
343
367
|
));
|
|
344
368
|
}
|
|
345
369
|
|
|
@@ -366,6 +390,12 @@ function reviewedRequirementIds() {
|
|
|
366
390
|
function renderSidebarUtility(utility, route, direct = false) {
|
|
367
391
|
const directClass = direct ? "nav-direct " : "";
|
|
368
392
|
if (utility === "obligation-board") return "";
|
|
393
|
+
if (utility === "evidence-sources") {
|
|
394
|
+
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>';
|
|
395
|
+
}
|
|
396
|
+
if (utility === "retention-schedule") {
|
|
397
|
+
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>';
|
|
398
|
+
}
|
|
369
399
|
if (utility === "audit-packet") {
|
|
370
400
|
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>';
|
|
371
401
|
}
|
|
@@ -383,8 +413,14 @@ function topbar(route) {
|
|
|
383
413
|
? "Repository"
|
|
384
414
|
: route.name === "obligations"
|
|
385
415
|
? "Work Queue"
|
|
386
|
-
|
|
416
|
+
: route.name === "audit-packet"
|
|
387
417
|
? "Audit Readiness"
|
|
418
|
+
: route.name === "evidence-sources"
|
|
419
|
+
? "Evidence Sources"
|
|
420
|
+
: route.name === "policies"
|
|
421
|
+
? "Policies"
|
|
422
|
+
: route.name === "retention-schedule"
|
|
423
|
+
? "Data Retention Schedule"
|
|
388
424
|
: route.name === "list" && route.type === "document"
|
|
389
425
|
? documentListTitle(route.params)
|
|
390
426
|
: state.model.resources[route.type]?.pluralTitle || "filegrc";
|
|
@@ -524,11 +560,12 @@ function nextProgramStageHref() {
|
|
|
524
560
|
function renderStageOverview(main, stageId, params = new URLSearchParams()) {
|
|
525
561
|
const stage = READINESS_STAGES.find((candidate) => candidate.id === stageId);
|
|
526
562
|
if (!stage) return renderNotFound(main);
|
|
563
|
+
if (stage.id === "policies" && !modelSupports("retention-schedule-approval")) return renderPoliciesPage(main, params);
|
|
527
564
|
if (stage.id === "run") return renderObligations(main, params);
|
|
528
565
|
const progress = stageProgress(stage);
|
|
529
566
|
main.innerHTML = '<div class="page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
|
|
530
567
|
'<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>' +
|
|
531
|
-
|
|
568
|
+
renderStagePageIndex(stage) + (stage.id === "controls" ? renderFinishStepThree() : "") + (stage.id === "audit" ? renderAuditDocumentActivationAssessments() : "") + '</div>';
|
|
532
569
|
main.querySelector("[data-show-evidence-families]")?.addEventListener("click", (event) => {
|
|
533
570
|
main.querySelectorAll("[data-evidence-family-extra]").forEach((card) => { card.hidden = false; });
|
|
534
571
|
event.currentTarget.remove();
|
|
@@ -540,6 +577,190 @@ function renderStageOverview(main, stageId, params = new URLSearchParams()) {
|
|
|
540
577
|
});
|
|
541
578
|
}
|
|
542
579
|
|
|
580
|
+
function renderPoliciesPage(main, params = new URLSearchParams()) {
|
|
581
|
+
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>';
|
|
582
|
+
main.innerHTML = '<div class="page policies-page"><nav class="breadcrumbs"><a href="#/stage/policies">Step 2</a><span>/</span><span>Policies</span></nav>' +
|
|
583
|
+
'<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() +
|
|
584
|
+
renderPolicyApprovalGuidance() + renderPoliciesTable(params) + '</div>';
|
|
585
|
+
resourceGuideCleanup = setupResourceGuide(main);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function policyContentGuide() {
|
|
589
|
+
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>';
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
|
|
593
|
+
const documentEntries = state.resources.filter(({ record }) => (
|
|
594
|
+
record.type === "document"
|
|
595
|
+
&& record.documentKind === "schedule"
|
|
596
|
+
&& record.workflowScope === "program"
|
|
597
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
598
|
+
));
|
|
599
|
+
const documentEntry = documentEntries.length === 1 ? documentEntries[0] : null;
|
|
600
|
+
const rows = resourcesOfType("retention-schedule-item")
|
|
601
|
+
.sort((left, right) => left.record.title.localeCompare(right.record.title));
|
|
602
|
+
const activeRows = rows.filter(({ record }) => record.status === "active").length;
|
|
603
|
+
const documentReadiness = documentEntry ? retentionScheduleDocumentReadiness(documentEntry.record.id) : null;
|
|
604
|
+
const documentReady = documentEntries.length === 1 && documentReadiness?.status === "complete";
|
|
605
|
+
const documentAction = documentEntry
|
|
606
|
+
? '<a class="button" href="#/resource/document/' + encodeURIComponent(documentEntry.record.id) + '?stage=policies&documentScope=program">Review document</a>'
|
|
607
|
+
: !documentEntries.length && !state.readOnly ? '<button class="button" type="button" data-add-retention-document>Add document</button>' : "";
|
|
608
|
+
const documentSummary = documentEntries.length
|
|
609
|
+
? 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("")
|
|
610
|
+
: '<span><strong>No governing document</strong><small>Add the schedule document before approving its rows.</small></span><span class="badge warn">Required</span>';
|
|
611
|
+
const rowHtml = rows.length
|
|
612
|
+
? rows.map((entry) => {
|
|
613
|
+
const { record } = entry;
|
|
614
|
+
const informationTypes = (record.informationTypeIds || []).map(referenceTitle).join(", ") || "Not selected";
|
|
615
|
+
const scope = (record.scopeResourceIds || []).map(referenceTitle).join(", ") || "Not selected";
|
|
616
|
+
const cutoff = retentionCutoffLabel(record.cutoff);
|
|
617
|
+
const period = retentionPeriodLabel(record.retentionPeriod);
|
|
618
|
+
const action = record.dispositionAction ? properCase(record.dispositionAction) : "Not selected";
|
|
619
|
+
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>';
|
|
620
|
+
}).join("")
|
|
621
|
+
: '<tr><td colspan="7">' + empty("No retention rules are needed until an actual information use requires one.") + '</td></tr>';
|
|
622
|
+
const add = state.readOnly ? "" : '<button class="button primary" type="button" data-add-retention-row>Add schedule row</button>';
|
|
623
|
+
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>';
|
|
624
|
+
const initialFilter = params.get("q") || "";
|
|
625
|
+
const showHistory = params.get("history") === "1";
|
|
626
|
+
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>';
|
|
627
|
+
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>' +
|
|
628
|
+
'<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") +
|
|
629
|
+
'<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>' +
|
|
630
|
+
'<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>' +
|
|
631
|
+
renderRetentionScheduleIssues() + collectionReviewPanel("retention-schedule-item", true) + '</div>';
|
|
632
|
+
resourceGuideCleanup = setupResourceGuide(main);
|
|
633
|
+
wireRetentionScheduleTable(rows.length);
|
|
634
|
+
main.querySelector("[data-review-collection]")?.addEventListener("click", () => openCollectionReviewDialog("retention-schedule-item"));
|
|
635
|
+
main.querySelector("[data-add-retention-document]")?.addEventListener("click", () => openEditor("document", null, {
|
|
636
|
+
seed: {
|
|
637
|
+
documentKind: "schedule",
|
|
638
|
+
workflowScope: "program",
|
|
639
|
+
programRole: "required"
|
|
640
|
+
},
|
|
641
|
+
description: "Write the governing Data Retention Schedule, link its Controls, and assign a separate approver before approving the document and schedule rows.",
|
|
642
|
+
saveLabel: "Save schedule document"
|
|
643
|
+
}));
|
|
644
|
+
main.querySelector("[data-add-retention-row]")?.addEventListener("click", () => openRetentionScheduleItemEditor());
|
|
645
|
+
main.querySelector(".retention-rows tbody")?.addEventListener("click", (event) => {
|
|
646
|
+
if (event.target.closest("[data-load-workflow]")) loadStateSection("workflow");
|
|
647
|
+
});
|
|
648
|
+
if (params.get("new") === "1" && !state.readOnly) {
|
|
649
|
+
const seed = {
|
|
650
|
+
...(params.get("title") ? { title: params.get("title") } : {}),
|
|
651
|
+
...(params.get("informationTypeId") ? { informationTypeIds: [params.get("informationTypeId")] } : {}),
|
|
652
|
+
...(params.get("scopeResourceId") ? { scopeResourceIds: [params.get("scopeResourceId")] } : {}),
|
|
653
|
+
...(params.get("scheduleDocumentId") ? { scheduleDocumentId: params.get("scheduleDocumentId") } : {})
|
|
654
|
+
};
|
|
655
|
+
history.replaceState(null, "", "#/retention-schedule");
|
|
656
|
+
queueMicrotask(() => openRetentionScheduleItemEditor(seed));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function openRetentionScheduleItemEditor(seed = {}) {
|
|
661
|
+
const scheduleDocuments = resourcesOfType("document").filter(({ record }) => (
|
|
662
|
+
record.documentKind === "schedule"
|
|
663
|
+
&& record.workflowScope !== "engagement"
|
|
664
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
665
|
+
));
|
|
666
|
+
openEditor("retention-schedule-item", null, {
|
|
667
|
+
seed: {
|
|
668
|
+
...(scheduleDocuments.length === 1 ? { scheduleDocumentId: scheduleDocuments[0].record.id } : {}),
|
|
669
|
+
...seed
|
|
670
|
+
},
|
|
671
|
+
description: "Define one retention decision. Keep it planned until its scope, cutoff, period, disposition, sources, and approval are complete.",
|
|
672
|
+
saveLabel: "Save schedule row"
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function wireRetentionScheduleTable(total) {
|
|
677
|
+
const table = root.querySelector(".retention-rows");
|
|
678
|
+
if (!table) return;
|
|
679
|
+
const search = table.querySelector("[data-retention-search]");
|
|
680
|
+
const historyToggle = table.querySelector("[data-retention-history]");
|
|
681
|
+
const count = table.querySelector("[data-retention-count]");
|
|
682
|
+
const noResults = table.querySelector("[data-retention-empty]");
|
|
683
|
+
const filter = () => {
|
|
684
|
+
const query = search.value.trim().toLowerCase();
|
|
685
|
+
let visible = 0;
|
|
686
|
+
table.querySelectorAll("[data-retention-row]").forEach((row) => {
|
|
687
|
+
const show = (historyToggle.checked || row.dataset.history !== "true")
|
|
688
|
+
&& (!query || row.textContent.toLowerCase().includes(query));
|
|
689
|
+
row.hidden = !show;
|
|
690
|
+
if (show) visible += 1;
|
|
691
|
+
});
|
|
692
|
+
noResults.hidden = visible !== 0 || total === 0;
|
|
693
|
+
count.textContent = visible + " of " + total + " " + pluralize("row", total);
|
|
694
|
+
};
|
|
695
|
+
const updateLocation = () => {
|
|
696
|
+
const params = new URLSearchParams();
|
|
697
|
+
if (search.value.trim()) params.set("q", search.value.trim());
|
|
698
|
+
if (historyToggle.checked) params.set("history", "1");
|
|
699
|
+
history.replaceState(null, "", "#/retention-schedule" + (params.size ? "?" + params : ""));
|
|
700
|
+
};
|
|
701
|
+
search.addEventListener("input", () => { filter(); updateLocation(); });
|
|
702
|
+
historyToggle.addEventListener("change", () => { filter(); updateLocation(); });
|
|
703
|
+
filter();
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function renderRetentionScheduleIssues() {
|
|
707
|
+
const items = retentionReviewItems("policies", false);
|
|
708
|
+
const plannedRows = resourcesOfType("retention-schedule-item")
|
|
709
|
+
.filter(({ record }) => record.status === "planned")
|
|
710
|
+
.map(({ record }) => ({
|
|
711
|
+
id: "planned-" + record.id,
|
|
712
|
+
title: "Complete or retire " + record.title,
|
|
713
|
+
message: "Planned rows are excluded from the approved schedule. Finish this decision or retire the row before schedule approval.",
|
|
714
|
+
href: '#/resource/retention-schedule-item/' + encodeURIComponent(record.id) + '?stage=policies'
|
|
715
|
+
}));
|
|
716
|
+
const actions = [...plannedRows, ...items.filter((item) => (
|
|
717
|
+
item.status === "action" && item.id !== "collection-review-retention-schedule-item"
|
|
718
|
+
))];
|
|
719
|
+
if (!actions.length) return "";
|
|
720
|
+
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) => {
|
|
721
|
+
const href = item.href || (item.id?.startsWith("retention-use-")
|
|
722
|
+
? retentionScheduleItemHref(item)
|
|
723
|
+
: item.resourceId && item.resourceType
|
|
724
|
+
? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
|
|
725
|
+
: '#/resources/' + encodeURIComponent(item.resourceType || "retention-schedule-item"));
|
|
726
|
+
return '<a href="' + href + '"><span><strong>' + esc(item.title) + '</strong><small>' + esc(item.message) + '</small></span><b>Review</b></a>';
|
|
727
|
+
}).join("") + '</div></section>';
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function retentionScheduleDocumentReadiness(documentId) {
|
|
731
|
+
return state.programReadiness?.stages
|
|
732
|
+
?.find(({ id }) => id === "policies")
|
|
733
|
+
?.items.find(({ id }) => id === "document-approval-" + documentId) || null;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function referenceTitle(id) {
|
|
737
|
+
return state.resources.find(({ record }) => record.id === id)?.record.title || id;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function retentionPeriodLabel(period) {
|
|
741
|
+
if (!period) return "Not selected";
|
|
742
|
+
if (period.basis === "permanent") return "Permanent";
|
|
743
|
+
if (period.basis === "until-event") return "Until " + (period.event || "specified event");
|
|
744
|
+
if (period.basis === "fixed" && Number.isFinite(period.amount) && period.unit) {
|
|
745
|
+
return period.amount + " " + period.unit + (period.amount === 1 ? "" : "s");
|
|
746
|
+
}
|
|
747
|
+
return properCase(period.basis || "Configured");
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function retentionCutoffLabel(cutoff) {
|
|
751
|
+
if (!cutoff) return "cutoff not selected";
|
|
752
|
+
if (cutoff.basis === "event") return cutoff.event || "specified event";
|
|
753
|
+
return properCase(cutoff.basis || "configured cutoff");
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function renderEvidenceSourcesPage(main) {
|
|
757
|
+
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>';
|
|
758
|
+
main.querySelector("[data-show-evidence-families]")?.addEventListener("click", (event) => {
|
|
759
|
+
main.querySelectorAll("[data-evidence-family-extra]").forEach((card) => { card.hidden = false; });
|
|
760
|
+
event.currentTarget.remove();
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
543
764
|
function renderPolicyApprovalGuidance() {
|
|
544
765
|
const proposals = state.programReadiness?.policyLibraryProposals || [];
|
|
545
766
|
const proposalRows = proposals.map((proposal) => {
|
|
@@ -552,11 +773,11 @@ function renderPolicyApprovalGuidance() {
|
|
|
552
773
|
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>';
|
|
553
774
|
}
|
|
554
775
|
|
|
555
|
-
function renderPoliciesTable() {
|
|
776
|
+
function renderPoliciesTable(params = new URLSearchParams()) {
|
|
556
777
|
const entries = state.resources.filter(({ record }) => (
|
|
557
778
|
record.type === "policy"
|
|
558
779
|
|| record.type === "training"
|
|
559
|
-
|| record.type === "document" && !auditSpecificDocument(record)
|
|
780
|
+
|| record.type === "document" && !auditSpecificDocument(record) && record.documentKind !== "schedule"
|
|
560
781
|
)).sort((left, right) => (
|
|
561
782
|
["policy", "document", "training"].indexOf(left.record.type) - ["policy", "document", "training"].indexOf(right.record.type)
|
|
562
783
|
|| left.record.title.localeCompare(right.record.title)
|
|
@@ -585,8 +806,10 @@ function renderPoliciesTable() {
|
|
|
585
806
|
const add = !state.readOnly
|
|
586
807
|
? '<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>'
|
|
587
808
|
: '';
|
|
809
|
+
const initialFilter = params.get("q") || "";
|
|
810
|
+
const showHistory = params.get("history") === "1";
|
|
588
811
|
const emptyRow = '<tr><td colspan="6">' + empty('No governed content has been created.') + '</td></tr>';
|
|
589
|
-
const html = '<section class="policy-content-table"><div class="section-head"><div><p class="kicker">
|
|
812
|
+
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>';
|
|
590
813
|
queueMicrotask(() => wirePoliciesTable(entries.length));
|
|
591
814
|
return html;
|
|
592
815
|
}
|
|
@@ -595,21 +818,29 @@ function wirePoliciesTable(total) {
|
|
|
595
818
|
const table = root.querySelector('.policy-content-table');
|
|
596
819
|
if (!table) return;
|
|
597
820
|
const search = table.querySelector('[data-policy-content-search]');
|
|
598
|
-
const
|
|
821
|
+
const historyToggle = table.querySelector('[data-policy-content-history]');
|
|
599
822
|
const count = table.querySelector('[data-policy-content-count]');
|
|
823
|
+
const noResults = table.querySelector('[data-policy-content-empty]');
|
|
600
824
|
const filter = () => {
|
|
601
825
|
const query = search.value.trim().toLowerCase();
|
|
602
826
|
let visible = 0;
|
|
603
827
|
table.querySelectorAll('[data-policy-content-row]').forEach((row) => {
|
|
604
|
-
const show = (
|
|
828
|
+
const show = (historyToggle.checked || row.dataset.history !== 'true')
|
|
605
829
|
&& (!query || row.textContent.toLowerCase().includes(query));
|
|
606
830
|
row.hidden = !show;
|
|
607
831
|
if (show) visible += 1;
|
|
608
832
|
});
|
|
833
|
+
noResults.hidden = visible !== 0 || total === 0;
|
|
609
834
|
count.textContent = visible + ' of ' + total + ' ' + pluralize('artifact', total);
|
|
610
835
|
};
|
|
611
|
-
|
|
612
|
-
|
|
836
|
+
const updateLocation = () => {
|
|
837
|
+
const params = new URLSearchParams();
|
|
838
|
+
if (search.value.trim()) params.set("q", search.value.trim());
|
|
839
|
+
if (historyToggle.checked) params.set("history", "1");
|
|
840
|
+
history.replaceState(null, "", "#/policies" + (params.size ? "?" + params : ""));
|
|
841
|
+
};
|
|
842
|
+
search.addEventListener('input', () => { filter(); updateLocation(); });
|
|
843
|
+
historyToggle.addEventListener('change', () => { filter(); updateLocation(); });
|
|
613
844
|
table.querySelectorAll('[data-add-policy-content]').forEach((button) => {
|
|
614
845
|
button.addEventListener('click', () => openEditor(button.dataset.addPolicyContent));
|
|
615
846
|
});
|
|
@@ -621,7 +852,8 @@ function policyContentWorkflowCell(entry) {
|
|
|
621
852
|
const items = recordWorkflowItems(record.type, record.id);
|
|
622
853
|
if (!items.length) return '<span class="record-workflow-clear">No calculated action</span>';
|
|
623
854
|
const item = items[0];
|
|
624
|
-
|
|
855
|
+
let href = workflowItemHref(item) || '#/resource/' + encodeURIComponent(record.type) + '/' + encodeURIComponent(record.id) + '?stage=policies';
|
|
856
|
+
if (href.startsWith('#/resource/' + encodeURIComponent(record.type) + '/') && !href.includes('?')) href += '?stage=policies';
|
|
625
857
|
const title = ["draft", "in-review"].includes(record.status)
|
|
626
858
|
? "Complete and approve"
|
|
627
859
|
: record.status === "approved"
|
|
@@ -892,37 +1124,105 @@ function recordCompletionState(record) {
|
|
|
892
1124
|
return '<section class="record-completion-state panel detail-support-panel"><p class="kicker">Record status</p><h3>' + esc(properCase(record.status)) + '</h3><p>This record has no direct next step.</p></section>';
|
|
893
1125
|
}
|
|
894
1126
|
|
|
895
|
-
function collectionReviewPanel(type) {
|
|
1127
|
+
function collectionReviewPanel(type, force = false) {
|
|
896
1128
|
const assessment = state.collectionReviews?.[type];
|
|
897
|
-
if (!assessment) return "";
|
|
1129
|
+
if (!assessment || (!force && !collectionReviewVisible(type))) return "";
|
|
898
1130
|
const configuration = assessment.configuration;
|
|
899
1131
|
const current = assessment.status === "current";
|
|
1132
|
+
const scheduleReview = type === "retention-schedule-item" && modelSupports("retention-schedule-approval");
|
|
1133
|
+
const scheduleBlocker = scheduleReview ? retentionScheduleApprovalBlocker() : null;
|
|
900
1134
|
const needsFirstRecord = collectionNeedsFirstRecord(type);
|
|
901
1135
|
const reviewerNames = (assessment.review?.reviewedByIds || [])
|
|
902
1136
|
.map((id) => state.resources.find(({ record }) => record.id === id)?.record.title || id);
|
|
903
1137
|
const reviewNote = assessment.review?.rationale
|
|
904
1138
|
? " Note: " + esc(assessment.review.rationale)
|
|
905
1139
|
: "";
|
|
906
|
-
const reviewSummary = current
|
|
1140
|
+
const reviewSummary = current && !scheduleBlocker
|
|
907
1141
|
? '<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>'
|
|
908
|
-
: '<p class="collection-review-result"><strong>' + (needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(assessment.message) + '</span></p>';
|
|
909
|
-
const compactReviewSummary = current
|
|
1142
|
+
: '<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>';
|
|
1143
|
+
const compactReviewSummary = current && !scheduleBlocker
|
|
910
1144
|
? '<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>"
|
|
911
1145
|
: reviewSummary;
|
|
912
1146
|
const action = state.readOnly
|
|
913
1147
|
? ""
|
|
1148
|
+
: scheduleBlocker
|
|
1149
|
+
? '<button class="button" type="button" disabled title="' + esc(scheduleBlocker) + '">Review and approve schedule</button>'
|
|
914
1150
|
: needsFirstRecord
|
|
915
1151
|
? '<a class="button primary" href="#/resources/' + encodeURIComponent(type) + '?new=1">Add first ' + esc(state.model.resources[type].title.toLowerCase()) + '</a>'
|
|
916
|
-
: '<button class="button ' + (current ? "" : "primary") + '" type="button" data-review-collection="' + esc(type) + '">' + (current ? "Review again" : "Review and confirm") + '</button>';
|
|
917
|
-
const status = current ? "Reviewed" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Stale" : "Review required";
|
|
918
|
-
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>';
|
|
1152
|
+
: '<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>';
|
|
1153
|
+
const status = scheduleBlocker ? "Not ready" : current ? "Reviewed" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Stale" : "Review required";
|
|
1154
|
+
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>';
|
|
919
1155
|
const summary = current
|
|
920
|
-
? '<div class="collection-review-current-row"><div class="collection-review-complete-summary"><span class="kicker">Scope confirmation</span>' + compactReviewSummary + '</div>' + details + action + '</div>'
|
|
1156
|
+
? '<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>'
|
|
921
1157
|
: details;
|
|
922
1158
|
return '<section class="collection-review-panel panel ' + (current ? "current" : "required") + '">' + summary +
|
|
923
1159
|
(current ? "" : '<div class="collection-review-foot">' + reviewSummary + action + '</div>') + '</section>';
|
|
924
1160
|
}
|
|
925
1161
|
|
|
1162
|
+
function retentionScheduleApprovalBlocker() {
|
|
1163
|
+
if (!modelSupports("retention-schedule-approval")) return null;
|
|
1164
|
+
const documents = state.resources.filter(({ record }) => (
|
|
1165
|
+
record.type === "document"
|
|
1166
|
+
&& record.documentKind === "schedule"
|
|
1167
|
+
&& record.workflowScope === "program"
|
|
1168
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
1169
|
+
)).map(({ record }) => record);
|
|
1170
|
+
const document = documents.length === 1 ? documents[0] : null;
|
|
1171
|
+
if (documents.length > 1) return "Keep exactly one current program Data Retention Schedule document before approval.";
|
|
1172
|
+
if (!document) return "Add the governing Data Retention Schedule document first.";
|
|
1173
|
+
if (!["approved", "active"].includes(document.status) || !document.approvedOn || !document.approvedContentRevisions) {
|
|
1174
|
+
return "Approve the governing document and bind its exact Markdown revision first.";
|
|
1175
|
+
}
|
|
1176
|
+
const documentReadiness = retentionScheduleDocumentReadiness(document.id);
|
|
1177
|
+
if (documentReadiness?.status !== "complete") {
|
|
1178
|
+
return documentReadiness?.message || "Complete the governing document before approving the schedule.";
|
|
1179
|
+
}
|
|
1180
|
+
const planned = resourcesOfType("retention-schedule-item").find(({ record }) => record.status === "planned")?.record;
|
|
1181
|
+
if (planned) return "Complete or retire the planned row: " + planned.title + ".";
|
|
1182
|
+
const incomplete = retentionReviewItems("policies", false).find((item) => (
|
|
1183
|
+
item.status === "action" && item.id !== "collection-review-retention-schedule-item"
|
|
1184
|
+
));
|
|
1185
|
+
return incomplete?.message || null;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function collectionReviewVisible(type) {
|
|
1189
|
+
return type !== "control";
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function renderFinishStepThree() {
|
|
1193
|
+
const stage = state.programReadiness?.stages?.find(({ id }) => id === "controls");
|
|
1194
|
+
const items = stage?.items || [];
|
|
1195
|
+
const oversightItem = items.find(({ id }) => id === "collection-review-control");
|
|
1196
|
+
const oversight = state.collectionReviews?.control;
|
|
1197
|
+
const activationItems = items.filter(({ id }) => (
|
|
1198
|
+
id.startsWith("document-activation-")
|
|
1199
|
+
|| id.startsWith("training-activation-")
|
|
1200
|
+
|| id.startsWith("policy-activation-")
|
|
1201
|
+
));
|
|
1202
|
+
const implementationItems = items.filter(({ id }) => (
|
|
1203
|
+
id !== "collection-review-control"
|
|
1204
|
+
&& !id.startsWith("document-activation-")
|
|
1205
|
+
&& !id.startsWith("training-activation-")
|
|
1206
|
+
&& !id.startsWith("policy-activation-")
|
|
1207
|
+
));
|
|
1208
|
+
const next = implementationItems.find(({ status }) => status !== "complete");
|
|
1209
|
+
if (!oversightItem) {
|
|
1210
|
+
const href = next?.id?.startsWith("collection-review-") && next.resourceType
|
|
1211
|
+
? "#/resources/" + encodeURIComponent(next.resourceType) + "?review-collection=1"
|
|
1212
|
+
: next?.id?.startsWith("source-family-")
|
|
1213
|
+
? "#/evidence-sources"
|
|
1214
|
+
: next ? workflowItemHref({ ...next, state: next.status }) || "#/resources/control" : "#/resources/control";
|
|
1215
|
+
return '<section class="finish-step panel blocked"><div><p class="kicker">Finish Step 3</p><h2>Complete implementation first</h2><p>' + esc(next?.message || "Finish the Step 3 work areas before the final review becomes available.") + '</p></div><a class="button primary" href="' + esc(href) + '">Continue implementation</a></section>';
|
|
1216
|
+
}
|
|
1217
|
+
if (!oversight?.complete) {
|
|
1218
|
+
return '<section class="finish-step-heading"><p class="kicker">Finish Step 3</p><h2>Review the implemented Control collection</h2><p>One eligible reviewer confirms the implemented Controls as a batch. Management activates the approved program content after this review.</p></section>' + collectionReviewPanel("control", true);
|
|
1219
|
+
}
|
|
1220
|
+
if (activationItems.some(({ status }) => status !== "complete")) {
|
|
1221
|
+
return '<section class="finish-step-heading"><p class="kicker">Finish Step 3</p><h2>Program Content Activation</h2><p>The Control collection review is current. Activate the unchanged approved program content to complete the implementation cutover.</p></section>' + renderDocumentActivationAssessments() + renderPolicyActivationAssessments();
|
|
1222
|
+
}
|
|
1223
|
+
return '<section class="finish-step panel complete"><div><p class="kicker">Step 3 complete</p><h2>Implementation cutover complete</h2><p>The Control collection review is current and required program content is active.</p></div><a class="button primary" href="#/stage/run">Continue to Step 4</a></section>';
|
|
1224
|
+
}
|
|
1225
|
+
|
|
926
1226
|
function collectionNeedsFirstRecord(type) {
|
|
927
1227
|
const assessment = state.collectionReviews?.[type];
|
|
928
1228
|
if (!assessment || assessment.recordCount) return false;
|
|
@@ -991,14 +1291,15 @@ function recordWorkflowItems(type, id) {
|
|
|
991
1291
|
));
|
|
992
1292
|
}
|
|
993
1293
|
|
|
994
|
-
function recordWorkflowCell(type, entry) {
|
|
1294
|
+
function recordWorkflowCell(type, entry, detailContext = "") {
|
|
995
1295
|
if (state.sections?.workflow !== "complete") {
|
|
996
1296
|
return '<button class="text-button record-workflow-clear" type="button" data-load-workflow>Calculate action</button>';
|
|
997
1297
|
}
|
|
998
1298
|
const items = recordWorkflowItems(type, entry.record.id);
|
|
999
1299
|
if (!items.length) return '<span class="record-workflow-clear">No calculated action</span>';
|
|
1000
1300
|
const item = items[0];
|
|
1001
|
-
|
|
1301
|
+
let href = workflowItemHref(item) || "#/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(entry.record.id);
|
|
1302
|
+
if (detailContext && href.startsWith("#/resource/" + encodeURIComponent(type) + "/") && !href.includes("?")) href += detailContext;
|
|
1002
1303
|
const title = type === "control" ? "Finish implementation" : item.title;
|
|
1003
1304
|
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>';
|
|
1004
1305
|
}
|
|
@@ -1007,7 +1308,13 @@ function openCollectionReviewDialog(type) {
|
|
|
1007
1308
|
const assessment = state.collectionReviews?.[type];
|
|
1008
1309
|
if (!assessment) return;
|
|
1009
1310
|
const configuration = assessment.configuration;
|
|
1010
|
-
const
|
|
1311
|
+
const scheduleReview = type === "retention-schedule-item" && modelSupports("retention-schedule-approval");
|
|
1312
|
+
const eligibleReviewerIds = assessment.eligibleReviewerIds
|
|
1313
|
+
? new Set(assessment.eligibleReviewerIds)
|
|
1314
|
+
: null;
|
|
1315
|
+
const people = resourcesOfType("person").filter(({ record }) => (
|
|
1316
|
+
record.status === "active" && (!eligibleReviewerIds || eligibleReviewerIds.has(record.id))
|
|
1317
|
+
));
|
|
1011
1318
|
const v4 = modelSupports("program-scope");
|
|
1012
1319
|
const sourceType = v4 ? "component" : "system";
|
|
1013
1320
|
const systems = resourcesOfType(sourceType).filter(({ record }) => record.status === "active");
|
|
@@ -1031,7 +1338,18 @@ function openCollectionReviewDialog(type) {
|
|
|
1031
1338
|
const dialog = document.createElement("dialog");
|
|
1032
1339
|
dialog.className = "commit-dialog event-dialog collection-review-dialog";
|
|
1033
1340
|
dialog.setAttribute("aria-labelledby", "collection-review-dialog-title");
|
|
1034
|
-
|
|
1341
|
+
const reviewerHelp = eligibleReviewerIds && !people.length
|
|
1342
|
+
? '<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>'
|
|
1343
|
+
: "";
|
|
1344
|
+
const dialogKicker = scheduleReview ? "Schedule approval" : "Scope confirmation";
|
|
1345
|
+
const dialogTitle = scheduleReview ? "Review and approve Data Retention Schedule" : "Confirm " + configuration.title.toLowerCase();
|
|
1346
|
+
const checklistLabel = scheduleReview ? "Before approving" : "Before confirming";
|
|
1347
|
+
const saveDescription = scheduleReview
|
|
1348
|
+
? '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.'
|
|
1349
|
+
: 'Confirm ' + assessment.recordCount + ' current ' + pluralize("record", assessment.recordCount) + '. If the collection or material scope changes, FileGRC will ask for another review.';
|
|
1350
|
+
const confirmSubmit = '<button type="submit" class="button primary">Confirm and save</button>';
|
|
1351
|
+
const submit = scheduleReview ? '<button type="submit" class="button primary">Approve schedule</button>' : confirmSubmit;
|
|
1352
|
+
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>';
|
|
1035
1353
|
document.body.append(dialog);
|
|
1036
1354
|
dialog.showModal();
|
|
1037
1355
|
const form = dialog.querySelector("form");
|
|
@@ -1063,7 +1381,8 @@ function openCollectionReviewDialog(type) {
|
|
|
1063
1381
|
reviewedOn: form.elements.reviewedOn.value,
|
|
1064
1382
|
scopeRevision: state.git?.commit || undefined,
|
|
1065
1383
|
[v4 ? "authoritativeComponentId" : "authoritativeSystemId"]: form.elements.authoritativeSourceId.value || undefined,
|
|
1066
|
-
expectedRevision: assessment.reviewRevision || undefined
|
|
1384
|
+
expectedRevision: assessment.reviewRevision || undefined,
|
|
1385
|
+
expectedCollectionRevision: assessment.collectionRevision
|
|
1067
1386
|
};
|
|
1068
1387
|
form.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = true; });
|
|
1069
1388
|
const submit = form.querySelector('button[type="submit"]');
|
|
@@ -1132,6 +1451,7 @@ function workflowItemHref(item) {
|
|
|
1132
1451
|
const collectionReviewCommand = commands.find((command) => command.includes(" review-collection "));
|
|
1133
1452
|
const collectionReviewType = collectionReviewCommand?.match(/review-collection\s+([a-z0-9-]+)/)?.[1];
|
|
1134
1453
|
if (collectionReviewType && state.model.collectionReviews?.[collectionReviewType]) {
|
|
1454
|
+
if (collectionReviewType === "retention-schedule-item" && modelSupports("retention-schedule-approval")) return "#/retention-schedule";
|
|
1135
1455
|
return "#/resources/" + encodeURIComponent(collectionReviewType) + "?review-collection=1";
|
|
1136
1456
|
}
|
|
1137
1457
|
if (
|
|
@@ -1188,7 +1508,7 @@ function retentionScheduleItemHref(item) {
|
|
|
1188
1508
|
&& !["superseded", "retired"].includes(record.status)
|
|
1189
1509
|
));
|
|
1190
1510
|
if (scheduleDocuments.length === 1) params.set("scheduleDocumentId", scheduleDocuments[0].record.id);
|
|
1191
|
-
return "#/
|
|
1511
|
+
return "#/retention-schedule?" + params;
|
|
1192
1512
|
}
|
|
1193
1513
|
|
|
1194
1514
|
function workflowItemDetail(item) {
|
|
@@ -1265,16 +1585,32 @@ function renderEvidenceReadiness() {
|
|
|
1265
1585
|
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>';
|
|
1266
1586
|
}
|
|
1267
1587
|
|
|
1268
|
-
function retentionReviewItems() {
|
|
1588
|
+
function retentionReviewItems(stageId = null, includeMappings = true) {
|
|
1589
|
+
const scheduleDocumentReviewIds = new Set(state.resources
|
|
1590
|
+
.filter(({ record }) => record.type === "document" && record.documentKind === "schedule" && record.workflowScope === "program")
|
|
1591
|
+
.map(({ record }) => "document-approval-" + record.id));
|
|
1269
1592
|
return (state.programReadiness?.stages || []).flatMap((stage) => (
|
|
1270
1593
|
(stage.items || []).filter((item) => (
|
|
1271
|
-
|
|
1272
|
-
|
|
1594
|
+
(!stageId || stage.id === stageId)
|
|
1595
|
+
&& (item.id?.startsWith("retention-")
|
|
1596
|
+
|| includeMappings && item.id?.startsWith("requirement-mapping-")
|
|
1273
1597
|
|| ["collection-review-information-type", "collection-review-retention-schedule-item"].includes(item.id)
|
|
1598
|
+
|| scheduleDocumentReviewIds.has(item.id)
|
|
1599
|
+
)
|
|
1274
1600
|
)).map((item) => ({ ...item, stage: stage.id }))
|
|
1275
1601
|
));
|
|
1276
1602
|
}
|
|
1277
1603
|
|
|
1604
|
+
function isRetentionScheduleWorkflowItem(item) {
|
|
1605
|
+
if (item.id?.startsWith("retention-") || item.id === "collection-review-retention-schedule-item") return true;
|
|
1606
|
+
return [item.subject, item.source].filter(Boolean).some((reference) => {
|
|
1607
|
+
if (reference.type === "retention-schedule-item") return true;
|
|
1608
|
+
if (reference.type !== "document" || !reference.id) return false;
|
|
1609
|
+
const record = state.resources.find(({ record }) => record.id === reference.id)?.record;
|
|
1610
|
+
return record?.documentKind === "schedule" && record.workflowScope !== "engagement";
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1278
1614
|
function programReadinessWorkflowItems() {
|
|
1279
1615
|
if (state.sections?.workflow === "complete") return [];
|
|
1280
1616
|
return retentionReviewItems()
|
|
@@ -1287,37 +1623,6 @@ function programReadinessWorkflowItems() {
|
|
|
1287
1623
|
}));
|
|
1288
1624
|
}
|
|
1289
1625
|
|
|
1290
|
-
function renderRetentionReadiness() {
|
|
1291
|
-
const items = retentionReviewItems();
|
|
1292
|
-
if (!items.length) return "";
|
|
1293
|
-
const actions = items.filter((item) => item.status === "action");
|
|
1294
|
-
const cards = actions.map((item) => {
|
|
1295
|
-
const ids = [...new Set([
|
|
1296
|
-
item.resourceId,
|
|
1297
|
-
item.informationTypeId,
|
|
1298
|
-
...(item.retentionScheduleItemIds || []),
|
|
1299
|
-
...(item.staleResourceIds || [])
|
|
1300
|
-
].filter(Boolean))];
|
|
1301
|
-
const references = ids.length
|
|
1302
|
-
? '<div class="evidence-map-references">' + ids.map((id) => formatReference(id)).join("") + '</div>'
|
|
1303
|
-
: "";
|
|
1304
|
-
const href = item.id?.startsWith("retention-use-")
|
|
1305
|
-
? retentionScheduleItemHref(item)
|
|
1306
|
-
: item.id?.startsWith("collection-review-")
|
|
1307
|
-
? '#/resources/' + encodeURIComponent(item.resourceType)
|
|
1308
|
-
: item.resourceId && item.resourceType
|
|
1309
|
-
? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
|
|
1310
|
-
: '#/resources/' + encodeURIComponent(item.resourceType || "retention-schedule-item");
|
|
1311
|
-
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>';
|
|
1312
|
-
});
|
|
1313
|
-
const visibleCount = 6;
|
|
1314
|
-
const visibleCards = cards.slice(0, visibleCount).join("");
|
|
1315
|
-
const moreCards = cards.length > visibleCount
|
|
1316
|
-
? '<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>'
|
|
1317
|
-
: "";
|
|
1318
|
-
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>';
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
1626
|
function evidenceSourceCheckLabel(name) {
|
|
1322
1627
|
return ({
|
|
1323
1628
|
active: "activate source",
|
|
@@ -1359,7 +1664,7 @@ function stagePageCard(stage, destination, index) {
|
|
|
1359
1664
|
const completionState = '<span class="stage-page-completion-state ' + (complete ? "complete" : "") + '">' + esc(derived.label) + '</span>';
|
|
1360
1665
|
const taskPreview = items.length
|
|
1361
1666
|
? '<div class="stage-page-tasks">' + items.slice(0, 3).map((item) => {
|
|
1362
|
-
const href = workflowItemHref(item) || destination.href;
|
|
1667
|
+
const href = destination.utility === "evidence-sources" ? destination.href : workflowItemHref(item) || destination.href;
|
|
1363
1668
|
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>';
|
|
1364
1669
|
}).join("") + (items.length > 3 ? '<small class="stage-page-tasks-more">+' + (items.length - 3) + ' more on this page</small>' : "") + '</div>'
|
|
1365
1670
|
: "";
|
|
@@ -1402,6 +1707,21 @@ function derivedStagePageState(stage, destination) {
|
|
|
1402
1707
|
) {
|
|
1403
1708
|
return { complete: false, label: "No engagement" };
|
|
1404
1709
|
}
|
|
1710
|
+
if (destination.utility === "evidence-sources") {
|
|
1711
|
+
const evidenceItems = state.programReadiness?.stages?.find(({ id }) => id === "controls")?.items
|
|
1712
|
+
.filter(({ id }) => id.startsWith("source-family-")) || [];
|
|
1713
|
+
const incomplete = evidenceItems.filter(({ status }) => status !== "complete").length;
|
|
1714
|
+
return incomplete
|
|
1715
|
+
? { complete: false, label: incomplete + " " + pluralize("source", incomplete) + " need work" }
|
|
1716
|
+
: { complete: evidenceItems.length > 0, label: evidenceItems.length ? "Ready" : "Not configured", countsTowardProgress: evidenceItems.length > 0 };
|
|
1717
|
+
}
|
|
1718
|
+
if (destination.utility === "retention-schedule") {
|
|
1719
|
+
const assessment = state.collectionReviews?.["retention-schedule-item"];
|
|
1720
|
+
const incomplete = retentionReviewItems("policies", false).filter(({ status }) => status !== "complete").length;
|
|
1721
|
+
return assessment?.status === "current" && !incomplete
|
|
1722
|
+
? { complete: true, label: "Approved" }
|
|
1723
|
+
: { complete: false, label: incomplete ? incomplete + " " + pluralize("item", incomplete) + " need work" : "Approval needed" };
|
|
1724
|
+
}
|
|
1405
1725
|
const items = stagePageItems(stage, destination);
|
|
1406
1726
|
const deferredStates = new Set(["later", "scheduled", "upcoming", "waiting-external"]);
|
|
1407
1727
|
const blocking = items.filter(({ state }) => !deferredStates.has(state));
|
|
@@ -1411,7 +1731,9 @@ function derivedStagePageState(stage, destination) {
|
|
|
1411
1731
|
if (items.some(({ state }) => deferredStates.has(state))) {
|
|
1412
1732
|
return { complete: false, countsTowardProgress: false, label: "Later" };
|
|
1413
1733
|
}
|
|
1414
|
-
const collectionReview = destination.type
|
|
1734
|
+
const collectionReview = destination.type && collectionReviewVisible(destination.type)
|
|
1735
|
+
? state.collectionReviews?.[destination.type]
|
|
1736
|
+
: null;
|
|
1415
1737
|
if (collectionReview) {
|
|
1416
1738
|
return collectionReview.status === "current"
|
|
1417
1739
|
? { complete: true, label: "Reviewed" }
|
|
@@ -1438,8 +1760,18 @@ function stagePageItems(stage, destination) {
|
|
|
1438
1760
|
&& item.code === "governance.appointment.independent-policy-reviewer"
|
|
1439
1761
|
)
|
|
1440
1762
|
));
|
|
1441
|
-
if (
|
|
1442
|
-
return
|
|
1763
|
+
if (destination.utility === "evidence-sources") {
|
|
1764
|
+
return (state.programReadiness?.stages?.find(({ id }) => id === "controls")?.items || [])
|
|
1765
|
+
.filter(({ id, status }) => id.startsWith("source-family-") && status !== "complete")
|
|
1766
|
+
.map((item) => ({ ...item, key: item.id, state: item.status, subject: { type: "source-coverage" } }));
|
|
1767
|
+
}
|
|
1768
|
+
if (destination.utility === "retention-schedule") {
|
|
1769
|
+
return retentionReviewItems("policies", false)
|
|
1770
|
+
.filter(({ status }) => status !== "complete")
|
|
1771
|
+
.map((item) => ({ ...item, key: item.id, state: item.status, subject: item.resourceId && item.resourceType ? { type: item.resourceType, id: item.resourceId } : { type: item.resourceType || "retention-schedule-item" } }));
|
|
1772
|
+
}
|
|
1773
|
+
if (stage.id === "policies" && destination.href === "#/policies") {
|
|
1774
|
+
return items.filter((item) => !isRetentionScheduleWorkflowItem(item)).sort((left, right) => (
|
|
1443
1775
|
workflowItemStatePriority(left) - workflowItemStatePriority(right)
|
|
1444
1776
|
|| (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
|
|
1445
1777
|
|| left.key.localeCompare(right.key)
|
|
@@ -1582,6 +1914,8 @@ function sectionDestinations(section) {
|
|
|
1582
1914
|
destinations.push({ type: link.type, kind: "Record page", label: link.label, href: link.href, description: section.description });
|
|
1583
1915
|
}
|
|
1584
1916
|
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." });
|
|
1917
|
+
if (section.utility === "evidence-sources") destinations.push({ utility: section.utility, kind: "Coverage page", label: "Evidence Sources", href: "#/evidence-sources", description: section.description });
|
|
1918
|
+
if (section.utility === "retention-schedule") destinations.push({ utility: section.utility, kind: "Governed schedule", label: "Data Retention Schedule", href: "#/retention-schedule", description: section.description });
|
|
1585
1919
|
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." });
|
|
1586
1920
|
return destinations;
|
|
1587
1921
|
}
|
|
@@ -2760,6 +3094,11 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
2760
3094
|
...(params.get("informationTypeId") ? { informationTypeIds: [params.get("informationTypeId")] } : {}),
|
|
2761
3095
|
...(params.get("scopeResourceId") ? { scopeResourceIds: [params.get("scopeResourceId")] } : {}),
|
|
2762
3096
|
...(params.get("scheduleDocumentId") ? { scheduleDocumentId: params.get("scheduleDocumentId") } : {})
|
|
3097
|
+
} : type === "document" ? {
|
|
3098
|
+
...(params.get("title") ? { title: params.get("title") } : {}),
|
|
3099
|
+
...(params.get("documentKind") ? { documentKind: params.get("documentKind") } : {}),
|
|
3100
|
+
...(documentScope ? { workflowScope: documentScope } : {}),
|
|
3101
|
+
...relationshipSeed
|
|
2763
3102
|
} : {
|
|
2764
3103
|
...(params.get("title") ? { title: params.get("title") } : {}),
|
|
2765
3104
|
...relationshipSeed
|
|
@@ -2794,8 +3133,19 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2794
3133
|
const entry = resourcesOfType(type).find(({ record }) => record.id === id);
|
|
2795
3134
|
const definition = state.model.resources[type];
|
|
2796
3135
|
if (!entry || !definition) return renderNotFound(main);
|
|
3136
|
+
const scheduleContext = params.get("stage") === "policies" && (
|
|
3137
|
+
type === "retention-schedule-item"
|
|
3138
|
+
|| type === "document" && entry.record.documentKind === "schedule" && entry.record.workflowScope !== "engagement"
|
|
3139
|
+
);
|
|
3140
|
+
const policiesContext = params.get("stage") === "policies" && (
|
|
3141
|
+
type === "policy"
|
|
3142
|
+
|| type === "training"
|
|
3143
|
+
|| type === "document" && !auditSpecificDocument(entry.record) && entry.record.documentKind !== "schedule"
|
|
3144
|
+
);
|
|
3145
|
+
const collectionHref = scheduleContext ? "#/retention-schedule" : policiesContext ? "#/policies" : "#/resources/" + encodeURIComponent(type);
|
|
3146
|
+
const collectionLabel = scheduleContext ? "Data Retention Schedule" : policiesContext ? "Policies" : titleCase(definition.pluralTitle);
|
|
2797
3147
|
if (entry.detailsLoaded === false) {
|
|
2798
|
-
main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="
|
|
3148
|
+
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>';
|
|
2799
3149
|
loadResourceDetail(type, id);
|
|
2800
3150
|
return;
|
|
2801
3151
|
}
|
|
@@ -2880,7 +3230,7 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2880
3230
|
connectionsPanel: resourceConnections(entry),
|
|
2881
3231
|
historyPanel
|
|
2882
3232
|
});
|
|
2883
|
-
main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="
|
|
3233
|
+
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>' + (!definition.singleton ? '<button class="button danger" id="delete-resource">Delete</button>' : "") : "") + '</div></div><div class="detail-grid ' + (hasRecordBody ? "" : "detail-grid-structured") + '">' + detailMain + supportPanels + '</div></div>';
|
|
2884
3234
|
main.querySelectorAll("[data-completion-requirements]").forEach((button) => button.addEventListener("click", () => openCompletionRequirementsDialog(button.dataset.completionRequirements)));
|
|
2885
3235
|
main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry));
|
|
2886
3236
|
main.querySelector("[data-external-reviewer-governance]")?.addEventListener("click", openExternalReviewerGovernanceDialog);
|
|
@@ -5986,6 +6336,7 @@ function esc(value) { return String(value ?? "").replace(/[&<>"']/g, (character)
|
|
|
5986
6336
|
`;
|
|
5987
6337
|
|
|
5988
6338
|
export const APP_STYLES = String.raw`
|
|
6339
|
+
.finish-step{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-top:24px;padding:20px 22px}.finish-step h2,.finish-step-heading h2{margin:5px 0 7px;font:500 24px Georgia,serif}.finish-step p:not(.kicker),.finish-step-heading p:not(.kicker){margin:0;color:var(--muted);font-size:12px;line-height:1.55}.finish-step.blocked{border-color:#d8bd78}.finish-step.complete{border-color:#b9dac6}.finish-step-heading{margin-top:28px;padding:20px 22px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:11px}
|
|
5989
6340
|
:root{--ink:#151827;--muted:#5d6475;--line:#dfe3ef;--paper:#f6f7fb;--panel:#fff;--accent:#0000a5;--accent-soft:#eef1ff;--accent-light:#8aa1ff;--focus:#0000e0;--amber:#8a5200;--red:#a13a31;--sidebar:linear-gradient(135deg,#000070 0%,#000035 60%);--primary-gradient:linear-gradient(135deg,#000070 0%,#000035 60%);--surface-soft:#f2f4fa;--surface-muted:#eceff7;--field:#fff;--field-readonly:#eef0f6;--code-bg:#10162b;--code-ink:#e8ebff;--shadow:0 8px 28px rgba(0,0,53,.08);color-scheme:light dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
|
|
5990
6341
|
*{box-sizing:border-box}body{margin:0;min-width:320px;background:var(--paper)}button,input,select,textarea{font:inherit}a{color:inherit}@keyframes spinner-rotate{to{transform:rotate(360deg)}}.spinner{display:inline-block;flex:0 0 auto;width:1em;height:1em;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spinner-rotate .7s linear infinite}.spinner-large{width:30px;height:30px;border-width:3px}.loading,.detail-loading,.muted.is-loading,.save-status.is-loading,.onboarding-save-status.is-loading,.button.is-loading{display:flex;align-items:center;gap:9px}.loading{min-height:100vh;justify-content:center;color:var(--muted)}.state-loading{display:flex;align-items:flex-start;gap:16px}.state-loading>.spinner{margin-top:5px;color:var(--accent)}.state-loading h2{margin:5px 0 8px}.state-loading p:last-child{margin:0;color:var(--muted)}.skip-link{position:fixed;left:1rem;top:-4rem;z-index:100;padding:.7rem 1rem;background:#fff}.skip-link:focus{top:1rem}.loading,.fatal{padding:3rem}.shell{display:grid;grid-template-columns:248px 1fr;min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:248px;background:var(--sidebar);color:#eef1ff;padding:25px 18px 18px;overflow:auto;z-index:20}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;margin:0 7px 27px}.brand .mark{display:block;width:39px;height:39px;border-radius:10px}.brand strong,.brand small{display:block}.brand strong{color:#fff;font-size:18px}.brand small{font-size:13.2px;color:#c5cae2;margin-top:2px}.nav-home,.nav-items a{display:flex;justify-content:space-between;align-items:center;text-decoration:none;border-radius:7px;padding:8px 10px;font-size:15.6px;color:#d5d9ed}.nav-home{margin-bottom:9px}.nav-home:hover,.nav-items a:hover,.nav-home.current,.nav-items a.current{background:#202066;color:#fff}.nav-heading{width:100%;border:0;background:none;color:#b4bbdc;text-transform:uppercase;letter-spacing:.11em;font-size:12px;font-weight:750;display:flex;align-items:center;justify-content:space-between;padding:13px 10px 5px;cursor:pointer}.chevron{display:grid;place-items:center;width:14px;height:22px;font-size:0;line-height:1;transform:none}.chevron:before{content:"";width:6px;height:6px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transform-origin:center;transition:transform .15s}.nav-items{display:none}.nav-group.open .nav-items{display:block}.nav-items small{font-size:12px;color:#b8bed7}.side-foot{position:sticky;bottom:-18px;margin:25px -18px -18px;padding:17px 25px;background:#000024;border-top:1px solid #34345f;color:#cbd0e5;font-size:13.2px;display:flex;align-items:center;gap:8px}.status-dot{width:8px;height:8px;border-radius:50%;background:#9aa39f;display:inline-block;flex:0 0 auto}.status-dot.good,.badge.good{background:#6abf8c}.status-dot.warn,.badge.warn{background:#e9a445}.status-dot.bad,.badge.bad{background:#dc6c5d}.status-dot.neutral{background:#9aabff}.workspace{grid-column:2;min-width:0}.topbar{height:86px;background:rgba(255,255,255,.88);backdrop-filter:blur(10px);border-bottom:1px solid var(--line);padding:0 32px;display:flex;align-items:center;gap:23px;position:sticky;top:0;z-index:10}.topbar>div:first-of-type{min-width:190px}.topbar h1{font-size:20.4px;line-height:1.1;margin:3px 0 0}.eyebrow,.kicker{color:var(--accent);text-transform:uppercase;letter-spacing:.12em;font-weight:760;font-size:10.8px;margin:0}.search{height:39px;max-width:240px;flex:1;margin-left:auto;display:flex;align-items:center;gap:9px;background:#f2f4fa;border:1px solid #dfe3ef;border-radius:8px;padding:0 10px;color:#5d6475}.search input{border:0;outline:0;background:none;min-width:0;flex:1;font-size:15.6px}.search kbd{background:#fff;border:1px solid #dfe3ef;border-radius:4px;padding:1px 5px;font-size:12px}.mobile-sidebar-search{display:none}.repo-chip{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:8px;padding:10px 12px;color:var(--muted);font-size:13.2px;white-space:nowrap;text-decoration:none}.mobile-nav{display:none}.page{padding:30px 34px 70px;max-width:1510px;margin:auto}.hero{color:#f8f9ff;background:linear-gradient(120deg,#000070,#000035);border-radius:13px;padding:28px 31px;display:flex;justify-content:space-between;align-items:end;min-height:158px;box-shadow:var(--shadow);position:relative;overflow:hidden}.hero:after{content:"";position:absolute;width:270px;height:270px;border:55px solid rgba(138,161,255,.1);border-radius:50%;right:-80px;top:-145px}.hero .kicker{color:#cbd3ff}.hero h2{font-family:Georgia,serif;font-weight:500;font-size:33.6px;margin:10px 0 8px;letter-spacing:-.02em}.hero p:not(.kicker){margin:0;color:#dde1f4;font-size:15.6px;max-width:650px}.hero-meta{display:flex;gap:15px;position:relative;z-index:1}.hero-meta span{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#e6e8f7;border-left:1px solid #6874ab;padding-left:15px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:14px 0}.metric{background:#fff;border:1px solid var(--line);border-radius:10px;padding:16px 18px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.metric-label{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);display:flex;align-items:center;gap:7px}.metric>strong{display:block;font-family:Georgia,serif;font-size:30px;font-weight:500;margin:8px 0 2px}.metric>small{font-size:12px;color:#697184}.dashboard-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.panel{background:#fff;border:1px solid var(--line);border-radius:11px;padding:21px;min-width:0;box-shadow:0 2px 8px rgba(21,40,33,.025)}.span-2{grid-column:span 2}.panel-head{display:flex;align-items:start;justify-content:space-between;gap:15px;margin-bottom:18px}.panel-head h3{font-size:16.8px;margin:4px 0 0}.panel-head>a{font-size:13.2px;color:var(--accent);font-weight:700}.audit-progress{display:grid;grid-template-columns:105px 1fr;gap:11px 20px;align-items:end}.progress-number strong{font-family:Georgia,serif;font-size:36px;font-weight:500;display:block}.progress-number span{font-size:12px;color:var(--muted)}.progress{height:9px;background:#eceff7;border-radius:9px;overflow:hidden}.progress span{display:block;height:100%;background:linear-gradient(90deg,#0000a5,var(--accent-light));border-radius:9px}.progress-meta{grid-column:2;display:flex;justify-content:space-between;font-size:10.8px;text-transform:uppercase;letter-spacing:.08em;color:#5d6475}.due-list{display:grid}.due-list a{display:grid;grid-template-columns:60px 1fr;text-decoration:none;border-top:1px solid #e8ebf3;padding:10px 0;align-items:center}.due-list a:first-child{border:0;padding-top:0}.due-list time{font-size:12px;color:var(--accent);font-weight:750}.due-list strong,.due-list small{display:block}.due-list strong{font-size:13.2px}.due-list small{font-size:10.8px;color:var(--muted);margin-top:3px}.resource-bars{display:grid;gap:11px}.resource-bars a{display:grid;grid-template-columns:105px 1fr 20px;gap:9px;align-items:center;text-decoration:none;font-size:12px}.resource-bars i{height:5px;background:#e8ebf3;border-radius:5px;overflow:hidden}.resource-bars b{display:block;height:100%;background:#6676dd;border-radius:5px}.resource-bars strong{text-align:right}.catalog{display:grid;grid-template-columns:repeat(4,1fr);gap:7px}.catalog a{display:flex;justify-content:space-between;text-decoration:none;padding:9px 11px;background:#f2f4fa;border-radius:6px;font-size:12px}.catalog a:hover{background:var(--accent-soft)}.page-intro{display:flex;justify-content:space-between;align-items:end;margin-bottom:25px}.page-intro h2,.detail-head h2{font-family:Georgia,serif;font-size:37.2px;font-weight:500;margin:7px 0}.page-intro p:not(.kicker){color:var(--muted);max-width:700px;font-size:15.6px;margin:0}.button{border:1px solid #d0d5e3;background:#fff;border-radius:7px;padding:9px 13px;cursor:pointer;font-size:14.4px;font-weight:650}.button.primary{background:var(--accent);border-color:var(--accent);color:#fff}.button.danger{color:var(--red)}.list-tools{display:flex;align-items:center;gap:10px;margin-bottom:12px}.list-tools label{flex:1}.list-tools input,.list-tools select{width:100%;border:1px solid var(--line);border-radius:7px;background:#fff;padding:10px 12px;font-size:14.4px}.list-tools select{width:auto}.list-tools>span{color:var(--muted);font-size:12px}.record-table-wrap{background:#fff;border:1px solid var(--line);border-radius:10px;overflow:auto}.record-table{width:100%;border-collapse:collapse;font-size:13.2px}.record-table th{background:#f2f4fa;text-align:left;text-transform:uppercase;letter-spacing:.08em;color:#75817b;font-size:10.8px;padding:11px 14px;border-bottom:1px solid var(--line)}.record-table td{padding:13px 14px;border-bottom:1px solid #e8ebf3;vertical-align:top}.record-table tr:last-child td{border-bottom:0}.record-table code{font-size:10.8px;color:#5d6475}.record-title{display:block;color:var(--ink);font-weight:700;text-decoration:none}.record-table td>small{display:block;color:#6a7181;margin-top:3px}.record-table td[data-label="Description"]{min-width:260px;max-width:520px;color:var(--muted);line-height:1.45}.badge,.tag,.type-pill{display:inline-block;border-radius:99px;background:#eceff7;padding:3px 7px;font-size:10.8px;text-transform:uppercase;letter-spacing:.05em;white-space:nowrap}.tag{text-transform:none;margin:1px}.badge.status-active,.badge.status-approved,.badge.status-complete,.badge.status-passed,.badge.status-accepted{background:#ddefe5;color:#176143}.badge.status-open,.badge.status-high,.badge.status-critical,.badge.status-failed{background:#f5ded9;color:#8d352c}.badge.status-draft,.badge.status-planned,.badge.status-in-progress,.badge.status-medium{background:#f7e9cf;color:#855717}.breadcrumbs{display:flex;gap:8px;color:var(--muted);font-size:13.2px;margin-bottom:20px}.detail-head{display:flex;justify-content:space-between;align-items:end;margin-bottom:22px}.detail-head h2{margin-bottom:4px}.detail-head>div>code{font-size:12px;color:var(--muted)}.actions{display:flex;gap:7px}.detail-grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(270px,1fr);gap:14px}.detail-grid aside{display:grid;gap:14px;align-content:start}.detail-main{padding:29px}.content-label{color:#75817b;text-transform:uppercase;letter-spacing:.08em;font-size:10.8px;border-bottom:1px solid var(--line);padding-bottom:13px;margin-bottom:23px}.markdown{max-width:790px}.markdown h1{font-family:Georgia,serif;font-size:34.8px;font-weight:500}.markdown h2{font-family:Georgia,serif;font-size:27.6px;font-weight:500;margin-top:1.8em}.markdown h3{font-size:18px;margin-top:1.7em}.markdown p,.markdown li{font-size:15.6px;line-height:1.65;color:#272c3b}.markdown code{background:#eef0f6;border-radius:3px;padding:1px 4px}.markdown pre{padding:15px;background:#10162b;color:#e8ebff;border-radius:7px;overflow:auto}.markdown blockquote{border-left:3px solid var(--accent-light);padding:4px 15px;color:var(--muted);margin-left:0}.table-wrap{overflow:auto}.markdown table{border-collapse:collapse;width:100%;font-size:13.2px}.markdown th,.markdown td{border:1px solid var(--line);padding:8px;text-align:left}.metadata{margin:0}.metadata>div{display:grid;grid-template-columns:105px 1fr;gap:10px;border-top:1px solid #e8ebf3;padding:10px 0}.metadata>div:first-child{border-top:0;padding-top:0}.metadata dt{font-size:10.8px;text-transform:uppercase;letter-spacing:.06em;color:#5d6475}.metadata dd{margin:0;font-size:13.2px;min-width:0}.compact-json{white-space:pre-wrap;font-size:10.8px}.git-panel>code{font-size:10.8px;word-break:break-all}.git-panel p{font-size:12px;color:var(--muted)}.relation{color:var(--accent);text-decoration:none}.history{display:grid}.history>div{display:grid;grid-template-columns:60px 1fr;gap:8px;padding:8px 0;border-top:1px solid #e8ebf3}.history>div:first-child{border-top:0}.history code{font-size:10.8px;color:var(--accent)}.history strong,.history small{display:block}.history strong{font-size:12px}.history small{font-size:10.8px;color:var(--muted);margin-top:2px}.empty{padding:25px;color:#697184;text-align:center;font-size:13.2px;background:#f4f5fa;border-radius:7px}.changes{padding-left:18px}.changes li{margin:8px 0}.diagnostics>div{display:grid;grid-template-columns:58px minmax(120px,180px) minmax(0,1fr);gap:10px;align-items:start;border-top:1px solid var(--line);padding:10px 0}.diagnostics p{margin:0;font-size:13.2px;overflow-wrap:anywhere}.diagnostics code{font-size:10.8px;overflow-wrap:anywhere}.editor,.search-results{width:min(760px,calc(100vw - 30px));border:0;border-radius:12px;padding:0;box-shadow:0 25px 80px rgba(0,0,24,.28)}dialog::backdrop{background:rgba(0,0,24,.55)}.editor form,.search-results{padding:23px}.dialog-head{display:flex;justify-content:space-between;align-items:start}.dialog-head h2{font-family:Georgia,serif;font-weight:500;margin:5px 0 0}.icon-button{border:0;background:#eceff7;width:32px;height:32px;border-radius:50%;font-size:26.4px;cursor:pointer}.editor form>p{font-size:13.2px;color:var(--muted)}.editor textarea{width:100%;height:440px;border:1px solid var(--line);border-radius:7px;background:#10162b;color:#e8ebff;padding:15px;font:13.2px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;tab-size:2px}.dialog-actions{display:flex;justify-content:end;gap:8px;margin-top:14px}.dialog-error{color:var(--red);font-size:13.2px;min-height:18px;margin-top:7px}.result-list{display:grid;margin-top:17px;max-height:60vh;overflow:auto}.result-list a{display:block;text-decoration:none;padding:11px;border-top:1px solid var(--line)}.result-list strong,.result-list small{display:block}.result-list small{color:var(--muted);margin-top:3px}.muted{color:#737a8b}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
|
5991
6342
|
.topbar-readiness{display:grid;grid-template-columns:minmax(0,1fr);gap:3px;flex:0 1 220px;min-width:155px;padding:5px 8px;border:1px solid transparent;border-radius:8px;color:var(--ink);text-decoration:none}.topbar-readiness:hover{border-color:var(--accent-light);background:var(--accent-soft)}.topbar-readiness-copy{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:center}.topbar-readiness-copy>span{overflow:hidden;color:var(--muted);font-size:9.6px;font-weight:700;text-overflow:ellipsis;text-transform:uppercase;letter-spacing:.08em;white-space:nowrap}.topbar-readiness-copy>strong{font-size:10.8px;line-height:1}.topbar-readiness>.progress{width:100%;height:5px}.topbar-status{display:flex;flex:1 1 auto;min-width:0;align-items:center;justify-content:flex-end;gap:8px;margin-left:auto}.program-select select{max-width:180px;border:1px solid var(--line);border-radius:8px;padding:9px 28px 9px 10px;background:var(--surface);color:var(--ink);font:inherit}.topbar-status .topbar-search{flex:0 1 240px;width:240px;min-width:140px;margin-left:0}.repo-chip,.validation-chip{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:8px;padding:10px 12px;color:var(--muted);font-size:13.2px;white-space:nowrap;text-decoration:none}.repo-chip:hover,.validation-chip:hover{color:var(--ink);border-color:var(--accent-light)}
|
|
@@ -6008,7 +6359,9 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
6008
6359
|
@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}}
|
|
6009
6360
|
.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)}
|
|
6010
6361
|
.policy-lifecycle-note.single{grid-template-columns:1fr}
|
|
6011
|
-
.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}
|
|
6362
|
+
.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}
|
|
6363
|
+
.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}
|
|
6364
|
+
@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}}
|
|
6012
6365
|
.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}
|
|
6013
6366
|
.policy-activation-actions{display:flex;justify-content:flex-end;margin-top:14px;padding-top:12px;border-top:1px solid var(--line)}
|
|
6014
6367
|
.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}
|
|
@@ -6045,6 +6398,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
6045
6398
|
@media(max-width:760px){.reconciliation-candidate{grid-template-columns:1fr}.reconciliation-actions{justify-items:start}.reconciliation-actions>code{text-align:left}}
|
|
6046
6399
|
@media(max-width:760px){.detail-grid-structured .detail-support-columns{display:flex;flex-direction:column}.detail-grid-structured .detail-support-stack{display:contents}.detail-grid-structured .detail-history-panel{order:1}}
|
|
6047
6400
|
@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}}
|
|
6401
|
+
@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}}
|
|
6048
6402
|
@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}}
|
|
6049
6403
|
@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}}
|
|
6050
6404
|
@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))}}
|