filegrc 0.3.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -6
- package/model/index.js +41 -3
- package/model/v1.json +81 -47
- package/model/v2.json +8022 -0
- package/model/v3.json +9391 -0
- package/package.json +2 -2
- package/src/agent.js +89 -8
- package/src/appointments.js +19 -0
- package/src/audit-preparation.js +109 -65
- package/src/audit-transition.js +96 -0
- package/src/batch-review.js +109 -0
- package/src/cli.js +563 -148
- package/src/collection-review.js +185 -0
- package/src/coverage.js +50 -0
- package/src/evidence-packet.js +149 -77
- package/src/external-reviewer.js +165 -0
- package/src/files.js +267 -29
- package/src/git.js +239 -41
- package/src/index.js +41 -7
- package/src/model-docs.js +103 -7
- package/src/model-migration.js +1958 -0
- package/src/mutation.js +42 -0
- package/src/obligations.js +502 -95
- package/src/parties.js +17 -2
- package/src/program-lifecycle.js +1 -0
- package/src/program-path.js +70 -60
- package/src/program-readiness.js +470 -130
- package/src/reconciliation.js +277 -0
- package/src/resource-status.js +17 -0
- package/src/server.js +347 -48
- package/src/setup.js +57 -26
- package/src/source-coverage.js +61 -0
- package/src/state.js +122 -25
- package/src/timing.js +41 -0
- package/src/validate.js +707 -44
- package/src/web.js +1440 -304
- package/src/workflow.js +1595 -0
- package/src/workspace.js +15 -7
- package/src/evidence-tests.js +0 -69
package/src/web.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
utcCalendarDate,
|
|
9
9
|
validCalendarRecurrence
|
|
10
10
|
} from "./recurrence.js";
|
|
11
|
-
import {
|
|
11
|
+
import { PROGRAM_PATH, RESOURCE_INSTRUCTIONS, RESOURCE_PAGE_SUMMARIES } from "./program-path.js";
|
|
12
12
|
import { formatCalendarDate, formatLocalDateTime } from "./time.js";
|
|
13
13
|
|
|
14
14
|
export function renderIndex(state = null) {
|
|
@@ -50,33 +50,12 @@ const READINESS_STAGES = SHARED_PROGRAM_STAGES.map((stage) => ({
|
|
|
50
50
|
...stage,
|
|
51
51
|
number: String(stage.number)
|
|
52
52
|
}));
|
|
53
|
+
const RESOURCE_GUIDE_INSTRUCTIONS = ${JSON.stringify(RESOURCE_INSTRUCTIONS)};
|
|
53
54
|
const STAGE_PAGE_SUMMARIES = ${JSON.stringify({
|
|
54
|
-
...
|
|
55
|
-
"utility:audit-packet": "Review
|
|
55
|
+
...RESOURCE_PAGE_SUMMARIES,
|
|
56
|
+
"utility:audit-packet": "Review fieldwork readiness and build the indexed evidence packet."
|
|
56
57
|
})};
|
|
57
|
-
const
|
|
58
|
-
const STAGE_PAGE_ID_ALIASES = {
|
|
59
|
-
"controls:complementary-control": ["scope:complementary-control"]
|
|
60
|
-
};
|
|
61
|
-
const OBLIGATION_COMPLETION_TYPES = {
|
|
62
|
-
"access-review": "access-review",
|
|
63
|
-
"backup-test": "backup-test",
|
|
64
|
-
"continuity-review": "evidence",
|
|
65
|
-
exercise: "exercise",
|
|
66
|
-
"inventory-review": "evidence",
|
|
67
|
-
"log-review": "evidence",
|
|
68
|
-
meeting: "meeting",
|
|
69
|
-
"network-review": "evidence",
|
|
70
|
-
"penetration-test": "penetration-test",
|
|
71
|
-
"performance-review": "evidence",
|
|
72
|
-
"policy-review": "policy-review",
|
|
73
|
-
"risk-assessment": "risk-assessment",
|
|
74
|
-
"security-scan": "evidence",
|
|
75
|
-
training: "attestation",
|
|
76
|
-
"vendor-review": "vendor-review",
|
|
77
|
-
"vulnerability-scan": "vulnerability-scan"
|
|
78
|
-
};
|
|
79
|
-
const RECORD_TEXT_FIELDS = new Set(["description", "statement", "activity", "purpose", "scope", "objective", "applicabilityRationale", "summary", "rationale", "acceptanceRationale", "businessPurpose", "changeSummary", "decisionSummary", "decisionRationale", "recommendation", "remediationPlan", "auditorNotes", "notPerformedReason"]);
|
|
58
|
+
const RECORD_TEXT_FIELDS = new Set(["description", "statement", "activity", "purpose", "scope", "objective", "applicabilityRationale", "summary", "rationale", "businessPurpose", "changeSummary", "decisionSummary", "decisionRationale", "recommendation", "remediationPlan", "auditorNotes", "notPerformedReason"]);
|
|
80
59
|
const FINDING_SOURCE_TYPES = new Set(["control-test", "policy-review", "meeting", "risk", "risk-assessment", "vendor-review", "access-review", "incident", "exercise", "backup-test", "penetration-test", "audit"]);
|
|
81
60
|
const ACTION_ITEM_SOURCE_TYPES = new Set(["finding", "exception", "policy-review", "meeting", "risk", "risk-assessment", "vendor-review", "access-review", "vulnerability", "incident", "exercise", "backup-test", "data-request", "audit-request"]);
|
|
82
61
|
const TITLE_CASE_MINOR_WORDS = new Set(["a", "an", "and", "as", "at", "but", "by", "for", "in", "nor", "of", "on", "or", "per", "the", "to", "via", "vs"]);
|
|
@@ -86,7 +65,12 @@ let onboardingShade = null;
|
|
|
86
65
|
let onboardingStep = 0;
|
|
87
66
|
let onboardingDraft = null;
|
|
88
67
|
let onboardingBusy = false;
|
|
68
|
+
let onboardingStillWorkingTimer = null;
|
|
69
|
+
let onboardingPendingDraft = false;
|
|
70
|
+
const resourceDetailRequests = new Map();
|
|
89
71
|
let resourceGuideCleanup = null;
|
|
72
|
+
let repositorySyncPollTimer = null;
|
|
73
|
+
let repositorySyncPollInFlight = false;
|
|
90
74
|
|
|
91
75
|
start().catch((error) => {
|
|
92
76
|
root.innerHTML = '<main class="fatal"><h1>Could Not Load the Workspace</h1><pre></pre></main>';
|
|
@@ -100,6 +84,7 @@ async function start() {
|
|
|
100
84
|
window.addEventListener("resize", positionCurrentOnboarding);
|
|
101
85
|
window.addEventListener("scroll", positionCurrentOnboarding, true);
|
|
102
86
|
render();
|
|
87
|
+
scheduleRepositorySyncPoll();
|
|
103
88
|
if (!state.readOnly && rendererSettingsEntry()?.record.showOnboarding === true) {
|
|
104
89
|
queueMicrotask(requestOnboarding);
|
|
105
90
|
}
|
|
@@ -112,7 +97,7 @@ function render() {
|
|
|
112
97
|
if (previousNavigation) navigationScrollTop = previousNavigation.scrollTop;
|
|
113
98
|
const route = parseRoute();
|
|
114
99
|
const nav = buildNavigation(route);
|
|
115
|
-
root.innerHTML = '<div class="shell">' + nav + '<div class="workspace"><header class="topbar">' + topbar(route) + '</header
|
|
100
|
+
root.innerHTML = '<div class="shell">' + nav + '<div class="workspace"><header class="topbar">' + topbar(route) + '</header>' + repositorySyncAlert() + '<main id="main"></main></div></div>';
|
|
116
101
|
const nextNavigation = root.querySelector(".sidebar-nav");
|
|
117
102
|
if (nextNavigation) nextNavigation.scrollTop = navigationScrollTop;
|
|
118
103
|
const main = root.querySelector("main");
|
|
@@ -121,7 +106,7 @@ function render() {
|
|
|
121
106
|
else if (route.name === "obligations") renderObligations(main, route.params);
|
|
122
107
|
else if (route.name === "audit-packet") renderAuditPacket(main, route.params);
|
|
123
108
|
else if (route.name === "list") renderList(main, route.type, route.params);
|
|
124
|
-
else if (route.name === "detail") renderDetail(main, route.type, route.id);
|
|
109
|
+
else if (route.name === "detail") renderDetail(main, route.type, route.id, route.params);
|
|
125
110
|
else if (route.name === "organization") renderOrganization(main);
|
|
126
111
|
else if (route.name === "repository") renderRepository(main);
|
|
127
112
|
else renderNotFound(main);
|
|
@@ -141,7 +126,7 @@ function parseRoute() {
|
|
|
141
126
|
if (parts.length === 1 && parts[0] === "obligations") return { name: "obligations", params: new URLSearchParams(query) };
|
|
142
127
|
if (parts.length === 1 && parts[0] === "audit-packet") return { name: "audit-packet", params: new URLSearchParams(query) };
|
|
143
128
|
if (parts.length === 2 && parts[0] === "resources" && parts[1]) return { name: "list", type: parts[1], params: new URLSearchParams(query) };
|
|
144
|
-
if (parts.length === 3 && parts[0] === "resource" && parts[1] && parts[2]) return { name: "detail", type: parts[1], id: parts[2] };
|
|
129
|
+
if (parts.length === 3 && parts[0] === "resource" && parts[1] && parts[2]) return { name: "detail", type: parts[1], id: parts[2], params: new URLSearchParams(query) };
|
|
145
130
|
if (parts.length === 1 && parts[0] === "organization") return { name: "organization" };
|
|
146
131
|
if (parts.length === 1 && parts[0] === "repository") return { name: "repository" };
|
|
147
132
|
return { name: "missing" };
|
|
@@ -149,13 +134,15 @@ function parseRoute() {
|
|
|
149
134
|
|
|
150
135
|
function buildNavigation(route) {
|
|
151
136
|
const currentStage = readinessStageForRoute(route);
|
|
137
|
+
const contextualStageId = route.params?.get("stage");
|
|
152
138
|
const stages = READINESS_STAGES.map((stage) => {
|
|
153
139
|
const stagePageCurrent = (route.name === "stage" && route.stageId === stage.id)
|
|
154
140
|
|| (stage.id === "run" && route.name === "obligations");
|
|
155
141
|
const stageOpen = navigationGroupState[stage.id] ?? currentStage?.id === stage.id;
|
|
156
142
|
const sections = stage.sections.map((section) => {
|
|
157
143
|
const sectionKey = stage.id + ":" + section.id;
|
|
158
|
-
const sectionCurrent = (route.type && section.types.includes(route.type))
|
|
144
|
+
const sectionCurrent = (route.type && section.types.includes(route.type) && (!contextualStageId || contextualStageId === stage.id))
|
|
145
|
+
|| (section.relatedLinks || []).some((link) => route.type === link.type && contextualStageId === stage.id)
|
|
159
146
|
|| (section.utility === "obligation-board" && route.name === "obligations")
|
|
160
147
|
|| (section.utility === "audit-packet" && route.name === "audit-packet");
|
|
161
148
|
const sectionOpen = navigationGroupState[sectionKey] ?? (sectionCurrent || section.defaultOpen);
|
|
@@ -164,7 +151,11 @@ function buildNavigation(route) {
|
|
|
164
151
|
.filter(([, definition]) => definition);
|
|
165
152
|
const direct = stage.sections.length === 1 || sectionDestinations(section).length === 1;
|
|
166
153
|
const links = resources.map(([type, definition]) => {
|
|
167
|
-
|
|
154
|
+
const current = route.type === type && (!contextualStageId || contextualStageId === stage.id);
|
|
155
|
+
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>';
|
|
156
|
+
}).join("") + (section.relatedLinks || []).map((link) => {
|
|
157
|
+
const current = route.type === link.type && contextualStageId === stage.id;
|
|
158
|
+
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>';
|
|
168
159
|
}).join("") + renderSidebarUtility(section.utility, route, direct);
|
|
169
160
|
if (direct) return links;
|
|
170
161
|
return '<section class="nav-group nav-subgroup ' + (sectionOpen ? "open" : "") + '" data-group="' + esc(sectionKey) + '"><button class="nav-subheading-row nav-subgroup-toggle" type="button" aria-label="' + (sectionOpen ? "Collapse " : "Expand ") + esc(section.title) + '" aria-expanded="' + sectionOpen + '" aria-controls="nav-group-' + esc(sectionKey) + '"><span class="nav-subheading">' + esc(section.title) + '</span><svg class="nav-chevron" viewBox="0 0 12 12" aria-hidden="true"><path d="M4 2.5 7.5 6 4 9.5"></path></svg></button><div class="nav-items" id="nav-group-' + esc(sectionKey) + '">' + links + '</div></section>';
|
|
@@ -179,6 +170,12 @@ function buildNavigation(route) {
|
|
|
179
170
|
|
|
180
171
|
function readinessStageForRoute(route) {
|
|
181
172
|
if (route.name === "stage") return READINESS_STAGES.find((stage) => stage.id === route.stageId);
|
|
173
|
+
const contextualStageId = route.params?.get("stage");
|
|
174
|
+
const contextualStage = contextualStageId && READINESS_STAGES.find((stage) => (
|
|
175
|
+
stage.id === contextualStageId
|
|
176
|
+
&& stage.sections.some((section) => (section.relatedLinks || []).some((link) => link.type === route.type))
|
|
177
|
+
));
|
|
178
|
+
if (contextualStage) return contextualStage;
|
|
182
179
|
return READINESS_STAGES.find((stage) => (
|
|
183
180
|
(stage.supportingResourceTypes || []).includes(route.type)
|
|
184
181
|
|| stage.sections.some((section) => section.types.includes(route.type)
|
|
@@ -232,10 +229,19 @@ function repositoryStatusTone(status) {
|
|
|
232
229
|
return "warn";
|
|
233
230
|
}
|
|
234
231
|
|
|
232
|
+
function repositorySyncAlert() {
|
|
233
|
+
if (state.repository?.status === "syncing") {
|
|
234
|
+
return '<div class="repository-sync-alert syncing" role="status" aria-live="polite"><span class="status-dot neutral"></span><span><strong>Saved and committed locally.</strong> Git push is continuing in the background. Other changes unlock when synchronization finishes.</span><a href="#/repository">View status</a></div>';
|
|
235
|
+
}
|
|
236
|
+
const message = state.repository?.backgroundSyncError;
|
|
237
|
+
if (!message) return "";
|
|
238
|
+
return '<div class="repository-sync-alert" role="alert"><span class="status-dot warn"></span><span><strong>Saved locally, but Git sync failed.</strong> ' + esc(message) + '</span><a href="#/repository">Review and retry</a></div>';
|
|
239
|
+
}
|
|
240
|
+
|
|
235
241
|
function renderHome(main) {
|
|
236
242
|
const activeAudit = resourcesOfType("audit").find((item) => !["complete", "closed", "cancelled"].includes(item.record.status));
|
|
237
243
|
const program = state.programReadiness;
|
|
238
|
-
const activeFirm = activeAudit
|
|
244
|
+
const activeFirm = activeAudit?.record.auditorVendorId;
|
|
239
245
|
const setupPending = rendererSettingsEntry()?.record.showOnboarding === true;
|
|
240
246
|
const acceptedEventTriggers = state.obligations.triggers.filter(({ programStatus }) => programStatus !== "proposed");
|
|
241
247
|
const openObligations = state.obligations.items.filter((item) => item.status !== "complete");
|
|
@@ -248,13 +254,13 @@ function renderHome(main) {
|
|
|
248
254
|
: "";
|
|
249
255
|
main.innerHTML = '<div class="page home-page"><section class="hero overview-hero"><div><p class="kicker">Current program state</p><h2>' + esc(titleCase(state.workspace.title)) + '</h2><p>' + esc(state.workspace.description || "Governance, risk, controls, evidence, and audit work maintained as plain files in Git.") + '</p></div></section>' + setupBanner + readinessOverview() +
|
|
250
256
|
'<div class="overview-grid"><section class="panel obligation-panel"><div class="panel-head"><div><p class="kicker">Policy obligations</p><h3>' + obligationHeading + '</h3></div><a href="#/stage/run">Open board</a></div>' + obligationPreview(previewObligations) + '</section>' +
|
|
251
|
-
'<section class="panel event-reminder-panel"><div class="panel-head"><div><p class="kicker">Event reminders</p><h3>Did Something Change?</h3></div><a href="#/stage/run?section=events">' + (acceptedEventTriggers.length ? "Trigger work" : "Review proposals") + '</a></div>' + eventReminderPreview(state.obligations.triggers.slice(0, 4)) + '</section>' +
|
|
257
|
+
'<section class="panel event-reminder-panel"><div class="panel-head"><div><p class="kicker">Event reminders</p><h3>Did Something Change?</h3></div><a href="#/stage/run?section=events">' + (acceptedEventTriggers.length ? "Trigger work" : "Review proposals") + '</a></div>' + eventReminderPreview(orderedPolicyEventTriggers(state.obligations.triggers).slice(0, 4)) + '</section>' +
|
|
252
258
|
auditPanel + '</div></div>';
|
|
253
259
|
main.querySelector("#resume-setup")?.addEventListener("click", requestOnboarding);
|
|
254
260
|
}
|
|
255
261
|
|
|
256
262
|
function initialSetupBanner() {
|
|
257
|
-
const system = resourcesOfType("system").find(({ record }) => record.
|
|
263
|
+
const system = resourcesOfType("system").find(({ record }) => (state.workspace.systemIds || []).includes(record.id) && record.status !== "retired")?.record;
|
|
258
264
|
if (!system) {
|
|
259
265
|
return '<section class="setup-banner"><div><p class="kicker">Setup incomplete</p><h3>Define the initial service boundary</h3><p>Record the management program goal and the systems that should enter policy and control review.</p></div><ol><li>Describe the service boundary.</li><li>Choose the program goal.</li><li><button class="text-button" type="button" id="resume-setup">Resume setup</button></li></ol></section>';
|
|
260
266
|
}
|
|
@@ -268,30 +274,29 @@ function initialSetupBanner() {
|
|
|
268
274
|
? "Choose the program goal."
|
|
269
275
|
: "Confirm the saved program goal: " + goalLabels[goal] + ".";
|
|
270
276
|
const completion = system.status === "planned"
|
|
271
|
-
? "
|
|
272
|
-
: "
|
|
273
|
-
return '<section class="setup-banner"><div><p class="kicker">Setup draft saved</p><h3>Review
|
|
277
|
+
? "Confirm the service scope to activate the planned service and continue to Step 1."
|
|
278
|
+
: "Confirm the service scope to close onboarding and continue to Step 1.";
|
|
279
|
+
return '<section class="setup-banner"><div><p class="kicker">Setup draft saved</p><h3>Review the initial service scope</h3><p>' + esc(system.title) + ' already has a saved service boundary.</p></div><ol><li>Review the saved service boundary.</li><li>' + esc(goalStep) + '</li><li>' + esc(completion) + '</li><li><button class="text-button" type="button" id="resume-setup">Resume setup</button></li></ol></section>';
|
|
274
280
|
}
|
|
275
281
|
|
|
276
282
|
function readinessOverview() {
|
|
277
283
|
const progress = programPathProgress();
|
|
278
284
|
const nextHref = nextProgramStageHref();
|
|
279
|
-
const programStage = (id,
|
|
285
|
+
const programStage = (id, href) => {
|
|
280
286
|
const stage = READINESS_STAGES.find((candidate) => candidate.id === id);
|
|
281
287
|
const current = stageProgress(stage);
|
|
282
288
|
const remaining = current.total - current.complete;
|
|
283
|
-
const status = !remaining ? "
|
|
284
|
-
return [stage.title,
|
|
289
|
+
const status = !remaining ? "Ready" : current.complete ? remaining + " pages need work" : "Needs work";
|
|
290
|
+
return [stage.title, stage.summary, href, status, !remaining ? "good" : current.complete ? "warn" : "neutral"];
|
|
285
291
|
};
|
|
286
292
|
const stages = [
|
|
287
|
-
programStage("scope", "
|
|
288
|
-
programStage("policies", "
|
|
289
|
-
programStage("controls", "
|
|
290
|
-
programStage("
|
|
291
|
-
programStage("
|
|
292
|
-
programStage("audit", "Engage the CPA firm, confirm the formal period, complete fieldwork, and generate the final evidence packet.", "#/stage/audit")
|
|
293
|
+
programStage("scope", "#/stage/scope"),
|
|
294
|
+
programStage("policies", "#/stage/policies"),
|
|
295
|
+
programStage("controls", "#/stage/controls"),
|
|
296
|
+
programStage("run", "#/stage/run"),
|
|
297
|
+
programStage("audit", "#/stage/audit")
|
|
293
298
|
];
|
|
294
|
-
return '<section class="readiness-map"><div class="readiness-map-head"><div><p class="kicker">SOC 2 program path</p><h3>Prepare, Operate, Then Audit</h3></div><div class="readiness-progress-summary"><div><span>
|
|
299
|
+
return '<section class="readiness-map"><div class="readiness-map-head"><div><p class="kicker">SOC 2 program path</p><h3>Prepare, Operate, Then Audit</h3></div><div class="readiness-progress-summary"><div><span>Program readiness</span><strong>' + progress.percent + '%</strong><div class="progress"><span style="width:' + progress.percent + '%"></span></div><small>' + esc(progress.complete + " of " + progress.total + " program pages " + (progress.complete === 1 ? "is" : "are") + " ready") + '</small></div><a class="button primary" href="' + nextHref + '">Continue</a></div></div><div class="readiness-flow">' + stages.map(([title, body, href, status, tone], index) => '<a href="' + href + '"><span>' + (index + 1) + '</span><strong>' + esc(title) + '</strong><small>' + esc(body) + '</small><b class="readiness-state ' + esc(tone) + '">' + esc(status) + '</b></a>').join("") + '</div></section>';
|
|
295
300
|
}
|
|
296
301
|
|
|
297
302
|
function nextProgramStageHref() {
|
|
@@ -309,8 +314,296 @@ function renderStageOverview(main, stageId, params = new URLSearchParams()) {
|
|
|
309
314
|
if (stage.id === "run") return renderObligations(main, params);
|
|
310
315
|
const progress = stageProgress(stage);
|
|
311
316
|
main.innerHTML = '<div class="page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
|
|
312
|
-
'<section class="stage-overview-hero"><div><p class="kicker">Step ' + esc(stage.number) + ' of
|
|
313
|
-
renderStagePageIndex(stage) + '</div>';
|
|
317
|
+
'<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>' +
|
|
318
|
+
renderStagePageIndex(stage) + (stage.id === "controls" ? renderEvidenceReadiness() : "") + '</div>';
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function workflowGuidance(options = {}) {
|
|
322
|
+
const workflow = state.workflow;
|
|
323
|
+
if (!workflow) return "";
|
|
324
|
+
const matches = (item) => {
|
|
325
|
+
if (options.stageId && item.stage !== options.stageId) return false;
|
|
326
|
+
if (options.type && item.subject?.type !== options.type && item.source?.type !== options.type) return false;
|
|
327
|
+
if (options.id && item.subject?.id !== options.id && item.source?.id !== options.id) return false;
|
|
328
|
+
return true;
|
|
329
|
+
};
|
|
330
|
+
const activeStates = new Set(["blocked", "due", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
|
|
331
|
+
const findings = workflow.findings.filter(matches);
|
|
332
|
+
const workItems = workflow.workItems.filter((item) => matches(item) && activeStates.has(item.state));
|
|
333
|
+
const items = [...findings, ...workItems].sort((left, right) => (
|
|
334
|
+
workflowItemStatePriority(left) - workflowItemStatePriority(right)
|
|
335
|
+
|| (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
|
|
336
|
+
|| left.key.localeCompare(right.key)
|
|
337
|
+
));
|
|
338
|
+
if (!items.length) return "";
|
|
339
|
+
const blocking = items.filter((item) => ["blocked", "due", "open", "overdue", "ready"].includes(item.state));
|
|
340
|
+
const status = blocking.length
|
|
341
|
+
? blocking.length + " " + pluralize("item", blocking.length) + (blocking.length === 1 ? " needs work" : " need work")
|
|
342
|
+
: items.length + " scheduled or external";
|
|
343
|
+
const visible = items.slice(0, 6);
|
|
344
|
+
const rows = visible.map((item) => {
|
|
345
|
+
const href = workflowItemHref(item);
|
|
346
|
+
const body = '<span class="workflow-finding-status ' + esc(item.state) + '">' + esc(properCase(item.state)) + '</span><span><strong>' + esc(item.title) + '</strong><small>' + esc(item.message || workflowItemDetail(item)) + '</small></span>';
|
|
347
|
+
return href ? '<a href="' + href + '">' + body + '</a>' : '<div>' + body + '</div>';
|
|
348
|
+
}).join("");
|
|
349
|
+
return '<section class="workflow-guidance panel"><div class="panel-head"><div><p class="kicker">To-do</p><h3>' + esc(options.title || "Checklist") + '</h3><p>' + esc(status) + '</p></div><span class="badge ' + (blocking.length ? "warn" : "good") + '">' + (blocking.length ? "Needs work" : "Current") + '</span></div><div class="workflow-findings">' + rows + '</div>' + (items.length > visible.length ? '<p class="workflow-guidance-more">Showing ' + visible.length + ' of ' + items.length + ' items. Use <code>filegrc workflow --json</code> for the complete reproducible result.</p>' : "") + '</section>';
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function collectionReviewPanel(type) {
|
|
353
|
+
const assessment = state.collectionReviews?.[type];
|
|
354
|
+
if (!assessment) return "";
|
|
355
|
+
const configuration = assessment.configuration;
|
|
356
|
+
const current = assessment.status === "current";
|
|
357
|
+
const reviewerNames = (assessment.review?.reviewedByIds || [])
|
|
358
|
+
.map((id) => state.resources.find(({ record }) => record.id === id)?.record.title || id);
|
|
359
|
+
const reviewSummary = current
|
|
360
|
+
? '<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>'
|
|
361
|
+
: '<p class="collection-review-result"><strong>' + (assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(assessment.message) + '</span></p>';
|
|
362
|
+
return '<section class="collection-review-panel panel ' + (current ? "current" : "required") + '"><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") + '">' + (current ? "Reviewed" : assessment.status === "stale" ? "Stale" : "Review required") + '</span></div>' +
|
|
363
|
+
'<details ' + (current ? "" : "open") + '><summary>What to review</summary><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></details>' +
|
|
364
|
+
'<div class="collection-review-foot">' + reviewSummary + (!state.readOnly ? '<button class="button ' + (current ? "" : "primary") + '" type="button" data-review-collection="' + esc(type) + '">' + (current ? "Review again" : "Review and confirm") + '</button>' : "") + '</div></section>';
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function resourceReviewCriteria(type) {
|
|
368
|
+
const reviewPoints = state.model.resources[type]?.guidance?.reviewPoints || [];
|
|
369
|
+
if (!reviewPoints.length) return "";
|
|
370
|
+
return '<section class="resource-review-criteria"><strong>What the reviewer should check</strong><ul>' + reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section>';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function recordWorkflowItems(type, id) {
|
|
374
|
+
const activeStates = new Set(["blocked", "due", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
|
|
375
|
+
return [
|
|
376
|
+
...(state.workflow?.findings || []),
|
|
377
|
+
...(state.workflow?.workItems || [])
|
|
378
|
+
].filter((item) => (
|
|
379
|
+
activeStates.has(item.state)
|
|
380
|
+
&& (
|
|
381
|
+
item.subject?.type === type && item.subject?.id === id
|
|
382
|
+
|| item.source?.type === type && item.source?.id === id
|
|
383
|
+
)
|
|
384
|
+
)).sort((left, right) => (
|
|
385
|
+
workflowItemStatePriority(left) - workflowItemStatePriority(right)
|
|
386
|
+
|| (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
|
|
387
|
+
|| left.key.localeCompare(right.key)
|
|
388
|
+
));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function recordWorkflowCell(type, entry) {
|
|
392
|
+
const items = recordWorkflowItems(type, entry.record.id);
|
|
393
|
+
if (!items.length) return '<span class="record-workflow-clear">No calculated action</span>';
|
|
394
|
+
const item = items[0];
|
|
395
|
+
const href = workflowItemHref(item) || "#/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(entry.record.id);
|
|
396
|
+
return '<a class="record-workflow-action" href="' + href + '"><span class="workflow-finding-status ' + esc(item.state) + '">' + esc(properCase(item.state)) + '</span><span><strong>' + esc(item.title) + '</strong><small>' + esc(item.message || workflowItemDetail(item)) + (items.length > 1 ? " +" + (items.length - 1) + " more" : "") + '</small></span></a>';
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function openCollectionReviewDialog(type) {
|
|
400
|
+
const assessment = state.collectionReviews?.[type];
|
|
401
|
+
if (!assessment) return;
|
|
402
|
+
const configuration = assessment.configuration;
|
|
403
|
+
const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
|
|
404
|
+
const systems = resourcesOfType("system").filter(({ record }) => record.status === "active");
|
|
405
|
+
const allowedDecisions = configuration.decisions || ["complete"];
|
|
406
|
+
const defaultDecision = assessment.review?.decision
|
|
407
|
+
|| (!assessment.recordCount && allowedDecisions.includes("zero-population") ? "zero-population" : allowedDecisions[0]);
|
|
408
|
+
const decisions = allowedDecisions.map((decision) => (
|
|
409
|
+
'<option value="' + esc(decision) + '" ' + (defaultDecision === decision ? "selected" : "") + '>' + esc(properCase(decision)) + '</option>'
|
|
410
|
+
)).join("");
|
|
411
|
+
const dialog = document.createElement("dialog");
|
|
412
|
+
dialog.className = "commit-dialog event-dialog collection-review-dialog";
|
|
413
|
+
dialog.setAttribute("aria-labelledby", "collection-review-dialog-title");
|
|
414
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Scope confirmation</p><h2 id="collection-review-dialog-title">Confirm ' + esc(configuration.title.toLowerCase()) + '</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>Before confirming</strong><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section><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(assessment.review?.reviewedOn || currentDate()) + '"></label><label data-authoritative-system><span>Authoritative System</span><select name="authoritativeSystemId"><option value="">Select</option>' + systems.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (assessment.review?.authoritativeSystemId === 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" role="status"></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary" data-preview-collection-review>Preview confirmation</button></div></form>';
|
|
415
|
+
document.body.append(dialog);
|
|
416
|
+
dialog.showModal();
|
|
417
|
+
const form = dialog.querySelector("form");
|
|
418
|
+
const systemField = dialog.querySelector("[data-authoritative-system]");
|
|
419
|
+
const syncDecision = () => {
|
|
420
|
+
const external = form.elements.decision.value === "externally-managed";
|
|
421
|
+
systemField.hidden = !external;
|
|
422
|
+
form.elements.authoritativeSystemId.required = external;
|
|
423
|
+
};
|
|
424
|
+
syncDecision();
|
|
425
|
+
dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
|
|
426
|
+
dialog.querySelector('[data-event="cancel"]').addEventListener("click", () => dialog.close());
|
|
427
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
428
|
+
let previewedPayload = null;
|
|
429
|
+
form.addEventListener("input", () => {
|
|
430
|
+
previewedPayload = null;
|
|
431
|
+
syncDecision();
|
|
432
|
+
dialog.querySelector(".workflow-preview").innerHTML = "";
|
|
433
|
+
dialog.querySelector("[data-preview-collection-review]").textContent = "Preview confirmation";
|
|
434
|
+
});
|
|
435
|
+
form.addEventListener("submit", async (event) => {
|
|
436
|
+
event.preventDefault();
|
|
437
|
+
if (!form.reportValidity()) return;
|
|
438
|
+
const error = dialog.querySelector(".dialog-error");
|
|
439
|
+
error.textContent = "";
|
|
440
|
+
const payload = {
|
|
441
|
+
resourceType: type,
|
|
442
|
+
decision: form.elements.decision.value,
|
|
443
|
+
rationale: form.elements.rationale.value.trim(),
|
|
444
|
+
reviewedByIds: [form.elements.reviewerId.value],
|
|
445
|
+
reviewedOn: form.elements.reviewedOn.value,
|
|
446
|
+
authoritativeSystemId: form.elements.authoritativeSystemId.value || undefined,
|
|
447
|
+
expectedRevision: assessment.reviewRevision || undefined
|
|
448
|
+
};
|
|
449
|
+
form.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = true; });
|
|
450
|
+
try {
|
|
451
|
+
if (!previewedPayload) {
|
|
452
|
+
const response = await localFetch("/api/collection-review/preview", {
|
|
453
|
+
method: "POST",
|
|
454
|
+
headers: { "content-type": "application/json" },
|
|
455
|
+
body: JSON.stringify(payload)
|
|
456
|
+
});
|
|
457
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
458
|
+
const preview = await response.json();
|
|
459
|
+
previewedPayload = payload;
|
|
460
|
+
dialog.querySelector(".workflow-preview").innerHTML = '<strong>Review before saving</strong><p>Save this confirmation for ' + preview.assessment.recordCount + ' current ' + esc(pluralize("record", preview.assessment.recordCount)) + '. If the collection or material scope changes, FileGRC will ask for another review.</p>';
|
|
461
|
+
dialog.querySelector("[data-preview-collection-review]").textContent = "Confirm and save";
|
|
462
|
+
} else {
|
|
463
|
+
const response = await localFetch("/api/collection-review", {
|
|
464
|
+
method: "POST",
|
|
465
|
+
headers: { "content-type": "application/json" },
|
|
466
|
+
body: JSON.stringify({ ...previewedPayload, confirmed: true })
|
|
467
|
+
});
|
|
468
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
469
|
+
applyMutationState(await response.json());
|
|
470
|
+
dialog.close();
|
|
471
|
+
render();
|
|
472
|
+
}
|
|
473
|
+
} catch (requestError) {
|
|
474
|
+
error.textContent = requestError.message;
|
|
475
|
+
} finally {
|
|
476
|
+
form.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = false; });
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function workflowItemHref(item) {
|
|
482
|
+
const source = item.source?.id && item.source?.type && item.source.type !== "unknown"
|
|
483
|
+
? item.source
|
|
484
|
+
: null;
|
|
485
|
+
if (source && ["assigned-work", "obligation-occurrence"].includes(item.kind)) {
|
|
486
|
+
return "#/stage/run?work=" + encodeURIComponent(source.type + ":" + source.id);
|
|
487
|
+
}
|
|
488
|
+
const commands = [
|
|
489
|
+
...(item.actions || []).map((action) => action.command),
|
|
490
|
+
item.nextAction?.command
|
|
491
|
+
].filter(Boolean);
|
|
492
|
+
const applicabilityCommand = commands.find((command) => command.includes(" review-applicability "));
|
|
493
|
+
const applicabilityType = applicabilityCommand?.match(/--type\s+([a-z0-9-]+)/)?.[1];
|
|
494
|
+
if (applicabilityType && state.model.resources[applicabilityType]) {
|
|
495
|
+
return "#/resources/" + encodeURIComponent(applicabilityType) + "?review=1";
|
|
496
|
+
}
|
|
497
|
+
const collectionReviewCommand = commands.find((command) => command.includes(" review-collection "));
|
|
498
|
+
const collectionReviewType = collectionReviewCommand?.match(/review-collection\s+([a-z0-9-]+)/)?.[1];
|
|
499
|
+
if (collectionReviewType && state.model.collectionReviews?.[collectionReviewType]) {
|
|
500
|
+
return "#/resources/" + encodeURIComponent(collectionReviewType) + "?review-collection=1";
|
|
501
|
+
}
|
|
502
|
+
if (commands.some((command) => command.includes(" external-reviewer-setup"))) {
|
|
503
|
+
const reviewer = resourcesOfType("appointment")
|
|
504
|
+
.find(({ record }) => record.appointmentKind === "independent-policy-reviewer");
|
|
505
|
+
if (reviewer) return "#/resource/appointment/" + encodeURIComponent(reviewer.record.id);
|
|
506
|
+
}
|
|
507
|
+
if (commands.some((command) => command.includes(" evidence-map"))) return "#/stage/controls";
|
|
508
|
+
const reference = item.subject?.id && item.subject?.type
|
|
509
|
+
? item.subject
|
|
510
|
+
: source;
|
|
511
|
+
if (reference && reference.type !== "unknown") {
|
|
512
|
+
return "#/resource/" + encodeURIComponent(reference.type) + "/" + encodeURIComponent(reference.id);
|
|
513
|
+
}
|
|
514
|
+
const missingType = item.subject?.type && state.model.resources[item.subject.type]
|
|
515
|
+
? item.subject.type
|
|
516
|
+
: null;
|
|
517
|
+
if (missingType) {
|
|
518
|
+
return "#/resources/" + encodeURIComponent(missingType) + (state.readOnly ? "" : "?new=1");
|
|
519
|
+
}
|
|
520
|
+
if (item.auditId) return "#/audit-packet?auditId=" + encodeURIComponent(item.auditId);
|
|
521
|
+
const stage = {
|
|
522
|
+
scope: "scope",
|
|
523
|
+
policies: "policies",
|
|
524
|
+
controls: "controls",
|
|
525
|
+
operate: "run",
|
|
526
|
+
operation: "run",
|
|
527
|
+
audit: "audit",
|
|
528
|
+
setup: "audit",
|
|
529
|
+
fieldwork: "audit",
|
|
530
|
+
deliver: "audit",
|
|
531
|
+
auditor: "audit"
|
|
532
|
+
}[item.stage];
|
|
533
|
+
return stage ? "#/stage/" + stage : "";
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function workflowItemDetail(item) {
|
|
537
|
+
if (item.dueOn) return "Due " + item.dueOn;
|
|
538
|
+
if (item.availableOn) return "Available " + item.availableOn;
|
|
539
|
+
return item.nextAction?.command || "Review the related source facts.";
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function workflowItemPriority(item) {
|
|
543
|
+
if (item.state === "overdue") return 0;
|
|
544
|
+
if (["due", "open", "ready"].includes(item.state)) return 20;
|
|
545
|
+
if (item.state === "blocked") return 30;
|
|
546
|
+
if (item.severity === "error") return 10;
|
|
547
|
+
if (item.state === "waiting-external") return 40;
|
|
548
|
+
return 50;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function workflowItemStatePriority(item) {
|
|
552
|
+
return {
|
|
553
|
+
overdue: 0,
|
|
554
|
+
due: 10,
|
|
555
|
+
open: 20,
|
|
556
|
+
ready: 30,
|
|
557
|
+
blocked: 40,
|
|
558
|
+
upcoming: 50,
|
|
559
|
+
scheduled: 60,
|
|
560
|
+
"waiting-external": 70
|
|
561
|
+
}[item.state] ?? 80;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function renderEvidenceReadiness() {
|
|
565
|
+
const items = state.programReadiness?.stages
|
|
566
|
+
?.find((stage) => stage.id === "controls")
|
|
567
|
+
?.items.filter((item) => item.id.startsWith("source-family-")) || [];
|
|
568
|
+
const completeCount = items.filter((item) => item.status === "complete").length;
|
|
569
|
+
const cards = items.map((item) => {
|
|
570
|
+
const sources = (item.sourceSystemIds || []).map((id) => {
|
|
571
|
+
const source = state.resources.find(({ record }) => record.type === "system" && record.id === id)?.record;
|
|
572
|
+
const sourceCheck = (item.sourceSystemChecks || []).find(({ sourceSystemId }) => sourceSystemId === id);
|
|
573
|
+
const complete = sourceCheck?.complete ?? (item.completeSourceSystemIds || []).includes(id);
|
|
574
|
+
const status = complete
|
|
575
|
+
? "Ready"
|
|
576
|
+
: Object.entries(sourceCheck?.checks || {})
|
|
577
|
+
.filter(([, passed]) => !passed)
|
|
578
|
+
.map(([name]) => evidenceSourceCheckLabel(name))
|
|
579
|
+
.join(", ") || "Needs details";
|
|
580
|
+
return source
|
|
581
|
+
? '<a class="evidence-map-source ' + (complete ? "complete" : "incomplete") + '" href="#/resource/system/' + encodeURIComponent(id) + '">' + esc(source.title) + '<small>' + esc(status) + '</small></a>'
|
|
582
|
+
: "";
|
|
583
|
+
}).join("");
|
|
584
|
+
const sourceAction = sources
|
|
585
|
+
? sources
|
|
586
|
+
: '<a class="button" href="#/resources/system?new=1">Add source System</a>';
|
|
587
|
+
const method = item.operationRecordTypes?.length
|
|
588
|
+
? "FileGRC records: " + item.operationRecordTypes.map(properCase).join(", ")
|
|
589
|
+
: properCase(item.evidenceForm || "External evidence");
|
|
590
|
+
return '<article class="evidence-map-card ' + esc(item.status) + '"><div class="evidence-map-card-head"><div><span class="badge ' + (item.status === "complete" ? "good" : "warn") + '">' + (item.status === "complete" ? "Mapped" : "Needs mapping") + '</span><h3>' + esc(item.title) + '</h3></div><small>' + esc(method) + '</small></div><p>' + esc(item.description || item.message) + '</p>' +
|
|
591
|
+
(item.sourceKinds?.length ? '<div class="evidence-map-expectation"><strong>Source role</strong><span>' + item.sourceKinds.map((kind) => '<code>' + esc(kind) + '</code>').join(" or ") + '</span></div>' : "") +
|
|
592
|
+
(item.evidencePrompt ? '<div class="evidence-map-expectation"><strong>Expected evidence</strong><span>' + esc(item.evidencePrompt) + '</span></div>' : "") +
|
|
593
|
+
(item.timing ? '<div class="evidence-map-expectation"><strong>When</strong><span>' + esc(item.timing) + '</span></div>' : "") +
|
|
594
|
+
'<div class="evidence-map-links"><div><small>Controls</small><div class="evidence-map-references">' + (item.controlIds || []).map((id) => formatReference(id)).join("") + '</div></div><div><small>Authoritative sources</small><div class="evidence-map-sources">' + sourceAction + '</div></div></div><p class="evidence-map-status">' + esc(item.message) + '</p></article>';
|
|
595
|
+
}).join("");
|
|
596
|
+
const empty = '<section class="evidence-map-empty"><p class="kicker">Evidence readiness</p><h3>Select the program controls first</h3><p>Control implementation checks are generated from the selected Controls and their authoritative evidence sources.</p><a class="button primary" href="#/resources/control">Review Controls</a></section>';
|
|
597
|
+
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 Systems that produce its evidence. The cards below show what each source still needs.</p></div><div class="evidence-map-actions"><a class="button" href="#/resources/system">Review Systems</a><a class="button primary" href="#/resources/control">Review Controls</a></div></div>' + (cards || empty) + '</section>';
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function evidenceSourceCheckLabel(name) {
|
|
601
|
+
return ({
|
|
602
|
+
active: "activate source",
|
|
603
|
+
sourceRole: "add source role",
|
|
604
|
+
accessOwners: "add access owner",
|
|
605
|
+
retrievalInstructions: "add retrieval instructions"
|
|
606
|
+
})[name] || humanize(name);
|
|
314
607
|
}
|
|
315
608
|
|
|
316
609
|
function renderStagePageIndex(stage) {
|
|
@@ -325,15 +618,6 @@ function stagePageDestinations(stage) {
|
|
|
325
618
|
.map((destination) => ({ ...destination, section })));
|
|
326
619
|
}
|
|
327
620
|
|
|
328
|
-
function stagePageId(stage, destination) {
|
|
329
|
-
return stage.id + ":" + (destination.type || "utility:" + destination.utility);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function stagePageComplete(pageId) {
|
|
333
|
-
const completedPageIds = rendererSettingsEntry()?.record.completedStagePageIds || [];
|
|
334
|
-
return [pageId, ...(STAGE_PAGE_ID_ALIASES[pageId] || [])].some((id) => completedPageIds.includes(id));
|
|
335
|
-
}
|
|
336
|
-
|
|
337
621
|
function stagePageSummary(destination) {
|
|
338
622
|
const summaryKey = destination.type || "utility:" + destination.utility;
|
|
339
623
|
const section = destination.section || (destination.type
|
|
@@ -343,35 +627,129 @@ function stagePageSummary(destination) {
|
|
|
343
627
|
}
|
|
344
628
|
|
|
345
629
|
function stagePageCard(stage, destination, index) {
|
|
346
|
-
const details = destination.type ? resourceRollup(destination.type) : utilityRollup(destination.utility);
|
|
347
630
|
const summary = stagePageSummary(destination);
|
|
348
631
|
const stepLabel = "Step " + stage.number + "." + String.fromCharCode(97 + index);
|
|
349
|
-
const
|
|
350
|
-
const complete =
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
632
|
+
const derived = derivedStagePageState(stage, destination);
|
|
633
|
+
const complete = derived.complete;
|
|
634
|
+
const items = stagePageItems(stage, destination);
|
|
635
|
+
const completionState = '<span class="stage-page-completion-state ' + (complete ? "complete" : "") + '">' + esc(derived.label) + '</span>';
|
|
636
|
+
const taskPreview = items.length
|
|
637
|
+
? '<div class="stage-page-tasks">' + items.slice(0, 3).map((item) => {
|
|
638
|
+
const href = workflowItemHref(item) || destination.href;
|
|
639
|
+
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>';
|
|
640
|
+
}).join("") + (items.length > 3 ? '<small class="stage-page-tasks-more">+' + (items.length - 3) + ' more on this page</small>' : "") + '</div>'
|
|
641
|
+
: "";
|
|
642
|
+
return '<article class="stage-page-card ' + (complete ? "complete" : "") + '"><a class="stage-page-card-link" href="' + destination.href + '" aria-label="Open ' + esc(destination.label) + '"></a><div class="stage-page-card-head"><div><small>' + esc(stepLabel) + '</small><h3>' + esc(destination.label) + '</h3></div>' + completionState + '</div><p>' + esc(summary) + '</p>' + taskPreview + '<div class="stage-page-card-foot"><span class="stage-page-open" aria-hidden="true">Open ›</span></div></article>';
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function stagePageItemDetail(item) {
|
|
646
|
+
const controlChecks = item.subject?.type === "control"
|
|
647
|
+
? String(item.message || "").match(/^Complete (\d+) checks before implementation:/)
|
|
648
|
+
: null;
|
|
649
|
+
if (controlChecks) return controlChecks[1] + " implementation checks remain. Open the Control to review them.";
|
|
650
|
+
return item.message || workflowItemDetail(item);
|
|
355
651
|
}
|
|
356
652
|
|
|
357
653
|
function stageProgress(stage) {
|
|
358
654
|
if (stage.id === "run") return operationProgress();
|
|
359
655
|
const pages = stagePageDestinations(stage);
|
|
360
|
-
|
|
656
|
+
if (stage.id === "audit" && state.workflow?.assessments?.auditReadiness?.status === "not-started") {
|
|
657
|
+
return {
|
|
658
|
+
percent: 0,
|
|
659
|
+
complete: 0,
|
|
660
|
+
total: pages.length,
|
|
661
|
+
status: "Not started",
|
|
662
|
+
tone: "neutral",
|
|
663
|
+
detail: "Create an Audit only after a real engagement or customer deadline exists."
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
const complete = pages.filter((destination) => derivedStagePageState(stage, destination).complete).length;
|
|
361
667
|
return progressFromCounts(complete, pages.length, "page");
|
|
362
668
|
}
|
|
363
669
|
|
|
670
|
+
function derivedStagePageState(stage, destination) {
|
|
671
|
+
if (
|
|
672
|
+
stage.id === "audit"
|
|
673
|
+
&& state.workflow?.assessments?.auditReadiness?.status === "not-started"
|
|
674
|
+
&& (destination.type === "audit" || destination.utility === "audit-packet")
|
|
675
|
+
) {
|
|
676
|
+
return { complete: false, label: "No engagement" };
|
|
677
|
+
}
|
|
678
|
+
const blocking = stagePageItems(stage, destination);
|
|
679
|
+
if (blocking.length) {
|
|
680
|
+
return { complete: false, label: blocking.length + " " + pluralize("item", blocking.length) + (blocking.length === 1 ? " needs work" : " need work") };
|
|
681
|
+
}
|
|
682
|
+
if (destination.type && resourcesOfType(destination.type).length === 0) {
|
|
683
|
+
const collectionReview = state.collectionReviews?.[destination.type];
|
|
684
|
+
if (collectionReview) {
|
|
685
|
+
return collectionReview.status === "current"
|
|
686
|
+
? { complete: true, label: "Reviewed" }
|
|
687
|
+
: { complete: false, label: "Review scope" };
|
|
688
|
+
}
|
|
689
|
+
return { complete: true, label: "Conditional" };
|
|
690
|
+
}
|
|
691
|
+
return { complete: true, label: "Ready" };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function stagePageItems(stage, destination) {
|
|
695
|
+
const activeStates = new Set(["blocked", "due", "open", "overdue", "ready"]);
|
|
696
|
+
const items = [
|
|
697
|
+
...(state.workflow?.findings || []),
|
|
698
|
+
...(state.workflow?.workItems || [])
|
|
699
|
+
].filter((item) => (
|
|
700
|
+
activeStates.has(item.state)
|
|
701
|
+
&& (
|
|
702
|
+
item.stage === stage.id
|
|
703
|
+
|| stage.id === "scope"
|
|
704
|
+
&& destination.type === "appointment"
|
|
705
|
+
&& item.code === "governance.appointment.independent-policy-reviewer"
|
|
706
|
+
)
|
|
707
|
+
));
|
|
708
|
+
return items.filter((item) => {
|
|
709
|
+
if (
|
|
710
|
+
destination.type === "requirement"
|
|
711
|
+
&& item.subject?.type === "requirement"
|
|
712
|
+
&& item.fieldPath === "applicabilityReview"
|
|
713
|
+
) return false;
|
|
714
|
+
if (
|
|
715
|
+
stage.id === "controls"
|
|
716
|
+
&& ["source-coverage", "system"].includes(item.subject?.type)
|
|
717
|
+
&& item.key?.startsWith("evidence-source.")
|
|
718
|
+
) return false;
|
|
719
|
+
if (
|
|
720
|
+
stage.id === "policies"
|
|
721
|
+
&& destination.type === "policy"
|
|
722
|
+
&& item.code === "governance.appointment.independent-policy-reviewer"
|
|
723
|
+
) return true;
|
|
724
|
+
if (
|
|
725
|
+
destination.type
|
|
726
|
+
&& (item.subject?.type === destination.type || item.source?.type === destination.type)
|
|
727
|
+
) return true;
|
|
728
|
+
const href = workflowItemHref(item);
|
|
729
|
+
if (!href) return false;
|
|
730
|
+
const destinationHref = destination.href.split("?")[0];
|
|
731
|
+
return href === destinationHref
|
|
732
|
+
|| href.startsWith(destinationHref + "?")
|
|
733
|
+
|| Boolean(destination.type && href.startsWith("#/resource/" + destination.type + "/"));
|
|
734
|
+
}).sort((left, right) => (
|
|
735
|
+
workflowItemStatePriority(left) - workflowItemStatePriority(right)
|
|
736
|
+
|| (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
|
|
737
|
+
|| left.key.localeCompare(right.key)
|
|
738
|
+
));
|
|
739
|
+
}
|
|
740
|
+
|
|
364
741
|
function operationProgress() {
|
|
365
742
|
const program = state.programReadiness;
|
|
366
743
|
const goal = program?.target?.goal || state.workspace.assuranceGoal || "none";
|
|
367
744
|
const asOf = program?.asOf || currentDate();
|
|
368
745
|
const candidateStarted = goal === "soc-2-type-2"
|
|
369
|
-
? Boolean(program?.target?.
|
|
746
|
+
? Boolean(program?.target?.candidateCoverage?.kind === "range" && program.target.candidateCoverage.startsOn <= asOf)
|
|
370
747
|
: goal === "soc-2-type-1"
|
|
371
|
-
? Boolean(program?.target?.
|
|
748
|
+
? Boolean(program?.target?.candidateCoverage?.kind === "as-of")
|
|
372
749
|
: Boolean(program?.evidenceReady);
|
|
373
750
|
const overdue = state.obligations.counts.overdue || 0;
|
|
374
|
-
const
|
|
751
|
+
const blocked = state.obligations.counts.blocked || 0;
|
|
752
|
+
const complete = Boolean(program?.evidenceReady && candidateStarted && overdue === 0 && blocked === 0);
|
|
375
753
|
if (complete) {
|
|
376
754
|
return {
|
|
377
755
|
percent: 100,
|
|
@@ -379,7 +757,7 @@ function operationProgress() {
|
|
|
379
757
|
total: 1,
|
|
380
758
|
status: "Operating",
|
|
381
759
|
tone: "good",
|
|
382
|
-
detail: "Evidence collection is running and the Work Queue has no overdue work."
|
|
760
|
+
detail: "Evidence collection is running and the Work Queue has no overdue or blocked work."
|
|
383
761
|
};
|
|
384
762
|
}
|
|
385
763
|
if (overdue) {
|
|
@@ -392,6 +770,16 @@ function operationProgress() {
|
|
|
392
770
|
detail: overdue + " overdue Work Queue " + pluralize("item", overdue) + " must be resolved."
|
|
393
771
|
};
|
|
394
772
|
}
|
|
773
|
+
if (blocked) {
|
|
774
|
+
return {
|
|
775
|
+
percent: 0,
|
|
776
|
+
complete: 0,
|
|
777
|
+
total: 1,
|
|
778
|
+
status: "Blocked",
|
|
779
|
+
tone: "bad",
|
|
780
|
+
detail: blocked + " blocked Work Queue " + pluralize("item", blocked) + " must be resolved."
|
|
781
|
+
};
|
|
782
|
+
}
|
|
395
783
|
if (program?.evidenceReady && goal === "soc-2-type-2" && !candidateStarted) {
|
|
396
784
|
return {
|
|
397
785
|
percent: 0,
|
|
@@ -424,10 +812,10 @@ function programPathProgress() {
|
|
|
424
812
|
function progressFromCounts(complete, total, noun) {
|
|
425
813
|
if (!total) return { percent: 0, complete: 0, total: 0, status: "Nothing to review", tone: "neutral", detail: "No " + pluralize(noun, 2) + " are configured yet." };
|
|
426
814
|
const percent = Math.round((complete / total) * 100);
|
|
427
|
-
const detail = complete + " of " + total + " " + pluralize(noun, total) +
|
|
428
|
-
if (complete === total) return { percent: 100, complete, total, status: "
|
|
429
|
-
if (!complete) return { percent: 0, complete, total, status: "
|
|
430
|
-
return { percent, complete, total, status: "
|
|
815
|
+
const detail = complete + " of " + total + " " + pluralize(noun, total) + (complete === 1 ? " is" : " are") + " ready.";
|
|
816
|
+
if (complete === total) return { percent: 100, complete, total, status: "Ready", tone: "good", detail };
|
|
817
|
+
if (!complete) return { percent: 0, complete, total, status: "Needs work", tone: "warn", detail };
|
|
818
|
+
return { percent, complete, total, status: "In progress", tone: "warn", detail };
|
|
431
819
|
}
|
|
432
820
|
|
|
433
821
|
function stageProgressCard(progress) {
|
|
@@ -446,72 +834,74 @@ function sectionDestinations(section) {
|
|
|
446
834
|
return destinations;
|
|
447
835
|
}
|
|
448
836
|
|
|
449
|
-
function resourceRollup(type) {
|
|
450
|
-
const records = resourcesOfType(type).map(({ record }) => record);
|
|
451
|
-
if (!records.length) return { value: "0", label: "No records yet" };
|
|
452
|
-
const statuses = new Map();
|
|
453
|
-
records.forEach((record) => {
|
|
454
|
-
if (record.status) statuses.set(record.status, (statuses.get(record.status) || 0) + 1);
|
|
455
|
-
});
|
|
456
|
-
const statusText = [...statuses.entries()].sort((a, b) => b[1] - a[1]).slice(0, 2).map(([status, count]) => count + " " + humanize(status).toLowerCase()).join(" · ");
|
|
457
|
-
return { value: String(records.length), label: statusText || pluralize("record", records.length) };
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
function utilityRollup(utility) {
|
|
461
|
-
if (utility === "obligation-board") {
|
|
462
|
-
const open = state.obligations.items.filter((item) => item.status !== "complete");
|
|
463
|
-
return { value: String(open.length), label: open.length ? "Open work items" : "No work due" };
|
|
464
|
-
}
|
|
465
|
-
if (utility === "audit-packet") {
|
|
466
|
-
const audits = resourcesOfType("audit");
|
|
467
|
-
return { value: String(audits.length), label: audits.length ? pluralize("engagement", audits.length) : "No engagement yet" };
|
|
468
|
-
}
|
|
469
|
-
return { value: "0", label: "Not started" };
|
|
470
|
-
}
|
|
471
|
-
|
|
472
837
|
function auditEngagementPrompt(audit = null) {
|
|
473
|
-
const hasAuditor = audit?.auditorVendorId
|
|
838
|
+
const hasAuditor = audit?.auditorVendorId;
|
|
474
839
|
if (hasAuditor) return "";
|
|
475
840
|
const heading = audit ? "CPA Firm Not Recorded" : "Optional: Engage a CPA Firm Early";
|
|
476
841
|
return '<div class="audit-engagement"><div><strong>' + heading + '</strong><p>The program can keep operating while management selects a firm. Engage early when a customer deadline, unusual scope, or other timing risk needs CPA input.</p></div><ul><li>Share the program boundary, goal, and evidence-source plan.</li><li>Keep management candidate dates separate from the firm-agreed report period.</li><li>Create or update the audit record only for a real engagement.</li></ul>' + (!audit ? '<a class="button" href="#/resources/audit?new=1">Create engagement</a>' : "") + '</div>';
|
|
477
842
|
}
|
|
478
843
|
|
|
844
|
+
function renderExternalEvidenceSection() {
|
|
845
|
+
const records = resourcesOfType("evidence").map(({ record }) => record);
|
|
846
|
+
const recent = records.slice(0, 6).map((record) => (
|
|
847
|
+
'<a href="#/resource/evidence/' + encodeURIComponent(record.id) + '"><span><strong>' + esc(record.title) + '</strong><small>' +
|
|
848
|
+
esc(properCase(record.artifactKind || "Evidence")) + (record.collectedOn ? " · " + esc(formatCalendarDate(record.collectedOn)) : "") +
|
|
849
|
+
'</small></span><span class="badge status-' + esc(record.status || "draft") + '">' + esc(properCase(record.status || "draft")) + '</span></a>'
|
|
850
|
+
)).join("");
|
|
851
|
+
const createButton = state.readOnly
|
|
852
|
+
? ""
|
|
853
|
+
: '<button class="button primary" type="button" data-new-external-evidence>New external evidence</button>';
|
|
854
|
+
return '<section class="workflow-section external-evidence-section"><div class="section-head"><div><p class="kicker">Fixed artifacts and approved references</p><h2>External Evidence</h2><p>Add fixed artifacts only when they exist. Link each one to its source System and the work it supports.</p></div><div class="page-actions">' +
|
|
855
|
+
createButton + '<a class="button" href="#/resources/evidence">View all</a></div></div><div class="external-evidence-list">' +
|
|
856
|
+
(recent || empty("No External Evidence has been collected yet. Create it during operation only when a real artifact or approved external reference exists.")) +
|
|
857
|
+
'</div></section>';
|
|
858
|
+
}
|
|
859
|
+
|
|
479
860
|
function renderObligations(main, params = new URLSearchParams()) {
|
|
480
861
|
const stage = READINESS_STAGES.find((candidate) => candidate.id === "run");
|
|
481
862
|
const plan = state.obligations;
|
|
482
|
-
const controls = resourcesOfType("control");
|
|
483
|
-
const linkedControlIds = new Set(resourcesOfType("obligation").flatMap(({ record }) => record.controlIds || []));
|
|
484
|
-
const scheduledControls = controls.filter(({ record }) => linkedControlIds.has(record.id)).length;
|
|
485
|
-
const assignedFollowUp = plan.standaloneItems.length;
|
|
486
863
|
const visibleCardLimit = 6;
|
|
487
|
-
const sections = ["proposed", "upcoming", "due", "overdue"].map((status) => {
|
|
488
|
-
const items = plan.items
|
|
864
|
+
const sections = ["proposed", "upcoming", "blocked", "due", "overdue"].map((status) => {
|
|
865
|
+
const items = obligationBoardItems(plan.items, status);
|
|
489
866
|
const cards = items.map((item, index) => obligationCard(item, index >= visibleCardLimit)).join("");
|
|
490
867
|
const more = items.length > visibleCardLimit
|
|
491
868
|
? '<button class="button obligation-more" type="button" data-expand-obligations="' + status + '" data-total="' + items.length + '" aria-expanded="false">Show ' + (items.length - visibleCardLimit) + ' more</button>'
|
|
492
869
|
: "";
|
|
493
870
|
return '<section class="obligation-column" data-obligation-column="' + status + '"><div class="obligation-column-head"><span class="badge status-' + status + '">' + esc(properCase(status)) + '</span><strong>' + items.length + '</strong></div><div class="obligation-cards">' + (items.length ? cards : empty("Nothing " + status + ".")) + '</div>' + more + '</section>';
|
|
494
871
|
}).join("");
|
|
495
|
-
const
|
|
872
|
+
const eventTriggerLimit = 6;
|
|
873
|
+
const orderedTriggers = orderedPolicyEventTriggers(plan.triggers);
|
|
874
|
+
const triggers = orderedTriggers.map((trigger, index) => policyEventTrigger(trigger, index, index >= eventTriggerLimit)).join("");
|
|
875
|
+
const eventMore = orderedTriggers.length > eventTriggerLimit
|
|
876
|
+
? '<button class="button policy-event-more" type="button" data-expand-policy-events aria-expanded="false">Show ' + (orderedTriggers.length - eventTriggerLimit) + ' more events</button>'
|
|
877
|
+
: "";
|
|
496
878
|
const feedback = policyEventFeedback
|
|
497
879
|
? '<section class="policy-event-feedback" role="status" aria-live="polite"><span class="status-dot good"></span><div><strong>Work added to the Work Queue</strong><p>' + esc(policyEventFeedback.name + " created " + policyEventFeedback.taskCount + " " + pluralize("task", policyEventFeedback.taskCount) + ".") + '</p></div><button class="button" type="button" data-view-added-work>View Work Queue</button><button class="icon-button" type="button" data-dismiss-policy-event-feedback aria-label="Dismiss confirmation">×</button></section>'
|
|
498
880
|
: "";
|
|
499
881
|
main.innerHTML = '<div class="page obligation-board-page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
|
|
500
|
-
'<section class="stage-overview-hero"><div><p class="kicker">Step ' + esc(stage.number) + ' of
|
|
882
|
+
'<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(stageProgress(stage)) + '</section>' +
|
|
501
883
|
feedback +
|
|
502
|
-
'<section class="workflow-section event-reminders"><div class="section-head"><div><p class="kicker">Changes that create work</p><h2>Policy Events</h2><p>Trigger the matching workflow when an event occurs. filegrc adds every required action to the Work Queue with its owner and deadline.</p></div></div><div class="policy-event-list">' + (triggers || empty("No event-driven obligations are configured.")) + '</div
|
|
503
|
-
'<section class="workflow-section work-queue-section"><div class="section-head"><div><p class="kicker">Recurring, event, and assigned work</p><h2>Work Queue</h2><p>
|
|
884
|
+
'<section class="workflow-section event-reminders"><div class="section-head"><div><p class="kicker">Changes that create work</p><h2>Policy Events</h2><p>Trigger the matching workflow when an event occurs. filegrc adds every required action to the Work Queue with its owner and deadline.</p></div></div><div class="policy-event-list">' + (triggers || empty("No event-driven obligations are configured.")) + '</div>' + eventMore + '</section>' +
|
|
885
|
+
'<section class="workflow-section work-queue-section"><div class="section-head"><div><p class="kicker">Recurring, event, and assigned work</p><h2>Work Queue</h2><p>Complete scheduled work and assigned follow-up here. Each card shows its due window, source, and next action.</p></div><div class="page-actions">' + (!state.readOnly ? '<button class="button" type="button" data-new-action-item>New task</button>' : "") + '<a class="button" href="#/resources/obligation">Edit schedules</a></div></div>' +
|
|
504
886
|
'<div class="obligation-board">' + sections + '</div>' +
|
|
505
|
-
'</section
|
|
887
|
+
'</section>' + renderExternalEvidenceSection() + '</div>';
|
|
506
888
|
main.querySelectorAll("[data-start-event]").forEach((button) => button.addEventListener("click", () => {
|
|
507
889
|
const trigger = plan.triggers.find((item) => item.eventType === button.dataset.startEvent);
|
|
508
890
|
if (trigger) openObligationEventDialog(trigger);
|
|
509
891
|
}));
|
|
892
|
+
main.querySelector("[data-expand-policy-events]")?.addEventListener("click", (event) => {
|
|
893
|
+
const button = event.currentTarget;
|
|
894
|
+
const expanded = button.getAttribute("aria-expanded") === "true";
|
|
895
|
+
main.querySelectorAll(".policy-event-row[data-collapsed]").forEach((row) => { row.hidden = expanded; });
|
|
896
|
+
button.setAttribute("aria-expanded", String(!expanded));
|
|
897
|
+
button.textContent = expanded ? "Show " + (orderedTriggers.length - eventTriggerLimit) + " more events" : "Show fewer events";
|
|
898
|
+
});
|
|
510
899
|
main.querySelector("[data-view-added-work]")?.addEventListener("click", () => main.querySelector(".work-queue-section")?.scrollIntoView({ behavior: "smooth", block: "start" }));
|
|
511
900
|
main.querySelector("[data-dismiss-policy-event-feedback]")?.addEventListener("click", (event) => {
|
|
512
901
|
policyEventFeedback = null;
|
|
513
902
|
event.currentTarget.closest(".policy-event-feedback")?.remove();
|
|
514
903
|
});
|
|
904
|
+
main.querySelector("[data-new-external-evidence]")?.addEventListener("click", () => openEditor("evidence"));
|
|
515
905
|
main.querySelectorAll("[data-expand-obligations]").forEach((button) => button.addEventListener("click", () => {
|
|
516
906
|
const column = button.closest("[data-obligation-column]");
|
|
517
907
|
const expanded = button.getAttribute("aria-expanded") === "true";
|
|
@@ -524,6 +914,10 @@ function renderObligations(main, params = new URLSearchParams()) {
|
|
|
524
914
|
const item = plan.items.find((candidate) => candidate.key === button.dataset.recordObligation);
|
|
525
915
|
if (item) openObligationCompletion(item);
|
|
526
916
|
}));
|
|
917
|
+
main.querySelectorAll("[data-complete-action]").forEach((button) => button.addEventListener("click", () => {
|
|
918
|
+
const item = plan.items.find((candidate) => candidate.key === button.dataset.completeAction);
|
|
919
|
+
if (item) openActionCompletion(item);
|
|
920
|
+
}));
|
|
527
921
|
main.querySelector("[data-new-action-item]")?.addEventListener("click", () => openEditor("action-item", null, {
|
|
528
922
|
description: "Create a task only when follow-up from another record needs its own assignee, deadline, and completion proof. Point it to that source record; it will remain in Work Queue until done or canceled."
|
|
529
923
|
}));
|
|
@@ -534,9 +928,52 @@ function renderObligations(main, params = new URLSearchParams()) {
|
|
|
534
928
|
if (requestedEvent) main.querySelector('[data-start-event="' + CSS.escape(requestedEvent) + '"]')?.click();
|
|
535
929
|
});
|
|
536
930
|
}
|
|
931
|
+
const requestedWork = params.get("work");
|
|
932
|
+
if (requestedWork) {
|
|
933
|
+
queueMicrotask(() => {
|
|
934
|
+
const card = [...main.querySelectorAll("[data-work-source]")]
|
|
935
|
+
.find((candidate) => candidate.dataset.workSource === requestedWork);
|
|
936
|
+
if (!card) return;
|
|
937
|
+
card.hidden = false;
|
|
938
|
+
card.classList.add("workflow-target");
|
|
939
|
+
card.scrollIntoView({ block: "center" });
|
|
940
|
+
});
|
|
941
|
+
}
|
|
537
942
|
}
|
|
538
943
|
|
|
539
|
-
function
|
|
944
|
+
function obligationBoardItems(items, status) {
|
|
945
|
+
const matching = items.filter((item) => item.status === status);
|
|
946
|
+
if (status !== "proposed") return matching;
|
|
947
|
+
const seen = new Set();
|
|
948
|
+
return matching.filter((item) => {
|
|
949
|
+
const source = item.actionItemId ? "action:" + item.actionItemId : "obligation:" + item.obligationId;
|
|
950
|
+
if (seen.has(source)) return false;
|
|
951
|
+
seen.add(source);
|
|
952
|
+
return true;
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function policyEventDisplayRank(eventType) {
|
|
957
|
+
const featured = [
|
|
958
|
+
"person-started",
|
|
959
|
+
"person-role-changed",
|
|
960
|
+
"person-ended",
|
|
961
|
+
"material-incident",
|
|
962
|
+
"system-material-change",
|
|
963
|
+
"vendor-activated"
|
|
964
|
+
];
|
|
965
|
+
const index = featured.indexOf(eventType);
|
|
966
|
+
return index === -1 ? featured.length : index;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function orderedPolicyEventTriggers(triggers) {
|
|
970
|
+
return [...triggers].sort((left, right) => (
|
|
971
|
+
policyEventDisplayRank(left.eventType) - policyEventDisplayRank(right.eventType)
|
|
972
|
+
|| policyEventName(left.eventType).localeCompare(policyEventName(right.eventType))
|
|
973
|
+
));
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function policyEventTrigger(trigger, index, collapsed = false) {
|
|
540
977
|
const tooltipId = "policy-event-tooltip-" + index;
|
|
541
978
|
const proposed = trigger.programStatus === "proposed";
|
|
542
979
|
const unavailable = state.readOnly || proposed;
|
|
@@ -545,34 +982,89 @@ function policyEventTrigger(trigger, index) {
|
|
|
545
982
|
: state.readOnly
|
|
546
983
|
? "Open this workspace in writable mode to trigger the workflow."
|
|
547
984
|
: trigger.steps.length + " " + pluralize("task", trigger.steps.length) + " will be added to the Work Queue.";
|
|
548
|
-
return '<article class="policy-event-row"><div class="policy-event-name"><div class="policy-event-title"><strong>' + esc(policyEventName(trigger.eventType)) + '</strong><span class="policy-event-guide"><button class="guide-trigger policy-event-guide-trigger" type="button" aria-label="Show ' + esc(policyEventName(trigger.eventType)) + ' workflow steps" aria-describedby="' + tooltipId + '"><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><div class="policy-event-tooltip" id="' + tooltipId + '" role="tooltip"><strong>' + esc(proposed ? "Proposed workflow" : "Adds " + trigger.steps.length + " " + pluralize("task", trigger.steps.length) + " to the Work Queue") + '</strong><ol>' + trigger.steps.map((step) => '<li><span>' + esc(step.title) + '</span><small>' + esc(eventStepSummary(step)) + '</small></li>').join("") + '</ol></div></span></div><small>' + esc(availability) + '</small></div><button class="button primary" type="button" data-start-event="' + esc(trigger.eventType) + '"' + (unavailable ? " disabled" : "") + '>Trigger Work</button></article>';
|
|
985
|
+
return '<article class="policy-event-row"' + (collapsed ? " data-collapsed hidden" : "") + '><div class="policy-event-name"><div class="policy-event-title"><strong>' + esc(policyEventName(trigger.eventType)) + '</strong><span class="policy-event-guide"><button class="guide-trigger policy-event-guide-trigger" type="button" aria-label="Show ' + esc(policyEventName(trigger.eventType)) + ' workflow steps" aria-describedby="' + tooltipId + '"><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><div class="policy-event-tooltip" id="' + tooltipId + '" role="tooltip"><strong>' + esc(proposed ? "Proposed workflow" : "Adds " + trigger.steps.length + " " + pluralize("task", trigger.steps.length) + " to the Work Queue") + '</strong><ol>' + trigger.steps.map((step) => '<li><span>' + esc(step.title) + '</span><small>' + esc(eventStepSummary(step)) + '</small></li>').join("") + '</ol></div></span></div><small>' + esc(availability) + '</small></div><button class="button primary" type="button" data-start-event="' + esc(trigger.eventType) + '"' + (unavailable ? " disabled" : "") + '>Trigger Work</button></article>';
|
|
549
986
|
}
|
|
550
987
|
|
|
551
988
|
function policyEventName(eventType) {
|
|
552
|
-
return
|
|
989
|
+
return state.model.policyEvents?.[eventType]?.title || titleCase(humanize(eventType));
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function rangeCoverage(startsOn, endsOn) {
|
|
993
|
+
return { kind: "range", startsOn, endsOn };
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function coverageStart(coverage) {
|
|
997
|
+
return coverage?.kind === "as-of" ? coverage.on : coverage?.startsOn;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function coverageEnd(coverage) {
|
|
1001
|
+
return coverage?.kind === "as-of" ? coverage.on : coverage?.endsOn;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function defaultClassificationId() {
|
|
1005
|
+
const definitions = state.workspace.classificationDefinitions || {};
|
|
1006
|
+
return Object.hasOwn(definitions, "internal") ? "internal" : Object.keys(definitions)[0] || "";
|
|
553
1007
|
}
|
|
554
1008
|
|
|
555
1009
|
function obligationCard(item, collapsed = false) {
|
|
556
1010
|
const type = item.actionItemId ? "action-item" : "obligation";
|
|
557
1011
|
const id = item.actionItemId || item.obligationId;
|
|
558
|
-
const completion =
|
|
1012
|
+
const completion = item.actionItemId ? actionCompletionPlan(item) : obligationCompletionPlan(item);
|
|
559
1013
|
const canAct = completion?.blocked === "Assign current owner"
|
|
560
1014
|
|| !["upcoming", "proposed"].includes(item.status);
|
|
561
1015
|
const action = !state.readOnly && canAct && completion
|
|
562
1016
|
? completion.blocked
|
|
563
1017
|
? '<a class="obligation-action blocked" href="' + completion.href + '">' + esc(completion.blocked) + '</a>'
|
|
564
|
-
:
|
|
1018
|
+
: item.actionItemId
|
|
1019
|
+
? '<button class="obligation-action" type="button" data-complete-action="' + esc(item.key) + '">Complete task</button>'
|
|
1020
|
+
: '<button class="obligation-action" type="button" data-record-obligation="' + esc(item.key) + '">Record work</button>'
|
|
565
1021
|
: "";
|
|
566
1022
|
const kind = item.kind === "event" ? "Policy Event Task" : item.kind === "action" ? "Assigned Follow-up" : properCase(item.activityType || "Recurring");
|
|
567
|
-
return '<article class="obligation-card status-' + esc(item.status) + '"' + (collapsed ? ' data-collapsed hidden' : "") + '><div class="obligation-card-head"><span>' + esc(kind) + '</span><strong>' + esc(timingText(item)) + '</strong></div><h3><a href="#/resource/' + type + '/' + encodeURIComponent(id) + '">' + esc(titleCase(item.title)) + '</a></h3><p>' + esc(windowText(item)) + '</p><div class="obligation-card-foot"><div class="obligation-links">' + (item.policyIds || []).map(formatReference).join("") + '</div>' + action + '</div></article>';
|
|
1023
|
+
return '<article class="obligation-card status-' + esc(item.status) + '" data-work-source="' + esc(type + ":" + id) + '"' + (collapsed ? ' data-collapsed hidden' : "") + '><div class="obligation-card-head"><span>' + esc(kind) + '</span><strong>' + esc(timingText(item)) + '</strong></div><h3><a href="#/resource/' + type + '/' + encodeURIComponent(id) + '">' + esc(titleCase(item.title)) + '</a></h3><p>' + esc(windowText(item)) + '</p><div class="obligation-card-foot"><div class="obligation-links">' + (item.policyIds || []).map(formatReference).join("") + '</div>' + action + '</div></article>';
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function actionCompletionPlan(item) {
|
|
1027
|
+
const action = state.resources.find(({ record }) => (
|
|
1028
|
+
record.type === "action-item" && record.id === item.actionItemId
|
|
1029
|
+
));
|
|
1030
|
+
if (!action) return null;
|
|
1031
|
+
if (action.record.status === "blocked") {
|
|
1032
|
+
return {
|
|
1033
|
+
blocked: "Resolve blockers",
|
|
1034
|
+
href: "#/resource/action-item/" + encodeURIComponent(action.record.id)
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
if (!action.record.obligationId) {
|
|
1038
|
+
return {
|
|
1039
|
+
blocked: "Open task",
|
|
1040
|
+
href: "#/resource/action-item/" + encodeURIComponent(action.record.id)
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
const obligation = state.resources.find(({ record }) => (
|
|
1044
|
+
record.type === "obligation" && record.id === action.record.obligationId
|
|
1045
|
+
));
|
|
1046
|
+
if (!obligation) {
|
|
1047
|
+
return {
|
|
1048
|
+
blocked: "Repair obligation link",
|
|
1049
|
+
href: "#/resource/action-item/" + encodeURIComponent(action.record.id)
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
const type = state.model.obligationActivities?.[obligation.record.activityType]?.completionType;
|
|
1053
|
+
if (!type) {
|
|
1054
|
+
return {
|
|
1055
|
+
blocked: "Review completion type",
|
|
1056
|
+
href: "#/resource/obligation/" + encodeURIComponent(obligation.record.id)
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
return { type, action, obligation };
|
|
568
1060
|
}
|
|
569
1061
|
|
|
570
1062
|
function obligationCompletionPlan(item) {
|
|
571
|
-
const type =
|
|
1063
|
+
const type = state.model.obligationActivities?.[item.activityType]?.completionType || "evidence";
|
|
572
1064
|
if (!currentPeopleForParties(item.ownerIds || []).length) {
|
|
573
1065
|
return { type, blocked: "Assign current owner", href: "#/resource/obligation/" + encodeURIComponent(item.obligationId) };
|
|
574
1066
|
}
|
|
575
|
-
if (["access-review", "backup-test"].includes(type) && !
|
|
1067
|
+
if (["access-review", "backup-test"].includes(type) && !(state.workspace.systemIds || []).some((id) => state.resources.some(({ record }) => record.id === id && record.status !== "retired"))) {
|
|
576
1068
|
return { type, blocked: "Add system first", href: "#/resources/system?new=1" };
|
|
577
1069
|
}
|
|
578
1070
|
if (type === "vendor-review" && !resourcesOfType("vendor").some(({ record }) => record.status !== "terminated")) {
|
|
@@ -584,6 +1076,21 @@ function obligationCompletionPlan(item) {
|
|
|
584
1076
|
return { type };
|
|
585
1077
|
}
|
|
586
1078
|
|
|
1079
|
+
function openActionCompletion(item) {
|
|
1080
|
+
const completion = actionCompletionPlan(item);
|
|
1081
|
+
if (!completion || completion.blocked) return;
|
|
1082
|
+
openEditor(completion.type, null, {
|
|
1083
|
+
seed: obligationCompletionSeed(completion.type, item, completion.obligation.record),
|
|
1084
|
+
actionCompletion: {
|
|
1085
|
+
actionItemId: completion.action.record.id,
|
|
1086
|
+
revision: completion.action.revision,
|
|
1087
|
+
completedOn: currentDate()
|
|
1088
|
+
},
|
|
1089
|
+
description: "Record the work that completed this assigned task. Saving creates and links the required operating record, then marks the Action Item done in the same validated write.",
|
|
1090
|
+
saveLabel: "Save and complete task"
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
|
|
587
1094
|
function openObligationCompletion(item) {
|
|
588
1095
|
const obligation = state.resources.find(({ record }) => record.type === "obligation" && record.id === item.obligationId);
|
|
589
1096
|
if (!obligation) return showError("The obligation template could not be found.");
|
|
@@ -604,77 +1111,114 @@ function obligationCompletionSeed(type, item, obligation) {
|
|
|
604
1111
|
const date = currentDate();
|
|
605
1112
|
const timestamp = new Date().toISOString();
|
|
606
1113
|
const responsiblePeople = currentPeopleForParties(item.ownerIds || []);
|
|
607
|
-
const
|
|
1114
|
+
const independentPeople = resourcesOfType("person")
|
|
1115
|
+
.map(({ record }) => record)
|
|
1116
|
+
.filter((record) => record.status === "active" && !responsiblePeople.includes(record.id))
|
|
1117
|
+
.map(({ id }) => id);
|
|
1118
|
+
const reviewerPeople = independentPeople.length ? [independentPeople[0]] : [];
|
|
1119
|
+
const inScopeSystems = resourcesOfType("system").filter(({ record }) => (state.workspace.systemIds || []).includes(record.id) && record.status !== "retired").map(({ record }) => record.id);
|
|
608
1120
|
const activeVendors = resourcesOfType("vendor").filter(({ record }) => record.status !== "terminated").map(({ record }) => record.id);
|
|
609
1121
|
const title = item.title + " · " + formatCalendarDate(item.dueWindowStart);
|
|
610
1122
|
const common = { title };
|
|
611
1123
|
if (type === "meeting") {
|
|
612
1124
|
const team = completionTeam(item);
|
|
613
|
-
return { ...common, status: "complete", teamId: team.id, chairIds: currentPeopleForParties(team.chairIds || []),
|
|
1125
|
+
return { ...common, status: "complete", teamId: team.id, chairIds: currentPeopleForParties(team.chairIds || []), scheduledFor: date, startedAt: timestamp, endedAt: timestamp, attendeeIds: responsiblePeople };
|
|
614
1126
|
}
|
|
615
1127
|
if (type === "policy-review") {
|
|
616
|
-
return { ...common, status: "complete", scopeResourceIds: obligation.scopeResourceIds || [], reviewerIds:
|
|
1128
|
+
return { ...common, status: "complete", scopeResourceIds: obligation.scopeResourceIds || [], reviewerIds: reviewerPeople, completedOn: date, outcome: "passed", changesRequired: false, evidenceIds: [], coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd) };
|
|
617
1129
|
}
|
|
618
1130
|
if (type === "risk-assessment") {
|
|
619
|
-
return { ...common, status: "complete",
|
|
1131
|
+
return { ...common, status: "complete", completedOn: date, assessmentKind: "enterprise-risk", scope: "In-scope SOC 2 systems and dependencies", assessorIds: responsiblePeople, reviewerIds: reviewerPeople, methodology: state.workspace.riskMethodology?.method || "Documented risk methodology", summary: "Assessment completed; link the supporting evidence and resulting risks.", evidenceIds: [], approvedOn: date };
|
|
620
1132
|
}
|
|
621
1133
|
if (type === "attestation") {
|
|
622
|
-
return { ...common, status: "completed", subjectResourceIds: [obligation.templateResourceId, ...(obligation.scopeResourceIds || [])].filter(Boolean), personId: responsiblePeople[0], attestationKind: item.activityType || "completion", assignedOn: item.dueWindowStart, dueOn: item.dueWindowEnd, completedOn: date, attestationMethod: "git-approval" };
|
|
1134
|
+
return { ...common, status: "completed", subjectResourceIds: [...new Set([obligation.templateResourceId, ...(obligation.scopeResourceIds || [])].filter(Boolean))], personId: responsiblePeople[0], attestationKind: item.activityType || "completion", assignedOn: item.dueWindowStart, dueOn: item.dueWindowEnd, completedOn: date, attestationMethod: "git-approval" };
|
|
623
1135
|
}
|
|
624
1136
|
if (type === "access-review") {
|
|
625
|
-
return { ...common, status: "complete",
|
|
1137
|
+
return { ...common, status: "complete", completedOn: date, reviewerIds: responsiblePeople, systemIds: inScopeSystems, scope: "Privileged, production, and important-system access", outcome: "passed", evidenceIds: [], approvedByIds: reviewerPeople, approvedOn: date, coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd) };
|
|
626
1138
|
}
|
|
627
1139
|
if (type === "vulnerability-scan") {
|
|
628
|
-
return { ...common, status: "complete", scanKind: "vulnerability", scope: "In-scope systems", operatorIds: responsiblePeople,
|
|
1140
|
+
return { ...common, status: "complete", scanKind: "vulnerability", scope: "In-scope systems", operatorIds: responsiblePeople, scheduledFor: date, completedAt: timestamp, systemIds: inScopeSystems, resultSummary: "Document the scan result and link findings or evidence.", evidenceIds: [], reviewerIds: reviewerPeople, reviewedOn: date };
|
|
629
1141
|
}
|
|
630
1142
|
if (type === "penetration-test") {
|
|
631
|
-
return { ...common, status: "complete", testKind: "independent", scope: "In-scope systems and service boundary",
|
|
1143
|
+
return { ...common, status: "complete", testKind: "independent", scope: "In-scope systems and service boundary", coverage: rangeCoverage(date, date), ownerIds: responsiblePeople, outcome: "passed", evidenceIds: [], systemIds: inScopeSystems, completedOn: date, reviewerIds: reviewerPeople, reviewedOn: date };
|
|
1144
|
+
}
|
|
1145
|
+
if (type === "control-test") {
|
|
1146
|
+
return { ...common, status: "complete", controlId: obligation.controlIds?.[0] || "", testKinds: [item.activityType || "control-operation"], performedBy: "management", testerIds: responsiblePeople, reviewerIds: reviewerPeople, completedOn: date, reviewedOn: date, outcome: "passed", evidenceIds: [], coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd) };
|
|
1147
|
+
}
|
|
1148
|
+
if (type === "control-activity") {
|
|
1149
|
+
return {
|
|
1150
|
+
...common,
|
|
1151
|
+
status: "complete",
|
|
1152
|
+
profileId: item.completionProfile || item.activityType,
|
|
1153
|
+
obligationId: item.obligationId,
|
|
1154
|
+
controlIds: item.controlIds || obligation.controlIds || [],
|
|
1155
|
+
scopeResourceIds: (item.subjectResourceIds || item.scopeResourceIds || obligation.scopeResourceIds || []).length
|
|
1156
|
+
? (item.subjectResourceIds || item.scopeResourceIds || obligation.scopeResourceIds)
|
|
1157
|
+
: [state.workspace.id],
|
|
1158
|
+
performerIds: responsiblePeople,
|
|
1159
|
+
completedAt: timestamp,
|
|
1160
|
+
method: "",
|
|
1161
|
+
result: "",
|
|
1162
|
+
reviewerIds: reviewerPeople,
|
|
1163
|
+
reviewedOn: date,
|
|
1164
|
+
ownerIds: item.ownerIds || obligation.ownerIds || []
|
|
1165
|
+
};
|
|
632
1166
|
}
|
|
633
1167
|
if (type === "exercise") {
|
|
634
|
-
return { ...common, status: "complete", exerciseKind: item.title.toLowerCase().includes("continuity") ? "business-continuity" : "incident-response",
|
|
1168
|
+
return { ...common, status: "complete", exerciseKind: item.title.toLowerCase().includes("continuity") ? "business-continuity" : "incident-response", scheduledFor: date, facilitatorIds: responsiblePeople, objective: item.title, outcome: "passed", evidenceIds: [], systemIds: inScopeSystems, completedAt: timestamp };
|
|
635
1169
|
}
|
|
636
1170
|
if (type === "backup-test") {
|
|
637
|
-
return { ...common, status: "
|
|
1171
|
+
return { ...common, status: "complete", systemIds: inScopeSystems, scheduledFor: date, operatorIds: responsiblePeople, reviewerIds: reviewerPeople, outcome: "passed", evidenceIds: [], completedAt: timestamp };
|
|
638
1172
|
}
|
|
639
1173
|
if (type === "vendor-review") {
|
|
640
|
-
|
|
1174
|
+
const eventVendorId = (item.subjectResourceIds || []).find((id) => state.resources.some(({ record }) => record.id === id && record.type === "vendor"));
|
|
1175
|
+
return { ...common, status: "complete", vendorId: eventVendorId || activeVendors[0] || "", reviewerIds: responsiblePeople, completedOn: date, decision: "approved", evidenceIds: [], coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd) };
|
|
641
1176
|
}
|
|
642
1177
|
return {
|
|
643
1178
|
...common,
|
|
644
1179
|
status: "collected",
|
|
645
|
-
|
|
646
|
-
|
|
1180
|
+
artifactKind: "business-record",
|
|
1181
|
+
artifactSubtype: item.activityType || "control-operation",
|
|
1182
|
+
sourceKind: "authored-record",
|
|
1183
|
+
sourceDescription: "Internal control operation",
|
|
647
1184
|
collectedOn: date,
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
periodEnd: item.dueWindowEnd,
|
|
1185
|
+
classificationId: defaultClassificationId(),
|
|
1186
|
+
coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd),
|
|
651
1187
|
controlIds: item.controlIds || [],
|
|
652
1188
|
sourceResourceIds: [item.obligationId]
|
|
653
1189
|
};
|
|
654
1190
|
}
|
|
655
1191
|
|
|
656
1192
|
function openObligationEventDialog(trigger) {
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
: trigger.eventType.includes("incident") ? "incident"
|
|
661
|
-
: null;
|
|
662
|
-
const subjects = subjectType ? resourcesOfType(subjectType) : [];
|
|
663
|
-
const needsTimestamp = trigger.steps.some((step) => Number.isInteger(step.window?.endOffsetHours));
|
|
1193
|
+
const subjectTypes = (state.model.policyEvents?.[trigger.eventType]?.subjectRules || []).map(({ resourceType }) => resourceType);
|
|
1194
|
+
const subjects = subjectTypes.flatMap((type) => resourcesOfType(type));
|
|
1195
|
+
const needsTimestamp = trigger.steps.some((step) => step.window?.precision === "timestamp");
|
|
664
1196
|
const eventField = needsTimestamp
|
|
665
1197
|
? '<label><span>Event time <small>your local time</small></span><input name="occurredAt" type="datetime-local" required value="' + esc(currentLocalDateTime()) + '"></label>'
|
|
666
1198
|
: '<label><span>Event date</span><input name="occurredOn" type="date" required value="' + esc(currentDate()) + '"></label>';
|
|
1199
|
+
const riskField = trigger.eventType === "person-ended"
|
|
1200
|
+
? '<label><span>Departure risk</span><select name="riskLevel" required><option value="normal">Normal</option><option value="high">High or involuntary</option></select></label>'
|
|
1201
|
+
: "";
|
|
667
1202
|
const dialog = document.createElement("dialog");
|
|
668
1203
|
dialog.className = "commit-dialog event-dialog";
|
|
669
1204
|
dialog.setAttribute("aria-labelledby", "event-dialog-title");
|
|
670
|
-
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Policy event</p><h2 id="event-dialog-title">' + esc(policyEventName(trigger.eventType)) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>This creates one event record and adds
|
|
671
|
-
(subjects.length ? '<label><span>Subject</span><select name="subject"><option value="">Select</option>' + subjects.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + '</option>').join("") + '</select></label>' : "") +
|
|
672
|
-
'<label><span>Workflow name <small>optional</small></span><input name="title" maxlength="200" placeholder="' + esc(policyEventName(trigger.eventType)) + '"></label><div class="event-dialog-steps">' + trigger.steps.map((step) => '<div><strong>' + esc(step.title) + '</strong><small>' + esc(eventStepSummary(step)) + '</small></div>').join("") + '</div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary">Add Tasks to Work Queue</button></div></form>';
|
|
1205
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Policy event</p><h2 id="event-dialog-title">' + esc(policyEventName(trigger.eventType)) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>This creates one event record and adds the matching linked tasks to the Work Queue.</p>' + eventField + riskField +
|
|
1206
|
+
(subjects.length ? '<label><span>Subject <small>required</small></span><select name="subject" required><option value="">Select</option>' + subjects.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + ' · ' + esc(state.model.resources[record.type].title) + '</option>').join("") + '</select></label>' : "") +
|
|
1207
|
+
'<label><span>Workflow name <small>optional</small></span><input name="title" maxlength="200" placeholder="' + esc(policyEventName(trigger.eventType)) + '"></label><div class="event-dialog-steps">' + trigger.steps.map((step) => '<div data-risk-levels="' + esc((step.eventRiskLevels || []).join(",")) + '" ' + ((step.eventRiskLevels || []).length ? "hidden" : "") + '><strong>' + esc(step.title) + '</strong><small>' + esc(eventStepSummary(step)) + '</small></div>').join("") + '</div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary">Add Tasks to Work Queue</button></div></form>';
|
|
673
1208
|
document.body.append(dialog);
|
|
674
1209
|
dialog.showModal();
|
|
675
1210
|
dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
|
|
676
1211
|
dialog.querySelector('[data-event="cancel"]').addEventListener("click", () => dialog.close());
|
|
677
1212
|
dialog.addEventListener("close", () => dialog.remove());
|
|
1213
|
+
const syncRiskSteps = () => {
|
|
1214
|
+
const selectedRisk = dialog.querySelector('[name="riskLevel"]')?.value || "";
|
|
1215
|
+
dialog.querySelectorAll("[data-risk-levels]").forEach((step) => {
|
|
1216
|
+
const levels = step.dataset.riskLevels.split(",").filter(Boolean);
|
|
1217
|
+
step.hidden = levels.length > 0 && !levels.includes(selectedRisk);
|
|
1218
|
+
});
|
|
1219
|
+
};
|
|
1220
|
+
dialog.querySelector('[name="riskLevel"]')?.addEventListener("change", syncRiskSteps);
|
|
1221
|
+
syncRiskSteps();
|
|
678
1222
|
dialog.querySelector("form").addEventListener("submit", async (event) => {
|
|
679
1223
|
event.preventDefault();
|
|
680
1224
|
const form = event.currentTarget;
|
|
@@ -688,6 +1232,7 @@ function openObligationEventDialog(trigger) {
|
|
|
688
1232
|
eventType: trigger.eventType,
|
|
689
1233
|
occurredOn: form.elements.occurredOn?.value || undefined,
|
|
690
1234
|
occurredAt: form.elements.occurredAt?.value ? new Date(form.elements.occurredAt.value).toISOString() : undefined,
|
|
1235
|
+
riskLevel: form.elements.riskLevel?.value || undefined,
|
|
691
1236
|
subjectResourceIds: form.elements.subject?.value ? [form.elements.subject.value] : [],
|
|
692
1237
|
title: form.elements.title.value
|
|
693
1238
|
})
|
|
@@ -698,7 +1243,7 @@ function openObligationEventDialog(trigger) {
|
|
|
698
1243
|
name: policyEventName(trigger.eventType),
|
|
699
1244
|
taskCount: created.actions?.length || trigger.steps.length
|
|
700
1245
|
};
|
|
701
|
-
|
|
1246
|
+
applyMutationState(created);
|
|
702
1247
|
dialog.close();
|
|
703
1248
|
history.replaceState(null, "", "#/stage/run");
|
|
704
1249
|
render();
|
|
@@ -710,18 +1255,235 @@ function openObligationEventDialog(trigger) {
|
|
|
710
1255
|
dialog.querySelector('input[name="occurredOn"], input[name="occurredAt"]').focus();
|
|
711
1256
|
}
|
|
712
1257
|
|
|
1258
|
+
function openExternalReviewerGovernanceDialog() {
|
|
1259
|
+
const dialog = document.createElement("dialog");
|
|
1260
|
+
dialog.className = "commit-dialog event-dialog";
|
|
1261
|
+
dialog.setAttribute("aria-labelledby", "external-reviewer-dialog-title");
|
|
1262
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Independent review</p><h2 id="external-reviewer-dialog-title">Set up an external reviewer</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>Use this flow when no suitable internal reviewer is available. It adds the external reviewer, activates the Independent Policy Reviewer and Oversight Chair Appointments, adds the reviewer to the oversight team, and routes draft policy approvals to that person.</p><div class="form-grid"><label><span>Reviewer name</span><input name="reviewerName" required maxlength="200"></label><label><span>Email <small>optional</small></span><input name="email" type="email"></label><label><span>Organization <small>optional</small></span><input name="organization" maxlength="200"></label><label><span>Organizational job title</span><input name="jobTitle" required maxlength="200" placeholder="Principal Consultant"></label><label><span>Appointment starts</span><input name="startsOn" type="date" required value="' + esc(currentDate()) + '"></label><label class="full"><span>Why this reviewer is independent</span><textarea name="independenceRationale" required rows="4" placeholder="Describe their separation from policy ownership and control operation."></textarea></label></div><div class="workflow-preview" role="status"></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary" data-preview-governance>Preview bundle</button></div></form>';
|
|
1263
|
+
document.body.append(dialog);
|
|
1264
|
+
dialog.showModal();
|
|
1265
|
+
const form = dialog.querySelector("form");
|
|
1266
|
+
const close = () => dialog.close();
|
|
1267
|
+
dialog.querySelector(".icon-button").addEventListener("click", close);
|
|
1268
|
+
dialog.querySelector('[data-event="cancel"]').addEventListener("click", close);
|
|
1269
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
1270
|
+
let previewedPayload = null;
|
|
1271
|
+
form.addEventListener("input", () => {
|
|
1272
|
+
previewedPayload = null;
|
|
1273
|
+
dialog.querySelector(".workflow-preview").innerHTML = "";
|
|
1274
|
+
dialog.querySelector("[data-preview-governance]").textContent = "Preview bundle";
|
|
1275
|
+
});
|
|
1276
|
+
form.addEventListener("submit", async (event) => {
|
|
1277
|
+
event.preventDefault();
|
|
1278
|
+
if (!form.reportValidity()) return;
|
|
1279
|
+
const payload = Object.fromEntries(new FormData(form).entries());
|
|
1280
|
+
const error = dialog.querySelector(".dialog-error");
|
|
1281
|
+
error.textContent = "";
|
|
1282
|
+
form.querySelectorAll("button,input,textarea").forEach((control) => { control.disabled = true; });
|
|
1283
|
+
try {
|
|
1284
|
+
if (!previewedPayload) {
|
|
1285
|
+
const response = await localFetch("/api/external-reviewer-governance/preview", {
|
|
1286
|
+
method: "POST",
|
|
1287
|
+
headers: { "content-type": "application/json" },
|
|
1288
|
+
body: JSON.stringify(payload)
|
|
1289
|
+
});
|
|
1290
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1291
|
+
const preview = await response.json();
|
|
1292
|
+
previewedPayload = payload;
|
|
1293
|
+
dialog.querySelector(".workflow-preview").innerHTML = '<strong>Review this bundle</strong><p>Create ' + preview.changes.create.length + ' records and update ' + preview.changes.update.length + '. No approval or historical date is inferred beyond the facts entered above.</p>';
|
|
1294
|
+
dialog.querySelector("[data-preview-governance]").textContent = "Confirm and apply";
|
|
1295
|
+
} else {
|
|
1296
|
+
const response = await localFetch("/api/external-reviewer-governance", {
|
|
1297
|
+
method: "POST",
|
|
1298
|
+
headers: { "content-type": "application/json" },
|
|
1299
|
+
body: JSON.stringify({ ...previewedPayload, confirmed: true })
|
|
1300
|
+
});
|
|
1301
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1302
|
+
applyMutationState(await response.json());
|
|
1303
|
+
dialog.close();
|
|
1304
|
+
render();
|
|
1305
|
+
}
|
|
1306
|
+
} catch (requestError) {
|
|
1307
|
+
error.textContent = requestError.message;
|
|
1308
|
+
} finally {
|
|
1309
|
+
form.querySelectorAll("button,input,textarea").forEach((control) => { control.disabled = false; });
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
1312
|
+
form.elements.reviewerName.focus();
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function openNextAuditCycleDialog(prior) {
|
|
1316
|
+
const priorEnd = coverageEnd(prior.coverage) || currentDate();
|
|
1317
|
+
const start = dateAfter(priorEnd);
|
|
1318
|
+
const end = start.slice(0, 4) + "-12-31";
|
|
1319
|
+
const dialog = document.createElement("dialog");
|
|
1320
|
+
dialog.className = "commit-dialog event-dialog";
|
|
1321
|
+
dialog.setAttribute("aria-labelledby", "audit-cycle-dialog-title");
|
|
1322
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Audit lifecycle</p><h2 id="audit-cycle-dialog-title">' + (prior.auditKind === "soc-2-type-1" ? "Start the Type 2 operating period" : "Start the next audit cycle") + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>Carry forward the approved scope and control selections as a new planning record. FileGRC leaves report, evidence, delivery, and closure facts behind and requires review of every later change.</p><div class="form-grid"><label><span>Period start</span><input name="startsOn" type="date" required value="' + esc(start) + '"></label><label><span>Period end</span><input name="endsOn" type="date" required value="' + esc(end) + '"></label><label class="full"><span>Audit name <small>optional</small></span><input name="title" maxlength="200" placeholder="Next SOC 2 Type 2 audit"></label></div><div class="workflow-preview" role="status"></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary" data-preview-cycle>Preview carry-forward</button></div></form>';
|
|
1323
|
+
document.body.append(dialog);
|
|
1324
|
+
dialog.showModal();
|
|
1325
|
+
const form = dialog.querySelector("form");
|
|
1326
|
+
dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
|
|
1327
|
+
dialog.querySelector('[data-event="cancel"]').addEventListener("click", () => dialog.close());
|
|
1328
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
1329
|
+
let previewedPayload = null;
|
|
1330
|
+
form.addEventListener("input", () => {
|
|
1331
|
+
previewedPayload = null;
|
|
1332
|
+
dialog.querySelector(".workflow-preview").innerHTML = "";
|
|
1333
|
+
dialog.querySelector("[data-preview-cycle]").textContent = "Preview carry-forward";
|
|
1334
|
+
});
|
|
1335
|
+
form.addEventListener("submit", async (event) => {
|
|
1336
|
+
event.preventDefault();
|
|
1337
|
+
if (!form.reportValidity()) return;
|
|
1338
|
+
const payload = {
|
|
1339
|
+
...Object.fromEntries(new FormData(form).entries()),
|
|
1340
|
+
priorAuditId: prior.id
|
|
1341
|
+
};
|
|
1342
|
+
const error = dialog.querySelector(".dialog-error");
|
|
1343
|
+
error.textContent = "";
|
|
1344
|
+
form.querySelectorAll("button,input").forEach((control) => { control.disabled = true; });
|
|
1345
|
+
try {
|
|
1346
|
+
if (!previewedPayload) {
|
|
1347
|
+
const response = await localFetch("/api/audit-cycle/preview", {
|
|
1348
|
+
method: "POST",
|
|
1349
|
+
headers: { "content-type": "application/json" },
|
|
1350
|
+
body: JSON.stringify(payload)
|
|
1351
|
+
});
|
|
1352
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1353
|
+
const preview = await response.json();
|
|
1354
|
+
previewedPayload = payload;
|
|
1355
|
+
dialog.querySelector(".workflow-preview").innerHTML = '<strong>Review the carried-forward scope</strong><p>' + preview.audit.controlIds.length + ' controls, ' + preview.audit.systemIds.length + ' systems, and ' + preview.audit.requirementIds.length + ' requirements will start in Planning. The new record still requires scope, continuity, and source review.</p>';
|
|
1356
|
+
dialog.querySelector("[data-preview-cycle]").textContent = "Confirm and create";
|
|
1357
|
+
} else {
|
|
1358
|
+
const response = await localFetch("/api/audit-cycle", {
|
|
1359
|
+
method: "POST",
|
|
1360
|
+
headers: { "content-type": "application/json" },
|
|
1361
|
+
body: JSON.stringify({ ...previewedPayload, confirmed: true })
|
|
1362
|
+
});
|
|
1363
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1364
|
+
const result = await response.json();
|
|
1365
|
+
applyMutationState(result);
|
|
1366
|
+
dialog.close();
|
|
1367
|
+
history.replaceState(null, "", "#/resources/audit/" + encodeURIComponent(result.audit.id));
|
|
1368
|
+
render();
|
|
1369
|
+
}
|
|
1370
|
+
} catch (requestError) {
|
|
1371
|
+
error.textContent = requestError.message;
|
|
1372
|
+
} finally {
|
|
1373
|
+
form.querySelectorAll("button,input").forEach((control) => { control.disabled = false; });
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function openApplicabilityReviewDialog(type, entries) {
|
|
1379
|
+
const definition = state.model.resources[type];
|
|
1380
|
+
const reviewPoints = definition?.guidance?.reviewPoints || [];
|
|
1381
|
+
const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
|
|
1382
|
+
const pending = entries.filter(({ record }) => (
|
|
1383
|
+
!record.applicabilityReview
|
|
1384
|
+
|| type === "requirement" && record.applicability === "undetermined"
|
|
1385
|
+
));
|
|
1386
|
+
const dialog = document.createElement("dialog");
|
|
1387
|
+
dialog.className = "commit-dialog applicability-dialog";
|
|
1388
|
+
dialog.setAttribute("aria-labelledby", "applicability-dialog-title");
|
|
1389
|
+
const options = type === "requirement"
|
|
1390
|
+
? '<option value="applicable">Applicable</option><option value="not-applicable">Not applicable</option>'
|
|
1391
|
+
: '<option value="applicable">Applicable</option><option value="not-applicable">Not applicable</option><option value="externally-managed">Externally managed</option><option value="zero-population">Zero population</option>';
|
|
1392
|
+
const rows = pending.map((entry) => '<div class="applicability-row" data-review-id="' + esc(entry.record.id) + '"><div><strong>' + esc(entry.record.title) + '</strong><small>' + esc(entry.record.code || entry.record.id) + '</small></div><select name="decision"><option value="">Review later</option>' + options + '</select><input name="rationale" placeholder="Decision rationale"></div>').join("");
|
|
1393
|
+
const reviewChecks = reviewPoints.length
|
|
1394
|
+
? '<section class="collection-review-checks"><strong>Before deciding</strong><ul>' + reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section>'
|
|
1395
|
+
: "";
|
|
1396
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Batch review</p><h2 id="applicability-dialog-title">Review ' + esc(definition.pluralTitle) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>Record only decisions reviewed against the current service scope. Leave an item at Review later when management has not decided it.</p>' + reviewChecks + '<div class="form-grid review-context"><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label></div><div class="applicability-rows">' + (rows || empty("Every record already has a reviewed applicability decision.")) + '</div><div class="workflow-preview" role="status"></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary" data-preview-review ' + (pending.length ? "" : "disabled") + '>Preview decisions</button></div></form>';
|
|
1397
|
+
document.body.append(dialog);
|
|
1398
|
+
dialog.showModal();
|
|
1399
|
+
const form = dialog.querySelector("form");
|
|
1400
|
+
dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
|
|
1401
|
+
dialog.querySelector('[data-event="cancel"]').addEventListener("click", () => dialog.close());
|
|
1402
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
1403
|
+
let previewedPayload = null;
|
|
1404
|
+
form.addEventListener("input", () => {
|
|
1405
|
+
previewedPayload = null;
|
|
1406
|
+
dialog.querySelector(".workflow-preview").innerHTML = "";
|
|
1407
|
+
dialog.querySelector("[data-preview-review]").textContent = "Preview decisions";
|
|
1408
|
+
});
|
|
1409
|
+
form.addEventListener("submit", async (event) => {
|
|
1410
|
+
event.preventDefault();
|
|
1411
|
+
if (!form.reportValidity()) return;
|
|
1412
|
+
const error = dialog.querySelector(".dialog-error");
|
|
1413
|
+
error.textContent = "";
|
|
1414
|
+
let missingRationale = false;
|
|
1415
|
+
const decisions = [...dialog.querySelectorAll("[data-review-id]")].flatMap((row) => {
|
|
1416
|
+
const decision = row.querySelector('[name="decision"]').value;
|
|
1417
|
+
if (!decision) return [];
|
|
1418
|
+
const rationale = row.querySelector('[name="rationale"]').value.trim();
|
|
1419
|
+
if (!rationale) missingRationale = true;
|
|
1420
|
+
return [{
|
|
1421
|
+
id: row.dataset.reviewId,
|
|
1422
|
+
decision,
|
|
1423
|
+
rationale
|
|
1424
|
+
}];
|
|
1425
|
+
});
|
|
1426
|
+
if (missingRationale) {
|
|
1427
|
+
error.textContent = "Add a rationale for every selected decision.";
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
if (!decisions.length) {
|
|
1431
|
+
error.textContent = "Select at least one decision.";
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
const payload = {
|
|
1435
|
+
decisions,
|
|
1436
|
+
reviewedByIds: [form.elements.reviewerId.value],
|
|
1437
|
+
reviewedOn: form.elements.reviewedOn.value,
|
|
1438
|
+
expectedRevisions: Object.fromEntries(entries.map((entry) => [entry.record.id, entry.revision]))
|
|
1439
|
+
};
|
|
1440
|
+
form.querySelectorAll("button,input,select").forEach((control) => { control.disabled = true; });
|
|
1441
|
+
try {
|
|
1442
|
+
if (!previewedPayload) {
|
|
1443
|
+
const response = await localFetch("/api/applicability-review/preview", {
|
|
1444
|
+
method: "POST",
|
|
1445
|
+
headers: { "content-type": "application/json" },
|
|
1446
|
+
body: JSON.stringify(payload)
|
|
1447
|
+
});
|
|
1448
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1449
|
+
const preview = await response.json();
|
|
1450
|
+
previewedPayload = payload;
|
|
1451
|
+
dialog.querySelector(".workflow-preview").innerHTML = '<strong>Review before saving</strong><p>' + preview.reviewedIds.length + ' decisions will be saved with the reviewer, review date, and current scope recorded automatically.</p>';
|
|
1452
|
+
dialog.querySelector("[data-preview-review]").textContent = "Confirm and save";
|
|
1453
|
+
} else {
|
|
1454
|
+
const response = await localFetch("/api/applicability-review", {
|
|
1455
|
+
method: "POST",
|
|
1456
|
+
headers: { "content-type": "application/json" },
|
|
1457
|
+
body: JSON.stringify({ ...previewedPayload, confirmed: true })
|
|
1458
|
+
});
|
|
1459
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1460
|
+
applyMutationState(await response.json());
|
|
1461
|
+
dialog.close();
|
|
1462
|
+
render();
|
|
1463
|
+
}
|
|
1464
|
+
} catch (requestError) {
|
|
1465
|
+
error.textContent = requestError.message;
|
|
1466
|
+
} finally {
|
|
1467
|
+
form.querySelectorAll("button,input,select").forEach((control) => { control.disabled = false; });
|
|
1468
|
+
}
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
|
|
713
1472
|
function renderAuditPacket(main, params = new URLSearchParams()) {
|
|
714
1473
|
const audits = resourcesOfType("audit");
|
|
715
1474
|
const evidence = resourcesOfType("evidence");
|
|
716
1475
|
const filegrcRecordTypes = new Set((state.model.evidenceSourceFamilies || [])
|
|
717
|
-
.filter((family) => family.
|
|
1476
|
+
.filter((family) => family.filegrcManaged === true)
|
|
718
1477
|
.flatMap((family) => family.operationRecordTypes || []));
|
|
719
1478
|
const filegrcRecords = state.resources.filter(({ record }) => filegrcRecordTypes.has(record.type));
|
|
720
1479
|
const requestedAudit = params.get("auditId");
|
|
721
|
-
const
|
|
1480
|
+
const requestedEntry = audits.find(({ record }) => record.id === requestedAudit);
|
|
1481
|
+
const openEntry = audits.find(({ record }) => record.status !== "complete");
|
|
1482
|
+
const soleEntry = audits.length === 1 ? audits[0] : null;
|
|
1483
|
+
const selected = (requestedEntry || openEntry || soleEntry)?.record || null;
|
|
722
1484
|
const today = currentDate();
|
|
723
|
-
const start = selected?.
|
|
724
|
-
const end = selected?.
|
|
1485
|
+
const start = coverageStart(selected?.coverage) || today.slice(0, 4) + "-01-01";
|
|
1486
|
+
const end = coverageEnd(selected?.coverage) || today;
|
|
725
1487
|
const typeOne = selected?.auditKind === "soc-2-type-1";
|
|
726
1488
|
const draft = !state.git.clean || !selected;
|
|
727
1489
|
const preparation = state.auditPreparations?.[selected?.id || "none"] || state.auditPreparations?.none;
|
|
@@ -730,13 +1492,13 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
|
|
|
730
1492
|
["Engagement", selected ? selected.title : "No audit record", "#/resources/audit", selected ? "good" : "warn"],
|
|
731
1493
|
["filegrc Evidence", filegrcRecords.length + " operating " + pluralize("record", filegrcRecords.length), "#/stage/run", "neutral"],
|
|
732
1494
|
["External Evidence", evidence.length + " " + pluralize("record", evidence.length), "#/resources/evidence", evidence.length ? "good" : "neutral"],
|
|
733
|
-
["Policy work", state.obligations.counts.overdue ? state.obligations.counts.overdue + " overdue" : state.obligations.counts.due ? state.obligations.counts.due + " due" : state.obligations.counts.proposed ? state.obligations.counts.proposed + " proposals" : "No work due", "#/stage/run", state.obligations.counts.overdue ? "bad" : state.obligations.counts.due ? "warn" : state.obligations.counts.proposed ? "neutral" : "good"]
|
|
1495
|
+
["Policy work", state.obligations.counts.overdue ? state.obligations.counts.overdue + " overdue" : state.obligations.counts.blocked ? state.obligations.counts.blocked + " blocked" : state.obligations.counts.due ? state.obligations.counts.due + " due" : state.obligations.counts.proposed ? state.obligations.counts.proposed + " proposals" : "No work due", "#/stage/run", state.obligations.counts.overdue || state.obligations.counts.blocked ? "bad" : state.obligations.counts.due ? "warn" : state.obligations.counts.proposed ? "neutral" : "good"]
|
|
734
1496
|
];
|
|
735
|
-
const evidencePaths = '<section class="panel audit-evidence-paths"><div class="panel-head"><div><p class="kicker">Evidence workflow</p><h3>Review both evidence paths</h3><p>Use the formal audit date or period. Each selected control may need one or both paths.</p></div></div><div class="audit-evidence-path-grid"><a href="#/stage/run"><span class="step-label">filegrc Evidence</span><h4>Review operating records</h4><p>
|
|
1497
|
+
const evidencePaths = '<section class="panel audit-evidence-paths"><div class="panel-head"><div><p class="kicker">Evidence workflow</p><h3>Review both evidence paths</h3><p>Use the formal audit date or period. Each selected control may need one or both paths.</p></div></div><div class="audit-evidence-path-grid"><a href="#/stage/run"><span class="step-label">filegrc Evidence</span><h4>Review operating records</h4><p>Confirm the applicable Step 4 records are complete and linked to their Controls.</p></a><a href="#/resources/evidence"><span class="step-label">External Evidence</span><h4>Review imported or referenced proof</h4><p>Confirm each artifact is fixed, verified, and linked to its source System and Controls.</p></a></div></section>';
|
|
736
1498
|
const dateFields = typeOne
|
|
737
1499
|
? '<label><span>As-of date</span><input type="date" name="start" required value="' + esc(start) + '"></label>'
|
|
738
1500
|
: '<label><span>Period start</span><input type="date" name="start" required value="' + esc(start) + '"></label><label><span>Period end</span><input type="date" name="end" required value="' + esc(end) + '"></label>';
|
|
739
|
-
main.innerHTML = '<div class="page audit-packet-page"><div class="page-intro"><div><p class="kicker">Audit evidence and packet</p><h2>Prepare Fieldwork</h2><p>
|
|
1501
|
+
main.innerHTML = '<div class="page audit-packet-page"><div class="page-intro"><div><p class="kicker">Audit evidence and packet</p><h2>Prepare Fieldwork</h2><p>Review fieldwork readiness, then build the evidence packet for the agreed audit date or period.</p></div></div><section class="packet-preflight" aria-label="Packet readiness">' + preflight.map(([label, value, href, tone]) => '<a href="' + href + '"><span class="status-dot ' + tone + '"></span><span><small>' + esc(label) + '</small><strong>' + esc(value) + '</strong></span></a>').join("") + '</section>' + evidencePaths + renderAuditPreparation(preparation) + '<section class="panel packet-builder"><div class="panel-head"><div><p class="kicker">Evidence delivery</p><h3>' + (typeOne ? "Build the As-of Packet" : "Build the Period Packet") + '</h3></div></div><form id="packet-form">' + dateFields + '<label><span>Audit <small>required for delivery</small></span><select name="auditId"><option value="">Draft Without Audit Scope</option>' + audits.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (record.id === selected?.id ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><button class="button primary" type="submit" ' + (state.readOnly ? "disabled" : "") + '>' + (draft ? "Generate draft" : "Generate packet") + '</button></form><p class="packet-note">' + (state.readOnly ? "Packet generation requires the local writable renderer or the CLI." : draft ? "Drafts expose coverage gaps now. Commit a clean revision and select an audit record before delivery." : "The packet is derived under .filegrc/ and bound to the selected audit and current Git revision. filegrc checks preparation and integrity; the engagement team determines evidence sufficiency.") + '</p><div class="dialog-error" role="alert"></div></section><div id="packet-results"></div></div>';
|
|
740
1502
|
main.querySelector('select[name="auditId"]').addEventListener("change", (event) => {
|
|
741
1503
|
const next = event.currentTarget.value;
|
|
742
1504
|
location.hash = "#/audit-packet" + (next ? "?auditId=" + encodeURIComponent(next) : "");
|
|
@@ -754,7 +1516,7 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
|
|
|
754
1516
|
body: JSON.stringify({ auditId: selected.id })
|
|
755
1517
|
});
|
|
756
1518
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
757
|
-
|
|
1519
|
+
applyMutationState(await response.json());
|
|
758
1520
|
render();
|
|
759
1521
|
} catch (caught) {
|
|
760
1522
|
error.textContent = caught.message;
|
|
@@ -836,11 +1598,11 @@ function renderPacketResults(container, result) {
|
|
|
836
1598
|
metric("External Evidence", packet.summary.evidence, packet.summary.policies + " policies · " + packet.summary.controls + " controls", "neutral") +
|
|
837
1599
|
metric("Review items", packet.summary.gaps, packet.summary.errors + " errors · " + packet.summary.warnings + " warnings", packet.summary.errors ? "bad" : packet.summary.warnings ? "warn" : "good") +
|
|
838
1600
|
'</section><section class="panel packet-output"><div class="panel-head"><div><p class="kicker">' + (ready ? "filegrc management checks passed" : "Draft packet") + '</p><h3>' + esc(result.output) + '</h3></div>' + (result.packetUrl ? '<a class="button primary" href="' + esc(result.packetUrl) + '" target="_blank" rel="noreferrer">Open index</a>' : "") + '</div><p>The directory contains ' + result.files.length + ' files. ' + (ready ? "Verify the checksums, reconcile external deliveries, and let the engagement team confirm evidence sufficiency." : "Do not deliver it until every error is resolved and each warning has been reviewed.") + '</p></section>' +
|
|
839
|
-
'<div class="dashboard-grid"><section class="panel span-2"><div class="panel-head"><h3>Coverage Gaps and Warnings</h3></div>' + (packet.gaps.length ? '<div class="packet-gaps">' + packet.gaps.map((gap) => '<div><span class="badge ' + (gap.severity === "error" ? "bad" : "warn") + '">' + esc(properCase(gap.severity)) + '</span><p>' + esc(gap.message) + '</p></div>').join("") + '</div>' : empty("No packet gaps were detected.")) + '</section><section class="panel"><div class="panel-head"><h3>Included filegrc Evidence</h3></div>' + (packet.filegrcRecords.length ? '<div class="packet-list">' + packet.filegrcRecords.slice(0, 12).map((item) => '<a href="#/resource/' + encodeURIComponent(item.type) + '/' + encodeURIComponent(item.id) + '"><strong>' + esc(item.title) + '</strong><small>' + esc(properCase(item.type)) + ' · ' + esc(item.primaryDate) + '</small></a>').join("") + '</div>' : empty("No filegrc Evidence matched.")) + '</section><section class="panel"><div class="panel-head"><h3>Included External Evidence</h3></div>' + (packet.evidence.length ? '<div class="packet-list">' + packet.evidence.slice(0, 12).map((item) => '<a href="#/resource/evidence/' + encodeURIComponent(item.id) + '"><strong>' + esc(item.title) + '</strong><small>' + esc(properCase(item.status)) + ' · ' + esc(properCase(item.
|
|
1601
|
+
'<div class="dashboard-grid"><section class="panel span-2"><div class="panel-head"><h3>Coverage Gaps and Warnings</h3></div>' + (packet.gaps.length ? '<div class="packet-gaps">' + packet.gaps.map((gap) => '<div><span class="badge ' + (gap.severity === "error" ? "bad" : "warn") + '">' + esc(properCase(gap.severity)) + '</span><p>' + esc(gap.message) + '</p></div>').join("") + '</div>' : empty("No packet gaps were detected.")) + '</section><section class="panel"><div class="panel-head"><h3>Included filegrc Evidence</h3></div>' + (packet.filegrcRecords.length ? '<div class="packet-list">' + packet.filegrcRecords.slice(0, 12).map((item) => '<a href="#/resource/' + encodeURIComponent(item.type) + '/' + encodeURIComponent(item.id) + '"><strong>' + esc(item.title) + '</strong><small>' + esc(properCase(item.type)) + ' · ' + esc(item.primaryDate) + '</small></a>').join("") + '</div>' : empty("No filegrc Evidence matched.")) + '</section><section class="panel"><div class="panel-head"><h3>Included External Evidence</h3></div>' + (packet.evidence.length ? '<div class="packet-list">' + packet.evidence.slice(0, 12).map((item) => '<a href="#/resource/evidence/' + encodeURIComponent(item.id) + '"><strong>' + esc(item.title) + '</strong><small>' + esc(properCase(item.status)) + ' · ' + esc(properCase(item.artifactKind)) + '</small></a>').join("") + '</div>' : empty("No External Evidence matched.")) + '</section></div>';
|
|
840
1602
|
}
|
|
841
1603
|
|
|
842
1604
|
function obligationPreview(items) {
|
|
843
|
-
return items.length ? '<div class="obligation-preview">' + items.map((item) => '<a href="#/stage/run"><span class="status-dot ' + (
|
|
1605
|
+
return items.length ? '<div class="obligation-preview">' + items.map((item) => '<a href="#/stage/run"><span class="status-dot ' + (["blocked", "overdue"].includes(item.status) ? "bad" : item.status === "due" ? "warn" : "neutral") + '"></span><span><strong>' + esc(item.title) + '</strong><small>' + esc(timingText(item)) + '</small></span></a>').join("") + '</div>' : empty("No open obligations.");
|
|
844
1606
|
}
|
|
845
1607
|
|
|
846
1608
|
function distinctObligationPreviews(items, limit) {
|
|
@@ -876,6 +1638,7 @@ function windowText(item) {
|
|
|
876
1638
|
function timingText(item) {
|
|
877
1639
|
if (item.canceledAction) return "Action canceled; resolve or cancel the event";
|
|
878
1640
|
if (item.missingCompletion) return "Link required completion proof";
|
|
1641
|
+
if (item.status === "blocked") return item.blockingReason || "Blocked";
|
|
879
1642
|
if (item.status === "proposed") return "Starter proposal";
|
|
880
1643
|
if (item.status === "overdue" && Number.isInteger(item.hoursOverdue)) {
|
|
881
1644
|
return item.hoursOverdue === 0 ? "Overdue less than 1 hour" : item.hoursOverdue + " hour" + (item.hoursOverdue === 1 ? "" : "s") + " overdue";
|
|
@@ -893,13 +1656,11 @@ function timingText(item) {
|
|
|
893
1656
|
}
|
|
894
1657
|
|
|
895
1658
|
function relativeEventWindow(window) {
|
|
896
|
-
if (Number.isInteger(window?.
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
}
|
|
902
|
-
return "Due within 30 days";
|
|
1659
|
+
if (!Number.isInteger(window?.dueAfter)) return "Deadline not configured";
|
|
1660
|
+
const unit = window.precision === "timestamp" ? "hour" : "day";
|
|
1661
|
+
return window.dueAfter === 0
|
|
1662
|
+
? (window.precision === "timestamp" ? "Due at the event time" : "Due on the event date")
|
|
1663
|
+
: "Due within " + window.dueAfter + " " + unit + (window.dueAfter === 1 ? "" : "s");
|
|
903
1664
|
}
|
|
904
1665
|
|
|
905
1666
|
function eventStepSummary(step) {
|
|
@@ -915,6 +1676,7 @@ function eventStepSummary(step) {
|
|
|
915
1676
|
function renderList(main, type, params = new URLSearchParams()) {
|
|
916
1677
|
const definition = state.model.resources[type];
|
|
917
1678
|
if (!definition) return renderNotFound(main);
|
|
1679
|
+
const listStage = readinessStageForType(type);
|
|
918
1680
|
const entries = resourcesOfType(type);
|
|
919
1681
|
const requestedPage = Number(params.get("page"));
|
|
920
1682
|
let pageNumber = Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
|
|
@@ -931,11 +1693,21 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
931
1693
|
return { name, label: field.label || humanize(name), values };
|
|
932
1694
|
}).filter(({ values }) => values.length > 1);
|
|
933
1695
|
const createButton = !state.readOnly && !definition.singleton ? '<button class="button primary" id="new-resource">New ' + esc(definition.title.toLowerCase()) + '</button>' : "";
|
|
1696
|
+
const hasPendingApplicability = entries.some(({ record }) => (
|
|
1697
|
+
!record.applicabilityReview
|
|
1698
|
+
|| type === "requirement" && record.applicability === "undetermined"
|
|
1699
|
+
));
|
|
1700
|
+
const applicabilityButton = !state.readOnly
|
|
1701
|
+
&& ["requirement", "control", "commitment", "complementary-control"].includes(type)
|
|
1702
|
+
&& hasPendingApplicability
|
|
1703
|
+
? '<button class="button" id="review-applicability">Review applicability</button>'
|
|
1704
|
+
: "";
|
|
934
1705
|
const guideTrigger = '<button class="guide-trigger" id="resource-guide-trigger" type="button" aria-label="About ' + esc(definition.pluralTitle) + '" 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>';
|
|
935
1706
|
const listTools = '<div class="list-tools list-header-tools"><label><span class="sr-only">Filter list</span><input id="list-search" type="search" placeholder="Filter ' + esc(definition.pluralTitle.toLowerCase()) + '"></label>' +
|
|
936
|
-
filters.map(({ name, label, values }) => '<select class="field-filter" data-field="' + esc(name) + '" aria-label="Filter by ' + esc(label.toLowerCase()) + '"><option value="">Any ' + esc(properCase(label)) + '</option>' + values.map((value) => '<option value="' + esc(value) + '">' + esc(filterOptionLabel(value)) + '</option>').join("") + '</select>').join("") + '<span id="result-count" aria-live="polite">' + entries.length + ' records</span>' + createButton + '</div>';
|
|
937
|
-
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">' + esc(
|
|
938
|
-
|
|
1707
|
+
filters.map(({ name, label, values }) => '<select class="field-filter" data-field="' + esc(name) + '" aria-label="Filter by ' + esc(label.toLowerCase()) + '"><option value="">Any ' + esc(properCase(label)) + '</option>' + values.map((value) => '<option value="' + esc(value) + '">' + esc(filterOptionLabel(value)) + '</option>').join("") + '</select>').join("") + '<span id="result-count" aria-live="polite">' + entries.length + ' records</span>' + applicabilityButton + createButton + '</div>';
|
|
1708
|
+
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">' + esc(listStage?.title || groupTitle(definition.group)) + '</p><div class="page-title-line"><h2>' + esc(titleCase(definition.pluralTitle)) + '</h2>' + guideTrigger + '</div></div>' + listTools + '</div>' + resourceGuide(type) +
|
|
1709
|
+
collectionReviewPanel(type) +
|
|
1710
|
+
'<section class="record-table-wrap"><table class="record-table"><thead><tr><th>' + esc(fieldLabel(type, "title")) + '</th>' + fields.map((name) => '<th>' + esc(fieldLabel(type, name)) + '</th>').join("") + '<th>Next action</th><th>Git file</th></tr></thead><tbody id="record-rows"></tbody></table></section>' +
|
|
939
1711
|
'<nav class="pagination list-pagination" aria-label="' + esc(definition.pluralTitle) + ' pages" hidden><button class="button" type="button" data-page="previous">Previous</button><span class="page-status" aria-live="polite"></span><button class="button" type="button" data-page="next">Next</button></nav></div>';
|
|
940
1712
|
resourceGuideCleanup = setupResourceGuide(main);
|
|
941
1713
|
const pagination = main.querySelector(".list-pagination");
|
|
@@ -951,7 +1723,7 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
951
1723
|
const start = (pageNumber - 1) * LIST_PAGE_SIZE;
|
|
952
1724
|
const visible = filtered.slice(start, start + LIST_PAGE_SIZE);
|
|
953
1725
|
main.querySelector("#result-count").textContent = filtered.length + (filtered.length === 1 ? " record" : " records");
|
|
954
|
-
main.querySelector("#record-rows").innerHTML = filtered.length ? visible.map((entry) => '<tr><td data-label="' + esc(fieldLabel(type, "title")) + '" data-primary-field><a class="record-title" href="#/resource/' + encodeURIComponent(type) + '/' + encodeURIComponent(entry.record.id) + '">' + esc(entry.record.title) + '</a></td>' + fields.map((name) => '<td data-label="' + esc(fieldLabel(type, name)) + '">' + (name === "$operationTracking" ? controlOperationTracking(entry.record) : name === "$workQueueStatus" ? obligationWorkQueueStatus(entry.record) : formatValue(entry.record[name], name, type)) + '</td>').join("") + '<td data-label="Git file"><code>' + esc(entry.relativePath.replace(/^data\//, "")) + '</code></td></tr>').join("") : '<tr><td colspan="' + (fields.length +
|
|
1726
|
+
main.querySelector("#record-rows").innerHTML = filtered.length ? visible.map((entry) => '<tr><td data-label="' + esc(fieldLabel(type, "title")) + '" data-primary-field><a class="record-title" href="#/resource/' + encodeURIComponent(type) + '/' + encodeURIComponent(entry.record.id) + '">' + esc(entry.record.title) + '</a></td>' + fields.map((name) => '<td data-label="' + esc(fieldLabel(type, name)) + '">' + (name === "$operationTracking" ? controlOperationTracking(entry.record) : name === "$workQueueStatus" ? obligationWorkQueueStatus(entry.record) : formatValue(name === "status" ? displayStatus(entry.record) : entry.record[name], name, type)) + '</td>').join("") + '<td data-label="Next action">' + recordWorkflowCell(type, entry) + '</td><td data-label="Git file"><code>' + esc(entry.relativePath.replace(/^data\//, "")) + '</code></td></tr>').join("") : '<tr><td colspan="' + (fields.length + 3) + '">' + empty(entries.length ? "No records match this filter." : state.collectionReviews?.[type] ? "No records exist. Use the scope confirmation above to record an allowed zero population or add the records management identified." : definition.guidance?.emptyState || "No records exist. Use the page guidance to decide whether a record is required, then add only real program facts.") + '</td></tr>';
|
|
955
1727
|
pagination.hidden = totalPages === 1;
|
|
956
1728
|
previous.disabled = pageNumber === 1;
|
|
957
1729
|
next.disabled = pageNumber === totalPages;
|
|
@@ -991,19 +1763,32 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
991
1763
|
renderRows();
|
|
992
1764
|
syncRoute();
|
|
993
1765
|
main.querySelector("#new-resource")?.addEventListener("click", () => openEditor(type));
|
|
1766
|
+
main.querySelector("#review-applicability")?.addEventListener("click", () => openApplicabilityReviewDialog(type, entries));
|
|
1767
|
+
main.querySelector("[data-review-collection]")?.addEventListener("click", () => openCollectionReviewDialog(type));
|
|
994
1768
|
if (params.get("new") === "1" && !state.readOnly && !definition.singleton) queueMicrotask(() => openEditor(type));
|
|
1769
|
+
if (params.get("review") === "1" && !state.readOnly) {
|
|
1770
|
+
queueMicrotask(() => main.querySelector("#review-applicability")?.click());
|
|
1771
|
+
}
|
|
1772
|
+
if (params.get("review-collection") === "1" && !state.readOnly) {
|
|
1773
|
+
queueMicrotask(() => main.querySelector("[data-review-collection]")?.click());
|
|
1774
|
+
}
|
|
995
1775
|
}
|
|
996
1776
|
|
|
997
1777
|
function renderDetail(main, type, id) {
|
|
998
1778
|
const entry = resourcesOfType(type).find(({ record }) => record.id === id);
|
|
999
1779
|
const definition = state.model.resources[type];
|
|
1000
1780
|
if (!entry || !definition) return renderNotFound(main);
|
|
1781
|
+
if (entry.detailsLoaded === false) {
|
|
1782
|
+
main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="#/resources/' + encodeURIComponent(type) + '">' + esc(titleCase(definition.pluralTitle)) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div></div><section class="panel detail-loading" role="status">Loading record content and file history…</section></div>';
|
|
1783
|
+
loadResourceDetail(type, id);
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1001
1786
|
const fields = { ...state.model.commonFields, ...definition.fields };
|
|
1002
1787
|
const recordContent = recordContentDefinition(type);
|
|
1003
1788
|
const narrative = recordNarrative(entry.record, fields);
|
|
1004
1789
|
const narrativeNames = new Set(narrative.map(([name]) => name));
|
|
1005
1790
|
const visible = Object.entries(entry.record).filter(([name]) => (
|
|
1006
|
-
!["
|
|
1791
|
+
!["id", "type", "title"].includes(name)
|
|
1007
1792
|
&& !fields[name]?.content
|
|
1008
1793
|
&& !narrativeNames.has(name)
|
|
1009
1794
|
));
|
|
@@ -1024,12 +1809,33 @@ function renderDetail(main, type, id) {
|
|
|
1024
1809
|
? ""
|
|
1025
1810
|
: (FINDING_SOURCE_TYPES.has(type) ? '<button class="button" type="button" data-record-finding>Record finding</button>' : "")
|
|
1026
1811
|
+ (ACTION_ITEM_SOURCE_TYPES.has(type) ? '<button class="button" type="button" data-add-action-item>Add task</button>' : "");
|
|
1812
|
+
const lifecycleActions = state.readOnly
|
|
1813
|
+
? ""
|
|
1814
|
+
: type === "action-item" && !["done", "canceled"].includes(entry.record.status) && entry.record.obligationId
|
|
1815
|
+
? '<button class="button primary" type="button" data-complete-action-detail>Complete task</button>'
|
|
1816
|
+
: type === "obligation-event" && entry.record.status !== "complete"
|
|
1817
|
+
? '<button class="button primary" type="button" data-complete-event>Complete event</button>'
|
|
1818
|
+
: "";
|
|
1819
|
+
const governanceActions = !state.readOnly
|
|
1820
|
+
&& type === "appointment"
|
|
1821
|
+
&& entry.record.appointmentKind === "independent-policy-reviewer"
|
|
1822
|
+
&& entry.record.status === "planned"
|
|
1823
|
+
? '<button class="button" type="button" data-external-reviewer-governance>Use external reviewer</button>'
|
|
1824
|
+
: "";
|
|
1825
|
+
const auditCycleAction = !state.readOnly
|
|
1826
|
+
&& type === "audit"
|
|
1827
|
+
&& entry.record.status === "complete"
|
|
1828
|
+
? '<button class="button primary" type="button" data-next-audit-cycle>' + (entry.record.auditKind === "soc-2-type-1" ? "Start Type 2 period" : "Start next cycle") + '</button>'
|
|
1829
|
+
: "";
|
|
1027
1830
|
const detailMain = hasRecordBody
|
|
1028
1831
|
? '<section class="panel detail-main">' + narrativeContent + markdownContent + addRecordContent + '</section>'
|
|
1029
1832
|
: "";
|
|
1030
|
-
|
|
1031
|
-
|
|
1833
|
+
const attachmentPanel = type === "evidence" ? evidenceAttachmentPanel(entry) : "";
|
|
1834
|
+
main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="#/resources/' + encodeURIComponent(type) + '">' + esc(titleCase(definition.pluralTitle)) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div><div class="actions">' + auditCycleAction + (type === "audit" ? '<a class="button primary" href="#/audit-packet?auditId=' + encodeURIComponent(entry.record.id) + '">Audit Evidence & Packet</a>' : "") + governanceActions + lifecycleActions + issueActions + addRecordContentAction + (!state.readOnly ? '<button class="button" id="edit-resource">Edit</button>' + (!definition.singleton ? '<button class="button danger" id="delete-resource">Delete</button>' : "") : "") + '</div></div>' + workflowGuidance({ type, id, title: "Finalization checklist" }) + resourceReviewCriteria(type) + '<div class="detail-grid ' + (hasRecordBody ? "" : "detail-grid-structured") + '">' + detailMain +
|
|
1835
|
+
'<aside><section class="panel"><div class="panel-head"><h3>Metadata</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>' + attachmentPanel + personParticipation(entry) + resourceConnections(entry) + '<section class="panel"><div class="panel-head"><h3>File History</h3></div>' + (entry.history?.length ? '<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>' : empty("No committed history for this file.")) + '</section></aside></div></div>';
|
|
1032
1836
|
main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry));
|
|
1837
|
+
main.querySelector("[data-external-reviewer-governance]")?.addEventListener("click", openExternalReviewerGovernanceDialog);
|
|
1838
|
+
main.querySelector("[data-next-audit-cycle]")?.addEventListener("click", () => openNextAuditCycleDialog(entry.record));
|
|
1033
1839
|
main.querySelector("[data-record-finding]")?.addEventListener("click", () => openEditor("finding", null, {
|
|
1034
1840
|
seed: issueSeed("finding", entry.record),
|
|
1035
1841
|
description: "Record only a confirmed gap that needs separate remediation tracking. Keep the report details in this source record’s Markdown."
|
|
@@ -1038,6 +1844,68 @@ function renderDetail(main, type, id) {
|
|
|
1038
1844
|
seed: issueSeed("action-item", entry.record),
|
|
1039
1845
|
description: "Create a separate task only when this follow-up needs its own assignee, deadline, and completion proof. It will appear in Work Queue."
|
|
1040
1846
|
}));
|
|
1847
|
+
main.querySelector("[data-complete-action-detail]")?.addEventListener("click", () => {
|
|
1848
|
+
const item = state.obligations.items.find(({ actionItemId }) => actionItemId === entry.record.id);
|
|
1849
|
+
if (item) openActionCompletion(item);
|
|
1850
|
+
else showError("This Action Item is not available in the current Work Queue calculation.");
|
|
1851
|
+
});
|
|
1852
|
+
main.querySelector("[data-complete-event]")?.addEventListener("click", async () => {
|
|
1853
|
+
try {
|
|
1854
|
+
const response = await localFetch("/api/obligation-event-completions", {
|
|
1855
|
+
method: "POST",
|
|
1856
|
+
headers: { "content-type": "application/json" },
|
|
1857
|
+
body: JSON.stringify({
|
|
1858
|
+
eventId: entry.record.id,
|
|
1859
|
+
completedOn: currentDate(),
|
|
1860
|
+
revision: entry.revision
|
|
1861
|
+
})
|
|
1862
|
+
});
|
|
1863
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1864
|
+
applyMutationState(await response.json());
|
|
1865
|
+
render();
|
|
1866
|
+
} catch (error) {
|
|
1867
|
+
showError(error.message);
|
|
1868
|
+
}
|
|
1869
|
+
});
|
|
1870
|
+
main.querySelector("[data-attach-evidence]")?.addEventListener("click", () => {
|
|
1871
|
+
main.querySelector("[data-evidence-file]")?.click();
|
|
1872
|
+
});
|
|
1873
|
+
main.querySelector("[data-evidence-file]")?.addEventListener("change", async (event) => {
|
|
1874
|
+
const file = event.currentTarget.files?.[0];
|
|
1875
|
+
if (!file) return;
|
|
1876
|
+
if (!confirm('Attach "' + file.name + '" to this Evidence record? Confirm that its classification, retention, and repository access are appropriate.')) {
|
|
1877
|
+
event.currentTarget.value = "";
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
try {
|
|
1881
|
+
const response = await localFetch(
|
|
1882
|
+
"/api/evidence-attachments/" + encodeURIComponent(entry.record.id) + "/" + encodeURIComponent(file.name)
|
|
1883
|
+
+ "?revision=" + encodeURIComponent(entry.revision),
|
|
1884
|
+
{ method: "POST", body: file }
|
|
1885
|
+
);
|
|
1886
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1887
|
+
applyMutationState(await response.json());
|
|
1888
|
+
render();
|
|
1889
|
+
} catch (error) {
|
|
1890
|
+
showError(error.message);
|
|
1891
|
+
}
|
|
1892
|
+
});
|
|
1893
|
+
main.querySelectorAll("[data-detach-evidence]").forEach((button) => button.addEventListener("click", async () => {
|
|
1894
|
+
const attachment = button.dataset.detachEvidence;
|
|
1895
|
+
if (!confirm('Remove "' + attachment + '" from this Evidence record and delete its local file?')) return;
|
|
1896
|
+
try {
|
|
1897
|
+
const response = await localFetch(
|
|
1898
|
+
"/api/evidence-attachments/" + encodeURIComponent(entry.record.id) + "/" + encodeURIComponent(attachment)
|
|
1899
|
+
+ "?revision=" + encodeURIComponent(entry.revision),
|
|
1900
|
+
{ method: "DELETE" }
|
|
1901
|
+
);
|
|
1902
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1903
|
+
applyMutationState(await response.json());
|
|
1904
|
+
render();
|
|
1905
|
+
} catch (error) {
|
|
1906
|
+
showError(error.message);
|
|
1907
|
+
}
|
|
1908
|
+
}));
|
|
1041
1909
|
main.querySelector("#add-record-content")?.addEventListener("click", () => openEditor(type, entry, { addRecordContent: true }));
|
|
1042
1910
|
main.querySelectorAll("[data-edit-content]").forEach((button) => button.addEventListener("click", () => openContentEditor(entry, button.dataset.editContent)));
|
|
1043
1911
|
main.querySelector("#delete-resource")?.addEventListener("click", async () => {
|
|
@@ -1045,7 +1913,7 @@ function renderDetail(main, type, id) {
|
|
|
1045
1913
|
try {
|
|
1046
1914
|
const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + "?revision=" + encodeURIComponent(entry.revision), { method: "DELETE" });
|
|
1047
1915
|
if (!response.ok) return showError(await responseMessage(response));
|
|
1048
|
-
|
|
1916
|
+
applyMutationState(await response.json());
|
|
1049
1917
|
location.hash = "#/resources/" + encodeURIComponent(type);
|
|
1050
1918
|
} catch (error) {
|
|
1051
1919
|
showError(error.message);
|
|
@@ -1053,6 +1921,49 @@ function renderDetail(main, type, id) {
|
|
|
1053
1921
|
});
|
|
1054
1922
|
}
|
|
1055
1923
|
|
|
1924
|
+
function evidenceAttachmentPanel(entry) {
|
|
1925
|
+
const attachments = (entry.record.filePaths || []).map((path) => ({
|
|
1926
|
+
path,
|
|
1927
|
+
name: path.split("/").at(-1)
|
|
1928
|
+
}));
|
|
1929
|
+
const rows = attachments.length
|
|
1930
|
+
? attachments.map(({ path, name }) => (
|
|
1931
|
+
'<li><span><strong>' + esc(name) + '</strong><small>' + esc(path) + '</small></span>'
|
|
1932
|
+
+ (!state.readOnly ? '<button class="text-button danger-text" type="button" data-detach-evidence="' + esc(name) + '">Remove</button>' : "")
|
|
1933
|
+
+ '</li>'
|
|
1934
|
+
)).join("")
|
|
1935
|
+
: '<li class="attachment-empty">No local attachments. An approved external reference may be used when the source cannot be stored here.</li>';
|
|
1936
|
+
return '<section class="panel evidence-attachments"><div class="panel-head"><div><h3>Attachments</h3><p>Fixed source files retained with this Evidence record.</p></div>'
|
|
1937
|
+
+ (!state.readOnly ? '<button class="button" type="button" data-attach-evidence>Attach file</button><input type="file" data-evidence-file hidden>' : "")
|
|
1938
|
+
+ '</div><ul>' + rows + '</ul></section>';
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
async function loadResourceDetail(type, id) {
|
|
1942
|
+
const key = type + "\0" + id;
|
|
1943
|
+
if (resourceDetailRequests.has(key)) return resourceDetailRequests.get(key);
|
|
1944
|
+
const request = (async () => {
|
|
1945
|
+
try {
|
|
1946
|
+
const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id));
|
|
1947
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1948
|
+
const detail = await response.json();
|
|
1949
|
+
const index = state.resources.findIndex(({ record }) => record.type === type && record.id === id);
|
|
1950
|
+
if (index >= 0) state.resources[index] = detail;
|
|
1951
|
+
const route = parseRoute();
|
|
1952
|
+
if (route.name === "detail" && route.type === type && route.id === id) render();
|
|
1953
|
+
} catch (error) {
|
|
1954
|
+
const route = parseRoute();
|
|
1955
|
+
if (route.name === "detail" && route.type === type && route.id === id) {
|
|
1956
|
+
const main = root.querySelector("main");
|
|
1957
|
+
if (main) main.innerHTML = '<div class="page"><section class="panel"><div class="dialog-error" role="alert">' + esc(error.message) + '</div></section></div>';
|
|
1958
|
+
}
|
|
1959
|
+
} finally {
|
|
1960
|
+
resourceDetailRequests.delete(key);
|
|
1961
|
+
}
|
|
1962
|
+
})();
|
|
1963
|
+
resourceDetailRequests.set(key, request);
|
|
1964
|
+
return request;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1056
1967
|
function issueSeed(type, source) {
|
|
1057
1968
|
const owners = source.ownerIds || source.reviewerIds || source.assessorIds || source.testerIds || [];
|
|
1058
1969
|
if (type === "finding") {
|
|
@@ -1091,11 +2002,67 @@ function recordContentDefinition(type) {
|
|
|
1091
2002
|
};
|
|
1092
2003
|
}
|
|
1093
2004
|
|
|
2005
|
+
function personParticipation(entry) {
|
|
2006
|
+
if (entry.record.type !== "person") return "";
|
|
2007
|
+
const personId = entry.record.id;
|
|
2008
|
+
const appointments = resourcesOfType("appointment")
|
|
2009
|
+
.map(({ record }) => record)
|
|
2010
|
+
.filter(({ holderId }) => holderId === personId);
|
|
2011
|
+
const appointmentIds = new Set(appointments.map(({ id }) => id));
|
|
2012
|
+
const affiliations = appointments.map((record) => ({
|
|
2013
|
+
record,
|
|
2014
|
+
detail: "Appointment · " + properCase(record.status)
|
|
2015
|
+
}));
|
|
2016
|
+
for (const { record } of resourcesOfType("team")) {
|
|
2017
|
+
const member = (record.memberIds || []).includes(personId);
|
|
2018
|
+
const chair = (record.chairIds || []).some((id) => id === personId || appointmentIds.has(id));
|
|
2019
|
+
if (member || chair) affiliations.push({
|
|
2020
|
+
record,
|
|
2021
|
+
detail: "Team · " + (chair ? "Chair" : "Member")
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
const assignments = [];
|
|
2025
|
+
for (const candidate of state.resources) {
|
|
2026
|
+
if (["person", "appointment", "team"].includes(candidate.record.type)) continue;
|
|
2027
|
+
const fields = { ...state.model.commonFields, ...state.model.resources[candidate.record.type].fields };
|
|
2028
|
+
const reasons = [];
|
|
2029
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
2030
|
+
if (!field.relation) continue;
|
|
2031
|
+
const values = Array.isArray(candidate.record[name]) ? candidate.record[name] : [candidate.record[name]];
|
|
2032
|
+
if (values.includes(personId)) reasons.push(fieldLabel(candidate.record.type, name));
|
|
2033
|
+
for (const appointment of appointments) {
|
|
2034
|
+
if (values.includes(appointment.id)) reasons.push(fieldLabel(candidate.record.type, name) + " via " + appointment.title);
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
if (reasons.length) assignments.push({
|
|
2038
|
+
record: candidate.record,
|
|
2039
|
+
detail: [...new Set(reasons)].join(" · ")
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
2042
|
+
const count = affiliations.length + assignments.length;
|
|
2043
|
+
if (!count) return "";
|
|
2044
|
+
return '<section class="panel connections-panel"><div class="panel-head"><h3>Participation</h3><span>' + count + '</span></div>'
|
|
2045
|
+
+ personParticipationGroup("Appointments and teams", affiliations, 8, "more appointments or teams")
|
|
2046
|
+
+ personParticipationGroup("Assigned records", assignments, 10, "more assigned records")
|
|
2047
|
+
+ '</section>';
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
function personParticipationGroup(title, rows, limit, moreLabel) {
|
|
2051
|
+
if (!rows.length) return "";
|
|
2052
|
+
const visible = rows.slice(0, limit);
|
|
2053
|
+
return '<div class="connection-group"><h4>' + esc(title) + '</h4><div class="connections">'
|
|
2054
|
+
+ visible.map(({ record, detail }) => '<a href="#/resource/' + encodeURIComponent(record.type) + '/' + encodeURIComponent(record.id) + '"><strong>' + esc(record.title) + '</strong><small>' + esc(detail) + '</small></a>').join("")
|
|
2055
|
+
+ '</div>' + (rows.length > visible.length ? '<p class="connections-more">' + (rows.length - visible.length) + ' ' + esc(moreLabel) + ' are available through connected records.</p>' : "")
|
|
2056
|
+
+ '</div>';
|
|
2057
|
+
}
|
|
2058
|
+
|
|
1094
2059
|
function resourceConnections(entry) {
|
|
2060
|
+
const relatedPeopleOnly = entry.record.type === "person";
|
|
1095
2061
|
const connections = new Map();
|
|
1096
2062
|
const entriesById = new Map(state.resources.map((item) => [item.record.id, item]));
|
|
1097
2063
|
const add = (connectedEntry, reason) => {
|
|
1098
2064
|
if (!connectedEntry || connectedEntry.record.id === entry.record.id) return;
|
|
2065
|
+
if (relatedPeopleOnly && connectedEntry.record.type !== "person") return;
|
|
1099
2066
|
const existing = connections.get(connectedEntry.record.id) || { entry: connectedEntry, reasons: new Set() };
|
|
1100
2067
|
existing.reasons.add(reason);
|
|
1101
2068
|
connections.set(connectedEntry.record.id, existing);
|
|
@@ -1125,7 +2092,7 @@ function resourceConnections(entry) {
|
|
|
1125
2092
|
});
|
|
1126
2093
|
if (!sorted.length) return "";
|
|
1127
2094
|
const visible = sorted.slice(0, 14);
|
|
1128
|
-
return '<section class="panel connections-panel"><div class="panel-head"><h3>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>';
|
|
2095
|
+
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>';
|
|
1129
2096
|
}
|
|
1130
2097
|
|
|
1131
2098
|
function navigationResourceTypes() {
|
|
@@ -1235,7 +2202,7 @@ async function runRepositoryGitAction(action) {
|
|
|
1235
2202
|
const response = await localFetch("/api/git/" + action, { method: "POST" });
|
|
1236
2203
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1237
2204
|
const result = await response.json();
|
|
1238
|
-
|
|
2205
|
+
applyMutationState(result);
|
|
1239
2206
|
render();
|
|
1240
2207
|
const currentStatus = document.querySelector(".repository-sync-status");
|
|
1241
2208
|
if (currentStatus) currentStatus.textContent = action === "pull"
|
|
@@ -1283,7 +2250,7 @@ function openCommitDialog() {
|
|
|
1283
2250
|
});
|
|
1284
2251
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1285
2252
|
const result = await response.json();
|
|
1286
|
-
|
|
2253
|
+
applyMutationState(result);
|
|
1287
2254
|
dialog.close();
|
|
1288
2255
|
render();
|
|
1289
2256
|
const status = document.querySelector(".repository-sync-status");
|
|
@@ -1308,14 +2275,18 @@ function resourceGuide(type) {
|
|
|
1308
2275
|
const definition = state.model.resources[type];
|
|
1309
2276
|
const guidance = definition?.guidance;
|
|
1310
2277
|
if (!definition || !guidance) return "";
|
|
1311
|
-
const instructions =
|
|
2278
|
+
const instructions = RESOURCE_GUIDE_INSTRUCTIONS[type] || definition.description;
|
|
1312
2279
|
const sources = (guidance.sourceResourceIds || [])
|
|
1313
2280
|
.map((id) => state.resources.find(({ record }) => record.id === id))
|
|
1314
2281
|
.filter(Boolean);
|
|
1315
2282
|
const sourceLinks = sources.length
|
|
1316
2283
|
? '<div class="guide-links">' + sources.map(({ record }) => '<a href="#/resource/' + encodeURIComponent(record.type) + '/' + encodeURIComponent(record.id) + '">' + esc(record.title) + '</a>').join("") + '</div>'
|
|
1317
2284
|
: "";
|
|
1318
|
-
|
|
2285
|
+
const reviewPoints = guidance.reviewPoints || [];
|
|
2286
|
+
const reviewGuide = reviewPoints.length
|
|
2287
|
+
? '<div class="guide-review"><span>When reviewing</span><ul>' + reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></div>'
|
|
2288
|
+
: "";
|
|
2289
|
+
return '<section class="page-guide resource-guide-popover" id="resource-guide" role="dialog" aria-label="How to use ' + esc(definition.pluralTitle) + '" hidden><div><span>Instructions</span><p>' + esc(instructions) + '</p></div><div><span>Use</span><p>' + esc(definition.description) + '</p></div><div><span>Policy basis</span><p>' + esc(guidance.policyBasis) + '</p>' + sourceLinks + '</div>' + reviewGuide + '</section>';
|
|
1319
2290
|
}
|
|
1320
2291
|
|
|
1321
2292
|
function setupResourceGuide(main) {
|
|
@@ -1429,12 +2400,13 @@ function requestOnboarding() {
|
|
|
1429
2400
|
onboardingDialog = null;
|
|
1430
2401
|
onboardingDraft = null;
|
|
1431
2402
|
onboardingBusy = false;
|
|
2403
|
+
onboardingPendingDraft = false;
|
|
1432
2404
|
});
|
|
1433
2405
|
renderOnboardingStep();
|
|
1434
2406
|
}
|
|
1435
2407
|
|
|
1436
2408
|
function initialOnboardingDraft() {
|
|
1437
|
-
const systemEntry = resourcesOfType("system").find(({ record }) => record.
|
|
2409
|
+
const systemEntry = resourcesOfType("system").find(({ record }) => (state.workspace.systemIds || []).includes(record.id) && record.status !== "retired");
|
|
1438
2410
|
const owner = resourcesOfType("person").find(({ record }) => record.status === "active")?.record;
|
|
1439
2411
|
return {
|
|
1440
2412
|
systemId: systemEntry?.record.id || "",
|
|
@@ -1442,7 +2414,7 @@ function initialOnboardingDraft() {
|
|
|
1442
2414
|
scope: systemEntry?.record.description || "",
|
|
1443
2415
|
ownerId: systemEntry?.record.ownerIds?.[0] || owner?.id || "",
|
|
1444
2416
|
criticality: systemEntry?.record.criticality || "high",
|
|
1445
|
-
|
|
2417
|
+
classificationId: systemEntry?.record.classificationId || defaultClassificationId(),
|
|
1446
2418
|
internetExposed: systemEntry?.record.internetExposed === false ? "false" : "true",
|
|
1447
2419
|
programGoal: programGoalFromKind(state.workspace.assuranceGoal)
|
|
1448
2420
|
};
|
|
@@ -1457,91 +2429,62 @@ function onboardingSteps() {
|
|
|
1457
2429
|
points: [
|
|
1458
2430
|
"Use the UI, an editor, the CLI, or an agent; every path changes the same files.",
|
|
1459
2431
|
"JSON holds structured records. Markdown holds policies, plans, minutes, and other long-form work.",
|
|
1460
|
-
"In trunk mode,
|
|
1461
|
-
"Record status represents approval. Draft, proposed, approved, and retired records all stay on the authoritative branch.",
|
|
1462
|
-
"Agents and terminal users continue to manage Git explicitly.",
|
|
1463
|
-
"The dashboard derives program status from the current repository state."
|
|
2432
|
+
"Record status represents approval. In trunk mode, the browser validates, commits, and pushes each save; agents and terminal users manage Git explicitly."
|
|
1464
2433
|
]
|
|
1465
2434
|
};
|
|
1466
2435
|
const path = {
|
|
1467
2436
|
target: ".readiness-map",
|
|
1468
2437
|
kicker: "Program model",
|
|
1469
2438
|
title: "Follow the audit chain",
|
|
1470
|
-
body: "
|
|
2439
|
+
body: "Follow the five program steps in order. Each step reveals the records and decisions needed next.",
|
|
1471
2440
|
points: [
|
|
1472
|
-
"
|
|
1473
|
-
"
|
|
1474
|
-
"
|
|
1475
|
-
"The CPA firm, formal report period, fieldwork, and final report are the last stage. Engage earlier only when timing or scope needs outside input."
|
|
2441
|
+
"Define the people, criteria, service, Systems, and providers in scope.",
|
|
2442
|
+
"Approve the policies, implement the controls, then operate them and retain dated proof.",
|
|
2443
|
+
"Create the audit engagement when a CPA firm is involved or a real customer deadline requires it."
|
|
1476
2444
|
]
|
|
1477
2445
|
};
|
|
1478
|
-
const
|
|
2446
|
+
const operation = {
|
|
1479
2447
|
target: ".obligation-panel",
|
|
1480
|
-
kicker: "
|
|
1481
|
-
title: "Work the policy
|
|
1482
|
-
body: "Recurring
|
|
1483
|
-
points: [
|
|
1484
|
-
"Quarterly means any date in that cycle is valid unless the policy sets a narrower window.",
|
|
1485
|
-
"Link a dated completion record and its evidence to satisfy one occurrence.",
|
|
1486
|
-
"The UI and filegrc CLI use the same calculation."
|
|
1487
|
-
]
|
|
1488
|
-
};
|
|
1489
|
-
const events = {
|
|
1490
|
-
target: ".event-reminder-panel",
|
|
1491
|
-
kicker: "Triggered work",
|
|
1492
|
-
title: "Complete a checklist when key events occur",
|
|
1493
|
-
body: "Use an event reminder for a new worker, role change, departure, personal device, vendor change or incident, material system or data-use change, or security incident. One action item is created for every policy requirement, with its own owner, evidence, due range, and cutoff.",
|
|
2448
|
+
kicker: "Program operation",
|
|
2449
|
+
title: "Work the queue and trigger policy events",
|
|
2450
|
+
body: "Recurring work and assigned follow-up appear in the Work Queue with owners, allowed completion dates, and overdue cutoffs. When a listed event happens, start its Policy Event to create the applicable tasks and deadlines.",
|
|
1494
2451
|
points: [
|
|
1495
|
-
"
|
|
1496
|
-
"
|
|
1497
|
-
"
|
|
1498
|
-
"Agents start the identical workflow with the filegrc CLI."
|
|
2452
|
+
"Complete each occurrence with the requested dated operating record and supporting evidence.",
|
|
2453
|
+
"Start Policy Events only after the real event occurs. FileGRC creates one owned Action Item for each applicable policy step.",
|
|
2454
|
+
"The browser and CLI use the same schedules, event rules, and completion checks."
|
|
1499
2455
|
]
|
|
1500
2456
|
};
|
|
1501
|
-
const
|
|
2457
|
+
const auditPath = {
|
|
1502
2458
|
target: null,
|
|
1503
|
-
kicker: "
|
|
1504
|
-
title: "Choose the report goal",
|
|
2459
|
+
kicker: "Report goal and audit",
|
|
2460
|
+
title: "Choose the report goal and plan fieldwork",
|
|
1505
2461
|
body: [
|
|
1506
2462
|
"SOC 2 is an independent CPA report on controls relevant to the selected Trust Services Criteria.",
|
|
1507
|
-
"Most customer requests focus on Security. Add
|
|
2463
|
+
"Most customer requests focus on Security. Add another category only when the service and customer need call for it. Choose a management goal now, then create an Audit record only after a real CPA engagement or customer deadline exists."
|
|
1508
2464
|
],
|
|
1509
2465
|
sections: [
|
|
1510
2466
|
{
|
|
1511
2467
|
title: "Type 1",
|
|
1512
|
-
body: "
|
|
2468
|
+
body: "The CPA evaluates control design and implementation at a point in time. Type 1 is optional before Type 2."
|
|
1513
2469
|
},
|
|
1514
2470
|
{
|
|
1515
2471
|
title: "Type 2",
|
|
1516
|
-
body: "
|
|
2472
|
+
body: "The CPA evaluates operation across an agreed period. Dated evidence and complete populations must cover that period."
|
|
1517
2473
|
}
|
|
1518
2474
|
],
|
|
1519
|
-
afterSections: "
|
|
1520
|
-
};
|
|
1521
|
-
const audit = {
|
|
1522
|
-
target: ".audit-panel",
|
|
1523
|
-
kicker: "Final stage",
|
|
1524
|
-
title: "Engage the firm and prepare fieldwork",
|
|
1525
|
-
body: "Once the program is ready and evidence collection is running, record the CPA firm and the agreed scope and period. Then reconcile populations, answer requests, and generate the delivery packet.",
|
|
1526
|
-
points: [
|
|
1527
|
-
"The audit record holds the firm-agreed date or period; the workspace keeps management's earlier candidate dates.",
|
|
1528
|
-
"Audit Readiness identifies missing management documents, populations, exact-period evidence, and request work.",
|
|
1529
|
-
"The CPA firm selects samples, tests controls, evaluates exceptions, and issues the report."
|
|
1530
|
-
]
|
|
2475
|
+
afterSections: "FileGRC prepares the records and packet. The CPA firm selects samples, tests controls, evaluates exceptions, and issues the report."
|
|
1531
2476
|
};
|
|
1532
2477
|
const setup = {
|
|
1533
2478
|
target: null,
|
|
1534
2479
|
kicker: "Initial scope",
|
|
1535
2480
|
title: "Describe the service you plan to audit",
|
|
1536
|
-
body: "
|
|
2481
|
+
body: "Create the first in-scope System and record management’s goal. Step 1 will guide the rest of the scope."
|
|
1537
2482
|
};
|
|
1538
2483
|
return [
|
|
1539
2484
|
files,
|
|
1540
2485
|
path,
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
reportTypes,
|
|
1544
|
-
audit,
|
|
2486
|
+
operation,
|
|
2487
|
+
auditPath,
|
|
1545
2488
|
setup
|
|
1546
2489
|
];
|
|
1547
2490
|
}
|
|
@@ -1562,7 +2505,7 @@ function renderOnboardingStep() {
|
|
|
1562
2505
|
? onboardingSetupForm()
|
|
1563
2506
|
: description + explanation + afterSections;
|
|
1564
2507
|
const finalActions = onboardingStep === steps.length - 1
|
|
1565
|
-
? '<button class="button" type="button" data-onboarding="draft">Save
|
|
2508
|
+
? '<span class="onboarding-save-status" role="status" aria-live="polite"></span><button class="button" type="button" data-onboarding="draft">Save as planned</button><button class="button primary" type="button" data-onboarding="next">Confirm service scope</button>'
|
|
1566
2509
|
: '<button class="button primary" type="button" data-onboarding="next">Next</button>';
|
|
1567
2510
|
onboardingDialog.innerHTML = '<div class="onboarding-progress" style="--onboarding-step-count:' + steps.length + '" aria-label="Onboarding step ' + (onboardingStep + 1) + ' of ' + steps.length + '">' + progress + '</div><div class="onboarding-scroll"><div class="onboarding-head"><p class="kicker">' + esc(step.kicker) + ' · ' + (onboardingStep + 1) + ' of ' + steps.length + '</p><h2 id="onboarding-title">' + esc(titleCase(step.title)) + '</h2></div>' + body + '<div class="dialog-error" role="alert"></div></div><div class="dialog-actions onboarding-actions"><button class="button text-button onboarding-skip" type="button" data-onboarding="skip">Skip onboarding</button>' + (onboardingStep ? '<button class="button" type="button" data-onboarding="back">Back</button>' : "") + finalActions + '</div>';
|
|
1568
2511
|
onboardingDialog.querySelector('[data-onboarding="skip"]').addEventListener("click", cancelOnboarding);
|
|
@@ -1599,27 +2542,22 @@ function renderOnboardingStep() {
|
|
|
1599
2542
|
function onboardingSetupForm() {
|
|
1600
2543
|
const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
|
|
1601
2544
|
const classifications = Object.keys(state.workspace.classificationDefinitions || {});
|
|
1602
|
-
if (onboardingDraft.
|
|
1603
|
-
classifications.push(onboardingDraft.
|
|
1604
|
-
}
|
|
1605
|
-
const currentSystem = onboardingDraft.systemId ? state.resources.find(({ record }) => record.id === onboardingDraft.systemId)?.record : null;
|
|
1606
|
-
const existing = [
|
|
1607
|
-
currentSystem ? "Updates system " + currentSystem.title + "." : "Creates a new in-scope system.",
|
|
1608
|
-
"Records a management program goal without creating an audit engagement."
|
|
1609
|
-
].filter(Boolean).join(" ");
|
|
2545
|
+
if (onboardingDraft.classificationId && !classifications.includes(onboardingDraft.classificationId)) {
|
|
2546
|
+
classifications.push(onboardingDraft.classificationId);
|
|
2547
|
+
}
|
|
1610
2548
|
const gitStatus = state.repository?.mode === "trunk"
|
|
1611
|
-
? '<div class="onboarding-git-status ' + (state.repository.status === "synced" ? "" : "warning") + '"><span class="status-dot ' + repositoryStatusTone(state.repository.status) + '"></span><span><strong>' + esc(state.repository.label) + '</strong><small>' + esc(state.repository.status === "synced" ? "Completing onboarding will save its related workspace, system, and renderer changes in one commit
|
|
2549
|
+
? '<div class="onboarding-git-status ' + (state.repository.status === "synced" ? "" : "warning") + '"><span class="status-dot ' + repositoryStatusTone(state.repository.status) + '"></span><span><strong>' + esc(state.repository.label) + '</strong><small>' + esc(state.repository.status === "synced" ? "Completing onboarding will save its related workspace, system, and renderer changes in one local commit, then push it in the background." : state.repository.message) + '</small></span></div>'
|
|
1612
2550
|
: state.git.available && state.git.branch
|
|
1613
2551
|
? '<div class="onboarding-git-status"><span class="status-dot good"></span><span><strong>Manual repository mode</strong><small>Setup changes will stay local until you commit and synchronize them.</small></span></div>'
|
|
1614
2552
|
: '<div class="onboarding-git-status warning"><span class="status-dot warn"></span><span><strong>Git setup needed</strong><small>Manual-mode writes still work, but Git history is unavailable until the repository is configured.</small></span></div>';
|
|
1615
|
-
return '<p class="onboarding-body">' + esc(onboardingSteps().at(-1).body) + '</p>' + gitStatus + '<form id="onboarding-setup" class="onboarding-form"><label class="wide"><span>Service name</span><input name="serviceName" required maxlength="200" value="' + esc(onboardingDraft.serviceName) + '" placeholder="Customer-facing application"></label><label class="wide"><span>Scope description</span><textarea name="scope" required maxlength="2000" placeholder="What the service does and which production boundary is in scope">' + esc(onboardingDraft.scope) + '</textarea></label><label><span>Accountable owner</span><select name="ownerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (record.id === onboardingDraft.ownerId ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Business criticality</span><select name="criticality" required>' + ["low", "medium", "high", "critical"].map((value) => '<option value="' + value + '" ' + (value === onboardingDraft.criticality ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Highest data classification</span><select name="
|
|
2553
|
+
return '<p class="onboarding-body">' + esc(onboardingSteps().at(-1).body) + '</p>' + gitStatus + '<form id="onboarding-setup" class="onboarding-form"><label class="wide"><span>Service name</span><input name="serviceName" required maxlength="200" value="' + esc(onboardingDraft.serviceName) + '" placeholder="Customer-facing application"></label><label class="wide"><span>Scope description</span><textarea name="scope" required maxlength="2000" placeholder="What the service does and which production boundary is in scope">' + esc(onboardingDraft.scope) + '</textarea></label><label><span>Accountable owner</span><select name="ownerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (record.id === onboardingDraft.ownerId ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Business criticality</span><select name="criticality" required>' + ["low", "medium", "high", "critical"].map((value) => '<option value="' + value + '" ' + (value === onboardingDraft.criticality ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Highest data classification</span><select name="classificationId" required>' + classifications.map((value) => '<option value="' + esc(value) + '" ' + (value === onboardingDraft.classificationId ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Internet exposed</span><select name="internetExposed" required><option value="true" ' + (onboardingDraft.internetExposed === "true" ? "selected" : "") + '>Yes</option><option value="false" ' + (onboardingDraft.internetExposed === "false" ? "selected" : "") + '>No</option></select></label><label class="wide"><span>Program goal</span><select name="programGoal" required><option value="none" ' + (onboardingDraft.programGoal === "none" ? "selected" : "") + '>No Assurance Goal Yet</option><option value="readiness" ' + (onboardingDraft.programGoal === "readiness" ? "selected" : "") + '>Program Readiness</option><option value="type-1" ' + (onboardingDraft.programGoal === "type-1" ? "selected" : "") + '>SOC 2 Type 1</option><option value="type-2" ' + (onboardingDraft.programGoal === "type-2" ? "selected" : "") + '>SOC 2 Type 2</option></select><small>This records management intent only. It does not create an engagement or establish the formal report period.</small></label></form><p class="onboarding-write-note">Save as planned keeps the System planned. Confirm service scope makes it active. Both add it to the Workspace scope.</p>';
|
|
1616
2554
|
}
|
|
1617
2555
|
|
|
1618
2556
|
function captureOnboardingForm() {
|
|
1619
2557
|
const form = onboardingDialog?.querySelector("#onboarding-setup");
|
|
1620
2558
|
if (!form) return;
|
|
1621
2559
|
const data = new FormData(form);
|
|
1622
|
-
for (const name of ["serviceName", "scope", "ownerId", "criticality", "
|
|
2560
|
+
for (const name of ["serviceName", "scope", "ownerId", "criticality", "classificationId", "internetExposed", "programGoal"]) {
|
|
1623
2561
|
onboardingDraft[name] = String(data.get(name) || "").trim();
|
|
1624
2562
|
}
|
|
1625
2563
|
}
|
|
@@ -1639,7 +2577,7 @@ async function saveOnboarding(draft = false) {
|
|
|
1639
2577
|
boundary: onboardingDraft.scope,
|
|
1640
2578
|
ownerId: onboardingDraft.ownerId,
|
|
1641
2579
|
criticality: onboardingDraft.criticality,
|
|
1642
|
-
|
|
2580
|
+
classificationId: onboardingDraft.classificationId,
|
|
1643
2581
|
internetExposed: onboardingDraft.internetExposed === "true",
|
|
1644
2582
|
programGoal: onboardingDraft.programGoal,
|
|
1645
2583
|
systemId: onboardingDraft.systemId,
|
|
@@ -1647,14 +2585,55 @@ async function saveOnboarding(draft = false) {
|
|
|
1647
2585
|
})
|
|
1648
2586
|
});
|
|
1649
2587
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1650
|
-
|
|
2588
|
+
const result = await response.json();
|
|
2589
|
+
applyMutationState(result);
|
|
2590
|
+
if (result.synchronization?.pushError) {
|
|
2591
|
+
onboardingDraft.systemId = result.system?.id || onboardingDraft.systemId;
|
|
2592
|
+
onboardingPendingDraft = draft;
|
|
2593
|
+
setOnboardingBusy(false);
|
|
2594
|
+
showOnboardingError(result.synchronization.pushError, true);
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
1651
2597
|
closeOnboarding();
|
|
1652
2598
|
history.replaceState(null, "", draft ? "#/" : "#/stage/scope");
|
|
1653
2599
|
render();
|
|
1654
2600
|
} catch (error) {
|
|
1655
2601
|
setOnboardingBusy(false);
|
|
1656
|
-
|
|
1657
|
-
|
|
2602
|
+
showOnboardingError(error.message);
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
function showOnboardingError(message, retrySync = false) {
|
|
2607
|
+
const errorNode = onboardingDialog?.querySelector(".dialog-error");
|
|
2608
|
+
if (!errorNode) return;
|
|
2609
|
+
errorNode.textContent = message;
|
|
2610
|
+
if (!retrySync) return;
|
|
2611
|
+
onboardingDialog.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = true; });
|
|
2612
|
+
const retry = document.createElement("button");
|
|
2613
|
+
retry.type = "button";
|
|
2614
|
+
retry.className = "button onboarding-retry-sync";
|
|
2615
|
+
retry.textContent = "Retry sync";
|
|
2616
|
+
retry.disabled = false;
|
|
2617
|
+
retry.addEventListener("click", retryOnboardingSync);
|
|
2618
|
+
errorNode.append(document.createElement("br"), retry);
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
async function retryOnboardingSync() {
|
|
2622
|
+
if (onboardingBusy) return;
|
|
2623
|
+
setOnboardingBusy(true, "Retrying sync…");
|
|
2624
|
+
try {
|
|
2625
|
+
const response = await localFetch("/api/git/retry-sync", { method: "POST" });
|
|
2626
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
2627
|
+
const result = await response.json();
|
|
2628
|
+
if (!result.state) throw new Error("The sync response did not include the current workspace state.");
|
|
2629
|
+
state = result.state;
|
|
2630
|
+
const draft = onboardingPendingDraft;
|
|
2631
|
+
closeOnboarding();
|
|
2632
|
+
history.replaceState(null, "", draft ? "#/" : "#/stage/scope");
|
|
2633
|
+
render();
|
|
2634
|
+
} catch (error) {
|
|
2635
|
+
setOnboardingBusy(false);
|
|
2636
|
+
showOnboardingError(error.message, true);
|
|
1658
2637
|
}
|
|
1659
2638
|
}
|
|
1660
2639
|
|
|
@@ -1675,8 +2654,7 @@ async function cancelOnboarding() {
|
|
|
1675
2654
|
async function persistOnboardingPreference(showOnboarding) {
|
|
1676
2655
|
const entry = rendererSettingsEntry();
|
|
1677
2656
|
if (!entry) throw new Error("Renderer settings are unavailable.");
|
|
1678
|
-
await writeRendererSettingsResource({ ...entry.record, showOnboarding }, entry);
|
|
1679
|
-
state = await fetchJson("/api/state");
|
|
2657
|
+
applyMutationState(await writeRendererSettingsResource({ ...entry.record, showOnboarding }, entry));
|
|
1680
2658
|
}
|
|
1681
2659
|
|
|
1682
2660
|
async function writeRendererSettingsResource(record, entry) {
|
|
@@ -1689,34 +2667,25 @@ async function writeRendererSettingsResource(record, entry) {
|
|
|
1689
2667
|
body: JSON.stringify({ record, revision: entry?.revision })
|
|
1690
2668
|
});
|
|
1691
2669
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
async function toggleStagePageCompletion(button) {
|
|
1695
|
-
const entry = rendererSettingsEntry();
|
|
1696
|
-
if (!entry) throw new Error("Renderer settings are unavailable.");
|
|
1697
|
-
const completed = new Set(entry.record.completedStagePageIds || []);
|
|
1698
|
-
const pageId = button.dataset.stagePageCompletion;
|
|
1699
|
-
if (button.dataset.complete === "true") {
|
|
1700
|
-
completed.delete(pageId);
|
|
1701
|
-
(STAGE_PAGE_ID_ALIASES[pageId] || []).forEach((id) => completed.delete(id));
|
|
1702
|
-
} else {
|
|
1703
|
-
completed.add(pageId);
|
|
1704
|
-
}
|
|
1705
|
-
await writeRendererSettingsResource({
|
|
1706
|
-
...entry.record,
|
|
1707
|
-
completedStagePageIds: [...completed].sort()
|
|
1708
|
-
}, entry);
|
|
1709
|
-
state = await fetchJson("/api/state");
|
|
2670
|
+
return response.json();
|
|
1710
2671
|
}
|
|
1711
2672
|
|
|
1712
2673
|
function setOnboardingBusy(busy, label = "") {
|
|
1713
2674
|
if (!onboardingDialog) return;
|
|
2675
|
+
clearTimeout(onboardingStillWorkingTimer);
|
|
2676
|
+
onboardingStillWorkingTimer = null;
|
|
1714
2677
|
onboardingBusy = busy;
|
|
1715
2678
|
onboardingDialog.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = busy; });
|
|
1716
2679
|
const next = onboardingDialog.querySelector('[data-onboarding="next"]');
|
|
1717
2680
|
if (next && label) next.textContent = label;
|
|
1718
2681
|
const skip = onboardingDialog.querySelector('[data-onboarding="skip"]');
|
|
1719
2682
|
if (skip && label && onboardingStep !== onboardingSteps().length - 1) skip.textContent = label;
|
|
2683
|
+
if (busy && onboardingStep === onboardingSteps().length - 1) {
|
|
2684
|
+
onboardingStillWorkingTimer = setTimeout(() => {
|
|
2685
|
+
const status = onboardingDialog?.querySelector(".onboarding-save-status");
|
|
2686
|
+
if (status) status.textContent = "Still working. Git sync and workspace checks can take a moment.";
|
|
2687
|
+
}, 1_500);
|
|
2688
|
+
}
|
|
1720
2689
|
if (!busy) renderOnboardingStep();
|
|
1721
2690
|
}
|
|
1722
2691
|
|
|
@@ -1809,6 +2778,8 @@ function clearOnboardingFocus() {
|
|
|
1809
2778
|
|
|
1810
2779
|
function closeOnboarding() {
|
|
1811
2780
|
if (!onboardingDialog) return;
|
|
2781
|
+
clearTimeout(onboardingStillWorkingTimer);
|
|
2782
|
+
onboardingStillWorkingTimer = null;
|
|
1812
2783
|
onboardingBusy = false;
|
|
1813
2784
|
clearOnboardingFocus();
|
|
1814
2785
|
onboardingDialog.close();
|
|
@@ -1845,9 +2816,10 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
1845
2816
|
"title",
|
|
1846
2817
|
...required,
|
|
1847
2818
|
...(definition.listFields || []),
|
|
2819
|
+
...(definition.formFields || []),
|
|
1848
2820
|
...Object.entries(fields).filter(([, field]) => field.requiredWhen).map(([name]) => name),
|
|
1849
2821
|
...oneOf
|
|
1850
|
-
])].filter((name) => !["
|
|
2822
|
+
])].filter((name) => !["id", "type"].includes(name) && fields[name]);
|
|
1851
2823
|
const dialog = document.createElement("dialog");
|
|
1852
2824
|
dialog.className = "editor";
|
|
1853
2825
|
dialog.setAttribute("aria-labelledby", "resource-editor-title");
|
|
@@ -1856,7 +2828,10 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
1856
2828
|
));
|
|
1857
2829
|
const recordContent = recordContentDefinition(type);
|
|
1858
2830
|
const recordContentItem = recordContent ? entry?.content?.[recordContent.slot] : null;
|
|
1859
|
-
|
|
2831
|
+
const editorDescription = options.description
|
|
2832
|
+
|| implementationEditorDescription(type)
|
|
2833
|
+
|| "Fill the core fields below. Git will record the author, time, reason, and diff when you commit this file.";
|
|
2834
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + (entry ? "Edit record" : options.actionCompletion ? "Complete assigned work" : options.obligationCompletion ? "Record obligation work" : "Create record") + '</p><h2 id="resource-editor-title">' + esc(titleCase(entry?.record.title || record.title || definition.title)) + '</h2></div><button type="button" class="icon-button" data-editor-dismiss aria-label="Close">×</button></div><p>' + esc(editorDescription) + '</p>' + resourceReviewCriteria(type) + '<div class="form-grid">' + names.map((name) => editorField(type, name, fields[name], record[name], required.has(name) || conditionMatches(record, fields[name].requiredWhen), Boolean(entry), oneOf.has(name), activeOneOf.has(name))).join("") + '</div>' +
|
|
1860
2835
|
activeMarkdown.map((markdown) => {
|
|
1861
2836
|
const generated = !entry?.content?.[markdown.name];
|
|
1862
2837
|
const source = entry?.content?.[markdown.name]?.source
|
|
@@ -1869,7 +2844,7 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
1869
2844
|
: "";
|
|
1870
2845
|
return '<label class="content-editor-field" data-content-editor="' + esc(markdown.name) + '"><span>' + esc(markdown.label) + ' Markdown' + requiredMark + '</span><textarea data-markdown-slot="' + esc(markdown.name) + '" data-generated-content="' + generated + '" spellcheck="true" ' + (requiredNow ? "required" : "") + '>' + esc(source) + '</textarea></label>';
|
|
1871
2846
|
}).join("") + renderRecordContentEditor(type, entry, options) +
|
|
1872
|
-
'<details class="advanced-editor"><summary>Advanced JSON</summary><p>Use this for optional fields, extensions, or bulk edits. Changes here replace the guided fields above.</p><textarea spellcheck="false" aria-label="Advanced resource JSON">' + esc(JSON.stringify(record, null, 2)) + '</textarea></details><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button type="button" class="button" data-editor-dismiss>Cancel</button><button type="submit" class="button primary" id="save-record">' + esc(options.saveLabel || "Save file") + '</button></div></form>';
|
|
2847
|
+
'<details class="advanced-editor"><summary>Advanced JSON</summary><p>Use this for optional fields, extensions, or bulk edits. Changes here replace the guided fields above.</p><textarea spellcheck="false" aria-label="Advanced resource JSON">' + esc(JSON.stringify(record, null, 2)) + '</textarea></details><div class="dialog-error" role="alert"></div><div class="save-status" role="status" aria-live="polite"></div><div class="dialog-actions"><button type="button" class="button" data-editor-dismiss>Cancel</button><button type="submit" class="button primary" id="save-record">' + esc(options.saveLabel || "Save file") + '</button></div></form>';
|
|
1873
2848
|
document.body.append(dialog);
|
|
1874
2849
|
dialog.showModal();
|
|
1875
2850
|
dialog.addEventListener("close", () => dialog.remove());
|
|
@@ -1900,6 +2875,7 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
1900
2875
|
}
|
|
1901
2876
|
dialog.querySelector("form").addEventListener("submit", async (event) => {
|
|
1902
2877
|
event.preventDefault();
|
|
2878
|
+
if (dialog.dataset.mutationBusy === "true") return;
|
|
1903
2879
|
try {
|
|
1904
2880
|
const advanced = dialog.dataset.jsonDirty === "true";
|
|
1905
2881
|
if (!advanced && !dialog.querySelector("form").reportValidity()) return;
|
|
@@ -1925,35 +2901,51 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
1925
2901
|
}
|
|
1926
2902
|
const url = entry
|
|
1927
2903
|
? "/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(entry.record.id)
|
|
1928
|
-
: options.
|
|
2904
|
+
: options.actionCompletion
|
|
2905
|
+
? "/api/action-completions"
|
|
2906
|
+
: options.obligationCompletion ? "/api/obligation-completions" : "/api/resources";
|
|
1929
2907
|
const contentRevisions = Object.fromEntries([
|
|
1930
2908
|
...activeMarkdown.map(({ name }) => [entry?.content?.[name]?.path, entry?.content?.[name]?.revision]),
|
|
1931
2909
|
[recordContentItem?.path, recordContentItem?.revision]
|
|
1932
2910
|
].filter(([path, revision]) => path && revision));
|
|
2911
|
+
setMutationBusy(dialog, true, "Saving…", options.saveLabel || "Save file");
|
|
1933
2912
|
const response = await localFetch(url, {
|
|
1934
2913
|
method: entry ? "PUT" : "POST",
|
|
1935
2914
|
headers: { "content-type": "application/json" },
|
|
1936
2915
|
body: JSON.stringify({
|
|
1937
2916
|
record: updated,
|
|
1938
2917
|
content,
|
|
1939
|
-
revision: entry?.revision || options.obligationCompletion?.revision,
|
|
2918
|
+
revision: entry?.revision || options.actionCompletion?.revision || options.obligationCompletion?.revision,
|
|
1940
2919
|
contentRevisions,
|
|
1941
|
-
obligationId: options.obligationCompletion?.obligationId
|
|
2920
|
+
obligationId: options.obligationCompletion?.obligationId,
|
|
2921
|
+
actionItemId: options.actionCompletion?.actionItemId,
|
|
2922
|
+
completedOn: options.actionCompletion?.completedOn
|
|
1942
2923
|
})
|
|
1943
2924
|
});
|
|
1944
2925
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1945
|
-
|
|
2926
|
+
applyMutationState(await response.json());
|
|
1946
2927
|
dialog.close();
|
|
1947
2928
|
location.hash = "#/resource/" + encodeURIComponent(updated.type) + "/" + encodeURIComponent(updated.id);
|
|
1948
2929
|
render();
|
|
1949
2930
|
} catch (error) {
|
|
2931
|
+
setMutationBusy(dialog, false, "", options.saveLabel || "Save file");
|
|
1950
2932
|
dialog.querySelector(".dialog-error").textContent = error.message;
|
|
1951
2933
|
}
|
|
1952
2934
|
});
|
|
1953
2935
|
}
|
|
1954
2936
|
|
|
2937
|
+
function implementationEditorDescription(type) {
|
|
2938
|
+
if (type === "system") {
|
|
2939
|
+
return "Complete the System inventory fields. If this System produces control evidence, add its evidence source roles and current access owners, then write the exact report, filters, date range, timezone, export format, and reconciliation steps in Record Markdown.";
|
|
2940
|
+
}
|
|
2941
|
+
if (type === "control") {
|
|
2942
|
+
return "Document the actual procedure and select every authoritative System that produces evidence for this Control. Before marking it implemented, confirm each source is active, has the required evidence role and current access owners, and includes repeatable retrieval instructions in Record Markdown.";
|
|
2943
|
+
}
|
|
2944
|
+
return "";
|
|
2945
|
+
}
|
|
2946
|
+
|
|
1955
2947
|
function seedRecord(type, definition) {
|
|
1956
|
-
const record = {
|
|
2948
|
+
const record = { id: createResourceId(type, "new", state.resources.map(({ record }) => record.id)), type, title: "" };
|
|
1957
2949
|
const fields = { ...state.model.commonFields, ...definition.fields };
|
|
1958
2950
|
for (const name of definition.required || []) {
|
|
1959
2951
|
const field = fields[name];
|
|
@@ -2028,20 +3020,29 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
|
|
|
2028
3020
|
: field.relation ? relationHelp(field)
|
|
2029
3021
|
: "";
|
|
2030
3022
|
let control;
|
|
3023
|
+
if (field.managed) {
|
|
3024
|
+
control = '<textarea readonly spellcheck="false" placeholder="Filled when approval is saved">' + esc(value === undefined ? "" : JSON.stringify(value, null, 2)) + '</textarea>';
|
|
3025
|
+
return fieldWrap(name, "object", label, requiredMark, control, "Managed by filegrc from the exact companion Markdown revisions", false);
|
|
3026
|
+
}
|
|
2031
3027
|
if (field.relation && field.type === "array") {
|
|
2032
3028
|
const candidates = relationCandidates(field);
|
|
2033
3029
|
control = candidates.length
|
|
2034
|
-
? '<div class="checkbox-list">' + candidates.map(({ record }) => '<label><input type="checkbox" value="' + esc(record.id) + '" ' + ((value || []).includes(record.id) ? "checked" : "") + '><span>' + esc(record.title) + '<small>' + esc(record.id) + '</small></span></label>').join("") + '</div>'
|
|
3030
|
+
? '<div class="checkbox-list">' + candidates.map(({ record }) => '<label><input type="checkbox" value="' + esc(record.id) + '" ' + ((value || []).includes(record.id) ? "checked" : "") + '><span>' + esc(record.title) + '<small>' + esc(state.model.resources[record.type].title + " · " + record.id) + '</small></span></label>').join("") + '</div>'
|
|
2035
3031
|
: required ? '<select><option value="">No Matching Resources Exist Yet</option></select>' : '<div class="missing-options">No matching resources exist yet.</div>';
|
|
2036
3032
|
return fieldWrap(name, "relation-array", label, requiredMark, control, help, required);
|
|
2037
3033
|
}
|
|
2038
3034
|
if (field.relation) {
|
|
2039
3035
|
const candidates = relationCandidates(field);
|
|
2040
3036
|
control = candidates.length
|
|
2041
|
-
? '<select><option value="">Select a Resource</option>' + candidates.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (value === record.id ? "selected" : "") + '>' + esc(record.title) + ' · ' + esc(record.id) + '</option>').join("") + '</select>'
|
|
3037
|
+
? '<select><option value="">Select a Resource</option>' + candidates.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (value === record.id ? "selected" : "") + '>' + esc(record.title) + ' · ' + esc(state.model.resources[record.type].title) + ' · ' + esc(record.id) + '</option>').join("") + '</select>'
|
|
2042
3038
|
: required ? '<select><option value="">No Matching Resources Exist Yet</option></select>' : '<div class="missing-options">No matching resources exist yet.</div>';
|
|
2043
3039
|
return fieldWrap(name, "relation", label, requiredMark, control, help, required);
|
|
2044
3040
|
}
|
|
3041
|
+
if (name === "classificationId") {
|
|
3042
|
+
const values = Object.keys(state.workspace.classificationDefinitions || {});
|
|
3043
|
+
control = '<select><option value="">Select</option>' + values.map((item) => '<option value="' + esc(item) + '" ' + (value === item ? "selected" : "") + '>' + esc(properCase(item)) + '</option>').join("") + '</select>';
|
|
3044
|
+
return fieldWrap(name, "string", label, requiredMark, control, "Defined by Workspace classificationDefinitions", required);
|
|
3045
|
+
}
|
|
2045
3046
|
if (field.type === "enum" || field.type === "rating" || field.type === "outcome") {
|
|
2046
3047
|
const values = field.values || (field.type === "rating" ? state.model.primitives.rating : state.model.primitives.outcome) || [];
|
|
2047
3048
|
control = '<select><option value="">Select</option>' + values.map((item) => '<option value="' + esc(item) + '" ' + (value === item ? "selected" : "") + '>' + esc(properCase(item)) + '</option>').join("") + '</select>';
|
|
@@ -2057,7 +3058,10 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
|
|
|
2057
3058
|
}
|
|
2058
3059
|
if (field.type === "array") {
|
|
2059
3060
|
control = '<textarea placeholder="One value per line">' + esc((value || []).join("\n")) + '</textarea>';
|
|
2060
|
-
|
|
3061
|
+
const arrayHelp = name === "evidenceSourceKinds"
|
|
3062
|
+
? "One role per line. Use the roles required by the Controls this System supports, such as " + evidenceSourceRoleOptions().join(", ") + "."
|
|
3063
|
+
: "One value per line";
|
|
3064
|
+
return fieldWrap(name, "array", label, requiredMark, control, arrayHelp, required);
|
|
2061
3065
|
}
|
|
2062
3066
|
if (["description", "statement", "scope", "rationale", "purpose"].some((part) => name.toLowerCase().includes(part))) {
|
|
2063
3067
|
control = '<textarea>' + esc(value ?? "") + '</textarea>';
|
|
@@ -2071,6 +3075,10 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
|
|
|
2071
3075
|
return fieldWrap(name, field.type, label, requiredMark, control, help, required);
|
|
2072
3076
|
}
|
|
2073
3077
|
|
|
3078
|
+
function evidenceSourceRoleOptions() {
|
|
3079
|
+
return [...new Set((state.model.evidenceSourceFamilies || []).flatMap(({ sourceKinds }) => sourceKinds || []))].sort();
|
|
3080
|
+
}
|
|
3081
|
+
|
|
2074
3082
|
function fieldWrap(name, kind, label, requiredMark, control, help, required) {
|
|
2075
3083
|
const labelId = "field-label-" + name;
|
|
2076
3084
|
let labelledControl = control.replace(/^<([a-z]+)/, '<$1 aria-labelledby="' + esc(labelId) + '"');
|
|
@@ -2111,14 +3119,28 @@ function wireEditorRequirements(dialog, base, fields, oneOfGroups, markdownDefin
|
|
|
2111
3119
|
};
|
|
2112
3120
|
const refresh = () => {
|
|
2113
3121
|
for (const [name, field] of Object.entries(fields)) {
|
|
2114
|
-
if (!field.requiredWhen) continue;
|
|
2115
3122
|
const group = dialog.querySelector('[data-field-group="' + CSS.escape(name) + '"]');
|
|
2116
3123
|
if (!group) continue;
|
|
3124
|
+
if (field.managed) {
|
|
3125
|
+
const visible = !field.allowedWhen || conditionMatchesValues(field.allowedWhen, currentValue);
|
|
3126
|
+
group.hidden = !visible;
|
|
3127
|
+
refreshGroup(group, false);
|
|
3128
|
+
continue;
|
|
3129
|
+
}
|
|
3130
|
+
if (field.allowedWhen && !conditionMatchesValues(field.allowedWhen, currentValue)) {
|
|
3131
|
+
group.hidden = true;
|
|
3132
|
+
refreshGroup(group, false);
|
|
3133
|
+
continue;
|
|
3134
|
+
}
|
|
3135
|
+
if (!field.requiredWhen) {
|
|
3136
|
+
group.hidden = false;
|
|
3137
|
+
continue;
|
|
3138
|
+
}
|
|
2117
3139
|
const visible = !field.visibleWhen || conditionMatchesValues(field.visibleWhen, currentValue);
|
|
2118
3140
|
const applicable = Object.entries(field.requiredWhen)
|
|
2119
3141
|
.filter(([conditionName]) => conditionName !== "status")
|
|
2120
3142
|
.every(([conditionName, expected]) => conditionValueMatches(currentValue(conditionName), expected));
|
|
2121
|
-
group.hidden = !visible || !applicable;
|
|
3143
|
+
group.hidden = !visible || (!applicable && field.showWhenInactive !== true);
|
|
2122
3144
|
const required = visible && applicable && conditionMatchesValues(field.requiredWhen, currentValue);
|
|
2123
3145
|
refreshGroup(group, required);
|
|
2124
3146
|
}
|
|
@@ -2176,6 +3198,10 @@ function readGuidedRecord(dialog, base, fields) {
|
|
|
2176
3198
|
const record = structuredClone(base);
|
|
2177
3199
|
for (const group of dialog.querySelectorAll("[data-field-group]")) {
|
|
2178
3200
|
const name = group.dataset.fieldGroup;
|
|
3201
|
+
if (group.hidden) {
|
|
3202
|
+
delete record[name];
|
|
3203
|
+
continue;
|
|
3204
|
+
}
|
|
2179
3205
|
const kind = group.dataset.kind;
|
|
2180
3206
|
let value;
|
|
2181
3207
|
if (kind === "relation-array") value = [...group.querySelectorAll('input[type="checkbox"]:checked')].map((input) => input.value);
|
|
@@ -2200,7 +3226,11 @@ function relationCandidates(field) {
|
|
|
2200
3226
|
}
|
|
2201
3227
|
|
|
2202
3228
|
function relationHelp(field) {
|
|
2203
|
-
|
|
3229
|
+
if (field.relation.includes("*")) return "References any resource";
|
|
3230
|
+
const labels = field.relation.map((type) => state.model.resources[type]?.pluralTitle || type);
|
|
3231
|
+
if (labels.length < 2) return "References " + labels.join("");
|
|
3232
|
+
if (labels.length === 2) return "References " + labels.join(" or ");
|
|
3233
|
+
return "References " + labels.slice(0, -1).join(", ") + ", or " + labels.at(-1);
|
|
2204
3234
|
}
|
|
2205
3235
|
|
|
2206
3236
|
function openContentEditor(entry, name) {
|
|
@@ -2209,39 +3239,27 @@ function openContentEditor(entry, name) {
|
|
|
2209
3239
|
const dialog = document.createElement("dialog");
|
|
2210
3240
|
dialog.className = "editor content-dialog";
|
|
2211
3241
|
dialog.setAttribute("aria-labelledby", "content-editor-title");
|
|
2212
|
-
dialog.innerHTML = '<form method="dialog"><div class="dialog-head"><div><p class="kicker">Edit Markdown</p><h2 id="content-editor-title">' + esc(titleCase(entry.record.title)) + '</h2></div><button value="cancel" class="icon-button" aria-label="Close">×</button></div><p><code>' + esc(item.path) + '</code></p><textarea class="markdown-source" spellcheck="true" aria-label="Markdown content">' + esc(item.source) + '</textarea><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button value="cancel" class="button">Cancel</button><button type="button" class="button primary" id="save-content">Save Markdown</button></div></form>';
|
|
3242
|
+
dialog.innerHTML = '<form method="dialog"><div class="dialog-head"><div><p class="kicker">Edit Markdown</p><h2 id="content-editor-title">' + esc(titleCase(entry.record.title)) + '</h2></div><button value="cancel" class="icon-button" aria-label="Close">×</button></div><p><code>' + esc(item.path) + '</code></p><textarea class="markdown-source" spellcheck="true" aria-label="Markdown content">' + esc(item.source) + '</textarea><div class="dialog-error" role="alert"></div><div class="save-status" role="status" aria-live="polite"></div><div class="dialog-actions"><button value="cancel" class="button">Cancel</button><button type="button" class="button primary" id="save-content">Save Markdown</button></div></form>';
|
|
2213
3243
|
document.body.append(dialog);
|
|
2214
3244
|
dialog.showModal();
|
|
2215
3245
|
dialog.addEventListener("close", () => dialog.remove());
|
|
2216
3246
|
dialog.querySelector("#save-content").addEventListener("click", async () => {
|
|
3247
|
+
if (dialog.dataset.mutationBusy === "true") return;
|
|
2217
3248
|
try {
|
|
3249
|
+
setMutationBusy(dialog, true, "Saving…", "Save Markdown");
|
|
2218
3250
|
const response = await localFetch("/api/content", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: item.path, source: dialog.querySelector(".markdown-source").value, revision: item.revision }) });
|
|
2219
3251
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
2220
|
-
|
|
3252
|
+
applyMutationState(await response.json());
|
|
2221
3253
|
dialog.close();
|
|
2222
3254
|
render();
|
|
2223
3255
|
} catch (error) {
|
|
3256
|
+
setMutationBusy(dialog, false, "", "Save Markdown");
|
|
2224
3257
|
dialog.querySelector(".dialog-error").textContent = error.message;
|
|
2225
3258
|
}
|
|
2226
3259
|
});
|
|
2227
3260
|
}
|
|
2228
3261
|
|
|
2229
3262
|
function bindCommon() {
|
|
2230
|
-
root.querySelectorAll("[data-stage-page-completion]").forEach((button) => button.addEventListener("click", async (event) => {
|
|
2231
|
-
event.preventDefault();
|
|
2232
|
-
event.stopPropagation();
|
|
2233
|
-
const label = button.textContent;
|
|
2234
|
-
button.disabled = true;
|
|
2235
|
-
button.textContent = "Saving…";
|
|
2236
|
-
try {
|
|
2237
|
-
await toggleStagePageCompletion(button);
|
|
2238
|
-
render();
|
|
2239
|
-
} catch (error) {
|
|
2240
|
-
button.disabled = false;
|
|
2241
|
-
button.textContent = label;
|
|
2242
|
-
button.title = error.message;
|
|
2243
|
-
}
|
|
2244
|
-
}));
|
|
2245
3263
|
root.querySelectorAll(".nav-toggle, .nav-subgroup-toggle").forEach((button) => button.addEventListener("click", () => {
|
|
2246
3264
|
const group = button.closest(".nav-group");
|
|
2247
3265
|
const open = group.classList.toggle("open");
|
|
@@ -2356,7 +3374,11 @@ function metric(label, value, note, tone) {
|
|
|
2356
3374
|
}
|
|
2357
3375
|
function countOverdue(entries) { const today = currentDate(); return entries.filter(({ record }) => dueDate(record) && dueDate(record) < today).length; }
|
|
2358
3376
|
function dueDate(record) {
|
|
2359
|
-
const explicit = record.dueOn
|
|
3377
|
+
const explicit = record.completionWindow?.dueOn
|
|
3378
|
+
|| record.completionWindow?.dueAt?.slice(0, 10)
|
|
3379
|
+
|| record.dueOn
|
|
3380
|
+
|| record.expiresOn
|
|
3381
|
+
|| record.scheduledFor;
|
|
2360
3382
|
if (explicit) return explicit;
|
|
2361
3383
|
if (record.type !== "obligation" || record.status !== "active") return null;
|
|
2362
3384
|
const recurrence = record.recurrence?.anchorDate
|
|
@@ -2373,6 +3395,11 @@ function currentDate() {
|
|
|
2373
3395
|
return new Date().toISOString().slice(0, 10);
|
|
2374
3396
|
}
|
|
2375
3397
|
}
|
|
3398
|
+
function dateAfter(value) {
|
|
3399
|
+
const date = new Date(value + "T00:00:00Z");
|
|
3400
|
+
date.setUTCDate(date.getUTCDate() + 1);
|
|
3401
|
+
return date.toISOString().slice(0, 10);
|
|
3402
|
+
}
|
|
2376
3403
|
function currentLocalDateTime() {
|
|
2377
3404
|
const now = new Date();
|
|
2378
3405
|
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
|
@@ -2396,6 +3423,9 @@ function currentPeopleForParties(ids = [], seen = new Set()) {
|
|
|
2396
3423
|
if (party?.type === "team" && party.status === "active") {
|
|
2397
3424
|
people.push(...currentPeopleForParties([...(party.memberIds || []), ...(party.chairIds || [])], seen));
|
|
2398
3425
|
}
|
|
3426
|
+
if (party?.type === "appointment" && party.status === "active") {
|
|
3427
|
+
people.push(...currentPeopleForParties([party.holderId], seen));
|
|
3428
|
+
}
|
|
2399
3429
|
}
|
|
2400
3430
|
return [...new Set(people)];
|
|
2401
3431
|
}
|
|
@@ -2463,6 +3493,19 @@ function fieldLabel(type, name) {
|
|
|
2463
3493
|
function filterOptionLabel(value) { return state.resources.find(({ record }) => record.id === value)?.record.title || properCase(value); }
|
|
2464
3494
|
function humanize(value) { return String(value).replace(/[-_]+/g, " ").replace(/Ids?$/, "").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (letter) => letter.toUpperCase()); }
|
|
2465
3495
|
function properCase(value) { return humanize(value).replace(/\b[a-z]/g, (letter) => letter.toUpperCase()).replace(/\bSoc 2\b/g, "SOC 2"); }
|
|
3496
|
+
function displayStatus(record) {
|
|
3497
|
+
if (record?.type === "attestation"
|
|
3498
|
+
&& record.status === "pending"
|
|
3499
|
+
&& record.dueOn
|
|
3500
|
+
&& state.asOf
|
|
3501
|
+
&& record.dueOn < state.asOf) return "overdue";
|
|
3502
|
+
if (record?.type === "evidence"
|
|
3503
|
+
&& ["collected", "verified"].includes(record.status)
|
|
3504
|
+
&& record.expiresOn
|
|
3505
|
+
&& state.asOf
|
|
3506
|
+
&& record.expiresOn < state.asOf) return "expired";
|
|
3507
|
+
return record?.status;
|
|
3508
|
+
}
|
|
2466
3509
|
function titleCase(value) {
|
|
2467
3510
|
const words = String(value).split(/\s+/);
|
|
2468
3511
|
return words.map((word, index) => {
|
|
@@ -2588,8 +3631,82 @@ ${nextCalendarOccurrence.toString()}
|
|
|
2588
3631
|
${formatCalendarDate.toString()}
|
|
2589
3632
|
${formatLocalDateTime.toString()}
|
|
2590
3633
|
function empty(message) { return '<div class="empty">' + esc(message) + '</div>'; }
|
|
2591
|
-
function pluralize(noun, count) {
|
|
3634
|
+
function pluralize(noun, count) {
|
|
3635
|
+
if (count === 1) return noun;
|
|
3636
|
+
if (/[^aeiou]y$/i.test(noun)) return noun.slice(0, -1) + "ies";
|
|
3637
|
+
return noun + "s";
|
|
3638
|
+
}
|
|
2592
3639
|
function renderNotFound(main) { main.innerHTML = '<div class="page">' + empty("That resource does not exist.") + '</div>'; }
|
|
3640
|
+
function applyMutationState(result) {
|
|
3641
|
+
if (!result?.state) throw new Error("The save response did not include the current workspace state.");
|
|
3642
|
+
state = result.state;
|
|
3643
|
+
scheduleRepositorySyncPoll(result.synchronization);
|
|
3644
|
+
}
|
|
3645
|
+
|
|
3646
|
+
function scheduleRepositorySyncPoll(synchronization = state.repository?.backgroundSynchronization) {
|
|
3647
|
+
const syncing = synchronization?.status === "syncing"
|
|
3648
|
+
|| state.repository?.status === "syncing";
|
|
3649
|
+
if (!syncing) {
|
|
3650
|
+
clearTimeout(repositorySyncPollTimer);
|
|
3651
|
+
repositorySyncPollTimer = null;
|
|
3652
|
+
return;
|
|
3653
|
+
}
|
|
3654
|
+
if (repositorySyncPollTimer || repositorySyncPollInFlight) return;
|
|
3655
|
+
repositorySyncPollTimer = setTimeout(pollRepositorySync, 400);
|
|
3656
|
+
}
|
|
3657
|
+
|
|
3658
|
+
async function pollRepositorySync() {
|
|
3659
|
+
repositorySyncPollTimer = null;
|
|
3660
|
+
if (repositorySyncPollInFlight) return;
|
|
3661
|
+
repositorySyncPollInFlight = true;
|
|
3662
|
+
let continuePolling = false;
|
|
3663
|
+
try {
|
|
3664
|
+
const response = await localFetch("/api/git/sync-status");
|
|
3665
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
3666
|
+
const result = await response.json();
|
|
3667
|
+
const wasSyncing = state.repository?.status === "syncing";
|
|
3668
|
+
state.repository = result.repository;
|
|
3669
|
+
state.git = result.git;
|
|
3670
|
+
state.readOnly = result.readOnly;
|
|
3671
|
+
if (result.repository?.status === "syncing") {
|
|
3672
|
+
continuePolling = true;
|
|
3673
|
+
} else if (wasSyncing) {
|
|
3674
|
+
render();
|
|
3675
|
+
}
|
|
3676
|
+
} catch {
|
|
3677
|
+
continuePolling = true;
|
|
3678
|
+
} finally {
|
|
3679
|
+
repositorySyncPollInFlight = false;
|
|
3680
|
+
}
|
|
3681
|
+
if (continuePolling) {
|
|
3682
|
+
repositorySyncPollTimer = setTimeout(pollRepositorySync, 1_000);
|
|
3683
|
+
}
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
function setMutationBusy(dialog, busy, label, idleLabel) {
|
|
3687
|
+
clearTimeout(dialog._stillWorkingTimer);
|
|
3688
|
+
dialog._stillWorkingTimer = null;
|
|
3689
|
+
dialog.dataset.mutationBusy = busy ? "true" : "false";
|
|
3690
|
+
dialog.querySelectorAll("button,input,select,textarea").forEach((control) => {
|
|
3691
|
+
if (busy) {
|
|
3692
|
+
control.dataset.mutationWasDisabled = control.disabled ? "true" : "false";
|
|
3693
|
+
control.disabled = true;
|
|
3694
|
+
} else if (control.dataset.mutationWasDisabled === "false") {
|
|
3695
|
+
control.disabled = false;
|
|
3696
|
+
delete control.dataset.mutationWasDisabled;
|
|
3697
|
+
}
|
|
3698
|
+
});
|
|
3699
|
+
const button = dialog.querySelector("#save-record,#save-content");
|
|
3700
|
+
if (button) button.textContent = busy ? label : idleLabel;
|
|
3701
|
+
const status = dialog.querySelector(".save-status");
|
|
3702
|
+
if (status) status.textContent = "";
|
|
3703
|
+
if (busy) {
|
|
3704
|
+
dialog._stillWorkingTimer = setTimeout(() => {
|
|
3705
|
+
if (status) status.textContent = "Still working. Git sync and workspace checks can take a moment.";
|
|
3706
|
+
}, 1_500);
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
|
|
2593
3710
|
function showError(message) {
|
|
2594
3711
|
const dialog = document.createElement("dialog");
|
|
2595
3712
|
dialog.className = "alert-dialog";
|
|
@@ -2653,8 +3770,11 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
2653
3770
|
.nav-stage>.nav-items>a.nav-direct{display:grid;grid-template-columns:minmax(0,1fr) var(--nav-control-width);gap:6px;align-items:center;width:100%;padding:6px 6px 6px 7px;font-size:13.2px}
|
|
2654
3771
|
.nav-heading-row{display:grid;grid-template-columns:minmax(0,1fr) 24px;gap:2px;align-items:stretch}.nav-heading-row>.nav-heading{display:grid;grid-template-columns:24px minmax(0,1fr);gap:6px;align-items:center;width:100%;padding:7px 6px;border-radius:7px;color:#d5d9ed;text-align:left;text-transform:none;letter-spacing:0;text-decoration:none}.nav-heading-row>.nav-heading:hover,.nav-heading-row>.nav-heading.current{background:rgba(255,255,255,.1);color:#fff}.nav-toggle{display:grid;place-items:center;width:24px;height:auto;min-height:100%;padding:0;border:0;border-radius:6px;background:none;color:#aeb6d8;cursor:pointer}.nav-toggle:hover{background:rgba(255,255,255,.1);color:#fff}.nav-chevron{display:block;width:12px;height:12px;place-self:center;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;transition:transform .15s}.nav-subheading-row{display:grid;grid-template-columns:minmax(0,1fr) 22px;gap:2px;align-items:center;width:100%;padding:0;border:0;border-radius:6px;background:none;color:#919bc4;cursor:pointer}.nav-subheading-row:hover{background:rgba(255,255,255,.1);color:#fff}.nav-subheading-row>.nav-subheading{display:flex;align-items:center;min-width:0;padding:7px;color:inherit;text-align:left;text-transform:uppercase;letter-spacing:.09em;font-size:9.6px;font-weight:780}.nav-group.open>.nav-heading-row .nav-chevron,.nav-group.open>.nav-subheading-row .nav-chevron{transform:rotate(90deg)}
|
|
2655
3772
|
.stage-overview-hero{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:28px;align-items:center;padding:26px 28px;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.stage-overview-hero h2,.group-overview-head h2{font:500 37.2px Georgia,serif;margin:7px 0}.stage-overview-hero>div>p:not(.kicker),.group-overview-head>div>p:not(.kicker){max-width:760px;color:var(--muted);font-size:14.4px;line-height:1.55;margin:0}.stage-progress-card{padding:15px 17px;background:var(--paper);border:1px solid var(--line);border-radius:9px}.stage-progress-card>div:first-child{display:flex;align-items:center;justify-content:space-between;margin-bottom:11px}.stage-progress-card>div:first-child>strong{font:500 33.6px Georgia,serif}.stage-progress-card p{color:var(--muted);font-size:10.8px;line-height:1.45;margin:9px 0 0}.badge.neutral{background:#e5e8f2;color:#555e73}.stage-overview-layout{display:grid;grid-template-columns:320px minmax(0,1fr);gap:15px;margin-top:15px;align-items:start}.stage-plan ol,.group-plan ol{display:grid;gap:12px;padding-left:20px;margin:0}.stage-plan li,.group-plan li{padding-left:4px;color:var(--ink);font-size:13.2px;line-height:1.5}.stage-groups{min-width:0}.stage-groups>.section-head{margin:4px 0 13px}.stage-group-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.stage-group-card{position:relative;display:block;min-height:138px;padding:18px 38px 17px 18px;background:var(--panel);border:1px solid var(--line);border-radius:10px;text-decoration:none;box-shadow:0 2px 8px rgba(21,40,33,.025)}.stage-group-card:hover{border-color:var(--accent-light)}.stage-group-card h3{font-size:15.6px;margin:0 0 7px}.stage-group-card p{color:var(--muted);font-size:12px;line-height:1.5;margin:0}.stage-group-card small{display:block;color:var(--accent);font-size:9.6px;font-weight:700;margin-top:12px}.stage-group-arrow{position:absolute;right:16px;top:16px;color:var(--accent);font-size:24px}.group-overview-head{display:flex;justify-content:space-between;align-items:end;gap:25px;margin-bottom:15px}.stage-status-link{display:grid;grid-template-columns:auto auto;align-items:center;gap:4px 12px;min-width:155px;padding:12px 14px;background:var(--panel);border:1px solid var(--line);border-radius:9px;text-decoration:none}.stage-status-link>strong{font:500 28.8px Georgia,serif;text-align:right}.stage-status-link>small{grid-column:1/-1;color:var(--muted);font-size:9.6px;text-align:right}.relationship-note{display:grid;grid-template-columns:250px minmax(0,1fr);gap:20px;align-items:center;margin-bottom:15px;padding:17px 20px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:10px}.relationship-note h3{font-size:15.6px;margin:5px 0 0}.relationship-note>p{color:var(--muted);font-size:12px;line-height:1.55;margin:0}.relationship-note code{font-size:10.8px}.group-plan{margin-bottom:24px}.group-related-links{display:flex;align-items:center;gap:8px;margin-top:18px;padding-top:14px;border-top:1px solid var(--line)}.group-related-links>span{color:var(--muted);font-size:10.8px;margin-right:auto}.group-destinations>.section-head{margin-bottom:12px}.group-destination-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}.group-destination-card{display:grid;grid-template-columns:minmax(0,1fr) 100px;gap:16px;min-height:150px;padding:18px;background:var(--panel);border:1px solid var(--line);border-radius:10px;text-decoration:none;box-shadow:0 2px 8px rgba(21,40,33,.025)}.group-destination-card:hover{border-color:var(--accent-light)}.group-destination-card h3{font-size:15.6px;margin:5px 0 7px}.group-destination-card p:not(.kicker){color:var(--muted);font-size:10.8px;line-height:1.5;margin:0}.destination-rollup{align-self:center;text-align:right}.destination-rollup strong,.destination-rollup small{display:block}.destination-rollup strong{font:500 32.4px Georgia,serif}.destination-rollup small{color:var(--muted);font-size:9.6px;line-height:1.35;margin-top:3px}
|
|
2656
|
-
.stage-pages{margin-top:24px}.stage-page-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.stage-page-card{position:relative;display:flex;flex-direction:column;min-width:0;padding:18px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025);transition:border-color .15s,box-shadow .15s}.stage-page-card:hover{border-color:var(--accent-light);box-shadow:0 5px 16px rgba(21,40,33,.07)}.stage-page-card.complete{border-color:#b9dac6}.stage-page-card-link{position:absolute;inset:0;z-index:1;border-radius:10px}.stage-page-card-link:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.stage-page-card-head{display:flex;align-items:center;justify-content:space-between;gap:18px}.stage-page-card-head h3{font-size:15.6px;line-height:1.35;margin:4px 0 0}.stage-page-card-head>div>small{display:block;color:var(--accent);font-size:9.6px;font-weight:700}.stage-page-card>p{color:var(--muted);font-size:12px;line-height:1.5;margin:13px 0 0}.stage-page-rollup{display:flex;flex:0 0 104px;flex-direction:column;justify-content:center;text-align:right}.stage-page-rollup strong,.stage-page-rollup small{display:block}.stage-page-rollup strong{font:500 24px Georgia,serif}.stage-page-rollup small{color:var(--muted);font-size:9.6px;line-height:1.35;margin-top:2px}.stage-page-card-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:auto;padding-top:15px}.stage-page-completion
|
|
3773
|
+
.collection-review-panel{margin:16px 0 22px}.collection-review-panel.required{border-color:#d8bd78}.collection-review-panel.current{border-color:#b9dac6}.collection-review-head{display:flex;align-items:flex-start;justify-content:space-between;gap:24px}.collection-review-head h3{margin:4px 0 6px}.collection-review-head p:not(.kicker){max-width:900px;margin:0;color:var(--muted);font-size:11px;line-height:1.5}.collection-review-panel details{margin-top:13px;padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-panel summary{cursor:pointer;font-size:11px;font-weight:750}.collection-review-panel ul,.collection-review-checks ul{margin:9px 0 0;padding-left:20px;color:var(--muted);font-size:10.4px;line-height:1.55}.collection-review-foot{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:13px}.collection-review-result{display:flex;align-items:baseline;gap:8px;margin:0}.collection-review-result strong{font-size:11px}.collection-review-result span{color:var(--muted);font-size:10.4px}.collection-review-checks{margin:13px 0;padding:11px 13px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-checks>strong{font-size:11px}.event-dialog-steps.collection-review-checks{margin:15px 0 0;padding:10px;border:0}.collection-review-dialog textarea{box-sizing:border-box;width:100%;resize:vertical}.resource-review-criteria{margin:16px 0 22px;padding:13px 15px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}.resource-review-criteria>strong{font-size:11px}.resource-review-criteria ul{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px 28px;margin:9px 0 0;padding-left:20px;color:var(--muted);font-size:10.4px;line-height:1.5}.commit-dialog .resource-review-criteria{margin:12px 0;background:var(--surface-soft)}.record-workflow-action{display:grid;grid-template-columns:auto minmax(140px,1fr);gap:7px;align-items:start;min-width:240px;text-decoration:none}.record-workflow-action strong,.record-workflow-action small{display:block}.record-workflow-action strong{font-size:10.4px}.record-workflow-action small{margin-top:2px;color:var(--muted);font-size:9.2px;line-height:1.35}.record-workflow-clear{color:var(--muted);font-size:10px;white-space:nowrap}.workflow-guidance{margin:16px 0 22px}.workflow-guidance .panel-head{align-items:flex-start;margin-bottom:12px}.workflow-guidance .panel-head h3{margin:4px 0}.workflow-guidance .panel-head p:not(.kicker){margin:4px 0 0;color:var(--muted);font-size:11px}.workflow-findings{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.workflow-findings>a,.workflow-findings>div{display:grid;grid-template-columns:auto minmax(0,1fr);gap:9px;align-items:start;padding:10px 11px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft);text-decoration:none}.workflow-findings strong,.workflow-findings small{display:block}.workflow-findings strong{font-size:11px}.workflow-findings small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4}.workflow-finding-status{min-width:62px;padding:3px 5px;border-radius:99px;background:var(--accent-soft);color:var(--accent);font-size:8.4px;font-weight:750;text-align:center;text-transform:uppercase;letter-spacing:.04em}.workflow-finding-status.overdue,.workflow-finding-status.blocked{background:#f5ded9;color:#8d352c}.workflow-finding-status.ready,.workflow-finding-status.due,.workflow-finding-status.open{background:#f7e9cf;color:#855717}.workflow-finding-status.complete{background:#ddefe5;color:#176143}.workflow-guidance-more{margin:10px 0 0;color:var(--muted);font-size:9.6px}.context-workflow{display:flex;align-items:center;justify-content:space-between;gap:24px;margin:-8px 0 18px;padding:17px 19px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:10px}.context-workflow h3{font-size:16px;margin:4px 0 5px}.context-workflow p:not(.kicker){color:var(--muted);font-size:12px;line-height:1.5;margin:0;max-width:850px}.context-workflow .button{white-space:nowrap}.evidence-map{display:grid;gap:12px;margin-top:18px}.evidence-map-head{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:20px 22px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:11px}.evidence-map-head>div:first-child{max-width:760px}.evidence-map-head h2{font:500 24px Georgia,serif;margin:5px 0 7px}.evidence-map-head p:not(.kicker){color:var(--muted);font-size:12px;line-height:1.55;margin:0}.evidence-map-actions{display:flex;gap:8px}.evidence-map-card{padding:18px 20px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.evidence-map-card.complete{border-color:#b9dac6}.evidence-map-card-head{display:flex;align-items:start;justify-content:space-between;gap:20px}.evidence-map-card-head h3{font-size:16px;margin:7px 0 0}.evidence-map-card-head>small{color:var(--muted);font-size:10px;text-align:right}.evidence-map-card>p{color:var(--muted);font-size:12px;line-height:1.55;margin:12px 0}.evidence-map-expectation{display:grid;grid-template-columns:120px minmax(0,1fr);gap:12px;padding:8px 0;border-top:1px solid var(--line);font-size:11px;line-height:1.5}.evidence-map-expectation strong{color:var(--muted);font-size:9.6px;text-transform:uppercase;letter-spacing:.06em}.evidence-map-expectation code{background:var(--paper);border-radius:4px;padding:2px 5px}.evidence-map-links{display:grid;grid-template-columns:minmax(220px,1fr) minmax(280px,1.2fr);gap:20px;margin-top:13px;padding-top:13px;border-top:1px solid var(--line)}.evidence-map-links>div>small{display:block;color:var(--muted);font-size:9.6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin-bottom:7px}.evidence-map-references,.evidence-map-sources{display:flex;flex-wrap:wrap;gap:5px}.evidence-map-source{display:flex;align-items:center;gap:7px;padding:7px 9px;background:var(--paper);border:1px solid var(--line);border-radius:7px;font-size:11px;text-decoration:none}.evidence-map-source.complete{border-color:#b9dac6;background:#edf7f1}.evidence-map-source small{color:var(--muted);font-size:9px}.evidence-map-status{padding:9px 11px;background:var(--paper);border-radius:7px}.evidence-map-empty{padding:24px;background:var(--panel);border:1px solid var(--line);border-radius:10px}.evidence-map-empty h3{margin:6px 0}.evidence-map-empty p:not(.kicker){color:var(--muted);font-size:12px;margin:0 0 14px}.stage-pages{margin-top:24px}.stage-page-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.stage-page-card{position:relative;display:flex;flex-direction:column;min-width:0;padding:18px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025);transition:border-color .15s,box-shadow .15s}.stage-page-card:hover{border-color:var(--accent-light);box-shadow:0 5px 16px rgba(21,40,33,.07)}.stage-page-card.complete{border-color:#b9dac6}.stage-page-card-link{position:absolute;inset:0;z-index:1;border-radius:10px}.stage-page-card-link:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.stage-page-card-head{display:flex;align-items:center;justify-content:space-between;gap:18px}.stage-page-card-head h3{font-size:15.6px;line-height:1.35;margin:4px 0 0}.stage-page-card-head>div>small{display:block;color:var(--accent);font-size:9.6px;font-weight:700}.stage-page-card>p{color:var(--muted);font-size:12px;line-height:1.5;margin:13px 0 0}.stage-page-tasks{position:relative;z-index:2;display:grid;gap:6px;margin-top:13px}.stage-page-tasks>a{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px;align-items:start;padding:9px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft);text-decoration:none}.stage-page-tasks>a:hover{border-color:var(--accent-light)}.stage-page-tasks strong,.stage-page-tasks small{display:block}.stage-page-tasks strong{font-size:10.8px}.stage-page-tasks small{margin-top:2px;color:var(--muted);font-size:9.4px;line-height:1.35}.stage-page-tasks-more{color:var(--muted);font-size:9.4px}.stage-page-rollup{display:flex;flex:0 0 104px;flex-direction:column;justify-content:center;text-align:right}.stage-page-rollup strong,.stage-page-rollup small{display:block}.stage-page-rollup strong{font:500 24px Georgia,serif}.stage-page-rollup small{color:var(--muted);font-size:9.6px;line-height:1.35;margin-top:2px}.stage-page-card-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:auto;padding-top:15px}.stage-page-completion-state{color:var(--muted);font-size:10.8px}.stage-page-completion-state.complete{color:#176143;font-weight:700}.stage-page-open{color:var(--accent);font-size:12px;font-weight:700}.work-queue-section{margin-top:28px}.work-queue-section>.section-head{align-items:end}
|
|
3774
|
+
.workflow-findings>a:hover{border-color:var(--accent-light);box-shadow:0 3px 9px rgba(21,40,33,.05)}.workflow-findings>a:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.stage-page-card-head{align-items:flex-start}.stage-page-completion-state{flex:0 0 auto;max-width:150px;padding:5px 8px;border-radius:99px;background:#f7e9cf;color:#855717;font-size:9.6px;font-weight:750;line-height:1.25;text-align:right}.stage-page-completion-state.complete{background:#ddefe5;color:#176143}.obligation-card.workflow-target{outline:2px solid var(--focus);outline-offset:3px}.obligation-card-foot{flex-wrap:wrap}.obligation-card-foot .obligation-links{flex:1 1 120px}
|
|
3775
|
+
.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)}
|
|
2657
3776
|
.button{text-decoration:none}
|
|
3777
|
+
.external-evidence-section{margin-top:28px}.external-evidence-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.external-evidence-list>.empty{grid-column:1/-1}.external-evidence-list>a{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:13px 15px;background:var(--panel);border:1px solid var(--line);border-radius:8px;text-decoration:none}.external-evidence-list>a:hover{border-color:var(--accent-light)}.external-evidence-list>a>span:first-child{min-width:0}.external-evidence-list strong,.external-evidence-list small{display:block}.external-evidence-list strong{font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.external-evidence-list small{color:var(--muted);font-size:9.6px;margin-top:4px}
|
|
2658
3778
|
.home-page{padding-top:16px;padding-bottom:16px}.overview-hero{min-height:72px;padding:10px 20px;align-items:center}.overview-hero h2{font-size:26.4px;margin:3px 0 2px}.overview-hero p:not(.kicker){font-size:12px}.home-page .readiness-map{padding:12px 15px}.home-page .readiness-map-head{margin-bottom:8px}.home-page .readiness-flow a{padding:7px}
|
|
2659
3779
|
.overview-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:10px}.overview-grid>.audit-panel{grid-column:1/-1}
|
|
2660
3780
|
.overview-grid>.panel{padding:15px}.overview-grid .panel-head{margin-bottom:10px}.overview-grid .audit-progress{gap:7px 20px}.overview-grid .progress-number strong{font-size:31.2px}.overview-grid .audit-engagement{padding:8px 11px}
|
|
@@ -2664,20 +3784,24 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
2664
3784
|
.record-content-action{display:flex;justify-content:flex-start;margin-top:20px}.record-content-details{border-top:1px solid var(--line);margin-top:18px;padding-top:13px}.record-content-details summary{cursor:pointer;color:var(--accent);font-size:13.2px;font-weight:750}.record-content-details>p{color:var(--muted);font-size:12px}.record-content-editor>span small{color:var(--muted);font-size:10.8px;font-weight:500}
|
|
2665
3785
|
.program-setup{grid-template-columns:minmax(250px,.75fr) minmax(440px,1.4fr)}.setup-steps{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.setup-steps a{display:grid;grid-template-columns:24px minmax(0,1fr);gap:9px;align-items:start;padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--ink);text-decoration:none}.setup-steps a:hover{border-color:var(--accent-light)}.setup-steps a>span:first-child{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;background:var(--accent-soft);color:var(--accent);font-size:12px}.setup-steps a.done>span:first-child{background:#dcefe4;color:#125733}.setup-steps strong,.setup-steps small{display:block}.setup-steps strong{font-size:12px}.setup-steps small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4;font-weight:500}
|
|
2666
3786
|
.readiness-map{margin:14px 0;background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:20px 22px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.readiness-map-head{display:grid;grid-template-columns:minmax(220px,1fr) minmax(320px,420px);gap:28px;align-items:center;margin-bottom:17px}.readiness-map-head h3{font-size:18px;margin:5px 0 0}.readiness-progress-summary{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 14px;align-items:center}.readiness-progress-summary>div{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:3px 12px;align-items:baseline}.readiness-progress-summary>div>span{color:var(--muted);font-size:9.6px;font-weight:700;text-transform:uppercase;letter-spacing:.08em}.readiness-progress-summary>div>strong{font-size:9.6px;font-weight:700;line-height:1.2}.readiness-progress-summary .progress,.readiness-progress-summary small{grid-column:1/-1}.readiness-progress-summary small{color:var(--muted);font-size:9.6px}.readiness-progress-summary>.button{white-space:nowrap}.readiness-flow{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.readiness-flow a{display:grid;grid-template-columns:23px minmax(0,1fr);column-gap:8px;align-content:start;min-width:0;padding:11px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft);text-decoration:none}.readiness-flow a:hover{border-color:var(--accent-light);background:var(--accent-soft)}.readiness-flow a>span{grid-row:1/4;display:grid;place-items:center;width:23px;height:23px;border-radius:50%;background:var(--primary-gradient);color:#fff;font-size:9.6px;font-weight:800}.readiness-flow strong{font-size:12px;line-height:1.25}.readiness-flow small{grid-column:2;color:var(--muted);font-size:9.6px;line-height:1.4;margin-top:3px}.readiness-state{grid-column:2;justify-self:start;margin-top:8px;padding:3px 6px;border-radius:99px;background:var(--surface-muted);color:var(--muted);font-size:8.4px;line-height:1.2}.readiness-state.good{background:#dcefe4;color:#125733}.readiness-state.warn{background:#f6e8c9;color:#79500f}.readiness-state.bad{background:#f7dfdc;color:#873027}.audit-engagement{display:grid;grid-template-columns:minmax(210px,1fr) minmax(260px,1.25fr) auto;gap:20px;align-items:center;padding:14px 15px;border-radius:8px;background:var(--surface-soft)}.audit-engagement strong{font-size:13.2px}.audit-engagement p,.audit-engagement li{color:var(--muted);font-size:10.8px;line-height:1.5}.audit-engagement p{margin:5px 0 0}.audit-engagement ul{margin:0;padding-left:18px}.audit-engagement .button{white-space:nowrap;text-decoration:none}.resource-directory{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.resource-directory>section{min-width:0;padding:12px;border-radius:8px;background:var(--surface-soft)}.resource-directory h4{margin:0 0 7px;color:var(--muted);font-size:10.8px;text-transform:uppercase;letter-spacing:.08em}.resource-directory a{display:flex;justify-content:space-between;gap:10px;padding:5px 0;border-top:1px solid var(--line);font-size:10.8px;text-decoration:none}.resource-directory a:first-of-type{border-top:0}.resource-directory a:hover span{color:var(--accent)}.resource-directory a strong{color:var(--muted);font-size:9.6px}.record-prose{max-width:790px}.record-prose section{padding:0 0 20px}.record-prose section+section{padding-top:20px;border-top:1px solid var(--line)}.record-prose h3{margin:0 0 7px;color:var(--muted);font-size:10.8px;text-transform:uppercase;letter-spacing:.08em}.record-prose p{margin:0;font-size:16.8px;line-height:1.65;white-space:pre-wrap}.connections-panel .panel-head>span{display:grid;place-items:center;min-width:22px;height:22px;border-radius:99px;background:var(--surface-muted);color:var(--muted);font-size:9.6px}.connections{display:grid}.connections a{display:block;padding:9px 0;border-top:1px solid var(--line);text-decoration:none}.connections a:first-child{padding-top:0;border-top:0}.connections strong,.connections small{display:block}.connections strong{font-size:12px}.connections small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4}.connections a:hover strong{color:var(--accent)}.connections-more{margin:9px 0 0;color:var(--muted);font-size:9.6px;line-height:1.4}.external-source{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;color:var(--accent);text-decoration:none}.external-source span,.external-source strong,.external-source small{display:block}.external-source strong{font-size:12px;line-height:1.35}.external-source small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.35;overflow-wrap:anywhere}.external-source b{font-size:13.2px}.external-source:hover strong{text-decoration:underline}
|
|
2667
|
-
.page-title-line{display:flex;align-items:center;gap:8px}.guide-trigger{display:grid;place-items:center;width:24px;height:24px;flex:0 0 auto;padding:0;border:1px solid var(--line);border-radius:50%;background:var(--panel);color:var(--muted);cursor:pointer}.guide-trigger svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round}.guide-trigger:hover{border-color:var(--accent-light);color:var(--accent)}.guide-trigger:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.page-guide{display:grid;grid-template-columns:1.05fr 1.25fr 1fr;gap:0;margin:0;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.resource-guide-popover{position:fixed;z-index:40;overflow:auto;box-shadow:0 18px 50px rgba(0,0,24,.24)}.resource-guide-popover[hidden]{display:none}.page-guide>div{padding:14px 16px;border-left:1px solid var(--line);min-width:0}.page-guide>div:first-child{border-left:0}.page-guide>div>span{display:block;color:var(--accent);text-transform:uppercase;letter-spacing:.09em;font-size:9.6px;font-weight:780;margin-bottom:6px}.page-guide p{color:var(--muted);font-size:12px;line-height:1.5;margin:0}.guide-links{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}.guide-links a{color:var(--accent);background:var(--accent-soft);border-radius:99px;padding:4px 7px;text-decoration:none;font-size:9.6px;font-weight:700}
|
|
3787
|
+
.page-title-line{display:flex;align-items:center;gap:8px}.guide-trigger{display:grid;place-items:center;width:24px;height:24px;flex:0 0 auto;padding:0;border:1px solid var(--line);border-radius:50%;background:var(--panel);color:var(--muted);cursor:pointer}.guide-trigger svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round}.guide-trigger:hover{border-color:var(--accent-light);color:var(--accent)}.guide-trigger:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.page-guide{display:grid;grid-template-columns:1.05fr 1.25fr 1fr;gap:0;margin:0;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.resource-guide-popover{position:fixed;z-index:40;overflow:auto;box-shadow:0 18px 50px rgba(0,0,24,.24)}.resource-guide-popover[hidden]{display:none}.page-guide>div{padding:14px 16px;border-left:1px solid var(--line);min-width:0}.page-guide>div:first-child{border-left:0}.page-guide>div>span{display:block;color:var(--accent);text-transform:uppercase;letter-spacing:.09em;font-size:9.6px;font-weight:780;margin-bottom:6px}.page-guide p{color:var(--muted);font-size:12px;line-height:1.5;margin:0}.page-guide>.guide-review{grid-column:1/-1;border-top:1px solid var(--line);border-left:0}.guide-review ul{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px 28px;margin:0;padding-left:18px;color:var(--muted);font-size:11px;line-height:1.45}.guide-links{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}.guide-links a{color:var(--accent);background:var(--accent-soft);border-radius:99px;padding:4px 7px;text-decoration:none;font-size:9.6px;font-weight:700}
|
|
2668
3788
|
.operation-tracking{display:grid;gap:2px;min-width:0;text-decoration:none}.operation-tracking strong,.operation-tracking small{display:block;overflow-wrap:anywhere}.operation-tracking small{color:var(--muted);line-height:1.35}.operation-tracking.running strong{color:#176143}.operation-tracking.waiting strong,.operation-tracking.mixed strong{color:var(--amber)}.operation-tracking.paused strong{color:var(--red)}a.operation-tracking:hover strong{text-decoration:underline}
|
|
2669
|
-
.page-actions{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap}.repository-sync-status{min-height:18px;margin:7px 0 0;color:var(--muted);font-size:13.2px}.repository-sync-status.error{color:var(--red)}.repository-state-banner,.repository-override{display:flex;align-items:flex-start;gap:13px;margin-bottom:14px}.repository-state-banner>.status-dot,.repository-override>.status-dot{margin-top:5px}.repository-state-banner h3{margin:3px 0 5px}.repository-state-banner p:last-child,.repository-override p{margin:0;color:var(--muted);line-height:1.5}.repository-override{padding:14px 17px;border:1px solid #e9c888;border-radius:9px;background:#fff8e8;font-size:13.2px}.repository-override strong{display:block;margin-bottom:4px}.onboarding-dialog{width:min(470px,calc(100vw - 30px));max-height:calc(100vh - 32px);margin:0;border:1px solid var(--line);border-radius:13px;padding:0;background:var(--panel);color:var(--ink);box-shadow:0 28px 90px rgba(0,0,24,.38);overflow:hidden}.onboarding-dialog[open]{display:flex;flex-direction:column}.onboarding-dialog::backdrop{background:transparent;backdrop-filter:none}.onboarding-shade{position:fixed;inset:0;z-index:60;pointer-events:none}.onboarding-shade span{position:absolute;background:rgba(0,0,24,.58)}.onboarding-progress{display:grid;flex:0 0 auto;grid-template-columns:repeat(var(--onboarding-step-count),1fr);gap:5px;padding:18px 24px 0}.onboarding-progress span{height:3px;border-radius:3px;background:var(--surface-muted)}.onboarding-progress span.active{background:var(--accent-light)}.onboarding-scroll{min-height:0;overflow-y:auto}.onboarding-head{padding:22px 25px 0}.onboarding-head h2{font-family:Georgia,serif;font-size:30px;font-weight:500;letter-spacing:-.015em;margin:8px 0 0}.onboarding-body{color:var(--muted);font-size:14.4px;line-height:1.6;margin:13px 25px 0}.onboarding-body+.onboarding-body{margin-top:8px}.onboarding-points{display:grid;gap:9px;margin:18px 25px 4px;padding-left:19px}.onboarding-points li{font-size:13.2px;line-height:1.5;padding-left:3px}.onboarding-sections{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:16px 25px 4px}.onboarding-sections section{padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.onboarding-sections strong{font-size:13.2px}.onboarding-sections p{margin:6px 0 0;color:var(--muted);font-size:12px;line-height:1.5}.onboarding-actions{flex:0 0 auto;padding:12px 25px 23px;border-top:1px solid var(--line);background:var(--panel)}.onboarding-skip{margin-right:auto;color:var(--muted);text-transform:none;letter-spacing:0;font-size:13.2px}.onboarding-form{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin:18px 25px 0}.onboarding-form label{display:block;min-width:0}.onboarding-form label.wide{grid-column:1/-1}.onboarding-form label>span{display:block;color:var(--ink);font-size:12px;font-weight:720;margin-bottom:6px}.onboarding-form input,.onboarding-form select,.onboarding-form textarea{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.onboarding-form textarea{min-height:78px;resize:vertical}.onboarding-form small{display:block;color:var(--muted);font-size:10.8px;line-height:1.45;margin-top:5px}.onboarding-write-note{color:var(--muted);font-size:10.8px;line-height:1.5;margin:12px 25px 16px}.onboarding-scroll>.dialog-error{margin:8px 25px 0}.onboarding-focus{outline:4px solid var(--accent-light)!important;outline-offset:5px;scroll-margin-top:102px}
|
|
3789
|
+
.page-actions{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap}.repository-sync-status{min-height:18px;margin:7px 0 0;color:var(--muted);font-size:13.2px}.repository-sync-status.error{color:var(--red)}.repository-sync-alert{display:flex;align-items:flex-start;gap:9px;padding:10px 22px;border-bottom:1px solid #e9c888;background:#fff8e8;color:var(--ink);font-size:12px;line-height:1.45}.repository-sync-alert.syncing{border-color:var(--line);background:var(--surface-soft)}.repository-sync-alert .status-dot{flex:0 0 auto;margin-top:4px}.repository-sync-alert a{margin-left:auto;white-space:nowrap}.repository-state-banner,.repository-override{display:flex;align-items:flex-start;gap:13px;margin-bottom:14px}.repository-state-banner>.status-dot,.repository-override>.status-dot{margin-top:5px}.repository-state-banner h3{margin:3px 0 5px}.repository-state-banner p:last-child,.repository-override p{margin:0;color:var(--muted);line-height:1.5}.repository-override{padding:14px 17px;border:1px solid #e9c888;border-radius:9px;background:#fff8e8;font-size:13.2px}.repository-override strong{display:block;margin-bottom:4px}.onboarding-dialog{width:min(470px,calc(100vw - 30px));max-height:calc(100vh - 32px);margin:0;border:1px solid var(--line);border-radius:13px;padding:0;background:var(--panel);color:var(--ink);box-shadow:0 28px 90px rgba(0,0,24,.38);overflow:hidden}.onboarding-dialog[open]{display:flex;flex-direction:column}.onboarding-dialog::backdrop{background:transparent;backdrop-filter:none}.onboarding-shade{position:fixed;inset:0;z-index:60;pointer-events:none}.onboarding-shade span{position:absolute;background:rgba(0,0,24,.58)}.onboarding-progress{display:grid;flex:0 0 auto;grid-template-columns:repeat(var(--onboarding-step-count),1fr);gap:5px;padding:18px 24px 0}.onboarding-progress span{height:3px;border-radius:3px;background:var(--surface-muted)}.onboarding-progress span.active{background:var(--accent-light)}.onboarding-scroll{min-height:0;overflow-y:auto}.onboarding-head{padding:22px 25px 0}.onboarding-head h2{font-family:Georgia,serif;font-size:30px;font-weight:500;letter-spacing:-.015em;margin:8px 0 0}.onboarding-body{color:var(--muted);font-size:14.4px;line-height:1.6;margin:13px 25px 0}.onboarding-body+.onboarding-body{margin-top:8px}.onboarding-points{display:grid;gap:9px;margin:18px 25px 4px;padding-left:19px}.onboarding-points li{font-size:13.2px;line-height:1.5;padding-left:3px}.onboarding-sections{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:16px 25px 4px}.onboarding-sections section{padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.onboarding-sections strong{font-size:13.2px}.onboarding-sections p{margin:6px 0 0;color:var(--muted);font-size:12px;line-height:1.5}.onboarding-actions{flex:0 0 auto;flex-wrap:wrap;padding:12px 25px 23px;border-top:1px solid var(--line);background:var(--panel)}.onboarding-skip{margin-right:auto;color:var(--muted);text-transform:none;letter-spacing:0;font-size:13.2px}.onboarding-form{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin:18px 25px 0}.onboarding-form label{display:block;min-width:0}.onboarding-form label.wide{grid-column:1/-1}.onboarding-form label>span{display:block;color:var(--ink);font-size:12px;font-weight:720;margin-bottom:6px}.onboarding-form input,.onboarding-form select,.onboarding-form textarea{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.onboarding-form textarea{min-height:78px;resize:vertical}.onboarding-form small{display:block;color:var(--muted);font-size:10.8px;line-height:1.45;margin-top:5px}.onboarding-write-note{color:var(--muted);font-size:10.8px;line-height:1.5;margin:12px 25px 16px}.onboarding-scroll>.dialog-error{margin:8px 25px 0}.onboarding-focus{outline:4px solid var(--accent-light)!important;outline-offset:5px;scroll-margin-top:102px}
|
|
3790
|
+
.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)}
|
|
3791
|
+
.save-status{min-height:16px;color:var(--muted);font-size:10.8px;line-height:1.35}
|
|
2670
3792
|
.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}
|
|
2671
3793
|
@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}}
|
|
2672
3794
|
@media(max-width:1100px){.search{display:none}.topbar-status{margin-left:auto}.metrics{grid-template-columns:repeat(2,1fr)}.dashboard-grid,.organization-grid{grid-template-columns:repeat(2,1fr)}.catalog{grid-template-columns:repeat(3,1fr)}.span-2{grid-column:span 2}.resource-directory{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
|
2673
3795
|
@media(max-width:760px){.shell{display:block}.sidebar{transform:translateX(-100%);transition:.2s;box-shadow:8px 0 30px rgba(0,0,0,.2)}.sidebar.shown{transform:translateX(0)}.workspace{min-width:0}.mobile-nav{display:block;border:0;background:none;font-size:24px}.topbar{height:72px;padding:0 16px}.topbar>div:first-of-type{min-width:0}.topbar-status{display:none}.search{display:flex;max-width:none}.search kbd,.topbar .eyebrow{display:none}.page{padding:20px 15px 60px}.hero{display:block;padding:23px}.hero-meta{margin-top:22px;flex-wrap:wrap}.metrics,.dashboard-grid,.organization-grid{grid-template-columns:1fr}.span-2{grid-column:auto}.catalog{grid-template-columns:repeat(2,1fr)}.detail-grid{grid-template-columns:1fr}.page-intro,.detail-head{display:block}.page-intro>.button,.actions{margin-top:15px}.page-intro>.list-header-tools{justify-content:flex-start;margin:15px 0 0}.list-header-tools label{max-width:none}.record-table{min-width:720px}.readiness-map{padding:17px}.readiness-map-head{grid-template-columns:1fr;gap:8px}.readiness-flow{grid-template-columns:repeat(2,minmax(0,1fr))}.audit-engagement{grid-template-columns:1fr}.audit-engagement .button{grid-column:auto}.resource-directory{grid-template-columns:1fr}}
|
|
2674
|
-
@media(max-width:760px){.setup-banner,.page-guide,.stage-overview-hero,.relationship-note,.group-destination-card,.stage-page-grid{grid-template-columns:1fr}.page-guide>div{border-left:0;border-top:1px solid var(--line)}.page-guide>div:first-child{border-top:0}.group-overview-head{display:block}.stage-progress-card,.stage-status-link{margin-top:15px}.destination-rollup{text-align:left}.form-grid{grid-template-columns:1fr}.record-table{min-width:0}.record-table thead{display:none}.record-table,.record-table tbody,.record-table tr{display:block}.record-table tr{padding:8px 12px;border-bottom:1px solid var(--line)}.record-table tr:last-child{border-bottom:0}.record-table td:not([data-label]){display:block}.record-table td[data-label]{display:grid;grid-template-columns:105px minmax(0,1fr);gap:10px;border:0;padding:7px 0;align-items:start}.record-table td[data-label]::before{content:attr(data-label);color:#75817b;text-transform:uppercase;letter-spacing:.07em;font-size:9.6px;font-weight:700}.record-table td[data-primary-field]{display:block;padding:8px 0 10px}.record-table td[data-primary-field]::before{display:none}.content-label{align-items:flex-start}.editor form{padding:18px}.diagnostics>div{grid-template-columns:58px minmax(0,1fr)}.diagnostics p{grid-column:1/-1}.changes code{overflow-wrap:anywhere}.onboarding-dialog{max-height:56vh}}
|
|
3796
|
+
@media(max-width:760px){.setup-banner,.page-guide,.stage-overview-hero,.relationship-note,.group-destination-card,.stage-page-grid,.evidence-map-expectation,.evidence-map-links,.external-evidence-list{grid-template-columns:1fr}.context-workflow,.evidence-map-head{align-items:stretch;flex-direction:column}.evidence-map-actions{flex-wrap:wrap}.page-guide>div{border-left:0;border-top:1px solid var(--line)}.page-guide>div:first-child{border-top:0}.group-overview-head{display:block}.stage-progress-card,.stage-status-link{margin-top:15px}.destination-rollup{text-align:left}.form-grid{grid-template-columns:1fr}.record-table{min-width:0}.record-table thead{display:none}.record-table,.record-table tbody,.record-table tr{display:block}.record-table tr{padding:8px 12px;border-bottom:1px solid var(--line)}.record-table tr:last-child{border-bottom:0}.record-table td:not([data-label]){display:block}.record-table td[data-label]{display:grid;grid-template-columns:105px minmax(0,1fr);gap:10px;border:0;padding:7px 0;align-items:start}.record-table td[data-label]::before{content:attr(data-label);color:#75817b;text-transform:uppercase;letter-spacing:.07em;font-size:9.6px;font-weight:700}.record-table td[data-primary-field]{display:block;padding:8px 0 10px}.record-table td[data-primary-field]::before{display:none}.content-label{align-items:flex-start}.editor form{padding:18px}.diagnostics>div{grid-template-columns:58px minmax(0,1fr)}.diagnostics p{grid-column:1/-1}.changes code{overflow-wrap:anywhere}.onboarding-dialog{max-height:56vh}}
|
|
3797
|
+
@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}}
|
|
2675
3798
|
@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}}
|
|
2676
3799
|
@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}}
|
|
2677
3800
|
@media(min-width:761px){.detail-grid.detail-grid-structured{grid-template-columns:1fr}.detail-grid-structured aside{grid-column:1;grid-row:1;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}.detail-grid-structured aside>.panel{align-self:start}}
|
|
2678
3801
|
@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}}
|
|
2679
3802
|
@media(max-width:760px){.topbar{height:56px}.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)}}
|
|
2680
3803
|
|
|
3804
|
+
.connection-group+.connection-group{margin-top:15px;padding-top:13px;border-top:1px solid var(--line)}.connection-group h4{margin:0 0 8px;color:var(--muted);font-size:9.6px;text-transform:uppercase;letter-spacing:.08em}
|
|
2681
3805
|
body,button,input,select,textarea,dialog{color:var(--ink)}
|
|
2682
3806
|
button,input,select,textarea{accent-color:var(--accent)}
|
|
2683
3807
|
:focus-visible{outline:3px solid var(--focus);outline-offset:2px}
|
|
@@ -2744,11 +3868,12 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
|
|
|
2744
3868
|
.commit-files code{font-size:10.8px;overflow-wrap:anywhere}
|
|
2745
3869
|
.onboarding-progress{grid-template-columns:repeat(var(--onboarding-step-count),1fr)}
|
|
2746
3870
|
.onboarding-git-status{display:flex;align-items:flex-start;gap:9px;margin:14px 25px 0;padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.onboarding-git-status .status-dot{margin-top:4px}.onboarding-git-status strong,.onboarding-git-status small{display:block}.onboarding-git-status strong{font-size:12px}.onboarding-git-status small{color:var(--muted);font-size:10.8px;line-height:1.45;margin-top:3px}.onboarding-git-status code{font-size:10.8px}
|
|
2747
|
-
.badge.status-overdue{background:#f7dfdc;color:#873027}.badge.status-due{background:#f6e8c9;color:#79500f}.badge.status-upcoming,.badge.status-proposed{background:var(--accent-soft);color:var(--accent)}.badge.status-complete{background:#dcefe4;color:#125733}
|
|
3871
|
+
.badge.status-overdue,.badge.status-blocked{background:#f7dfdc;color:#873027}.badge.status-due{background:#f6e8c9;color:#79500f}.badge.status-upcoming,.badge.status-proposed{background:var(--accent-soft);color:var(--accent)}.badge.status-complete{background:#dcefe4;color:#125733}
|
|
2748
3872
|
.obligation-preview,.event-reminder-preview{display:grid;gap:8px}.obligation-preview a{display:flex;align-items:flex-start;gap:9px;text-decoration:none;padding:7px 0;border-top:1px solid var(--line)}.obligation-preview a:first-child{border-top:0;padding-top:0}.obligation-preview strong,.obligation-preview small,.event-reminder-preview strong,.event-reminder-preview small{display:block}.obligation-preview strong,.event-reminder-preview strong{font-size:12px}.obligation-preview small,.event-reminder-preview small{font-size:10.8px;color:var(--muted);margin-top:2px}.event-reminder-preview{grid-template-columns:repeat(2,minmax(0,1fr))}.event-reminder-preview a{padding:10px;border-radius:7px;background:var(--surface-soft);text-decoration:none}
|
|
2749
|
-
.obligation-board{display:grid;grid-template-columns:repeat(
|
|
3873
|
+
.obligation-board{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px;align-items:start}.obligation-column{min-width:0}.obligation-column-head{display:flex;align-items:center;justify-content:space-between;margin:5px 1px 10px}.obligation-column-head>strong{font:500 26.4px Georgia,serif}.obligation-cards{display:grid;gap:9px}.obligation-card{background:var(--panel);border:1px solid var(--line);border-left:4px solid var(--accent-light);border-radius:9px;padding:14px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.obligation-card.status-overdue,.obligation-card.status-blocked{border-left-color:var(--red)}.obligation-card.status-due{border-left-color:#d89021}.obligation-card-head{display:flex;justify-content:space-between;gap:8px;text-transform:uppercase;letter-spacing:.06em;font-size:9.6px;color:var(--muted)}.obligation-card-head strong{color:var(--ink);text-align:right}.obligation-card h3{font-size:14.4px;margin:9px 0 7px}.obligation-card h3 a{text-decoration:none}.obligation-card p{font-size:10.8px;line-height:1.5;color:var(--muted);margin:0}.obligation-links{margin-top:10px}.workflow-section{margin-top:30px}.section-head{display:flex;justify-content:space-between;margin-bottom:13px}.section-head h2{font:500 28.8px Georgia,serif;margin:6px 0}.section-head p:not(.kicker){font-size:13.2px;color:var(--muted);margin:0;max-width:720px}.policy-event-feedback{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;gap:10px;align-items:center;margin-top:14px;padding:12px 14px;border:1px solid #9ccfb2;border-radius:8px;background:#e7f5ec}.policy-event-feedback .status-dot{align-self:start;margin-top:4px}.policy-event-feedback strong,.policy-event-feedback p{display:block}.policy-event-feedback strong{font-size:12px}.policy-event-feedback p{margin:3px 0 0;color:#315d44;font-size:10.8px}.policy-event-feedback .icon-button{width:30px;height:30px}.policy-event-list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.policy-event-more{margin-top:10px}.policy-event-row{position:relative;display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}.policy-event-row[hidden]{display:none}.policy-event-name{min-width:0}.policy-event-title{display:flex;align-items:center;gap:6px;min-width:0}.policy-event-title>strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.policy-event-guide{display:inline-flex;flex:0 0 auto}.policy-event-guide .guide-trigger{width:20px;height:20px}.policy-event-guide .guide-trigger svg{width:14px;height:14px}.policy-event-name strong,.policy-event-name>small{display:block}.policy-event-name strong{font-size:12px}.policy-event-name>small{margin-top:2px;color:var(--muted);font-size:9.6px;line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.policy-event-row>.button{flex:none;padding:7px 9px;font-size:10.8px}.policy-event-tooltip{position:absolute;z-index:8;top:calc(100% + 7px);left:0;width:min(420px,calc(100vw - 48px));padding:12px 14px;border:1px solid var(--line);border-radius:8px;background:var(--panel);box-shadow:var(--shadow);opacity:0;visibility:hidden;transform:translateY(-3px);transition:opacity .12s,transform .12s,visibility 0s .12s;pointer-events:none}.policy-event-row:nth-child(3n) .policy-event-tooltip{right:0;left:auto}.policy-event-row:hover,.policy-event-row:focus-within{z-index:9}.policy-event-guide:hover .policy-event-tooltip,.policy-event-guide:focus-within .policy-event-tooltip{opacity:1;visibility:visible;transform:none;transition-delay:0s}.policy-event-tooltip>strong{font-size:12px}.policy-event-tooltip ol{display:grid;gap:7px;margin:9px 0 0;padding-left:20px}.policy-event-tooltip li span,.policy-event-tooltip li small{display:block}.policy-event-tooltip li span{font-size:10.8px}.policy-event-tooltip li small{margin-top:2px;color:var(--muted);font-size:9.6px;line-height:1.4}
|
|
2750
3874
|
.obligation-card-foot{display:flex;align-items:flex-end;justify-content:space-between;gap:9px;margin-top:10px}.obligation-card-foot .obligation-links{margin-top:0;min-width:0}.obligation-action{flex:0 0 auto;border:0;border-radius:6px;background:var(--accent-soft);color:var(--accent);padding:7px 9px;font-family:inherit;font-size:10.8px;font-weight:700;line-height:1;text-decoration:none;cursor:pointer}.obligation-action:hover{filter:brightness(1.08)}.obligation-action.blocked{background:var(--surface-muted);color:var(--muted)}.obligation-more{width:100%;margin-top:9px}.workflow-section{scroll-margin-top:92px}
|
|
2751
|
-
.event-dialog label{display:block;margin-top:13px}.event-dialog label>span{display:block;font-size:12px;font-weight:720;margin-bottom:6px}.event-dialog input,.event-dialog select{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.event-dialog-steps{display:grid;gap:6px;margin-top:15px;padding:10px;background:var(--surface-soft);border-radius:7px}.event-dialog-steps strong,.event-dialog-steps small{display:block}.event-dialog-steps strong{font-size:12px}.event-dialog-steps small{font-size:9.6px;color:var(--muted);margin-top:2px}
|
|
3875
|
+
.event-dialog label{display:block;margin-top:13px}.event-dialog label[hidden]{display:none}.event-dialog label>span{display:block;font-size:12px;font-weight:720;margin-bottom:6px}.event-dialog input,.event-dialog select,.event-dialog textarea{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.event-dialog textarea{resize:vertical}.commit-dialog .form-grid label.full{grid-column:1/-1}.workflow-preview{margin-top:14px;padding:0 12px;border-radius:7px;background:var(--surface-soft)}.workflow-preview:not(:empty){padding-top:10px;padding-bottom:10px}.workflow-preview strong,.workflow-preview p{display:block;margin:0}.workflow-preview p{margin-top:5px;color:var(--muted);font-size:12px;line-height:1.5}.event-dialog-steps{display:grid;gap:6px;margin-top:15px;padding:10px;background:var(--surface-soft);border-radius:7px}.event-dialog-steps strong,.event-dialog-steps small{display:block}.event-dialog-steps strong{font-size:12px}.event-dialog-steps small{font-size:9.6px;color:var(--muted);margin-top:2px}
|
|
3876
|
+
.applicability-dialog{width:min(980px,calc(100vw - 30px));max-height:calc(100vh - 32px);border:0;border-radius:12px;padding:0;background:var(--panel);color:var(--ink);box-shadow:0 25px 80px rgba(0,0,24,.28)}.applicability-dialog form{padding:23px}.applicability-dialog form>p{color:var(--muted);font-size:13.2px}.review-context label>span{display:block;font-size:12px;font-weight:720;margin-bottom:6px}.review-context input,.review-context select,.applicability-row input,.applicability-row select{width:100%;min-height:38px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:8px 9px;font-size:13.2px}.review-context label.full{grid-column:1/-1}.applicability-rows{display:grid;gap:7px;max-height:46vh;overflow:auto;margin-top:16px;padding-right:4px}.applicability-row{display:grid;grid-template-columns:minmax(210px,1fr) 180px minmax(240px,1.3fr);gap:9px;align-items:center;padding:9px;border:1px solid var(--line);border-radius:8px}.applicability-row strong,.applicability-row small{display:block}.applicability-row small{margin-top:3px;color:var(--muted);font-size:10.8px}
|
|
2752
3877
|
.packet-builder form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;gap:12px;align-items:end}.packet-builder label>span{display:block;font-size:10.8px;font-weight:720;margin-bottom:6px}.packet-builder input,.packet-builder select{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:13.2px}.packet-note,.packet-output>p{font-size:12px;color:var(--muted);margin:12px 0 0}.packet-output{margin:14px 0}.packet-output h3{overflow-wrap:anywhere}.packet-gaps{display:grid}.packet-gaps>div{display:grid;grid-template-columns:58px 1fr;gap:10px;border-top:1px solid var(--line);padding:10px 0}.packet-gaps>div:first-child{border-top:0}.packet-gaps p{font-size:12px;margin:0}.packet-list{display:grid}.packet-list a{display:block;text-decoration:none;border-top:1px solid var(--line);padding:9px 0}.packet-list a:first-child{border-top:0}.packet-list strong,.packet-list small{display:block}.packet-list strong{font-size:12px}.packet-list small{font-size:9.6px;color:var(--muted);margin-top:2px}
|
|
2753
3878
|
.packet-preflight{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px;margin-bottom:12px}.packet-preflight a{display:flex;align-items:flex-start;gap:9px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel);text-decoration:none}.packet-preflight .status-dot{margin-top:4px}.packet-preflight small,.packet-preflight strong{display:block}.packet-preflight small{color:var(--muted);font-size:9.6px;text-transform:uppercase;letter-spacing:.07em}.packet-preflight strong{margin-top:3px;font-size:12px}.audit-evidence-paths{margin-bottom:12px}.audit-evidence-paths .panel-head p:not(.kicker){margin:4px 0 0;color:var(--muted);font-size:10.8px}.audit-evidence-path-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:12px}.audit-evidence-path-grid>a{display:block;padding:14px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft);text-decoration:none}.audit-evidence-path-grid h4{margin:7px 0 5px;font-size:13.2px}.audit-evidence-path-grid p{margin:0;color:var(--muted);font-size:10.8px;line-height:1.55}
|
|
2754
3879
|
.audit-preparation{margin-bottom:12px}.audit-preparation .panel-head{align-items:flex-start}.audit-preparation .panel-head h3{margin:3px 0}.audit-preparation .panel-head p:not(.kicker){margin:4px 0 0;color:var(--muted);font-size:10.8px}.preparation-progress{height:5px;margin:12px 0 0;border-radius:99px;background:var(--surface-muted);overflow:hidden}.preparation-progress span{display:block;height:100%;border-radius:inherit;background:var(--primary-gradient)}.audit-preparation-note{margin:9px 0 0;color:var(--muted);font-size:10.8px;line-height:1.5}.preparation-stages{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:12px}.preparation-stage{border:1px solid var(--line);border-radius:8px;background:var(--surface-soft);overflow:hidden}.preparation-stage summary{display:flex;justify-content:space-between;gap:12px;align-items:center;padding:11px 12px;cursor:pointer;list-style:none}.preparation-stage summary::-webkit-details-marker{display:none}.preparation-stage summary span,.preparation-stage summary strong,.preparation-stage summary small{display:block}.preparation-stage summary strong{font-size:12px}.preparation-stage summary small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4}.preparation-stage summary b{flex:none;color:var(--muted);font-size:9.6px;font-weight:650}.preparation-items{border-top:1px solid var(--line);background:var(--panel)}.preparation-items>a,.preparation-items>div{display:grid;grid-template-columns:22px minmax(0,1fr);gap:9px;padding:10px 12px;border-top:1px solid var(--line);text-decoration:none}.preparation-items>:first-child{border-top:0}.preparation-items strong,.preparation-items small{display:block}.preparation-items strong{font-size:10.8px}.preparation-items small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.45}.preparation-status{display:grid;place-items:center;width:20px;height:20px;border-radius:50%;background:var(--surface-muted);color:var(--muted);font-size:10.8px;font-weight:800}.preparation-status.complete{background:#dcefe4;color:#125733}.preparation-status.action{background:#f7dfdc;color:#873027}.preparation-status.later{background:#f6e8c9;color:#79500f}.preparation-status.external,.preparation-status.info{background:var(--accent-soft);color:var(--accent)}.audit-preparation-error:empty{display:none}
|
|
@@ -2758,6 +3883,7 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
|
|
|
2758
3883
|
@media(max-width:900px){.overview-grid{grid-template-columns:1fr}.overview-grid>.audit-panel{grid-column:auto}}
|
|
2759
3884
|
@media(max-width:520px){.event-reminder-preview,.policy-event-list,.packet-builder form,.packet-preflight,.audit-evidence-path-grid{grid-template-columns:1fr}.policy-event-row:nth-child(n) .policy-event-tooltip{right:auto;left:0}.packet-metrics{grid-template-columns:1fr}.obligation-card-head{display:block}.obligation-card-head strong{display:block;text-align:left;margin-top:3px}}
|
|
2760
3885
|
@media(max-width:520px){.policy-event-feedback{grid-template-columns:auto minmax(0,1fr) auto}.policy-event-feedback>.button{grid-column:2}.policy-event-feedback>.icon-button{grid-column:3;grid-row:1}}
|
|
3886
|
+
@media(max-width:760px){.workflow-findings{grid-template-columns:1fr}}
|
|
2761
3887
|
|
|
2762
3888
|
@media(prefers-color-scheme:dark){
|
|
2763
3889
|
:root{--ink:#f4f5ff;--muted:#b8bfd3;--line:#343d5c;--paper:#000;--panel:#141a2e;--accent:#aab7ff;--accent-soft:#252e52;--accent-light:#9aabff;--focus:#bdc7ff;--amber:#ffd08a;--red:#ffaaa0;--surface-soft:#1b2238;--surface-muted:#252d48;--field:#11172a;--field-readonly:#1c2338;--shadow:0 12px 34px rgba(0,0,0,.3)}
|
|
@@ -2769,9 +3895,19 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
|
|
|
2769
3895
|
.badge.status-active,.badge.status-approved,.badge.status-complete,.badge.status-passed,.badge.status-accepted{background:#173b2b;color:#a8edc4}
|
|
2770
3896
|
.badge.status-open,.badge.status-high,.badge.status-critical,.badge.status-failed{background:#4a252a;color:#ffb5ad}
|
|
2771
3897
|
.badge.status-draft,.badge.status-planned,.badge.status-in-progress,.badge.status-medium{background:#483714;color:#ffd991}
|
|
2772
|
-
.badge.status-overdue{background:#4a252a;color:#ffb5ad}
|
|
3898
|
+
.badge.status-overdue,.badge.status-blocked{background:#4a252a;color:#ffb5ad}
|
|
2773
3899
|
.badge.status-due{background:#483714;color:#ffd991}
|
|
2774
3900
|
.badge.status-complete{background:#173b2b;color:#a8edc4}
|
|
3901
|
+
.repository-override,.repository-sync-alert{border-color:#77612f;background:#382f19}
|
|
3902
|
+
.repository-override code{color:#ffe2a3}
|
|
3903
|
+
.workflow-finding-status.ready,.workflow-finding-status.due,.workflow-finding-status.open,.stage-page-completion-state,.readiness-state.warn,.preparation-status.later{background:#483714;color:#ffd991}
|
|
3904
|
+
.workflow-finding-status.overdue,.workflow-finding-status.blocked,.readiness-state.bad,.preparation-status.action{background:#4a252a;color:#ffb5ad}
|
|
3905
|
+
.workflow-finding-status.complete,.stage-page-completion-state.complete,.readiness-state.good,.preparation-status.complete{background:#173b2b;color:#a8edc4}
|
|
3906
|
+
.stage-page-card.complete,.evidence-map-card.complete{border-color:#315f48}
|
|
3907
|
+
.collection-review-panel.required{border-color:#77612f}
|
|
3908
|
+
.collection-review-panel.current{border-color:#315f48}
|
|
3909
|
+
.evidence-map-source.complete{border-color:#315f48;background:#183426}
|
|
3910
|
+
.operation-tracking.running strong{color:#a8edc4}
|
|
2775
3911
|
.policy-event-feedback{border-color:#315f48;background:#183426}.policy-event-feedback p{color:#b7d8c3}
|
|
2776
3912
|
.missing-options{border-color:#77612f;background:#382f19;color:#ffdc92}
|
|
2777
3913
|
.status-dot.neutral{background:#9aabff}
|