filegrc 0.14.0 → 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/model/index.js +2 -1
- package/model/v11.json +5 -5
- package/package.json +1 -1
- package/src/agent.js +1 -1
- package/src/collection-review.js +61 -2
- package/src/collection-revision.js +3 -1
- package/src/collection-scope.js +45 -1
- package/src/program-path.js +59 -14
- package/src/program-readiness.js +16 -4
- package/src/retention-schedule-approval.js +121 -0
- package/src/retention.js +56 -1
- package/src/state.js +19 -22
- package/src/validate.js +46 -0
- package/src/web.js +342 -68
- package/src/workflow-history-integrity.js +5 -1
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
|
}));
|
|
@@ -73,6 +74,7 @@ const dashboardProgramReadiness = ${dashboardProgramReadiness.toString()};
|
|
|
73
74
|
const STAGE_PAGE_SUMMARIES = ${JSON.stringify({
|
|
74
75
|
...RESOURCE_PAGE_SUMMARIES,
|
|
75
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.",
|
|
76
78
|
"utility:audit-packet": "Review fieldwork readiness and build the indexed evidence packet."
|
|
77
79
|
})};
|
|
78
80
|
const RECORD_TEXT_FIELDS = new Set(["description", "statement", "activity", "purpose", "scope", "objective", "applicabilityRationale", "summary", "rationale", "businessPurpose", "changeSummary", "decisionSummary", "decisionRationale", "recommendation", "remediationPlan", "auditorNotes", "notPerformedReason"]);
|
|
@@ -176,6 +178,9 @@ function render() {
|
|
|
176
178
|
else if (waitingFor.length) renderStateLoading(main, route, waitingFor);
|
|
177
179
|
else if (route.name === "home") renderHome(main);
|
|
178
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);
|
|
179
184
|
else if (route.name === "evidence-sources") renderEvidenceSourcesPage(main);
|
|
180
185
|
else if (route.name === "obligations") renderObligations(main, route.params);
|
|
181
186
|
else if (route.name === "audit-packet") renderAuditPacket(main, route.params);
|
|
@@ -188,6 +193,10 @@ function render() {
|
|
|
188
193
|
}
|
|
189
194
|
|
|
190
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) }));
|
|
191
200
|
if (next.sections) return next;
|
|
192
201
|
return {
|
|
193
202
|
...next,
|
|
@@ -208,6 +217,8 @@ function blockingStateSections(route) {
|
|
|
208
217
|
if (route.name === "obligations" || route.name === "stage" && route.stageId === "run") return ["program", "obligations"];
|
|
209
218
|
if (route.name === "audit-packet") return ["repository", "program", "obligations", "audits"];
|
|
210
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"];
|
|
211
222
|
if (route.name === "stage" && route.stageId === "audit") return ["program", "workflow", "audits"];
|
|
212
223
|
if (route.name === "stage") return ["program", "workflow"];
|
|
213
224
|
return [];
|
|
@@ -286,6 +297,8 @@ function parseRoute() {
|
|
|
286
297
|
if (parts.length === 2 && parts[0] === "stage" && parts[1]) return { name: "stage", stageId: parts[1], params: new URLSearchParams(query) };
|
|
287
298
|
if (parts.length === 1 && parts[0] === "obligations") return { name: "obligations", params: new URLSearchParams(query) };
|
|
288
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) };
|
|
289
302
|
if (parts.length === 1 && parts[0] === "audit-packet") return { name: "audit-packet", params: new URLSearchParams(query) };
|
|
290
303
|
if (parts.length === 2 && parts[0] === "resources" && parts[1]) return { name: "list", type: parts[1], params: new URLSearchParams(query) };
|
|
291
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) };
|
|
@@ -307,7 +320,9 @@ function buildNavigation(route) {
|
|
|
307
320
|
|| (section.relatedLinks || []).some((link) => route.type === link.type && contextualStageId === stage.id)
|
|
308
321
|
|| (section.utility === "obligation-board" && route.name === "obligations")
|
|
309
322
|
|| (section.utility === "audit-packet" && route.name === "audit-packet")
|
|
310
|
-
|| (section.utility === "evidence-sources" && route.name === "evidence-sources")
|
|
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");
|
|
311
326
|
const sectionOpen = navigationGroupState[sectionKey] ?? (sectionCurrent || section.defaultOpen);
|
|
312
327
|
const resources = section.types
|
|
313
328
|
.map((type) => [type, state.model.resources[type]])
|
|
@@ -318,7 +333,8 @@ function buildNavigation(route) {
|
|
|
318
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>';
|
|
319
334
|
}).join("") + (section.relatedLinks || []).map((link) => {
|
|
320
335
|
const current = route.type === link.type && contextualStageId === stage.id
|
|
321
|
-
|| link.href === "#/stage/" + route.stageId
|
|
336
|
+
|| link.href === "#/stage/" + route.stageId
|
|
337
|
+
|| link.href === "#/" + route.name;
|
|
322
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>';
|
|
323
339
|
}).join("") + renderSidebarUtility(section.utility, route, direct);
|
|
324
340
|
if (direct) return links;
|
|
@@ -334,6 +350,7 @@ function buildNavigation(route) {
|
|
|
334
350
|
|
|
335
351
|
function readinessStageForRoute(route) {
|
|
336
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");
|
|
337
354
|
const contextualStageId = route.params?.get("stage");
|
|
338
355
|
const contextualStage = contextualStageId && READINESS_STAGES.find((stage) => (
|
|
339
356
|
stage.id === contextualStageId
|
|
@@ -345,7 +362,8 @@ function readinessStageForRoute(route) {
|
|
|
345
362
|
|| stage.sections.some((section) => section.types.includes(route.type)
|
|
346
363
|
|| (section.utility === "obligation-board" && route.name === "obligations")
|
|
347
364
|
|| (section.utility === "audit-packet" && route.name === "audit-packet")
|
|
348
|
-
|| (section.utility === "evidence-sources" && route.name === "evidence-sources")
|
|
365
|
+
|| (section.utility === "evidence-sources" && route.name === "evidence-sources")
|
|
366
|
+
|| (section.utility === "retention-schedule" && route.name === "retention-schedule"))
|
|
349
367
|
));
|
|
350
368
|
}
|
|
351
369
|
|
|
@@ -375,6 +393,9 @@ function renderSidebarUtility(utility, route, direct = false) {
|
|
|
375
393
|
if (utility === "evidence-sources") {
|
|
376
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>';
|
|
377
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
|
+
}
|
|
378
399
|
if (utility === "audit-packet") {
|
|
379
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>';
|
|
380
401
|
}
|
|
@@ -396,6 +417,10 @@ function topbar(route) {
|
|
|
396
417
|
? "Audit Readiness"
|
|
397
418
|
: route.name === "evidence-sources"
|
|
398
419
|
? "Evidence Sources"
|
|
420
|
+
: route.name === "policies"
|
|
421
|
+
? "Policies"
|
|
422
|
+
: route.name === "retention-schedule"
|
|
423
|
+
? "Data Retention Schedule"
|
|
399
424
|
: route.name === "list" && route.type === "document"
|
|
400
425
|
? documentListTitle(route.params)
|
|
401
426
|
: state.model.resources[route.type]?.pluralTitle || "filegrc";
|
|
@@ -535,11 +560,12 @@ function nextProgramStageHref() {
|
|
|
535
560
|
function renderStageOverview(main, stageId, params = new URLSearchParams()) {
|
|
536
561
|
const stage = READINESS_STAGES.find((candidate) => candidate.id === stageId);
|
|
537
562
|
if (!stage) return renderNotFound(main);
|
|
563
|
+
if (stage.id === "policies" && !modelSupports("retention-schedule-approval")) return renderPoliciesPage(main, params);
|
|
538
564
|
if (stage.id === "run") return renderObligations(main, params);
|
|
539
565
|
const progress = stageProgress(stage);
|
|
540
566
|
main.innerHTML = '<div class="page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
|
|
541
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>' +
|
|
542
|
-
|
|
568
|
+
renderStagePageIndex(stage) + (stage.id === "controls" ? renderFinishStepThree() : "") + (stage.id === "audit" ? renderAuditDocumentActivationAssessments() : "") + '</div>';
|
|
543
569
|
main.querySelector("[data-show-evidence-families]")?.addEventListener("click", (event) => {
|
|
544
570
|
main.querySelectorAll("[data-evidence-family-extra]").forEach((card) => { card.hidden = false; });
|
|
545
571
|
event.currentTarget.remove();
|
|
@@ -551,6 +577,182 @@ function renderStageOverview(main, stageId, params = new URLSearchParams()) {
|
|
|
551
577
|
});
|
|
552
578
|
}
|
|
553
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
|
+
|
|
554
756
|
function renderEvidenceSourcesPage(main) {
|
|
555
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>';
|
|
556
758
|
main.querySelector("[data-show-evidence-families]")?.addEventListener("click", (event) => {
|
|
@@ -571,11 +773,11 @@ function renderPolicyApprovalGuidance() {
|
|
|
571
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>';
|
|
572
774
|
}
|
|
573
775
|
|
|
574
|
-
function renderPoliciesTable() {
|
|
776
|
+
function renderPoliciesTable(params = new URLSearchParams()) {
|
|
575
777
|
const entries = state.resources.filter(({ record }) => (
|
|
576
778
|
record.type === "policy"
|
|
577
779
|
|| record.type === "training"
|
|
578
|
-
|| record.type === "document" && !auditSpecificDocument(record)
|
|
780
|
+
|| record.type === "document" && !auditSpecificDocument(record) && record.documentKind !== "schedule"
|
|
579
781
|
)).sort((left, right) => (
|
|
580
782
|
["policy", "document", "training"].indexOf(left.record.type) - ["policy", "document", "training"].indexOf(right.record.type)
|
|
581
783
|
|| left.record.title.localeCompare(right.record.title)
|
|
@@ -604,8 +806,10 @@ function renderPoliciesTable() {
|
|
|
604
806
|
const add = !state.readOnly
|
|
605
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>'
|
|
606
808
|
: '';
|
|
809
|
+
const initialFilter = params.get("q") || "";
|
|
810
|
+
const showHistory = params.get("history") === "1";
|
|
607
811
|
const emptyRow = '<tr><td colspan="6">' + empty('No governed content has been created.') + '</td></tr>';
|
|
608
|
-
const html = '<section class="policy-content-table"><div class="section-head"><div><p class="kicker">
|
|
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>';
|
|
609
813
|
queueMicrotask(() => wirePoliciesTable(entries.length));
|
|
610
814
|
return html;
|
|
611
815
|
}
|
|
@@ -614,21 +818,29 @@ function wirePoliciesTable(total) {
|
|
|
614
818
|
const table = root.querySelector('.policy-content-table');
|
|
615
819
|
if (!table) return;
|
|
616
820
|
const search = table.querySelector('[data-policy-content-search]');
|
|
617
|
-
const
|
|
821
|
+
const historyToggle = table.querySelector('[data-policy-content-history]');
|
|
618
822
|
const count = table.querySelector('[data-policy-content-count]');
|
|
823
|
+
const noResults = table.querySelector('[data-policy-content-empty]');
|
|
619
824
|
const filter = () => {
|
|
620
825
|
const query = search.value.trim().toLowerCase();
|
|
621
826
|
let visible = 0;
|
|
622
827
|
table.querySelectorAll('[data-policy-content-row]').forEach((row) => {
|
|
623
|
-
const show = (
|
|
828
|
+
const show = (historyToggle.checked || row.dataset.history !== 'true')
|
|
624
829
|
&& (!query || row.textContent.toLowerCase().includes(query));
|
|
625
830
|
row.hidden = !show;
|
|
626
831
|
if (show) visible += 1;
|
|
627
832
|
});
|
|
833
|
+
noResults.hidden = visible !== 0 || total === 0;
|
|
628
834
|
count.textContent = visible + ' of ' + total + ' ' + pluralize('artifact', total);
|
|
629
835
|
};
|
|
630
|
-
|
|
631
|
-
|
|
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(); });
|
|
632
844
|
table.querySelectorAll('[data-add-policy-content]').forEach((button) => {
|
|
633
845
|
button.addEventListener('click', () => openEditor(button.dataset.addPolicyContent));
|
|
634
846
|
});
|
|
@@ -640,7 +852,8 @@ function policyContentWorkflowCell(entry) {
|
|
|
640
852
|
const items = recordWorkflowItems(record.type, record.id);
|
|
641
853
|
if (!items.length) return '<span class="record-workflow-clear">No calculated action</span>';
|
|
642
854
|
const item = items[0];
|
|
643
|
-
|
|
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';
|
|
644
857
|
const title = ["draft", "in-review"].includes(record.status)
|
|
645
858
|
? "Complete and approve"
|
|
646
859
|
: record.status === "approved"
|
|
@@ -916,32 +1129,62 @@ function collectionReviewPanel(type, force = false) {
|
|
|
916
1129
|
if (!assessment || (!force && !collectionReviewVisible(type))) return "";
|
|
917
1130
|
const configuration = assessment.configuration;
|
|
918
1131
|
const current = assessment.status === "current";
|
|
1132
|
+
const scheduleReview = type === "retention-schedule-item" && modelSupports("retention-schedule-approval");
|
|
1133
|
+
const scheduleBlocker = scheduleReview ? retentionScheduleApprovalBlocker() : null;
|
|
919
1134
|
const needsFirstRecord = collectionNeedsFirstRecord(type);
|
|
920
1135
|
const reviewerNames = (assessment.review?.reviewedByIds || [])
|
|
921
1136
|
.map((id) => state.resources.find(({ record }) => record.id === id)?.record.title || id);
|
|
922
1137
|
const reviewNote = assessment.review?.rationale
|
|
923
1138
|
? " Note: " + esc(assessment.review.rationale)
|
|
924
1139
|
: "";
|
|
925
|
-
const reviewSummary = current
|
|
1140
|
+
const reviewSummary = current && !scheduleBlocker
|
|
926
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>'
|
|
927
|
-
: '<p class="collection-review-result"><strong>' + (needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(assessment.message) + '</span></p>';
|
|
928
|
-
const compactReviewSummary = current
|
|
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
|
|
929
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>"
|
|
930
1145
|
: reviewSummary;
|
|
931
1146
|
const action = state.readOnly
|
|
932
1147
|
? ""
|
|
1148
|
+
: scheduleBlocker
|
|
1149
|
+
? '<button class="button" type="button" disabled title="' + esc(scheduleBlocker) + '">Review and approve schedule</button>'
|
|
933
1150
|
: needsFirstRecord
|
|
934
1151
|
? '<a class="button primary" href="#/resources/' + encodeURIComponent(type) + '?new=1">Add first ' + esc(state.model.resources[type].title.toLowerCase()) + '</a>'
|
|
935
|
-
: '<button class="button ' + (current ? "" : "primary") + '" type="button" data-review-collection="' + esc(type) + '">' + (current ? "Review again" : "Review and confirm") + '</button>';
|
|
936
|
-
const status = current ? "Reviewed" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Stale" : "Review required";
|
|
937
|
-
const details = '<details ' + (current ? 'class="collection-review-details"' : "open") + '><summary>' + (current ? "Show scope confirmation" : "What to review") + '</summary><div class="collection-review-detail-content"><div class="collection-review-head"><div><p class="kicker">Scope confirmation</p><h3>' + esc(configuration.title) + '</h3><p>' + esc(configuration.description) + '</p></div><span class="badge ' + (current ? "good" : "warn") + '">' + status + '</span></div><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul>' + (current ? reviewSummary : "") + '</div></details>';
|
|
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>';
|
|
938
1155
|
const summary = current
|
|
939
|
-
? '<div class="collection-review-current-row"><div class="collection-review-complete-summary"><span class="kicker">Scope confirmation</span>' + compactReviewSummary + '</div>' + details + action + '</div>'
|
|
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>'
|
|
940
1157
|
: details;
|
|
941
1158
|
return '<section class="collection-review-panel panel ' + (current ? "current" : "required") + '">' + summary +
|
|
942
1159
|
(current ? "" : '<div class="collection-review-foot">' + reviewSummary + action + '</div>') + '</section>';
|
|
943
1160
|
}
|
|
944
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
|
+
|
|
945
1188
|
function collectionReviewVisible(type) {
|
|
946
1189
|
return type !== "control";
|
|
947
1190
|
}
|
|
@@ -1048,14 +1291,15 @@ function recordWorkflowItems(type, id) {
|
|
|
1048
1291
|
));
|
|
1049
1292
|
}
|
|
1050
1293
|
|
|
1051
|
-
function recordWorkflowCell(type, entry) {
|
|
1294
|
+
function recordWorkflowCell(type, entry, detailContext = "") {
|
|
1052
1295
|
if (state.sections?.workflow !== "complete") {
|
|
1053
1296
|
return '<button class="text-button record-workflow-clear" type="button" data-load-workflow>Calculate action</button>';
|
|
1054
1297
|
}
|
|
1055
1298
|
const items = recordWorkflowItems(type, entry.record.id);
|
|
1056
1299
|
if (!items.length) return '<span class="record-workflow-clear">No calculated action</span>';
|
|
1057
1300
|
const item = items[0];
|
|
1058
|
-
|
|
1301
|
+
let href = workflowItemHref(item) || "#/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(entry.record.id);
|
|
1302
|
+
if (detailContext && href.startsWith("#/resource/" + encodeURIComponent(type) + "/") && !href.includes("?")) href += detailContext;
|
|
1059
1303
|
const title = type === "control" ? "Finish implementation" : item.title;
|
|
1060
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>';
|
|
1061
1305
|
}
|
|
@@ -1064,7 +1308,10 @@ function openCollectionReviewDialog(type) {
|
|
|
1064
1308
|
const assessment = state.collectionReviews?.[type];
|
|
1065
1309
|
if (!assessment) return;
|
|
1066
1310
|
const configuration = assessment.configuration;
|
|
1067
|
-
const
|
|
1311
|
+
const scheduleReview = type === "retention-schedule-item" && modelSupports("retention-schedule-approval");
|
|
1312
|
+
const eligibleReviewerIds = assessment.eligibleReviewerIds
|
|
1313
|
+
? new Set(assessment.eligibleReviewerIds)
|
|
1314
|
+
: null;
|
|
1068
1315
|
const people = resourcesOfType("person").filter(({ record }) => (
|
|
1069
1316
|
record.status === "active" && (!eligibleReviewerIds || eligibleReviewerIds.has(record.id))
|
|
1070
1317
|
));
|
|
@@ -1091,10 +1338,18 @@ function openCollectionReviewDialog(type) {
|
|
|
1091
1338
|
const dialog = document.createElement("dialog");
|
|
1092
1339
|
dialog.className = "commit-dialog event-dialog collection-review-dialog";
|
|
1093
1340
|
dialog.setAttribute("aria-labelledby", "collection-review-dialog-title");
|
|
1094
|
-
const reviewerHelp =
|
|
1095
|
-
? '<p class="policy-activation-warning">No eligible reviewer is configured. Add an active
|
|
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>'
|
|
1096
1343
|
: "";
|
|
1097
|
-
|
|
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>';
|
|
1098
1353
|
document.body.append(dialog);
|
|
1099
1354
|
dialog.showModal();
|
|
1100
1355
|
const form = dialog.querySelector("form");
|
|
@@ -1126,7 +1381,8 @@ function openCollectionReviewDialog(type) {
|
|
|
1126
1381
|
reviewedOn: form.elements.reviewedOn.value,
|
|
1127
1382
|
scopeRevision: state.git?.commit || undefined,
|
|
1128
1383
|
[v4 ? "authoritativeComponentId" : "authoritativeSystemId"]: form.elements.authoritativeSourceId.value || undefined,
|
|
1129
|
-
expectedRevision: assessment.reviewRevision || undefined
|
|
1384
|
+
expectedRevision: assessment.reviewRevision || undefined,
|
|
1385
|
+
expectedCollectionRevision: assessment.collectionRevision
|
|
1130
1386
|
};
|
|
1131
1387
|
form.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = true; });
|
|
1132
1388
|
const submit = form.querySelector('button[type="submit"]');
|
|
@@ -1195,6 +1451,7 @@ function workflowItemHref(item) {
|
|
|
1195
1451
|
const collectionReviewCommand = commands.find((command) => command.includes(" review-collection "));
|
|
1196
1452
|
const collectionReviewType = collectionReviewCommand?.match(/review-collection\s+([a-z0-9-]+)/)?.[1];
|
|
1197
1453
|
if (collectionReviewType && state.model.collectionReviews?.[collectionReviewType]) {
|
|
1454
|
+
if (collectionReviewType === "retention-schedule-item" && modelSupports("retention-schedule-approval")) return "#/retention-schedule";
|
|
1198
1455
|
return "#/resources/" + encodeURIComponent(collectionReviewType) + "?review-collection=1";
|
|
1199
1456
|
}
|
|
1200
1457
|
if (
|
|
@@ -1251,7 +1508,7 @@ function retentionScheduleItemHref(item) {
|
|
|
1251
1508
|
&& !["superseded", "retired"].includes(record.status)
|
|
1252
1509
|
));
|
|
1253
1510
|
if (scheduleDocuments.length === 1) params.set("scheduleDocumentId", scheduleDocuments[0].record.id);
|
|
1254
|
-
return "#/
|
|
1511
|
+
return "#/retention-schedule?" + params;
|
|
1255
1512
|
}
|
|
1256
1513
|
|
|
1257
1514
|
function workflowItemDetail(item) {
|
|
@@ -1328,16 +1585,32 @@ function renderEvidenceReadiness() {
|
|
|
1328
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>';
|
|
1329
1586
|
}
|
|
1330
1587
|
|
|
1331
|
-
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));
|
|
1332
1592
|
return (state.programReadiness?.stages || []).flatMap((stage) => (
|
|
1333
1593
|
(stage.items || []).filter((item) => (
|
|
1334
|
-
|
|
1335
|
-
|
|
1594
|
+
(!stageId || stage.id === stageId)
|
|
1595
|
+
&& (item.id?.startsWith("retention-")
|
|
1596
|
+
|| includeMappings && item.id?.startsWith("requirement-mapping-")
|
|
1336
1597
|
|| ["collection-review-information-type", "collection-review-retention-schedule-item"].includes(item.id)
|
|
1598
|
+
|| scheduleDocumentReviewIds.has(item.id)
|
|
1599
|
+
)
|
|
1337
1600
|
)).map((item) => ({ ...item, stage: stage.id }))
|
|
1338
1601
|
));
|
|
1339
1602
|
}
|
|
1340
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
|
+
|
|
1341
1614
|
function programReadinessWorkflowItems() {
|
|
1342
1615
|
if (state.sections?.workflow === "complete") return [];
|
|
1343
1616
|
return retentionReviewItems()
|
|
@@ -1350,37 +1623,6 @@ function programReadinessWorkflowItems() {
|
|
|
1350
1623
|
}));
|
|
1351
1624
|
}
|
|
1352
1625
|
|
|
1353
|
-
function renderRetentionReadiness() {
|
|
1354
|
-
const items = retentionReviewItems();
|
|
1355
|
-
if (!items.length) return "";
|
|
1356
|
-
const actions = items.filter((item) => item.status === "action");
|
|
1357
|
-
const cards = actions.map((item) => {
|
|
1358
|
-
const ids = [...new Set([
|
|
1359
|
-
item.resourceId,
|
|
1360
|
-
item.informationTypeId,
|
|
1361
|
-
...(item.retentionScheduleItemIds || []),
|
|
1362
|
-
...(item.staleResourceIds || [])
|
|
1363
|
-
].filter(Boolean))];
|
|
1364
|
-
const references = ids.length
|
|
1365
|
-
? '<div class="evidence-map-references">' + ids.map((id) => formatReference(id)).join("") + '</div>'
|
|
1366
|
-
: "";
|
|
1367
|
-
const href = item.id?.startsWith("retention-use-")
|
|
1368
|
-
? retentionScheduleItemHref(item)
|
|
1369
|
-
: item.id?.startsWith("collection-review-")
|
|
1370
|
-
? '#/resources/' + encodeURIComponent(item.resourceType)
|
|
1371
|
-
: item.resourceId && item.resourceType
|
|
1372
|
-
? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
|
|
1373
|
-
: '#/resources/' + encodeURIComponent(item.resourceType || "retention-schedule-item");
|
|
1374
|
-
return '<article class="evidence-map-card"><div class="evidence-map-card-head"><div><span class="badge warn">Review</span><h3><a href="' + href + '">' + esc(item.title) + '</a></h3></div></div><p>' + esc(item.message) + '</p>' + references + '</article>';
|
|
1375
|
-
});
|
|
1376
|
-
const visibleCount = 6;
|
|
1377
|
-
const visibleCards = cards.slice(0, visibleCount).join("");
|
|
1378
|
-
const moreCards = cards.length > visibleCount
|
|
1379
|
-
? '<details class="workflow-guidance-more retention-readiness-more"><summary>Show ' + (cards.length - visibleCount) + ' more retention and mapping items</summary><div class="policy-activation-grid workflow-findings-more">' + cards.slice(visibleCount).join("") + '</div></details>'
|
|
1380
|
-
: "";
|
|
1381
|
-
return '<section class="evidence-map retention-readiness"><div class="evidence-map-head"><div><p class="kicker">Information lifecycle</p><h2>' + (items.length - actions.length) + ' of ' + items.length + ' retention and mapping checks current</h2><p>Review Information Types, schedule coverage, and mappings here when source records or processing uses change.</p></div><div class="evidence-map-actions"><a class="button" href="#/resources/requirement-mapping">Review mappings</a><a class="button primary" href="#/resources/retention-schedule-item">Review schedule</a></div></div>' + (cards.length ? '<div class="policy-activation-grid">' + visibleCards + '</div>' + moreCards : '<article class="evidence-map-card complete"><span class="badge good">Current</span><p>Every retention decision and Requirement Mapping is bound to its current sources.</p></article>') + '</section>';
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
1626
|
function evidenceSourceCheckLabel(name) {
|
|
1385
1627
|
return ({
|
|
1386
1628
|
active: "activate source",
|
|
@@ -1473,6 +1715,13 @@ function derivedStagePageState(stage, destination) {
|
|
|
1473
1715
|
? { complete: false, label: incomplete + " " + pluralize("source", incomplete) + " need work" }
|
|
1474
1716
|
: { complete: evidenceItems.length > 0, label: evidenceItems.length ? "Ready" : "Not configured", countsTowardProgress: evidenceItems.length > 0 };
|
|
1475
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
|
+
}
|
|
1476
1725
|
const items = stagePageItems(stage, destination);
|
|
1477
1726
|
const deferredStates = new Set(["later", "scheduled", "upcoming", "waiting-external"]);
|
|
1478
1727
|
const blocking = items.filter(({ state }) => !deferredStates.has(state));
|
|
@@ -1516,8 +1765,13 @@ function stagePageItems(stage, destination) {
|
|
|
1516
1765
|
.filter(({ id, status }) => id.startsWith("source-family-") && status !== "complete")
|
|
1517
1766
|
.map((item) => ({ ...item, key: item.id, state: item.status, subject: { type: "source-coverage" } }));
|
|
1518
1767
|
}
|
|
1519
|
-
if (
|
|
1520
|
-
return
|
|
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) => (
|
|
1521
1775
|
workflowItemStatePriority(left) - workflowItemStatePriority(right)
|
|
1522
1776
|
|| (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
|
|
1523
1777
|
|| left.key.localeCompare(right.key)
|
|
@@ -1661,6 +1915,7 @@ function sectionDestinations(section) {
|
|
|
1661
1915
|
}
|
|
1662
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." });
|
|
1663
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 });
|
|
1664
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." });
|
|
1665
1920
|
return destinations;
|
|
1666
1921
|
}
|
|
@@ -2839,6 +3094,11 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
2839
3094
|
...(params.get("informationTypeId") ? { informationTypeIds: [params.get("informationTypeId")] } : {}),
|
|
2840
3095
|
...(params.get("scopeResourceId") ? { scopeResourceIds: [params.get("scopeResourceId")] } : {}),
|
|
2841
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
|
|
2842
3102
|
} : {
|
|
2843
3103
|
...(params.get("title") ? { title: params.get("title") } : {}),
|
|
2844
3104
|
...relationshipSeed
|
|
@@ -2873,8 +3133,19 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2873
3133
|
const entry = resourcesOfType(type).find(({ record }) => record.id === id);
|
|
2874
3134
|
const definition = state.model.resources[type];
|
|
2875
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);
|
|
2876
3147
|
if (entry.detailsLoaded === false) {
|
|
2877
|
-
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>';
|
|
2878
3149
|
loadResourceDetail(type, id);
|
|
2879
3150
|
return;
|
|
2880
3151
|
}
|
|
@@ -2959,7 +3230,7 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2959
3230
|
connectionsPanel: resourceConnections(entry),
|
|
2960
3231
|
historyPanel
|
|
2961
3232
|
});
|
|
2962
|
-
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>';
|
|
2963
3234
|
main.querySelectorAll("[data-completion-requirements]").forEach((button) => button.addEventListener("click", () => openCompletionRequirementsDialog(button.dataset.completionRequirements)));
|
|
2964
3235
|
main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry));
|
|
2965
3236
|
main.querySelector("[data-external-reviewer-governance]")?.addEventListener("click", openExternalReviewerGovernanceDialog);
|
|
@@ -6088,7 +6359,9 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
6088
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}}
|
|
6089
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)}
|
|
6090
6361
|
.policy-lifecycle-note.single{grid-template-columns:1fr}
|
|
6091
|
-
.policy-content-table{margin-top:22px}.policy-content-table>.section-head{align-items:end}.policy-content-table>.section-head h2{font:500 27.6px Georgia,serif;margin:5px 0 6px}.policy-content-table>.section-head p:not(.kicker){max-width:720px;margin:0;color:var(--muted);font-size:12px;line-height:1.5}.policy-content-tools{display:flex;align-items:center;justify-content:flex-end;gap:9px;flex-wrap:wrap}.policy-content-tools input[type="search"]{min-width:210px;border:1px solid var(--line);border-radius:7px;background:var(--field);padding:9px 11px}.history-toggle{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:11px}.policy-content-add{position:relative}.policy-content-add>summary{list-style:none}.policy-content-add>summary::-webkit-details-marker{display:none}.policy-content-add>div{position:absolute;right:0;z-index:5;display:grid;min-width:150px;margin-top:6px;padding:5px;border:1px solid var(--line);border-radius:8px;background:var(--panel);box-shadow:var(--shadow)}.policy-content-add button{border:0;border-radius:5px;background:transparent;padding:8px 10px;color:var(--ink);text-align:left;cursor:pointer}.policy-content-add button:hover{background:var(--accent-soft)}.policy-content-type span,.policy-content-approval span{display:block}.policy-content-type small,.policy-content-approval small{display:block;margin-top:3px;color:var(--muted);font-size:9.6px}.policy-content-count{margin:9px 2px 0;color:var(--muted);font-size:10.8px;text-align:right}
|
|
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}}
|
|
6092
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}
|
|
6093
6366
|
.policy-activation-actions{display:flex;justify-content:flex-end;margin-top:14px;padding-top:12px;border-top:1px solid var(--line)}
|
|
6094
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}
|
|
@@ -6125,6 +6398,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
6125
6398
|
@media(max-width:760px){.reconciliation-candidate{grid-template-columns:1fr}.reconciliation-actions{justify-items:start}.reconciliation-actions>code{text-align:left}}
|
|
6126
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}}
|
|
6127
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}}
|
|
6128
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}}
|
|
6129
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}}
|
|
6130
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))}}
|