filegrc 0.15.0 → 0.15.2

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/src/web.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createResourceId } from "./id.js";
2
+ import { selectDefaultAudit } from "./audit-selection.js";
2
3
  import { MODEL_CAPABILITY_VERSIONS } from "../model/index.js";
3
4
  import {
4
5
  calendarOccurrence,
@@ -14,12 +15,20 @@ import { formatCalendarDate, formatLocalDateTime } from "./time.js";
14
15
 
15
16
  export function dashboardProgramReadiness(programReadiness = {}) {
16
17
  const progress = programReadiness.progress || {};
17
- const lifecycle = programReadiness.operating
18
- ? { status: "Operating", tone: "good" }
19
- : programReadiness.evidenceReady
20
- ? { status: "Evidence ready", tone: "good" }
21
- : { status: "Needs work", tone: "warn" };
18
+ const lifecycle = progress.mode === "operating-window"
19
+ ? {
20
+ status: progress.status || (programReadiness.operating ? "Operating" : "Needs attention"),
21
+ tone: programReadiness.operating ? "good" : "warn"
22
+ }
23
+ : programReadiness.operating
24
+ ? { status: "Operating", tone: "good" }
25
+ : programReadiness.evidenceReady
26
+ ? { status: progress.status || "Evidence ready", tone: "good" }
27
+ : { status: progress.status || "Needs work", tone: "warn" };
22
28
  return {
29
+ mode: progress.mode || "setup-progress",
30
+ label: progress.label || "Program setup",
31
+ detail: progress.detail || `${progress.complete ?? 0} of ${progress.total ?? 0} required actions complete.`,
23
32
  percent: progress.percent ?? 0,
24
33
  complete: progress.complete ?? 0,
25
34
  total: progress.total ?? 0,
@@ -27,6 +36,23 @@ export function dashboardProgramReadiness(programReadiness = {}) {
27
36
  };
28
37
  }
29
38
 
39
+ export function workflowHrefBelongsToPage(destination = {}, href = "") {
40
+ const route = String(href).split("?")[0];
41
+ if (!route) return false;
42
+ if (destination.href === "#/policies") {
43
+ return ["policy", "document", "training"].some((type) => route.startsWith(`#/resource/${type}/`));
44
+ }
45
+ if (destination.type) {
46
+ return route === `#/resources/${destination.type}` || route.startsWith(`#/resource/${destination.type}/`);
47
+ }
48
+ return route === String(destination.href || "").split("?")[0];
49
+ }
50
+
51
+ export function pageActionIdentity(href = "", fallback = "") {
52
+ const route = String(href).split("?")[0];
53
+ return route.startsWith("#/resource/") ? route : fallback || href;
54
+ }
55
+
30
56
  export function renderIndex(state = null) {
31
57
  const snapshot = state
32
58
  ? `<script id="filegrc-data" type="application/json">${safeJson(state)}</script>`
@@ -71,6 +97,9 @@ let READINESS_STAGES = SHARED_PROGRAM_STAGES.map((stage) => ({
71
97
  }));
72
98
  const RESOURCE_GUIDE_INSTRUCTIONS = ${JSON.stringify(RESOURCE_INSTRUCTIONS)};
73
99
  const dashboardProgramReadiness = ${dashboardProgramReadiness.toString()};
100
+ const workflowHrefBelongsToPage = ${workflowHrefBelongsToPage.toString()};
101
+ const pageActionIdentity = ${pageActionIdentity.toString()};
102
+ const selectDefaultAudit = ${selectDefaultAudit.toString()};
74
103
  const STAGE_PAGE_SUMMARIES = ${JSON.stringify({
75
104
  ...RESOURCE_PAGE_SUMMARIES,
76
105
  "utility:evidence-sources": "Check that each Control has an authoritative, retrievable evidence source.",
@@ -93,6 +122,7 @@ let onboardingPendingDraft = false;
93
122
  const resourceDetailRequests = new Map();
94
123
  const stateSectionRequests = new Map();
95
124
  let resourceGuideCleanup = null;
125
+ let recordOverflowCleanup = null;
96
126
  let repositorySyncPollTimer = null;
97
127
  let repositorySyncPollInFlight = false;
98
128
  let mutationStateRefreshInFlight = false;
@@ -165,6 +195,8 @@ function handleRouteChange() {
165
195
  function render() {
166
196
  resourceGuideCleanup?.();
167
197
  resourceGuideCleanup = null;
198
+ recordOverflowCleanup?.();
199
+ recordOverflowCleanup = null;
168
200
  const previousNavigation = root.querySelector(".sidebar-nav");
169
201
  if (previousNavigation) navigationScrollTop = previousNavigation.scrollTop;
170
202
  const route = parseRoute();
@@ -460,8 +492,16 @@ function topbarProgramReadiness() {
460
492
  return '<a class="topbar-readiness' + (loading ? ' is-loading' : '') + '" href="#/"' + (loading ? ' aria-busy="true"' : '') + '><span class="topbar-readiness-copy"><span>Program readiness</span><strong>' + label + '</strong></span><span class="progress" aria-hidden="true"><span style="width:0%"></span></span></a>';
461
493
  }
462
494
  const progress = dashboardProgramReadiness(state.programReadiness);
463
- const detail = progress.complete + " of " + progress.total + " readiness items complete";
464
- return '<a class="topbar-readiness" href="' + nextProgramStageHref() + '" aria-label="' + esc("Program readiness: " + progress.percent + "%. " + detail + ". " + progress.status + ".") + '"><span class="topbar-readiness-copy"><span>Program readiness</span><strong>' + progress.percent + '%</strong></span><span class="progress" aria-hidden="true"><span style="width:' + progress.percent + '%"></span></span></a>';
495
+ const setupRemaining = state.programReadiness.setupProgress?.remaining || 0;
496
+ const setupRegression = progress.mode === "operating-window" && setupRemaining > 0;
497
+ const href = progress.mode === "operating-window" && !setupRegression ? "#/stage/run" : nextProgramStageHref();
498
+ const label = setupRegression
499
+ ? progress.label + " · " + setupRemaining + " setup " + (setupRemaining === 1 ? "action" : "actions")
500
+ : progress.label;
501
+ const detail = setupRegression
502
+ ? progress.detail + " " + setupRemaining + " required setup " + (setupRemaining === 1 ? "action also needs" : "actions also need") + " attention."
503
+ : progress.detail;
504
+ return '<a class="topbar-readiness" href="' + href + '" aria-label="' + esc(label + ": " + progress.percent + "%. " + detail + " " + progress.status + ".") + '"><span class="topbar-readiness-copy"><span>' + esc(label) + '</span><strong>' + progress.percent + '%</strong></span><span class="progress" aria-hidden="true"><span style="width:' + progress.percent + '%"></span></span></a>';
465
505
  }
466
506
 
467
507
  function repositoryStatusTone(status) {
@@ -599,7 +639,11 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
599
639
  const documentEntry = documentEntries.length === 1 ? documentEntries[0] : null;
600
640
  const rows = resourcesOfType("retention-schedule-item")
601
641
  .sort((left, right) => left.record.title.localeCompare(right.record.title));
602
- const activeRows = rows.filter(({ record }) => record.status === "active").length;
642
+ const completedProposalIds = new Set(state.programReadiness?.stages
643
+ ?.find(({ id }) => id === "policies")
644
+ ?.items.filter(({ id, status }) => id.startsWith("retention-rule-") && status === "complete")
645
+ .map(({ id }) => id.slice("retention-rule-".length)) || []);
646
+ const proposedRows = rows.filter(({ record }) => completedProposalIds.has(record.id)).length;
603
647
  const documentReadiness = documentEntry ? retentionScheduleDocumentReadiness(documentEntry.record.id) : null;
604
648
  const documentReady = documentEntries.length === 1 && documentReadiness?.status === "complete";
605
649
  const documentAction = documentEntry
@@ -616,7 +660,10 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
616
660
  const cutoff = retentionCutoffLabel(record.cutoff);
617
661
  const period = retentionPeriodLabel(record.retentionPeriod);
618
662
  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>';
663
+ const status = record.status === "active" && state.collectionReviews?.["retention-schedule-item"]?.status !== "current"
664
+ ? "proposed"
665
+ : displayStatus(record);
666
+ 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(status, "status", record.type, true) + '</td><td data-label="Next action">' + recordWorkflowCell("retention-schedule-item", entry, "?stage=policies") + '</td></tr>';
620
667
  }).join("")
621
668
  : '<tr><td colspan="7">' + empty("No retention rules are needed until an actual information use requires one.") + '</td></tr>';
622
669
  const add = state.readOnly ? "" : '<button class="button primary" type="button" data-add-retention-row>Add schedule row</button>';
@@ -625,12 +672,12 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
625
672
  const showHistory = params.get("history") === "1";
626
673
  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
674
  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>' +
675
+ '<div class="page-intro"><div><p class="kicker">Step 2</p><div class="page-title-line"><h2>Data Retention Schedule</h2>' + guideTrigger + '</div><p>Owners finish each proposed retention row first. A separate reviewer then approves the complete document and all proposed rows once.</p></div><div class="actions">' + documentAction + add + '</div></div>' + resourceGuide("retention-schedule-item") +
676
+ '<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>' + proposedRows + ' completed ' + pluralize("proposal", proposedRows) + '</strong><small>Final approval follows every row proposal</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>' +
677
+ '<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><nav class="pagination retention-pagination" aria-label="Data Retention Schedule pages" hidden><button class="button" type="button" data-retention-page="previous">Previous</button><span class="page-status" aria-live="polite"></span><button class="button" type="button" data-retention-page="next">Next</button></nav></section>' +
631
678
  renderRetentionScheduleIssues() + collectionReviewPanel("retention-schedule-item", true) + '</div>';
632
679
  resourceGuideCleanup = setupResourceGuide(main);
633
- wireRetentionScheduleTable(rows.length);
680
+ wireRetentionScheduleTable(rows.length, Number(params.get("page")) || 1);
634
681
  main.querySelector("[data-review-collection]")?.addEventListener("click", () => openCollectionReviewDialog("retention-schedule-item"));
635
682
  main.querySelector("[data-add-retention-document]")?.addEventListener("click", () => openEditor("document", null, {
636
683
  seed: {
@@ -649,7 +696,7 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
649
696
  const seed = {
650
697
  ...(params.get("title") ? { title: params.get("title") } : {}),
651
698
  ...(params.get("informationTypeId") ? { informationTypeIds: [params.get("informationTypeId")] } : {}),
652
- ...(params.get("scopeResourceId") ? { scopeResourceIds: [params.get("scopeResourceId")] } : {}),
699
+ ...(params.getAll("scopeResourceId").length ? { scopeResourceIds: params.getAll("scopeResourceId") } : {}),
653
700
  ...(params.get("scheduleDocumentId") ? { scheduleDocumentId: params.get("scheduleDocumentId") } : {})
654
701
  };
655
702
  history.replaceState(null, "", "#/retention-schedule");
@@ -668,54 +715,61 @@ function openRetentionScheduleItemEditor(seed = {}) {
668
715
  ...(scheduleDocuments.length === 1 ? { scheduleDocumentId: scheduleDocuments[0].record.id } : {}),
669
716
  ...seed
670
717
  },
671
- description: "Define one retention decision. Keep it planned until its scope, cutoff, period, disposition, sources, and approval are complete.",
718
+ description: "Define one retention decision. Keep it planned until its scope, cutoff, period, disposition, sources, owner review, and current revision bindings are complete.",
672
719
  saveLabel: "Save schedule row"
673
720
  });
674
721
  }
675
722
 
676
- function wireRetentionScheduleTable(total) {
723
+ function wireRetentionScheduleTable(total, initialPage = 1) {
677
724
  const table = root.querySelector(".retention-rows");
678
725
  if (!table) return;
679
726
  const search = table.querySelector("[data-retention-search]");
680
727
  const historyToggle = table.querySelector("[data-retention-history]");
681
728
  const count = table.querySelector("[data-retention-count]");
682
729
  const noResults = table.querySelector("[data-retention-empty]");
730
+ const pagination = table.querySelector(".retention-pagination");
731
+ const pageStatus = pagination.querySelector(".page-status");
732
+ const previous = pagination.querySelector('[data-retention-page="previous"]');
733
+ const next = pagination.querySelector('[data-retention-page="next"]');
734
+ let pageNumber = Number.isInteger(initialPage) && initialPage > 0 ? initialPage : 1;
683
735
  const filter = () => {
684
736
  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);
737
+ const filtered = [...table.querySelectorAll("[data-retention-row]")].filter((row) => (
738
+ (historyToggle.checked || row.dataset.history !== "true")
739
+ && (!query || row.textContent.toLowerCase().includes(query))
740
+ ));
741
+ const totalPages = Math.max(1, Math.ceil(filtered.length / LIST_PAGE_SIZE));
742
+ pageNumber = Math.min(pageNumber, totalPages);
743
+ const start = (pageNumber - 1) * LIST_PAGE_SIZE;
744
+ const visible = new Set(filtered.slice(start, start + LIST_PAGE_SIZE));
745
+ table.querySelectorAll("[data-retention-row]").forEach((row) => { row.hidden = !visible.has(row); });
746
+ noResults.hidden = filtered.length !== 0 || total === 0;
747
+ const firstVisible = filtered.length ? start + 1 : 0;
748
+ const lastVisible = Math.min(start + LIST_PAGE_SIZE, filtered.length);
749
+ count.textContent = firstVisible + "–" + lastVisible + " of " + filtered.length + " " + pluralize("row", filtered.length) + (filtered.length === total ? "" : " · " + total + " total");
750
+ pagination.hidden = totalPages === 1;
751
+ pageStatus.textContent = "Page " + pageNumber + " of " + totalPages;
752
+ previous.disabled = pageNumber === 1;
753
+ next.disabled = pageNumber === totalPages;
694
754
  };
695
755
  const updateLocation = () => {
696
756
  const params = new URLSearchParams();
697
757
  if (search.value.trim()) params.set("q", search.value.trim());
698
758
  if (historyToggle.checked) params.set("history", "1");
759
+ if (pageNumber > 1) params.set("page", String(pageNumber));
699
760
  history.replaceState(null, "", "#/retention-schedule" + (params.size ? "?" + params : ""));
700
761
  };
701
- search.addEventListener("input", () => { filter(); updateLocation(); });
702
- historyToggle.addEventListener("change", () => { filter(); updateLocation(); });
762
+ search.addEventListener("input", () => { pageNumber = 1; filter(); updateLocation(); });
763
+ historyToggle.addEventListener("change", () => { pageNumber = 1; filter(); updateLocation(); });
764
+ previous.addEventListener("click", () => { pageNumber -= 1; filter(); updateLocation(); });
765
+ next.addEventListener("click", () => { pageNumber += 1; filter(); updateLocation(); });
703
766
  filter();
704
767
  }
705
768
 
706
769
  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
- ))];
770
+ const actions = retentionSchedulePageItems().filter((item) => (
771
+ item.id !== "collection-review-retention-schedule-item"
772
+ ));
719
773
  if (!actions.length) return "";
720
774
  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
775
  const href = item.href || (item.id?.startsWith("retention-use-")
@@ -728,9 +782,16 @@ function renderRetentionScheduleIssues() {
728
782
  }
729
783
 
730
784
  function retentionScheduleDocumentReadiness(documentId) {
731
- return state.programReadiness?.stages
732
- ?.find(({ id }) => id === "policies")
733
- ?.items.find(({ id }) => id === "document-approval-" + documentId) || null;
785
+ const documentIssueCodes = new Set([
786
+ "missing-retention-schedule-document",
787
+ "multiple-retention-schedule-documents",
788
+ "incomplete-retention-schedule-document"
789
+ ]);
790
+ const issue = state.collectionReviews?.["retention-schedule-item"]?.approvalIssues
791
+ ?.find(({ code }) => documentIssueCodes.has(code));
792
+ return issue
793
+ ? { status: "action", message: issue.message }
794
+ : { status: "complete", message: "The document proposal is complete. Final approval follows all completed row proposals." };
734
795
  }
735
796
 
736
797
  function referenceTitle(id) {
@@ -888,7 +949,7 @@ function renderDocumentActivationAssessments() {
888
949
 
889
950
  function renderAuditDocumentActivationAssessments() {
890
951
  const audits = resourcesOfType("audit").map(({ record }) => record);
891
- const audit = audits.find(({ status }) => !["complete", "closed", "canceled"].includes(status)) || audits[0];
952
+ const audit = selectDefaultAudit(audits, state.programReadiness?.asOf || currentDate());
892
953
  const preparation = audit ? state.auditPreparations?.[audit.id] : null;
893
954
  const assessments = preparation?.documentActivations || [];
894
955
  if (!audit || !assessments.length) return "";
@@ -1131,27 +1192,34 @@ function collectionReviewPanel(type, force = false) {
1131
1192
  const current = assessment.status === "current";
1132
1193
  const scheduleReview = type === "retention-schedule-item" && modelSupports("retention-schedule-approval");
1133
1194
  const scheduleBlocker = scheduleReview ? retentionScheduleApprovalBlocker() : null;
1195
+ const proposalBlocker = assessment.incompleteRecordProposals?.length
1196
+ ? "Complete " + assessment.incompleteRecordProposals.length + " record " + (assessment.incompleteRecordProposals.length === 1 ? "proposal" : "proposals") + " before the collection review."
1197
+ : null;
1198
+ const reviewBlocker = scheduleBlocker || proposalBlocker;
1134
1199
  const needsFirstRecord = collectionNeedsFirstRecord(type);
1135
1200
  const reviewerNames = (assessment.review?.reviewedByIds || [])
1136
1201
  .map((id) => state.resources.find(({ record }) => record.id === id)?.record.title || id);
1137
1202
  const reviewNote = assessment.review?.rationale
1138
1203
  ? " Note: " + esc(assessment.review.rationale)
1139
1204
  : "";
1140
- const reviewSummary = current && !scheduleBlocker
1205
+ const reviewSummary = current && !reviewBlocker
1141
1206
  ? '<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>'
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
1207
+ : '<p class="collection-review-result"><strong>' + (scheduleBlocker ? "Complete schedule first" : proposalBlocker ? "Complete proposals first" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(reviewBlocker || assessment.message) + '</span></p>';
1208
+ const compactReviewSummary = current && !reviewBlocker
1144
1209
  ? '<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>"
1145
1210
  : reviewSummary;
1146
1211
  const action = state.readOnly
1147
1212
  ? ""
1148
- : scheduleBlocker
1149
- ? '<button class="button" type="button" disabled title="' + esc(scheduleBlocker) + '">Review and approve schedule</button>'
1213
+ : reviewBlocker
1214
+ ? '<button class="button" type="button" disabled title="' + esc(reviewBlocker) + '">' + (scheduleReview ? "Review and approve schedule" : "Review and confirm") + '</button>'
1150
1215
  : needsFirstRecord
1151
1216
  ? '<a class="button primary" href="#/resources/' + encodeURIComponent(type) + '?new=1">Add first ' + esc(state.model.resources[type].title.toLowerCase()) + '</a>'
1152
1217
  : '<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>';
1218
+ const status = reviewBlocker ? "Not ready" : current ? "Reviewed" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Stale" : "Review required";
1219
+ const proposalSummary = assessment.recordProposals?.length
1220
+ ? '<p class="collection-review-proposal-summary"><strong>' + assessment.recordProposals.filter(({ complete }) => complete).length + ' of ' + assessment.recordProposals.length + ' record proposals complete.</strong> The collection review follows every proposal.</p>'
1221
+ : "";
1222
+ 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>' + proposalSummary + '<ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul>' + (current ? reviewSummary : "") + '</div></details>';
1155
1223
  const summary = current
1156
1224
  ? '<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>'
1157
1225
  : details;
@@ -1170,15 +1238,8 @@ function retentionScheduleApprovalBlocker() {
1170
1238
  const document = documents.length === 1 ? documents[0] : null;
1171
1239
  if (documents.length > 1) return "Keep exactly one current program Data Retention Schedule document before approval.";
1172
1240
  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 + ".";
1241
+ const approvalIssue = state.collectionReviews?.["retention-schedule-item"]?.approvalIssues?.[0];
1242
+ if (approvalIssue) return approvalIssue.message;
1182
1243
  const incomplete = retentionReviewItems("policies", false).find((item) => (
1183
1244
  item.status === "action" && item.id !== "collection-review-retention-schedule-item"
1184
1245
  ));
@@ -1276,8 +1337,7 @@ function recordWorkflowItems(type, id) {
1276
1337
  const activeStates = new Set(["blocked", "due", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
1277
1338
  return [
1278
1339
  ...(state.workflow?.findings || []),
1279
- ...(state.workflow?.workItems || []),
1280
- ...programReadinessWorkflowItems()
1340
+ ...(state.workflow?.workItems || [])
1281
1341
  ].filter((item) => (
1282
1342
  activeStates.has(item.state)
1283
1343
  && (
@@ -1499,7 +1559,10 @@ function workflowItemHref(item) {
1499
1559
  function retentionScheduleItemHref(item) {
1500
1560
  const params = new URLSearchParams({ new: "1" });
1501
1561
  if (item.informationTypeId) params.set("informationTypeId", item.informationTypeId);
1502
- if (item.resourceId || item.subject?.id) params.set("scopeResourceId", item.resourceId || item.subject.id);
1562
+ for (const id of item.retentionScopeResourceIds || []) params.append("scopeResourceId", id);
1563
+ if (!params.has("scopeResourceId") && (item.resourceId || item.subject?.id)) {
1564
+ params.set("scopeResourceId", item.resourceId || item.subject.id);
1565
+ }
1503
1566
  const informationTypeTitle = String(item.title || "").replace(/^Decide retention for\s+/i, "").trim();
1504
1567
  if (informationTypeTitle && informationTypeTitle !== item.title) params.set("title", "Retention for " + informationTypeTitle);
1505
1568
  const scheduleDocuments = resourcesOfType("document").filter(({ record }) => (
@@ -1611,16 +1674,50 @@ function isRetentionScheduleWorkflowItem(item) {
1611
1674
  });
1612
1675
  }
1613
1676
 
1614
- function programReadinessWorkflowItems() {
1615
- if (state.sections?.workflow === "complete") return [];
1616
- return retentionReviewItems()
1617
- .filter((item) => item.status === "action")
1618
- .map((item) => ({
1619
- ...item,
1620
- key: "program-readiness." + item.id,
1677
+ function retentionSchedulePageItems() {
1678
+ const plannedRows = resourcesOfType("retention-schedule-item")
1679
+ .filter(({ record }) => record.status === "planned")
1680
+ .map(({ record }) => ({
1681
+ id: "planned-" + record.id,
1682
+ key: "planned-" + record.id,
1683
+ status: "action",
1621
1684
  state: "ready",
1622
- subject: item.resourceId && item.resourceType ? { type: item.resourceType, id: item.resourceId } : { type: item.resourceType || "unknown" }
1685
+ title: "Complete or retire " + record.title,
1686
+ message: "Planned rows are excluded from the approved schedule. Finish this decision or retire the row before schedule approval.",
1687
+ resourceType: record.type,
1688
+ resourceId: record.id,
1689
+ href: '#/resource/retention-schedule-item/' + encodeURIComponent(record.id) + '?stage=policies'
1623
1690
  }));
1691
+ return distinctPageActionItems([
1692
+ ...plannedRows,
1693
+ ...retentionReviewItems("policies", false)
1694
+ .filter(({ status }) => status === "action")
1695
+ .map((item) => ({
1696
+ ...item,
1697
+ key: item.id,
1698
+ state: "ready",
1699
+ href: item.id === "collection-review-retention-schedule-item"
1700
+ ? "#/retention-schedule"
1701
+ : item.id?.startsWith("retention-use-")
1702
+ ? retentionScheduleItemHref(item)
1703
+ : item.resourceId && item.resourceType
1704
+ ? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
1705
+ : workflowItemHref(item) || "#/retention-schedule",
1706
+ subject: item.resourceId && item.resourceType
1707
+ ? { type: item.resourceType, id: item.resourceId }
1708
+ : item.subject
1709
+ }))
1710
+ ]);
1711
+ }
1712
+
1713
+ function distinctPageActionItems(items) {
1714
+ const distinct = new Map();
1715
+ for (const item of items) {
1716
+ const href = item.href || workflowItemHref(item);
1717
+ const key = pageActionIdentity(href, item.key || item.id);
1718
+ if (!distinct.has(key)) distinct.set(key, item);
1719
+ }
1720
+ return [...distinct.values()];
1624
1721
  }
1625
1722
 
1626
1723
  function evidenceSourceCheckLabel(name) {
@@ -1660,11 +1757,11 @@ function stagePageCard(stage, destination, index) {
1660
1757
  const stepLabel = "Step " + stage.number + "." + String.fromCharCode(97 + index);
1661
1758
  const derived = derivedStagePageState(stage, destination);
1662
1759
  const complete = derived.complete;
1663
- const items = stagePageItems(stage, destination);
1760
+ const items = stagePageActionItems(stage, destination);
1664
1761
  const completionState = '<span class="stage-page-completion-state ' + (complete ? "complete" : "") + '">' + esc(derived.label) + '</span>';
1665
1762
  const taskPreview = items.length
1666
1763
  ? '<div class="stage-page-tasks">' + items.slice(0, 3).map((item) => {
1667
- const href = destination.utility === "evidence-sources" ? destination.href : workflowItemHref(item) || destination.href;
1764
+ const href = destination.utility === "evidence-sources" ? destination.href : item.href || workflowItemHref(item) || destination.href;
1668
1765
  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>';
1669
1766
  }).join("") + (items.length > 3 ? '<small class="stage-page-tasks-more">+' + (items.length - 3) + ' more on this page</small>' : "") + '</div>'
1670
1767
  : "";
@@ -1699,6 +1796,11 @@ function stageProgress(stage) {
1699
1796
  return progressFromCounts(complete, applicable.length, "page");
1700
1797
  }
1701
1798
 
1799
+ function stagePageActionItems(stage, destination) {
1800
+ const deferredStates = new Set(["later", "scheduled", "upcoming", "waiting-external"]);
1801
+ return stagePageItems(stage, destination).filter(({ state }) => !deferredStates.has(state));
1802
+ }
1803
+
1702
1804
  function derivedStagePageState(stage, destination) {
1703
1805
  if (
1704
1806
  stage.id === "audit"
@@ -1716,15 +1818,14 @@ function derivedStagePageState(stage, destination) {
1716
1818
  : { complete: evidenceItems.length > 0, label: evidenceItems.length ? "Ready" : "Not configured", countsTowardProgress: evidenceItems.length > 0 };
1717
1819
  }
1718
1820
  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
1821
+ const incomplete = retentionSchedulePageItems().length;
1822
+ return !incomplete
1722
1823
  ? { complete: true, label: "Approved" }
1723
1824
  : { complete: false, label: incomplete ? incomplete + " " + pluralize("item", incomplete) + " need work" : "Approval needed" };
1724
1825
  }
1725
1826
  const items = stagePageItems(stage, destination);
1726
1827
  const deferredStates = new Set(["later", "scheduled", "upcoming", "waiting-external"]);
1727
- const blocking = items.filter(({ state }) => !deferredStates.has(state));
1828
+ const blocking = stagePageActionItems(stage, destination);
1728
1829
  if (blocking.length) {
1729
1830
  return { complete: false, label: blocking.length + " " + pluralize("item", blocking.length) + (blocking.length === 1 ? " needs work" : " need work") };
1730
1831
  }
@@ -1749,8 +1850,7 @@ function stagePageItems(stage, destination) {
1749
1850
  const activeStates = new Set(["blocked", "due", "later", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
1750
1851
  const items = [
1751
1852
  ...(state.workflow?.findings || []),
1752
- ...(state.workflow?.workItems || []),
1753
- ...programReadinessWorkflowItems()
1853
+ ...(state.workflow?.workItems || [])
1754
1854
  ].filter((item) => (
1755
1855
  activeStates.has(item.state)
1756
1856
  && (
@@ -1766,18 +1866,9 @@ function stagePageItems(stage, destination) {
1766
1866
  .map((item) => ({ ...item, key: item.id, state: item.status, subject: { type: "source-coverage" } }));
1767
1867
  }
1768
1868
  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) => (
1775
- workflowItemStatePriority(left) - workflowItemStatePriority(right)
1776
- || (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
1777
- || left.key.localeCompare(right.key)
1778
- ));
1869
+ return retentionSchedulePageItems();
1779
1870
  }
1780
- return items.filter((item) => {
1871
+ return distinctPageActionItems(items.filter((item) => {
1781
1872
  if (destination.type === "program" && item.key === "program.scope.criteria") return false;
1782
1873
  if (
1783
1874
  destination.type === "requirement"
@@ -1789,22 +1880,10 @@ function stagePageItems(stage, destination) {
1789
1880
  && ["source-coverage", "system"].includes(item.subject?.type)
1790
1881
  && item.key?.startsWith("evidence-source.")
1791
1882
  ) return false;
1792
- if (
1793
- stage.id === "policies"
1794
- && destination.type === "policy"
1795
- && item.code === "governance.appointment.independent-policy-reviewer"
1796
- ) return true;
1797
- if (
1798
- destination.type
1799
- && (item.subject?.type === destination.type || item.source?.type === destination.type)
1800
- ) return true;
1801
1883
  const href = workflowItemHref(item);
1802
- if (!href) return false;
1803
- const destinationHref = destination.href.split("?")[0];
1804
- return href === destinationHref
1805
- || href.startsWith(destinationHref + "?")
1806
- || Boolean(destination.type && href.startsWith("#/resource/" + destination.type + "/"));
1807
- }).sort((left, right) => (
1884
+ return workflowHrefBelongsToPage(destination, href)
1885
+ && !(destination.href === "#/policies" && isRetentionScheduleWorkflowItem(item));
1886
+ })).sort((left, right) => (
1808
1887
  workflowItemStatePriority(left) - workflowItemStatePriority(right)
1809
1888
  || (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
1810
1889
  || left.key.localeCompare(right.key)
@@ -1820,7 +1899,7 @@ function operationProgress() {
1820
1899
  const goal = program?.target?.goal || activeProgram().assuranceGoal || "none";
1821
1900
  const asOf = program?.asOf || currentDate();
1822
1901
  const candidateStarted = goal === "soc-2-type-2"
1823
- ? Boolean(program?.target?.candidateCoverage?.kind === "range" && program.target.candidateCoverage.startsOn <= asOf)
1902
+ ? Boolean(program?.progress?.mode === "operating-window")
1824
1903
  : goal === "soc-2-type-1"
1825
1904
  ? Boolean(program?.target?.candidateCoverage?.kind === "as-of")
1826
1905
  : Boolean(program?.evidenceReady);
@@ -3092,7 +3171,7 @@ function renderList(main, type, params = new URLSearchParams()) {
3092
3171
  const seed = type === "retention-schedule-item" ? {
3093
3172
  ...(params.get("title") ? { title: params.get("title") } : {}),
3094
3173
  ...(params.get("informationTypeId") ? { informationTypeIds: [params.get("informationTypeId")] } : {}),
3095
- ...(params.get("scopeResourceId") ? { scopeResourceIds: [params.get("scopeResourceId")] } : {}),
3174
+ ...(params.getAll("scopeResourceId").length ? { scopeResourceIds: params.getAll("scopeResourceId") } : {}),
3096
3175
  ...(params.get("scheduleDocumentId") ? { scheduleDocumentId: params.get("scheduleDocumentId") } : {})
3097
3176
  } : type === "document" ? {
3098
3177
  ...(params.get("title") ? { title: params.get("title") } : {}),
@@ -3155,12 +3234,11 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
3155
3234
  const narrative = recordNarrative(entry.record, fields);
3156
3235
  const narrativeNames = new Set(narrative.map(([name]) => name));
3157
3236
  const visible = Object.entries(entry.record).filter(([name]) => (
3158
- !["id", "type", "title"].includes(name)
3237
+ !["id", "type", "title", "extensions"].includes(name)
3159
3238
  && !fields[name]?.content
3160
3239
  && !narrativeNames.has(name)
3161
3240
  ));
3162
3241
  const content = Object.entries(entry.content);
3163
- const sourceMetadata = '<div><dt>Source file</dt><dd><code>' + esc(entry.relativePath) + '</code></dd></div><div><dt>Workspace revision</dt><dd>' + (state.git.available ? '<code>' + esc(state.git.shortCommit) + '</code>' : "Unavailable until the workspace is committed.") + '</dd></div>';
3164
3242
  const narrativeContent = narrative.length
3165
3243
  ? '<div class="content-label"><span>Record</span></div><div class="record-prose">' + narrative.map(([name, value]) => '<section><h3>' + esc(titleCase(fields[name]?.label || humanize(name))) + '</h3><p>' + esc(value) + '</p></section>').join("") + '</div>'
3166
3244
  : "";
@@ -3213,26 +3291,41 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
3213
3291
  ? '<section class="panel detail-main">' + narrativeContent + markdownContent + addRecordContent + '</section>'
3214
3292
  : "";
3215
3293
  const attachmentPanel = type === "evidence" ? evidenceAttachmentPanel(entry) : "";
3216
- const historyPanel = entry.history?.length
3217
- ? '<section class="panel detail-history-panel"><div class="panel-head"><h3>File History</h3></div><div class="history">' + entry.history.map((commit) => '<div><code>' + esc(commit.shortCommit) + '</code><span><strong>' + esc(commit.subject) + '</strong><small>' + esc(commit.author) + ' · ' + esc(formatLocalDateTime(commit.timestamp)) + '</small></span></div>').join("") + '</div></section>'
3294
+ const recordFieldsPanel = visible.length
3295
+ ? '<section class="panel detail-support-panel detail-metadata-panel"><div class="panel-head"><h3>Record details</h3></div><dl class="metadata">' + visible.map(([name, value]) => '<div><dt>' + esc(fields[name]?.label || humanize(name)) + '</dt><dd>' + formatValue(name === "status" ? displayStatus(entry.record) : value, name, type) + '</dd></div>').join("") + '</dl></section>'
3218
3296
  : "";
3219
- const participationPanel = entry.historyLoaded === false
3220
- ? '<section class="panel detail-support-panel" role="status" aria-live="polite" aria-busy="true"><div class="panel-head"><h3>Participation</h3></div><p class="muted is-loading">' + loadingIndicator("Loading participation and file history…") + '</p></section>'
3221
- : entry.historyError
3222
- ? '<section class="panel detail-support-panel" role="status" aria-live="polite"><div class="panel-head"><h3>Participation</h3></div><p class="muted">Participation and file history are unavailable. Reload the record to try again.</p></section>'
3223
- : personParticipation(entry);
3224
3297
  const supportPanels = renderDetailSupport({
3225
3298
  hasRecordBody,
3226
3299
  workflowPanel: workflowGuidance({ type, id, title: "Next steps", workflow: entry.workflow }) || recordCompletionState(entry.record),
3227
- metadataPanel: '<section class="panel detail-support-panel detail-metadata-panel"><div class="panel-head"><h3>Record details</h3></div><dl class="metadata">' + sourceMetadata + visible.map(([name, value]) => '<div><dt>' + esc(fields[name]?.label || humanize(name)) + '</dt><dd>' + formatValue(name === "status" ? displayStatus(entry.record) : value, name, type) + '</dd></div>').join("") + '</dl></section>',
3228
- attachmentPanel,
3229
- participationPanel,
3230
- connectionsPanel: resourceConnections(entry),
3231
- historyPanel
3300
+ metadataPanel: recordFieldsPanel,
3301
+ attachmentPanel
3232
3302
  });
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 &amp; 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>';
3303
+ const mayDelete = !state.readOnly && !routeSetLocked && !definition.singleton;
3304
+ const recordMenu = '<details class="record-overflow"><summary aria-label="More record actions" title="More record actions"><svg viewBox="0 0 18 4" aria-hidden="true"><circle cx="2" cy="2" r="2"></circle><circle cx="9" cy="2" r="2"></circle><circle cx="16" cy="2" r="2"></circle></svg></summary><div class="record-overflow-menu" aria-label="Record actions"><button type="button" data-record-modal="info">Record info</button><button type="button" data-record-modal="connections">Connections</button><button type="button" data-record-modal="history"' + (entry.historyLoaded === false ? ' disabled' : '') + '>File history' + (entry.historyLoaded === false ? ' (loading)' : '') + '</button>' + (mayDelete ? '<div class="record-overflow-separator"></div><button class="danger-text" type="button" data-delete-resource>Delete record</button>' : '') + '</div></details>';
3305
+ 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 &amp; Packet</a>' : "") + governanceActions + lifecycleActions + issueActions + addRecordContentAction + (!state.readOnly && !routeSetLocked ? '<button class="button" id="edit-resource">Edit</button>' : "") + recordMenu + '</div></div><div class="detail-grid ' + (hasRecordBody ? "" : "detail-grid-structured") + '">' + detailMain + supportPanels + '</div></div>';
3234
3306
  main.querySelectorAll("[data-completion-requirements]").forEach((button) => button.addEventListener("click", () => openCompletionRequirementsDialog(button.dataset.completionRequirements)));
3235
3307
  main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry));
3308
+ main.querySelectorAll("[data-record-modal]").forEach((button) => button.addEventListener("click", () => {
3309
+ button.closest("details")?.removeAttribute("open");
3310
+ openRecordUtilityDialog(entry, button.dataset.recordModal);
3311
+ }));
3312
+ const overflowMenu = main.querySelector(".record-overflow");
3313
+ const stopOutsideDismissal = () => document.removeEventListener("click", dismissOnOutsideClick);
3314
+ const dismissOnOutsideClick = (event) => {
3315
+ if (overflowMenu.contains(event.target)) return;
3316
+ overflowMenu.removeAttribute("open");
3317
+ stopOutsideDismissal();
3318
+ };
3319
+ overflowMenu?.addEventListener("toggle", () => {
3320
+ stopOutsideDismissal();
3321
+ if (overflowMenu.open) queueMicrotask(() => document.addEventListener("click", dismissOnOutsideClick));
3322
+ });
3323
+ overflowMenu?.addEventListener("keydown", (event) => {
3324
+ if (event.key !== "Escape" || !overflowMenu.open) return;
3325
+ overflowMenu.removeAttribute("open");
3326
+ overflowMenu.querySelector("summary")?.focus();
3327
+ });
3328
+ recordOverflowCleanup = stopOutsideDismissal;
3236
3329
  main.querySelector("[data-external-reviewer-governance]")?.addEventListener("click", openExternalReviewerGovernanceDialog);
3237
3330
  main.querySelector("[data-next-audit-cycle]")?.addEventListener("click", () => openNextAuditCycleDialog(entry.record));
3238
3331
  main.querySelector("[data-correct-audit-population]")?.addEventListener("click", async () => {
@@ -3343,7 +3436,8 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
3343
3436
  }));
3344
3437
  main.querySelector("#add-record-content")?.addEventListener("click", () => openEditor(type, entry, { addRecordContent: true }));
3345
3438
  main.querySelectorAll("[data-edit-content]").forEach((button) => button.addEventListener("click", () => openContentEditor(entry, button.dataset.editContent)));
3346
- main.querySelector("#delete-resource")?.addEventListener("click", async () => {
3439
+ main.querySelector("[data-delete-resource]")?.addEventListener("click", async () => {
3440
+ main.querySelector(".record-overflow")?.removeAttribute("open");
3347
3441
  const repositoryPrefetch = prefetchRepositoryForReview();
3348
3442
  if (!await confirmAction({
3349
3443
  kicker: "Delete record",
@@ -3369,17 +3463,50 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
3369
3463
  }
3370
3464
  }
3371
3465
 
3372
- function renderDetailSupport({ hasRecordBody, workflowPanel, metadataPanel, attachmentPanel, participationPanel, connectionsPanel, historyPanel }) {
3373
- const panels = workflowPanel + metadataPanel + attachmentPanel + participationPanel + connectionsPanel + historyPanel;
3466
+ function renderDetailSupport({ hasRecordBody, workflowPanel, metadataPanel, attachmentPanel }) {
3467
+ const panels = workflowPanel + metadataPanel + attachmentPanel;
3374
3468
  if (hasRecordBody) return '<aside>' + panels + '</aside>';
3375
3469
  const stacks = [
3376
3470
  { name: "guidance", content: workflowPanel },
3377
- { name: "record", content: metadataPanel + attachmentPanel + historyPanel },
3378
- { name: "relationships", content: participationPanel + connectionsPanel }
3471
+ { name: "record", content: metadataPanel + attachmentPanel }
3379
3472
  ].filter(({ content }) => content);
3380
3473
  return '<aside class="detail-support-columns">' + stacks.map(({ name, content }) => '<div class="detail-support-stack ' + name + '">' + content + '</div>').join("") + '</aside>';
3381
3474
  }
3382
3475
 
3476
+ function openRecordUtilityDialog(entry, view) {
3477
+ const dialog = document.createElement("dialog");
3478
+ dialog.className = "alert-dialog record-utility-dialog";
3479
+ const definitions = {
3480
+ info: {
3481
+ kicker: "Record info",
3482
+ title: entry.record.title,
3483
+ body: '<dl class="metadata record-info"><div><dt>Record ID</dt><dd><code>' + esc(entry.record.id) + '</code></dd></div><div><dt>Type</dt><dd>' + esc(state.model.resources[entry.record.type].title) + '</dd></div><div><dt>Source file</dt><dd><code>' + esc(entry.relativePath) + '</code></dd></div><div><dt>Workspace revision</dt><dd>' + (state.git.available ? '<code>' + esc(state.git.shortCommit) + '</code>' : "Unavailable until the workspace is committed.") + '</dd></div>' + (entry.record.extensions ? '<div><dt>Extensions</dt><dd>' + formatValue(entry.record.extensions, "extensions", entry.record.type) + '</dd></div>' : '') + '</dl><details class="record-json"><summary>View raw JSON</summary><pre class="compact-json">' + esc(JSON.stringify(entry.record, null, 2)) + '</pre></details>'
3484
+ },
3485
+ connections: {
3486
+ kicker: "Connections",
3487
+ title: entry.record.title,
3488
+ body: (personParticipation(entry, true) + resourceConnections(entry, true)) || '<p class="muted">This record has no connected records.</p>'
3489
+ },
3490
+ history: {
3491
+ kicker: "File history",
3492
+ title: entry.record.title,
3493
+ body: entry.historyError
3494
+ ? '<p class="muted">File history is unavailable. Reload the record to try again.</p>'
3495
+ : entry.history?.length
3496
+ ? '<div class="history">' + entry.history.map((commit) => '<div><code>' + esc(commit.shortCommit) + '</code><span><strong>' + esc(commit.subject) + '</strong><small>' + esc(commit.author) + ' · ' + esc(formatLocalDateTime(commit.timestamp)) + '</small></span></div>').join("") + '</div>'
3497
+ : '<p class="muted">No committed history is available for this record.</p>'
3498
+ }
3499
+ };
3500
+ const definition = definitions[view];
3501
+ if (!definition) return;
3502
+ dialog.setAttribute("aria-labelledby", "record-utility-title");
3503
+ dialog.innerHTML = '<div class="dialog-head"><div><p class="kicker">' + esc(definition.kicker) + '</p><h2 id="record-utility-title">' + esc(titleCase(definition.title)) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><div class="record-utility-body">' + definition.body + '</div><div class="dialog-actions"><button type="button" class="button primary">Close</button></div>';
3504
+ document.body.append(dialog);
3505
+ dialog.querySelectorAll("button").forEach((button) => button.addEventListener("click", () => dialog.close()));
3506
+ dialog.addEventListener("close", () => dialog.remove(), { once: true });
3507
+ dialog.showModal();
3508
+ }
3509
+
3383
3510
  function evidenceAttachmentPanel(entry) {
3384
3511
  const attachments = (entry.record.filePaths || []).map((path) => ({
3385
3512
  path,
@@ -3563,7 +3690,7 @@ function recordContentDefinition(type) {
3563
3690
  };
3564
3691
  }
3565
3692
 
3566
- function personParticipation(entry) {
3693
+ function personParticipation(entry, expanded = false) {
3567
3694
  if (entry.record.type !== "person") return "";
3568
3695
  const personId = entry.record.id;
3569
3696
  const appointments = resourcesOfType("appointment")
@@ -3603,8 +3730,8 @@ function personParticipation(entry) {
3603
3730
  const count = affiliations.length + assignments.length;
3604
3731
  if (!count) return "";
3605
3732
  return '<section class="panel connections-panel"><div class="panel-head"><h3>Participation</h3><span>' + count + '</span></div>'
3606
- + personParticipationGroup("Appointments and teams", affiliations, 8, "more appointments or teams")
3607
- + personParticipationGroup("Assigned records", assignments, 10, "more assigned records")
3733
+ + personParticipationGroup("Appointments and teams", affiliations, expanded ? affiliations.length : 8, "more appointments or teams")
3734
+ + personParticipationGroup("Assigned records", assignments, expanded ? assignments.length : 10, "more assigned records")
3608
3735
  + '</section>';
3609
3736
  }
3610
3737
 
@@ -3617,7 +3744,7 @@ function personParticipationGroup(title, rows, limit, moreLabel) {
3617
3744
  + '</div>';
3618
3745
  }
3619
3746
 
3620
- function resourceConnections(entry) {
3747
+ function resourceConnections(entry, expanded = false) {
3621
3748
  const relatedPeopleOnly = entry.record.type === "person";
3622
3749
  const connections = new Map();
3623
3750
  const entriesById = new Map(state.resources.map((item) => [item.record.id, item]));
@@ -3652,7 +3779,7 @@ function resourceConnections(entry) {
3652
3779
  return firstType - secondType || first.entry.record.title.localeCompare(second.entry.record.title);
3653
3780
  });
3654
3781
  if (!sorted.length) return "";
3655
- const visible = sorted.slice(0, 14);
3782
+ const visible = expanded ? sorted : sorted.slice(0, 14);
3656
3783
  return '<section class="panel connections-panel"><div class="panel-head"><h3>' + (relatedPeopleOnly ? "Related people" : "Connections") + '</h3><span>' + sorted.length + '</span></div><div class="connections">' + visible.map(({ entry: connected, reasons }) => '<a href="#/resource/' + encodeURIComponent(connected.record.type) + '/' + encodeURIComponent(connected.record.id) + '"><strong>' + esc(connected.record.title) + '</strong><small>' + esc([...reasons].join(" · ")) + '</small></a>').join("") + '</div>' + (sorted.length > visible.length ? '<p class="connections-more">' + (sorted.length - visible.length) + ' more connections are available through the linked records.</p>' : "") + '</section>';
3657
3784
  }
3658
3785
 
@@ -4655,6 +4782,9 @@ function openEditor(type, entry = null, options = {}) {
4655
4782
  Array.isArray(group) ? { fields: group } : group
4656
4783
  ));
4657
4784
  const oneOf = new Set(oneOfGroups.flatMap((group) => group.fields || []).filter((name) => !name.startsWith("$markdown:")));
4785
+ const hiddenFields = type === "retention-schedule-item" && modelSupports("retention-schedule-approval")
4786
+ ? new Set(["approvedByIds", "approvedOn"])
4787
+ : new Set();
4658
4788
  const activeOneOf = new Set(oneOfGroups
4659
4789
  .filter((group) => !group.when || conditionMatches(record, group.when))
4660
4790
  .flatMap((group) => group.fields || []));
@@ -4668,6 +4798,7 @@ function openEditor(type, entry = null, options = {}) {
4668
4798
  ...oneOf
4669
4799
  ])].filter((name) => (
4670
4800
  !["id", "type"].includes(name)
4801
+ && !hiddenFields.has(name)
4671
4802
  && fields[name]
4672
4803
  && !(options.occurrenceReview && ["members", "expectedCount", "completedCount", "conclusion"].includes(name))
4673
4804
  ));
@@ -6385,7 +6516,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
6385
6516
  .reconciliation-panel>p{margin:0 0 12px;color:var(--muted);font-size:12px;line-height:1.5}.reconciliation-candidates{display:grid;gap:9px}.reconciliation-candidate{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,.8fr);gap:16px;padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.reconciliation-candidate strong{margin-left:8px;font-size:13.2px}.reconciliation-candidate p{margin:7px 0;color:var(--muted);font-size:12px;line-height:1.5}.reconciliation-candidate small{display:block;overflow-wrap:anywhere;color:var(--muted);font-size:9.6px}.reconciliation-actions{display:grid;gap:8px;justify-items:end;align-content:center}.reconciliation-actions>code{max-width:100%;overflow-wrap:anywhere;color:var(--muted);font-size:9.6px;text-align:right}
6386
6517
  .onboarding-save-status{color:var(--muted);font-size:10.8px;line-height:1.35}.onboarding-save-status:empty{display:none}.onboarding-save-status:not(:empty){order:-1;flex-basis:100%;margin-bottom:4px}.onboarding-retry-sync{margin-top:9px}.detail-loading{color:var(--muted)}
6387
6518
  .save-status{min-height:16px;color:var(--muted);font-size:10.8px;line-height:1.35}
6388
- .page-intro,.detail-head{align-items:center;margin-bottom:12px}.actions{align-items:center}.detail-head>div:first-child{min-width:0}.detail-head h2{margin:7px 0}.detail-head .header-breadcrumbs{margin:0;font-size:10.8px;line-height:normal;min-height:11px;align-items:center}.header-breadcrumbs span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60ch}
6519
+ .page-intro,.detail-head{align-items:center;margin-bottom:12px}.actions{align-items:center;justify-content:flex-end;flex-wrap:wrap}.detail-head>div:first-child{min-width:0}.detail-head h2{margin:7px 0}.detail-head .header-breadcrumbs{margin:0;font-size:10.8px;line-height:normal;min-height:11px;align-items:center}.header-breadcrumbs span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60ch}.record-overflow{position:relative;flex:0 0 auto;margin-left:auto}.record-overflow>summary{display:grid;place-items:center;width:38px;height:38px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--ink);cursor:pointer;list-style:none}.record-overflow>summary::-webkit-details-marker{display:none}.record-overflow>summary svg{display:block;width:18px;height:4px;fill:currentColor}.record-overflow>summary:hover,.record-overflow[open]>summary{border-color:var(--accent-light);background:var(--accent-soft);color:var(--accent)}.record-overflow-menu{position:absolute;z-index:12;top:calc(100% + 6px);right:0;display:grid;width:190px;padding:6px;border:1px solid var(--line);border-radius:9px;background:var(--panel);box-shadow:0 14px 36px rgba(0,0,53,.16)}.record-overflow-menu button{width:100%;border:0;border-radius:6px;padding:9px 10px;background:none;color:var(--ink);font-size:13.2px;text-align:left;cursor:pointer}.record-overflow-menu button:hover{background:var(--surface-soft)}.record-overflow-menu button:disabled{cursor:wait;color:var(--muted)}.record-overflow-menu .danger-text{color:var(--red)}.record-overflow-separator{height:1px;margin:5px 4px;background:var(--line)}.record-utility-dialog{width:min(680px,calc(100vw - 30px));max-height:min(760px,calc(100vh - 30px));overflow:auto}.record-utility-body{display:grid;gap:12px;margin:19px 0}.record-utility-body>.panel{box-shadow:none}.record-info>div:first-child{border-top:0}.record-json{border-top:1px solid var(--line);padding-top:13px}.record-json summary{color:var(--accent);font-size:13.2px;font-weight:750;cursor:pointer}.record-json pre{max-height:360px;overflow:auto;margin:12px 0 0}.record-utility-dialog .history{max-height:none}
6389
6520
  .not-found{max-width:620px;padding:28px}.not-found h2{font-family:Georgia,serif;font-size:37.2px;font-weight:500;margin:7px 0}.not-found>p:not(.kicker){margin:0;color:var(--muted);font-size:15.6px}.not-found .page-actions{justify-content:flex-start;margin-top:22px}
6390
6521
  .button.danger-action{background:var(--red);border-color:var(--red);color:#fff}
6391
6522
  @media(max-width:1200px){.readiness-flow{grid-template-columns:repeat(3,minmax(0,1fr))}.audit-engagement{grid-template-columns:1fr 1fr}.audit-engagement .button{grid-column:1/-1;justify-self:start}}
@@ -6396,13 +6527,12 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
6396
6527
  .detail-grid aside .panel{width:100%;max-height:min(520px,65vh);overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable}.detail-grid aside .workflow-guidance,.detail-grid aside .resource-review-criteria{margin:0}.detail-grid aside .resource-review-criteria{padding:21px}.detail-grid aside .detail-workflow-panel{max-height:min(440px,60vh)}.detail-grid aside .detail-review-panel{max-height:min(300px,50vh)}.detail-grid aside .workflow-findings,.detail-grid aside .resource-review-criteria ul{grid-template-columns:1fr}.detail-support-stack{display:grid;min-width:0;gap:14px;align-content:start}
6397
6528
  @media(max-width:760px){.detail-grid aside{order:-1}}
6398
6529
  @media(max-width:760px){.reconciliation-candidate{grid-template-columns:1fr}.reconciliation-actions{justify-items:start}.reconciliation-actions>code{text-align:left}}
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}}
6530
+ @media(max-width:760px){.detail-grid-structured .detail-support-columns{display:flex;flex-direction:column}.detail-grid-structured .detail-support-stack{display:contents}}
6400
6531
  @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
6532
  @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}}
6402
6533
  @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}}
6403
6534
  @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}}
6404
6535
  @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))}}
6405
- @media(min-width:1300px){.detail-grid-structured .detail-support-columns{grid-template-columns:repeat(3,minmax(0,1fr))}}
6406
6536
  @media(max-width:760px){.sidebar{visibility:hidden;transition:transform .2s,visibility 0s .2s}.sidebar.shown{visibility:visible;transition-delay:0s}.nav-close{display:grid;place-items:center;position:absolute;top:25px;right:18px;width:34px;height:34px;border:1px solid #5966a4;border-radius:50%;background:#11174a;color:#eef1ff;font-size:24px;cursor:pointer}.nav-scrim{display:block;position:fixed;inset:0;border:0;background:rgba(0,0,24,.38);opacity:0;pointer-events:none;transition:opacity .2s;z-index:15}.sidebar.shown+.nav-scrim{opacity:1;pointer-events:auto}.pagination{justify-content:space-between;gap:8px}.page-status{min-width:0}}
6407
6537
  @media(max-width:760px){.topbar{height:56px}.topbar>div:first-of-type{display:none}.topbar-readiness{flex:0 1 220px;min-width:120px}.nav-close{font-size:0}.nav-close:before,.nav-close:after{content:"";position:absolute;width:13px;height:2px;border-radius:2px;background:currentColor;transform:rotate(45deg)}.nav-close:after{transform:rotate(-45deg)}}
6408
6538