filegrc 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/model/index.js +9 -5
- package/model/v3.json +9391 -0
- package/package.json +3 -3
- package/src/agent.js +53 -0
- package/src/appointments.js +19 -0
- package/src/audit-preparation.js +47 -6
- package/src/audit-transition.js +96 -0
- package/src/batch-review.js +109 -0
- package/src/cli.js +418 -61
- package/src/collection-review.js +185 -0
- package/src/evidence-packet.js +34 -2
- package/src/external-reviewer.js +165 -0
- package/src/files.js +46 -10
- package/src/index.js +36 -2
- package/src/model-docs.js +15 -0
- package/src/model-migration.js +516 -21
- package/src/obligations.js +396 -13
- package/src/program-lifecycle.js +1 -0
- package/src/program-path.js +49 -12
- package/src/program-readiness.js +331 -27
- package/src/reconciliation.js +277 -0
- package/src/server.js +242 -14
- package/src/setup.js +36 -4
- package/src/source-coverage.js +61 -0
- package/src/startup.js +1 -1
- package/src/state.js +37 -1
- package/src/validate.js +100 -3
- package/src/web.js +979 -213
- package/src/workflow.js +1595 -0
package/src/web.js
CHANGED
|
@@ -8,9 +8,24 @@ import {
|
|
|
8
8
|
utcCalendarDate,
|
|
9
9
|
validCalendarRecurrence
|
|
10
10
|
} from "./recurrence.js";
|
|
11
|
-
import { PROGRAM_PATH, RESOURCE_INSTRUCTIONS } from "./program-path.js";
|
|
11
|
+
import { PROGRAM_PATH, RESOURCE_INSTRUCTIONS, RESOURCE_PAGE_SUMMARIES } from "./program-path.js";
|
|
12
12
|
import { formatCalendarDate, formatLocalDateTime } from "./time.js";
|
|
13
13
|
|
|
14
|
+
export function dashboardProgramReadiness(programReadiness = {}) {
|
|
15
|
+
const progress = programReadiness.progress || {};
|
|
16
|
+
const lifecycle = programReadiness.operating
|
|
17
|
+
? { status: "Operating", tone: "good" }
|
|
18
|
+
: programReadiness.evidenceReady
|
|
19
|
+
? { status: "Evidence ready", tone: "good" }
|
|
20
|
+
: { status: "Needs work", tone: "warn" };
|
|
21
|
+
return {
|
|
22
|
+
percent: progress.percent ?? 0,
|
|
23
|
+
complete: progress.complete ?? 0,
|
|
24
|
+
total: progress.total ?? 0,
|
|
25
|
+
...lifecycle
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
export function renderIndex(state = null) {
|
|
15
30
|
const snapshot = state
|
|
16
31
|
? `<script id="filegrc-data" type="application/json">${safeJson(state)}</script>`
|
|
@@ -50,9 +65,11 @@ const READINESS_STAGES = SHARED_PROGRAM_STAGES.map((stage) => ({
|
|
|
50
65
|
...stage,
|
|
51
66
|
number: String(stage.number)
|
|
52
67
|
}));
|
|
68
|
+
const RESOURCE_GUIDE_INSTRUCTIONS = ${JSON.stringify(RESOURCE_INSTRUCTIONS)};
|
|
69
|
+
const dashboardProgramReadiness = ${dashboardProgramReadiness.toString()};
|
|
53
70
|
const STAGE_PAGE_SUMMARIES = ${JSON.stringify({
|
|
54
|
-
...
|
|
55
|
-
"utility:audit-packet": "Review
|
|
71
|
+
...RESOURCE_PAGE_SUMMARIES,
|
|
72
|
+
"utility:audit-packet": "Review fieldwork readiness and build the indexed evidence packet."
|
|
56
73
|
})};
|
|
57
74
|
const RECORD_TEXT_FIELDS = new Set(["description", "statement", "activity", "purpose", "scope", "objective", "applicabilityRationale", "summary", "rationale", "businessPurpose", "changeSummary", "decisionSummary", "decisionRationale", "recommendation", "remediationPlan", "auditorNotes", "notPerformedReason"]);
|
|
58
75
|
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"]);
|
|
@@ -253,7 +270,7 @@ function renderHome(main) {
|
|
|
253
270
|
: "";
|
|
254
271
|
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() +
|
|
255
272
|
'<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>' +
|
|
256
|
-
'<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>' +
|
|
273
|
+
'<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>' +
|
|
257
274
|
auditPanel + '</div></div>';
|
|
258
275
|
main.querySelector("#resume-setup")?.addEventListener("click", requestOnboarding);
|
|
259
276
|
}
|
|
@@ -273,29 +290,29 @@ function initialSetupBanner() {
|
|
|
273
290
|
? "Choose the program goal."
|
|
274
291
|
: "Confirm the saved program goal: " + goalLabels[goal] + ".";
|
|
275
292
|
const completion = system.status === "planned"
|
|
276
|
-
? "
|
|
277
|
-
: "
|
|
278
|
-
return '<section class="setup-banner"><div><p class="kicker">Setup draft saved</p><h3>Review
|
|
293
|
+
? "Confirm the service scope to activate the planned service and continue to Step 1."
|
|
294
|
+
: "Confirm the service scope to close onboarding and continue to Step 1.";
|
|
295
|
+
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>';
|
|
279
296
|
}
|
|
280
297
|
|
|
281
298
|
function readinessOverview() {
|
|
282
|
-
const progress =
|
|
299
|
+
const progress = dashboardProgramReadiness(state.programReadiness);
|
|
283
300
|
const nextHref = nextProgramStageHref();
|
|
284
|
-
const programStage = (id,
|
|
301
|
+
const programStage = (id, href) => {
|
|
285
302
|
const stage = READINESS_STAGES.find((candidate) => candidate.id === id);
|
|
286
303
|
const current = stageProgress(stage);
|
|
287
304
|
const remaining = current.total - current.complete;
|
|
288
|
-
const status = !remaining ? "
|
|
289
|
-
return [stage.title,
|
|
305
|
+
const status = !remaining ? "Ready" : current.complete ? remaining + " pages need work" : "Needs work";
|
|
306
|
+
return [stage.title, stage.summary, href, status, !remaining ? "good" : current.complete ? "warn" : "neutral"];
|
|
290
307
|
};
|
|
291
308
|
const stages = [
|
|
292
|
-
programStage("scope", "
|
|
293
|
-
programStage("policies", "
|
|
294
|
-
programStage("controls", "
|
|
295
|
-
programStage("run", "
|
|
296
|
-
programStage("audit", "
|
|
309
|
+
programStage("scope", "#/stage/scope"),
|
|
310
|
+
programStage("policies", "#/stage/policies"),
|
|
311
|
+
programStage("controls", "#/stage/controls"),
|
|
312
|
+
programStage("run", "#/stage/run"),
|
|
313
|
+
programStage("audit", "#/stage/audit")
|
|
297
314
|
];
|
|
298
|
-
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>
|
|
315
|
+
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><b class="badge ' + esc(progress.tone) + '">' + esc(progress.status) + '</b><div class="progress"><span style="width:' + progress.percent + '%"></span></div><small>' + esc(progress.complete + " of " + progress.total + " readiness items complete") + '</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>';
|
|
299
316
|
}
|
|
300
317
|
|
|
301
318
|
function nextProgramStageHref() {
|
|
@@ -317,6 +334,249 @@ function renderStageOverview(main, stageId, params = new URLSearchParams()) {
|
|
|
317
334
|
renderStagePageIndex(stage) + (stage.id === "controls" ? renderEvidenceReadiness() : "") + '</div>';
|
|
318
335
|
}
|
|
319
336
|
|
|
337
|
+
function workflowGuidance(options = {}) {
|
|
338
|
+
const workflow = state.workflow;
|
|
339
|
+
if (!workflow) return "";
|
|
340
|
+
const matches = (item) => {
|
|
341
|
+
if (options.stageId && item.stage !== options.stageId) return false;
|
|
342
|
+
if (options.type && item.subject?.type !== options.type && item.source?.type !== options.type) return false;
|
|
343
|
+
if (options.id && item.subject?.id !== options.id && item.source?.id !== options.id) return false;
|
|
344
|
+
return true;
|
|
345
|
+
};
|
|
346
|
+
const activeStates = new Set(["blocked", "due", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
|
|
347
|
+
const findings = workflow.findings.filter(matches);
|
|
348
|
+
const workItems = workflow.workItems.filter((item) => matches(item) && activeStates.has(item.state));
|
|
349
|
+
const items = [...findings, ...workItems].sort((left, right) => (
|
|
350
|
+
workflowItemStatePriority(left) - workflowItemStatePriority(right)
|
|
351
|
+
|| (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
|
|
352
|
+
|| left.key.localeCompare(right.key)
|
|
353
|
+
));
|
|
354
|
+
if (!items.length) return "";
|
|
355
|
+
const blocking = items.filter((item) => ["blocked", "due", "open", "overdue", "ready"].includes(item.state));
|
|
356
|
+
const status = blocking.length
|
|
357
|
+
? blocking.length + " " + pluralize("item", blocking.length) + (blocking.length === 1 ? " needs work" : " need work")
|
|
358
|
+
: items.length + " scheduled or external";
|
|
359
|
+
const visible = items.slice(0, 6);
|
|
360
|
+
const rows = visible.map((item) => {
|
|
361
|
+
const href = workflowItemHref(item);
|
|
362
|
+
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>';
|
|
363
|
+
return href ? '<a href="' + href + '">' + body + '</a>' : '<div>' + body + '</div>';
|
|
364
|
+
}).join("");
|
|
365
|
+
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>';
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function collectionReviewPanel(type) {
|
|
369
|
+
const assessment = state.collectionReviews?.[type];
|
|
370
|
+
if (!assessment) return "";
|
|
371
|
+
const configuration = assessment.configuration;
|
|
372
|
+
const current = assessment.status === "current";
|
|
373
|
+
const reviewerNames = (assessment.review?.reviewedByIds || [])
|
|
374
|
+
.map((id) => state.resources.find(({ record }) => record.id === id)?.record.title || id);
|
|
375
|
+
const reviewSummary = current
|
|
376
|
+
? '<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>'
|
|
377
|
+
: '<p class="collection-review-result"><strong>' + (assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(assessment.message) + '</span></p>';
|
|
378
|
+
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>' +
|
|
379
|
+
'<details ' + (current ? "" : "open") + '><summary>What to review</summary><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></details>' +
|
|
380
|
+
'<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>';
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function resourceReviewCriteria(type) {
|
|
384
|
+
const reviewPoints = state.model.resources[type]?.guidance?.reviewPoints || [];
|
|
385
|
+
if (!reviewPoints.length) return "";
|
|
386
|
+
return '<section class="resource-review-criteria"><strong>What the reviewer should check</strong><ul>' + reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section>';
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function recordWorkflowItems(type, id) {
|
|
390
|
+
const activeStates = new Set(["blocked", "due", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
|
|
391
|
+
return [
|
|
392
|
+
...(state.workflow?.findings || []),
|
|
393
|
+
...(state.workflow?.workItems || [])
|
|
394
|
+
].filter((item) => (
|
|
395
|
+
activeStates.has(item.state)
|
|
396
|
+
&& (
|
|
397
|
+
item.subject?.type === type && item.subject?.id === id
|
|
398
|
+
|| item.source?.type === type && item.source?.id === id
|
|
399
|
+
)
|
|
400
|
+
)).sort((left, right) => (
|
|
401
|
+
workflowItemStatePriority(left) - workflowItemStatePriority(right)
|
|
402
|
+
|| (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
|
|
403
|
+
|| left.key.localeCompare(right.key)
|
|
404
|
+
));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function recordWorkflowCell(type, entry) {
|
|
408
|
+
const items = recordWorkflowItems(type, entry.record.id);
|
|
409
|
+
if (!items.length) return '<span class="record-workflow-clear">No calculated action</span>';
|
|
410
|
+
const item = items[0];
|
|
411
|
+
const href = workflowItemHref(item) || "#/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(entry.record.id);
|
|
412
|
+
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>';
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function openCollectionReviewDialog(type) {
|
|
416
|
+
const assessment = state.collectionReviews?.[type];
|
|
417
|
+
if (!assessment) return;
|
|
418
|
+
const configuration = assessment.configuration;
|
|
419
|
+
const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
|
|
420
|
+
const systems = resourcesOfType("system").filter(({ record }) => record.status === "active");
|
|
421
|
+
const allowedDecisions = configuration.decisions || ["complete"];
|
|
422
|
+
const defaultDecision = assessment.review?.decision
|
|
423
|
+
|| (!assessment.recordCount && allowedDecisions.includes("zero-population") ? "zero-population" : allowedDecisions[0]);
|
|
424
|
+
const decisions = allowedDecisions.map((decision) => (
|
|
425
|
+
'<option value="' + esc(decision) + '" ' + (defaultDecision === decision ? "selected" : "") + '>' + esc(properCase(decision)) + '</option>'
|
|
426
|
+
)).join("");
|
|
427
|
+
const dialog = document.createElement("dialog");
|
|
428
|
+
dialog.className = "commit-dialog event-dialog collection-review-dialog";
|
|
429
|
+
dialog.setAttribute("aria-labelledby", "collection-review-dialog-title");
|
|
430
|
+
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>';
|
|
431
|
+
document.body.append(dialog);
|
|
432
|
+
dialog.showModal();
|
|
433
|
+
const form = dialog.querySelector("form");
|
|
434
|
+
const systemField = dialog.querySelector("[data-authoritative-system]");
|
|
435
|
+
const syncDecision = () => {
|
|
436
|
+
const external = form.elements.decision.value === "externally-managed";
|
|
437
|
+
systemField.hidden = !external;
|
|
438
|
+
form.elements.authoritativeSystemId.required = external;
|
|
439
|
+
};
|
|
440
|
+
syncDecision();
|
|
441
|
+
dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
|
|
442
|
+
dialog.querySelector('[data-event="cancel"]').addEventListener("click", () => dialog.close());
|
|
443
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
444
|
+
let previewedPayload = null;
|
|
445
|
+
form.addEventListener("input", () => {
|
|
446
|
+
previewedPayload = null;
|
|
447
|
+
syncDecision();
|
|
448
|
+
dialog.querySelector(".workflow-preview").innerHTML = "";
|
|
449
|
+
dialog.querySelector("[data-preview-collection-review]").textContent = "Preview confirmation";
|
|
450
|
+
});
|
|
451
|
+
form.addEventListener("submit", async (event) => {
|
|
452
|
+
event.preventDefault();
|
|
453
|
+
if (!form.reportValidity()) return;
|
|
454
|
+
const error = dialog.querySelector(".dialog-error");
|
|
455
|
+
error.textContent = "";
|
|
456
|
+
const payload = {
|
|
457
|
+
resourceType: type,
|
|
458
|
+
decision: form.elements.decision.value,
|
|
459
|
+
rationale: form.elements.rationale.value.trim(),
|
|
460
|
+
reviewedByIds: [form.elements.reviewerId.value],
|
|
461
|
+
reviewedOn: form.elements.reviewedOn.value,
|
|
462
|
+
authoritativeSystemId: form.elements.authoritativeSystemId.value || undefined,
|
|
463
|
+
expectedRevision: assessment.reviewRevision || undefined
|
|
464
|
+
};
|
|
465
|
+
form.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = true; });
|
|
466
|
+
try {
|
|
467
|
+
if (!previewedPayload) {
|
|
468
|
+
const response = await localFetch("/api/collection-review/preview", {
|
|
469
|
+
method: "POST",
|
|
470
|
+
headers: { "content-type": "application/json" },
|
|
471
|
+
body: JSON.stringify(payload)
|
|
472
|
+
});
|
|
473
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
474
|
+
const preview = await response.json();
|
|
475
|
+
previewedPayload = payload;
|
|
476
|
+
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>';
|
|
477
|
+
dialog.querySelector("[data-preview-collection-review]").textContent = "Confirm and save";
|
|
478
|
+
} else {
|
|
479
|
+
const response = await localFetch("/api/collection-review", {
|
|
480
|
+
method: "POST",
|
|
481
|
+
headers: { "content-type": "application/json" },
|
|
482
|
+
body: JSON.stringify({ ...previewedPayload, confirmed: true })
|
|
483
|
+
});
|
|
484
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
485
|
+
applyMutationState(await response.json());
|
|
486
|
+
dialog.close();
|
|
487
|
+
render();
|
|
488
|
+
}
|
|
489
|
+
} catch (requestError) {
|
|
490
|
+
error.textContent = requestError.message;
|
|
491
|
+
} finally {
|
|
492
|
+
form.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = false; });
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function workflowItemHref(item) {
|
|
498
|
+
const source = item.source?.id && item.source?.type && item.source.type !== "unknown"
|
|
499
|
+
? item.source
|
|
500
|
+
: null;
|
|
501
|
+
if (source && ["assigned-work", "obligation-occurrence"].includes(item.kind)) {
|
|
502
|
+
return "#/stage/run?work=" + encodeURIComponent(source.type + ":" + source.id);
|
|
503
|
+
}
|
|
504
|
+
const commands = [
|
|
505
|
+
...(item.actions || []).map((action) => action.command),
|
|
506
|
+
item.nextAction?.command
|
|
507
|
+
].filter(Boolean);
|
|
508
|
+
const applicabilityCommand = commands.find((command) => command.includes(" review-applicability "));
|
|
509
|
+
const applicabilityType = applicabilityCommand?.match(/--type\s+([a-z0-9-]+)/)?.[1];
|
|
510
|
+
if (applicabilityType && state.model.resources[applicabilityType]) {
|
|
511
|
+
return "#/resources/" + encodeURIComponent(applicabilityType) + "?review=1";
|
|
512
|
+
}
|
|
513
|
+
const collectionReviewCommand = commands.find((command) => command.includes(" review-collection "));
|
|
514
|
+
const collectionReviewType = collectionReviewCommand?.match(/review-collection\s+([a-z0-9-]+)/)?.[1];
|
|
515
|
+
if (collectionReviewType && state.model.collectionReviews?.[collectionReviewType]) {
|
|
516
|
+
return "#/resources/" + encodeURIComponent(collectionReviewType) + "?review-collection=1";
|
|
517
|
+
}
|
|
518
|
+
if (commands.some((command) => command.includes(" external-reviewer-setup"))) {
|
|
519
|
+
const reviewer = resourcesOfType("appointment")
|
|
520
|
+
.find(({ record }) => record.appointmentKind === "independent-policy-reviewer");
|
|
521
|
+
if (reviewer) return "#/resource/appointment/" + encodeURIComponent(reviewer.record.id);
|
|
522
|
+
}
|
|
523
|
+
if (commands.some((command) => command.includes(" evidence-map"))) return "#/stage/controls";
|
|
524
|
+
const reference = item.subject?.id && item.subject?.type
|
|
525
|
+
? item.subject
|
|
526
|
+
: source;
|
|
527
|
+
if (reference && reference.type !== "unknown") {
|
|
528
|
+
return "#/resource/" + encodeURIComponent(reference.type) + "/" + encodeURIComponent(reference.id);
|
|
529
|
+
}
|
|
530
|
+
const missingType = item.subject?.type && state.model.resources[item.subject.type]
|
|
531
|
+
? item.subject.type
|
|
532
|
+
: null;
|
|
533
|
+
if (missingType) {
|
|
534
|
+
return "#/resources/" + encodeURIComponent(missingType) + (state.readOnly ? "" : "?new=1");
|
|
535
|
+
}
|
|
536
|
+
if (item.auditId) return "#/audit-packet?auditId=" + encodeURIComponent(item.auditId);
|
|
537
|
+
const stage = {
|
|
538
|
+
scope: "scope",
|
|
539
|
+
policies: "policies",
|
|
540
|
+
controls: "controls",
|
|
541
|
+
operate: "run",
|
|
542
|
+
operation: "run",
|
|
543
|
+
audit: "audit",
|
|
544
|
+
setup: "audit",
|
|
545
|
+
fieldwork: "audit",
|
|
546
|
+
deliver: "audit",
|
|
547
|
+
auditor: "audit"
|
|
548
|
+
}[item.stage];
|
|
549
|
+
return stage ? "#/stage/" + stage : "";
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function workflowItemDetail(item) {
|
|
553
|
+
if (item.dueOn) return "Due " + item.dueOn;
|
|
554
|
+
if (item.availableOn) return "Available " + item.availableOn;
|
|
555
|
+
return item.nextAction?.command || "Review the related source facts.";
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function workflowItemPriority(item) {
|
|
559
|
+
if (item.state === "overdue") return 0;
|
|
560
|
+
if (["due", "open", "ready"].includes(item.state)) return 20;
|
|
561
|
+
if (item.state === "blocked") return 30;
|
|
562
|
+
if (item.severity === "error") return 10;
|
|
563
|
+
if (item.state === "waiting-external") return 40;
|
|
564
|
+
return 50;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function workflowItemStatePriority(item) {
|
|
568
|
+
return {
|
|
569
|
+
overdue: 0,
|
|
570
|
+
due: 10,
|
|
571
|
+
open: 20,
|
|
572
|
+
ready: 30,
|
|
573
|
+
blocked: 40,
|
|
574
|
+
upcoming: 50,
|
|
575
|
+
scheduled: 60,
|
|
576
|
+
"waiting-external": 70
|
|
577
|
+
}[item.state] ?? 80;
|
|
578
|
+
}
|
|
579
|
+
|
|
320
580
|
function renderEvidenceReadiness() {
|
|
321
581
|
const items = state.programReadiness?.stages
|
|
322
582
|
?.find((stage) => stage.id === "controls")
|
|
@@ -350,7 +610,7 @@ function renderEvidenceReadiness() {
|
|
|
350
610
|
'<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>';
|
|
351
611
|
}).join("");
|
|
352
612
|
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>';
|
|
353
|
-
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>
|
|
613
|
+
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>';
|
|
354
614
|
}
|
|
355
615
|
|
|
356
616
|
function evidenceSourceCheckLabel(name) {
|
|
@@ -374,15 +634,6 @@ function stagePageDestinations(stage) {
|
|
|
374
634
|
.map((destination) => ({ ...destination, section })));
|
|
375
635
|
}
|
|
376
636
|
|
|
377
|
-
function stagePageId(stage, destination) {
|
|
378
|
-
return stage.id + ":" + (destination.type || "utility:" + destination.utility);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function stagePageComplete(pageId) {
|
|
382
|
-
const completedPageIds = rendererSettingsEntry()?.record.completedStagePageIds || [];
|
|
383
|
-
return completedPageIds.includes(pageId);
|
|
384
|
-
}
|
|
385
|
-
|
|
386
637
|
function stagePageSummary(destination) {
|
|
387
638
|
const summaryKey = destination.type || "utility:" + destination.utility;
|
|
388
639
|
const section = destination.section || (destination.type
|
|
@@ -392,24 +643,117 @@ function stagePageSummary(destination) {
|
|
|
392
643
|
}
|
|
393
644
|
|
|
394
645
|
function stagePageCard(stage, destination, index) {
|
|
395
|
-
const details = destination.type ? resourceRollup(destination.type) : utilityRollup(destination.utility);
|
|
396
646
|
const summary = stagePageSummary(destination);
|
|
397
647
|
const stepLabel = "Step " + stage.number + "." + String.fromCharCode(97 + index);
|
|
398
|
-
const
|
|
399
|
-
const complete =
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
648
|
+
const derived = derivedStagePageState(stage, destination);
|
|
649
|
+
const complete = derived.complete;
|
|
650
|
+
const items = stagePageItems(stage, destination);
|
|
651
|
+
const completionState = '<span class="stage-page-completion-state ' + (complete ? "complete" : "") + '">' + esc(derived.label) + '</span>';
|
|
652
|
+
const taskPreview = items.length
|
|
653
|
+
? '<div class="stage-page-tasks">' + items.slice(0, 3).map((item) => {
|
|
654
|
+
const href = workflowItemHref(item) || destination.href;
|
|
655
|
+
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>';
|
|
656
|
+
}).join("") + (items.length > 3 ? '<small class="stage-page-tasks-more">+' + (items.length - 3) + ' more on this page</small>' : "") + '</div>'
|
|
657
|
+
: "";
|
|
658
|
+
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>';
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function stagePageItemDetail(item) {
|
|
662
|
+
const controlChecks = item.subject?.type === "control"
|
|
663
|
+
? String(item.message || "").match(/^Complete (\d+) checks before implementation:/)
|
|
664
|
+
: null;
|
|
665
|
+
if (controlChecks) return controlChecks[1] + " implementation checks remain. Open the Control to review them.";
|
|
666
|
+
return item.message || workflowItemDetail(item);
|
|
404
667
|
}
|
|
405
668
|
|
|
406
669
|
function stageProgress(stage) {
|
|
407
670
|
if (stage.id === "run") return operationProgress();
|
|
408
671
|
const pages = stagePageDestinations(stage);
|
|
409
|
-
|
|
672
|
+
if (stage.id === "audit" && state.workflow?.assessments?.auditReadiness?.status === "not-started") {
|
|
673
|
+
return {
|
|
674
|
+
percent: 0,
|
|
675
|
+
complete: 0,
|
|
676
|
+
total: pages.length,
|
|
677
|
+
status: "Not started",
|
|
678
|
+
tone: "neutral",
|
|
679
|
+
detail: "Create an Audit only after a real engagement or customer deadline exists."
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
const complete = pages.filter((destination) => derivedStagePageState(stage, destination).complete).length;
|
|
410
683
|
return progressFromCounts(complete, pages.length, "page");
|
|
411
684
|
}
|
|
412
685
|
|
|
686
|
+
function derivedStagePageState(stage, destination) {
|
|
687
|
+
if (
|
|
688
|
+
stage.id === "audit"
|
|
689
|
+
&& state.workflow?.assessments?.auditReadiness?.status === "not-started"
|
|
690
|
+
&& (destination.type === "audit" || destination.utility === "audit-packet")
|
|
691
|
+
) {
|
|
692
|
+
return { complete: false, label: "No engagement" };
|
|
693
|
+
}
|
|
694
|
+
const blocking = stagePageItems(stage, destination);
|
|
695
|
+
if (blocking.length) {
|
|
696
|
+
return { complete: false, label: blocking.length + " " + pluralize("item", blocking.length) + (blocking.length === 1 ? " needs work" : " need work") };
|
|
697
|
+
}
|
|
698
|
+
if (destination.type && resourcesOfType(destination.type).length === 0) {
|
|
699
|
+
const collectionReview = state.collectionReviews?.[destination.type];
|
|
700
|
+
if (collectionReview) {
|
|
701
|
+
return collectionReview.status === "current"
|
|
702
|
+
? { complete: true, label: "Reviewed" }
|
|
703
|
+
: { complete: false, label: "Review scope" };
|
|
704
|
+
}
|
|
705
|
+
return { complete: true, label: "Conditional" };
|
|
706
|
+
}
|
|
707
|
+
return { complete: true, label: "Ready" };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function stagePageItems(stage, destination) {
|
|
711
|
+
const activeStates = new Set(["blocked", "due", "open", "overdue", "ready"]);
|
|
712
|
+
const items = [
|
|
713
|
+
...(state.workflow?.findings || []),
|
|
714
|
+
...(state.workflow?.workItems || [])
|
|
715
|
+
].filter((item) => (
|
|
716
|
+
activeStates.has(item.state)
|
|
717
|
+
&& (
|
|
718
|
+
item.stage === stage.id
|
|
719
|
+
|| stage.id === "scope"
|
|
720
|
+
&& destination.type === "appointment"
|
|
721
|
+
&& item.code === "governance.appointment.independent-policy-reviewer"
|
|
722
|
+
)
|
|
723
|
+
));
|
|
724
|
+
return items.filter((item) => {
|
|
725
|
+
if (
|
|
726
|
+
destination.type === "requirement"
|
|
727
|
+
&& item.subject?.type === "requirement"
|
|
728
|
+
&& item.fieldPath === "applicabilityReview"
|
|
729
|
+
) return false;
|
|
730
|
+
if (
|
|
731
|
+
stage.id === "controls"
|
|
732
|
+
&& ["source-coverage", "system"].includes(item.subject?.type)
|
|
733
|
+
&& item.key?.startsWith("evidence-source.")
|
|
734
|
+
) return false;
|
|
735
|
+
if (
|
|
736
|
+
stage.id === "policies"
|
|
737
|
+
&& destination.type === "policy"
|
|
738
|
+
&& item.code === "governance.appointment.independent-policy-reviewer"
|
|
739
|
+
) return true;
|
|
740
|
+
if (
|
|
741
|
+
destination.type
|
|
742
|
+
&& (item.subject?.type === destination.type || item.source?.type === destination.type)
|
|
743
|
+
) return true;
|
|
744
|
+
const href = workflowItemHref(item);
|
|
745
|
+
if (!href) return false;
|
|
746
|
+
const destinationHref = destination.href.split("?")[0];
|
|
747
|
+
return href === destinationHref
|
|
748
|
+
|| href.startsWith(destinationHref + "?")
|
|
749
|
+
|| Boolean(destination.type && href.startsWith("#/resource/" + destination.type + "/"));
|
|
750
|
+
}).sort((left, right) => (
|
|
751
|
+
workflowItemStatePriority(left) - workflowItemStatePriority(right)
|
|
752
|
+
|| (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
|
|
753
|
+
|| left.key.localeCompare(right.key)
|
|
754
|
+
));
|
|
755
|
+
}
|
|
756
|
+
|
|
413
757
|
function operationProgress() {
|
|
414
758
|
const program = state.programReadiness;
|
|
415
759
|
const goal = program?.target?.goal || state.workspace.assuranceGoal || "none";
|
|
@@ -420,7 +764,8 @@ function operationProgress() {
|
|
|
420
764
|
? Boolean(program?.target?.candidateCoverage?.kind === "as-of")
|
|
421
765
|
: Boolean(program?.evidenceReady);
|
|
422
766
|
const overdue = state.obligations.counts.overdue || 0;
|
|
423
|
-
const
|
|
767
|
+
const blocked = state.obligations.counts.blocked || 0;
|
|
768
|
+
const complete = Boolean(program?.evidenceReady && candidateStarted && overdue === 0 && blocked === 0);
|
|
424
769
|
if (complete) {
|
|
425
770
|
return {
|
|
426
771
|
percent: 100,
|
|
@@ -428,7 +773,7 @@ function operationProgress() {
|
|
|
428
773
|
total: 1,
|
|
429
774
|
status: "Operating",
|
|
430
775
|
tone: "good",
|
|
431
|
-
detail: "Evidence collection is running and the Work Queue has no overdue work."
|
|
776
|
+
detail: "Evidence collection is running and the Work Queue has no overdue or blocked work."
|
|
432
777
|
};
|
|
433
778
|
}
|
|
434
779
|
if (overdue) {
|
|
@@ -441,6 +786,16 @@ function operationProgress() {
|
|
|
441
786
|
detail: overdue + " overdue Work Queue " + pluralize("item", overdue) + " must be resolved."
|
|
442
787
|
};
|
|
443
788
|
}
|
|
789
|
+
if (blocked) {
|
|
790
|
+
return {
|
|
791
|
+
percent: 0,
|
|
792
|
+
complete: 0,
|
|
793
|
+
total: 1,
|
|
794
|
+
status: "Blocked",
|
|
795
|
+
tone: "bad",
|
|
796
|
+
detail: blocked + " blocked Work Queue " + pluralize("item", blocked) + " must be resolved."
|
|
797
|
+
};
|
|
798
|
+
}
|
|
444
799
|
if (program?.evidenceReady && goal === "soc-2-type-2" && !candidateStarted) {
|
|
445
800
|
return {
|
|
446
801
|
percent: 0,
|
|
@@ -461,22 +816,13 @@ function operationProgress() {
|
|
|
461
816
|
};
|
|
462
817
|
}
|
|
463
818
|
|
|
464
|
-
function programPathProgress() {
|
|
465
|
-
const progress = READINESS_STAGES.map((stage) => stageProgress(stage));
|
|
466
|
-
return progressFromCounts(
|
|
467
|
-
progress.reduce((sum, current) => sum + current.complete, 0),
|
|
468
|
-
progress.reduce((sum, current) => sum + current.total, 0),
|
|
469
|
-
"program milestone"
|
|
470
|
-
);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
819
|
function progressFromCounts(complete, total, noun) {
|
|
474
820
|
if (!total) return { percent: 0, complete: 0, total: 0, status: "Nothing to review", tone: "neutral", detail: "No " + pluralize(noun, 2) + " are configured yet." };
|
|
475
821
|
const percent = Math.round((complete / total) * 100);
|
|
476
|
-
const detail = complete + " of " + total + " " + pluralize(noun, total) +
|
|
477
|
-
if (complete === total) return { percent: 100, complete, total, status: "
|
|
478
|
-
if (!complete) return { percent: 0, complete, total, status: "
|
|
479
|
-
return { percent, complete, total, status: "
|
|
822
|
+
const detail = complete + " of " + total + " " + pluralize(noun, total) + (complete === 1 ? " is" : " are") + " ready.";
|
|
823
|
+
if (complete === total) return { percent: 100, complete, total, status: "Ready", tone: "good", detail };
|
|
824
|
+
if (!complete) return { percent: 0, complete, total, status: "Needs work", tone: "warn", detail };
|
|
825
|
+
return { percent, complete, total, status: "In progress", tone: "warn", detail };
|
|
480
826
|
}
|
|
481
827
|
|
|
482
828
|
function stageProgressCard(progress) {
|
|
@@ -495,30 +841,6 @@ function sectionDestinations(section) {
|
|
|
495
841
|
return destinations;
|
|
496
842
|
}
|
|
497
843
|
|
|
498
|
-
function resourceRollup(type) {
|
|
499
|
-
const records = resourcesOfType(type).map(({ record }) => record);
|
|
500
|
-
if (!records.length) return { value: "0", label: "No records yet" };
|
|
501
|
-
const statuses = new Map();
|
|
502
|
-
records.forEach((record) => {
|
|
503
|
-
const status = displayStatus(record);
|
|
504
|
-
if (status) statuses.set(status, (statuses.get(status) || 0) + 1);
|
|
505
|
-
});
|
|
506
|
-
const statusText = [...statuses.entries()].sort((a, b) => b[1] - a[1]).slice(0, 2).map(([status, count]) => count + " " + humanize(status).toLowerCase()).join(" · ");
|
|
507
|
-
return { value: String(records.length), label: statusText || pluralize("record", records.length) };
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
function utilityRollup(utility) {
|
|
511
|
-
if (utility === "obligation-board") {
|
|
512
|
-
const open = state.obligations.items.filter((item) => item.status !== "complete");
|
|
513
|
-
return { value: String(open.length), label: open.length ? "Open work items" : "No work due" };
|
|
514
|
-
}
|
|
515
|
-
if (utility === "audit-packet") {
|
|
516
|
-
const audits = resourcesOfType("audit");
|
|
517
|
-
return { value: String(audits.length), label: audits.length ? pluralize("engagement", audits.length) : "No engagement yet" };
|
|
518
|
-
}
|
|
519
|
-
return { value: "0", label: "Not started" };
|
|
520
|
-
}
|
|
521
|
-
|
|
522
844
|
function auditEngagementPrompt(audit = null) {
|
|
523
845
|
const hasAuditor = audit?.auditorVendorId;
|
|
524
846
|
if (hasAuditor) return "";
|
|
@@ -536,7 +858,7 @@ function renderExternalEvidenceSection() {
|
|
|
536
858
|
const createButton = state.readOnly
|
|
537
859
|
? ""
|
|
538
860
|
: '<button class="button primary" type="button" data-new-external-evidence>New external evidence</button>';
|
|
539
|
-
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>
|
|
861
|
+
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">' +
|
|
540
862
|
createButton + '<a class="button" href="#/resources/evidence">View all</a></div></div><div class="external-evidence-list">' +
|
|
541
863
|
(recent || empty("No External Evidence has been collected yet. Create it during operation only when a real artifact or approved external reference exists.")) +
|
|
542
864
|
'</div></section>';
|
|
@@ -545,34 +867,42 @@ function renderExternalEvidenceSection() {
|
|
|
545
867
|
function renderObligations(main, params = new URLSearchParams()) {
|
|
546
868
|
const stage = READINESS_STAGES.find((candidate) => candidate.id === "run");
|
|
547
869
|
const plan = state.obligations;
|
|
548
|
-
const controls = resourcesOfType("control");
|
|
549
|
-
const linkedControlIds = new Set(resourcesOfType("obligation").flatMap(({ record }) => record.controlIds || []));
|
|
550
|
-
const scheduledControls = controls.filter(({ record }) => linkedControlIds.has(record.id)).length;
|
|
551
|
-
const assignedFollowUp = plan.standaloneItems.length;
|
|
552
870
|
const visibleCardLimit = 6;
|
|
553
|
-
const sections = ["proposed", "upcoming", "due", "overdue"].map((status) => {
|
|
554
|
-
const items = plan.items
|
|
871
|
+
const sections = ["proposed", "upcoming", "blocked", "due", "overdue"].map((status) => {
|
|
872
|
+
const items = obligationBoardItems(plan.items, status);
|
|
555
873
|
const cards = items.map((item, index) => obligationCard(item, index >= visibleCardLimit)).join("");
|
|
556
874
|
const more = items.length > visibleCardLimit
|
|
557
875
|
? '<button class="button obligation-more" type="button" data-expand-obligations="' + status + '" data-total="' + items.length + '" aria-expanded="false">Show ' + (items.length - visibleCardLimit) + ' more</button>'
|
|
558
876
|
: "";
|
|
559
877
|
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>';
|
|
560
878
|
}).join("");
|
|
561
|
-
const
|
|
879
|
+
const eventTriggerLimit = 6;
|
|
880
|
+
const orderedTriggers = orderedPolicyEventTriggers(plan.triggers);
|
|
881
|
+
const triggers = orderedTriggers.map((trigger, index) => policyEventTrigger(trigger, index, index >= eventTriggerLimit)).join("");
|
|
882
|
+
const eventMore = orderedTriggers.length > eventTriggerLimit
|
|
883
|
+
? '<button class="button policy-event-more" type="button" data-expand-policy-events aria-expanded="false">Show ' + (orderedTriggers.length - eventTriggerLimit) + ' more events</button>'
|
|
884
|
+
: "";
|
|
562
885
|
const feedback = policyEventFeedback
|
|
563
886
|
? '<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>'
|
|
564
887
|
: "";
|
|
565
888
|
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>' +
|
|
566
889
|
'<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>' +
|
|
567
890
|
feedback +
|
|
568
|
-
'<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
|
|
569
|
-
'<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>
|
|
891
|
+
'<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>' +
|
|
892
|
+
'<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>' +
|
|
570
893
|
'<div class="obligation-board">' + sections + '</div>' +
|
|
571
894
|
'</section>' + renderExternalEvidenceSection() + '</div>';
|
|
572
895
|
main.querySelectorAll("[data-start-event]").forEach((button) => button.addEventListener("click", () => {
|
|
573
896
|
const trigger = plan.triggers.find((item) => item.eventType === button.dataset.startEvent);
|
|
574
897
|
if (trigger) openObligationEventDialog(trigger);
|
|
575
898
|
}));
|
|
899
|
+
main.querySelector("[data-expand-policy-events]")?.addEventListener("click", (event) => {
|
|
900
|
+
const button = event.currentTarget;
|
|
901
|
+
const expanded = button.getAttribute("aria-expanded") === "true";
|
|
902
|
+
main.querySelectorAll(".policy-event-row[data-collapsed]").forEach((row) => { row.hidden = expanded; });
|
|
903
|
+
button.setAttribute("aria-expanded", String(!expanded));
|
|
904
|
+
button.textContent = expanded ? "Show " + (orderedTriggers.length - eventTriggerLimit) + " more events" : "Show fewer events";
|
|
905
|
+
});
|
|
576
906
|
main.querySelector("[data-view-added-work]")?.addEventListener("click", () => main.querySelector(".work-queue-section")?.scrollIntoView({ behavior: "smooth", block: "start" }));
|
|
577
907
|
main.querySelector("[data-dismiss-policy-event-feedback]")?.addEventListener("click", (event) => {
|
|
578
908
|
policyEventFeedback = null;
|
|
@@ -591,6 +921,10 @@ function renderObligations(main, params = new URLSearchParams()) {
|
|
|
591
921
|
const item = plan.items.find((candidate) => candidate.key === button.dataset.recordObligation);
|
|
592
922
|
if (item) openObligationCompletion(item);
|
|
593
923
|
}));
|
|
924
|
+
main.querySelectorAll("[data-complete-action]").forEach((button) => button.addEventListener("click", () => {
|
|
925
|
+
const item = plan.items.find((candidate) => candidate.key === button.dataset.completeAction);
|
|
926
|
+
if (item) openActionCompletion(item);
|
|
927
|
+
}));
|
|
594
928
|
main.querySelector("[data-new-action-item]")?.addEventListener("click", () => openEditor("action-item", null, {
|
|
595
929
|
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."
|
|
596
930
|
}));
|
|
@@ -601,9 +935,52 @@ function renderObligations(main, params = new URLSearchParams()) {
|
|
|
601
935
|
if (requestedEvent) main.querySelector('[data-start-event="' + CSS.escape(requestedEvent) + '"]')?.click();
|
|
602
936
|
});
|
|
603
937
|
}
|
|
938
|
+
const requestedWork = params.get("work");
|
|
939
|
+
if (requestedWork) {
|
|
940
|
+
queueMicrotask(() => {
|
|
941
|
+
const card = [...main.querySelectorAll("[data-work-source]")]
|
|
942
|
+
.find((candidate) => candidate.dataset.workSource === requestedWork);
|
|
943
|
+
if (!card) return;
|
|
944
|
+
card.hidden = false;
|
|
945
|
+
card.classList.add("workflow-target");
|
|
946
|
+
card.scrollIntoView({ block: "center" });
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function obligationBoardItems(items, status) {
|
|
952
|
+
const matching = items.filter((item) => item.status === status);
|
|
953
|
+
if (status !== "proposed") return matching;
|
|
954
|
+
const seen = new Set();
|
|
955
|
+
return matching.filter((item) => {
|
|
956
|
+
const source = item.actionItemId ? "action:" + item.actionItemId : "obligation:" + item.obligationId;
|
|
957
|
+
if (seen.has(source)) return false;
|
|
958
|
+
seen.add(source);
|
|
959
|
+
return true;
|
|
960
|
+
});
|
|
604
961
|
}
|
|
605
962
|
|
|
606
|
-
function
|
|
963
|
+
function policyEventDisplayRank(eventType) {
|
|
964
|
+
const featured = [
|
|
965
|
+
"person-started",
|
|
966
|
+
"person-role-changed",
|
|
967
|
+
"person-ended",
|
|
968
|
+
"material-incident",
|
|
969
|
+
"system-material-change",
|
|
970
|
+
"vendor-activated"
|
|
971
|
+
];
|
|
972
|
+
const index = featured.indexOf(eventType);
|
|
973
|
+
return index === -1 ? featured.length : index;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function orderedPolicyEventTriggers(triggers) {
|
|
977
|
+
return [...triggers].sort((left, right) => (
|
|
978
|
+
policyEventDisplayRank(left.eventType) - policyEventDisplayRank(right.eventType)
|
|
979
|
+
|| policyEventName(left.eventType).localeCompare(policyEventName(right.eventType))
|
|
980
|
+
));
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function policyEventTrigger(trigger, index, collapsed = false) {
|
|
607
984
|
const tooltipId = "policy-event-tooltip-" + index;
|
|
608
985
|
const proposed = trigger.programStatus === "proposed";
|
|
609
986
|
const unavailable = state.readOnly || proposed;
|
|
@@ -612,7 +989,7 @@ function policyEventTrigger(trigger, index) {
|
|
|
612
989
|
: state.readOnly
|
|
613
990
|
? "Open this workspace in writable mode to trigger the workflow."
|
|
614
991
|
: trigger.steps.length + " " + pluralize("task", trigger.steps.length) + " will be added to the Work Queue.";
|
|
615
|
-
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>';
|
|
992
|
+
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>';
|
|
616
993
|
}
|
|
617
994
|
|
|
618
995
|
function policyEventName(eventType) {
|
|
@@ -639,16 +1016,54 @@ function defaultClassificationId() {
|
|
|
639
1016
|
function obligationCard(item, collapsed = false) {
|
|
640
1017
|
const type = item.actionItemId ? "action-item" : "obligation";
|
|
641
1018
|
const id = item.actionItemId || item.obligationId;
|
|
642
|
-
const completion =
|
|
1019
|
+
const completion = item.actionItemId ? actionCompletionPlan(item) : obligationCompletionPlan(item);
|
|
643
1020
|
const canAct = completion?.blocked === "Assign current owner"
|
|
644
1021
|
|| !["upcoming", "proposed"].includes(item.status);
|
|
645
1022
|
const action = !state.readOnly && canAct && completion
|
|
646
1023
|
? completion.blocked
|
|
647
1024
|
? '<a class="obligation-action blocked" href="' + completion.href + '">' + esc(completion.blocked) + '</a>'
|
|
648
|
-
:
|
|
1025
|
+
: item.actionItemId
|
|
1026
|
+
? '<button class="obligation-action" type="button" data-complete-action="' + esc(item.key) + '">Complete task</button>'
|
|
1027
|
+
: '<button class="obligation-action" type="button" data-record-obligation="' + esc(item.key) + '">Record work</button>'
|
|
649
1028
|
: "";
|
|
650
1029
|
const kind = item.kind === "event" ? "Policy Event Task" : item.kind === "action" ? "Assigned Follow-up" : properCase(item.activityType || "Recurring");
|
|
651
|
-
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>';
|
|
1030
|
+
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>';
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function actionCompletionPlan(item) {
|
|
1034
|
+
const action = state.resources.find(({ record }) => (
|
|
1035
|
+
record.type === "action-item" && record.id === item.actionItemId
|
|
1036
|
+
));
|
|
1037
|
+
if (!action) return null;
|
|
1038
|
+
if (action.record.status === "blocked") {
|
|
1039
|
+
return {
|
|
1040
|
+
blocked: "Resolve blockers",
|
|
1041
|
+
href: "#/resource/action-item/" + encodeURIComponent(action.record.id)
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
if (!action.record.obligationId) {
|
|
1045
|
+
return {
|
|
1046
|
+
blocked: "Open task",
|
|
1047
|
+
href: "#/resource/action-item/" + encodeURIComponent(action.record.id)
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
const obligation = state.resources.find(({ record }) => (
|
|
1051
|
+
record.type === "obligation" && record.id === action.record.obligationId
|
|
1052
|
+
));
|
|
1053
|
+
if (!obligation) {
|
|
1054
|
+
return {
|
|
1055
|
+
blocked: "Repair obligation link",
|
|
1056
|
+
href: "#/resource/action-item/" + encodeURIComponent(action.record.id)
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
const type = state.model.obligationActivities?.[obligation.record.activityType]?.completionType;
|
|
1060
|
+
if (!type) {
|
|
1061
|
+
return {
|
|
1062
|
+
blocked: "Review completion type",
|
|
1063
|
+
href: "#/resource/obligation/" + encodeURIComponent(obligation.record.id)
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
return { type, action, obligation };
|
|
652
1067
|
}
|
|
653
1068
|
|
|
654
1069
|
function obligationCompletionPlan(item) {
|
|
@@ -668,6 +1083,21 @@ function obligationCompletionPlan(item) {
|
|
|
668
1083
|
return { type };
|
|
669
1084
|
}
|
|
670
1085
|
|
|
1086
|
+
function openActionCompletion(item) {
|
|
1087
|
+
const completion = actionCompletionPlan(item);
|
|
1088
|
+
if (!completion || completion.blocked) return;
|
|
1089
|
+
openEditor(completion.type, null, {
|
|
1090
|
+
seed: obligationCompletionSeed(completion.type, item, completion.obligation.record),
|
|
1091
|
+
actionCompletion: {
|
|
1092
|
+
actionItemId: completion.action.record.id,
|
|
1093
|
+
revision: completion.action.revision,
|
|
1094
|
+
completedOn: currentDate()
|
|
1095
|
+
},
|
|
1096
|
+
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.",
|
|
1097
|
+
saveLabel: "Save and complete task"
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
|
|
671
1101
|
function openObligationCompletion(item) {
|
|
672
1102
|
const obligation = state.resources.find(({ record }) => record.type === "obligation" && record.id === item.obligationId);
|
|
673
1103
|
if (!obligation) return showError("The obligation template could not be found.");
|
|
@@ -688,6 +1118,11 @@ function obligationCompletionSeed(type, item, obligation) {
|
|
|
688
1118
|
const date = currentDate();
|
|
689
1119
|
const timestamp = new Date().toISOString();
|
|
690
1120
|
const responsiblePeople = currentPeopleForParties(item.ownerIds || []);
|
|
1121
|
+
const independentPeople = resourcesOfType("person")
|
|
1122
|
+
.map(({ record }) => record)
|
|
1123
|
+
.filter((record) => record.status === "active" && !responsiblePeople.includes(record.id))
|
|
1124
|
+
.map(({ id }) => id);
|
|
1125
|
+
const reviewerPeople = independentPeople.length ? [independentPeople[0]] : [];
|
|
691
1126
|
const inScopeSystems = resourcesOfType("system").filter(({ record }) => (state.workspace.systemIds || []).includes(record.id) && record.status !== "retired").map(({ record }) => record.id);
|
|
692
1127
|
const activeVendors = resourcesOfType("vendor").filter(({ record }) => record.status !== "terminated").map(({ record }) => record.id);
|
|
693
1128
|
const title = item.title + " · " + formatCalendarDate(item.dueWindowStart);
|
|
@@ -697,35 +1132,54 @@ function obligationCompletionSeed(type, item, obligation) {
|
|
|
697
1132
|
return { ...common, status: "complete", teamId: team.id, chairIds: currentPeopleForParties(team.chairIds || []), scheduledFor: date, startedAt: timestamp, endedAt: timestamp, attendeeIds: responsiblePeople };
|
|
698
1133
|
}
|
|
699
1134
|
if (type === "policy-review") {
|
|
700
|
-
return { ...common, status: "complete", scopeResourceIds: obligation.scopeResourceIds || [], reviewerIds:
|
|
1135
|
+
return { ...common, status: "complete", scopeResourceIds: obligation.scopeResourceIds || [], reviewerIds: reviewerPeople, completedOn: date, outcome: "passed", changesRequired: false, evidenceIds: [], coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd) };
|
|
701
1136
|
}
|
|
702
1137
|
if (type === "risk-assessment") {
|
|
703
|
-
return { ...common, status: "complete", completedOn: date, assessmentKind: "enterprise-risk", scope: "In-scope SOC 2 systems and dependencies", assessorIds: responsiblePeople, reviewerIds:
|
|
1138
|
+
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 };
|
|
704
1139
|
}
|
|
705
1140
|
if (type === "attestation") {
|
|
706
|
-
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" };
|
|
1141
|
+
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" };
|
|
707
1142
|
}
|
|
708
1143
|
if (type === "access-review") {
|
|
709
|
-
return { ...common, status: "complete", completedOn: date, reviewerIds: responsiblePeople, systemIds: inScopeSystems, scope: "Privileged, production, and important-system access", outcome: "passed", approvedByIds:
|
|
1144
|
+
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) };
|
|
710
1145
|
}
|
|
711
1146
|
if (type === "vulnerability-scan") {
|
|
712
|
-
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." };
|
|
1147
|
+
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 };
|
|
713
1148
|
}
|
|
714
1149
|
if (type === "penetration-test") {
|
|
715
|
-
return { ...common, status: "complete", testKind: "independent", scope: "In-scope systems and service boundary", coverage: rangeCoverage(date, date), ownerIds: responsiblePeople, outcome: "passed", systemIds: inScopeSystems, completedOn: date, reviewerIds:
|
|
1150
|
+
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 };
|
|
716
1151
|
}
|
|
717
1152
|
if (type === "control-test") {
|
|
718
|
-
return { ...common, status: "complete", controlId: obligation.controlIds?.[0] || "", testKinds: [item.activityType || "control-operation"], performedBy: "management", testerIds: responsiblePeople, reviewerIds:
|
|
1153
|
+
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) };
|
|
1154
|
+
}
|
|
1155
|
+
if (type === "control-activity") {
|
|
1156
|
+
return {
|
|
1157
|
+
...common,
|
|
1158
|
+
status: "complete",
|
|
1159
|
+
profileId: item.completionProfile || item.activityType,
|
|
1160
|
+
obligationId: item.obligationId,
|
|
1161
|
+
controlIds: item.controlIds || obligation.controlIds || [],
|
|
1162
|
+
scopeResourceIds: (item.subjectResourceIds || item.scopeResourceIds || obligation.scopeResourceIds || []).length
|
|
1163
|
+
? (item.subjectResourceIds || item.scopeResourceIds || obligation.scopeResourceIds)
|
|
1164
|
+
: [state.workspace.id],
|
|
1165
|
+
performerIds: responsiblePeople,
|
|
1166
|
+
completedAt: timestamp,
|
|
1167
|
+
method: "",
|
|
1168
|
+
result: "",
|
|
1169
|
+
reviewerIds: reviewerPeople,
|
|
1170
|
+
reviewedOn: date,
|
|
1171
|
+
ownerIds: item.ownerIds || obligation.ownerIds || []
|
|
1172
|
+
};
|
|
719
1173
|
}
|
|
720
1174
|
if (type === "exercise") {
|
|
721
|
-
return { ...common, status: "complete", exerciseKind: item.title.toLowerCase().includes("continuity") ? "business-continuity" : "incident-response", scheduledFor: date, facilitatorIds: responsiblePeople, objective: item.title, outcome: "passed", systemIds: inScopeSystems, completedAt: timestamp };
|
|
1175
|
+
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 };
|
|
722
1176
|
}
|
|
723
1177
|
if (type === "backup-test") {
|
|
724
|
-
return { ...common, status: "complete", systemIds: inScopeSystems, scheduledFor: date, operatorIds: responsiblePeople, reviewerIds:
|
|
1178
|
+
return { ...common, status: "complete", systemIds: inScopeSystems, scheduledFor: date, operatorIds: responsiblePeople, reviewerIds: reviewerPeople, outcome: "passed", evidenceIds: [], completedAt: timestamp };
|
|
725
1179
|
}
|
|
726
1180
|
if (type === "vendor-review") {
|
|
727
1181
|
const eventVendorId = (item.subjectResourceIds || []).find((id) => state.resources.some(({ record }) => record.id === id && record.type === "vendor"));
|
|
728
|
-
return { ...common, status: "complete", vendorId: eventVendorId || activeVendors[0] || "", reviewerIds: responsiblePeople, completedOn: date, decision: "approved", coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd) };
|
|
1182
|
+
return { ...common, status: "complete", vendorId: eventVendorId || activeVendors[0] || "", reviewerIds: responsiblePeople, completedOn: date, decision: "approved", evidenceIds: [], coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd) };
|
|
729
1183
|
}
|
|
730
1184
|
return {
|
|
731
1185
|
...common,
|
|
@@ -749,17 +1203,29 @@ function openObligationEventDialog(trigger) {
|
|
|
749
1203
|
const eventField = needsTimestamp
|
|
750
1204
|
? '<label><span>Event time <small>your local time</small></span><input name="occurredAt" type="datetime-local" required value="' + esc(currentLocalDateTime()) + '"></label>'
|
|
751
1205
|
: '<label><span>Event date</span><input name="occurredOn" type="date" required value="' + esc(currentDate()) + '"></label>';
|
|
1206
|
+
const riskField = trigger.eventType === "person-ended"
|
|
1207
|
+
? '<label><span>Departure risk</span><select name="riskLevel" required><option value="normal">Normal</option><option value="high">High or involuntary</option></select></label>'
|
|
1208
|
+
: "";
|
|
752
1209
|
const dialog = document.createElement("dialog");
|
|
753
1210
|
dialog.className = "commit-dialog event-dialog";
|
|
754
1211
|
dialog.setAttribute("aria-labelledby", "event-dialog-title");
|
|
755
|
-
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
|
|
1212
|
+
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 +
|
|
756
1213
|
(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>' : "") +
|
|
757
|
-
'<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>';
|
|
1214
|
+
'<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>';
|
|
758
1215
|
document.body.append(dialog);
|
|
759
1216
|
dialog.showModal();
|
|
760
1217
|
dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
|
|
761
1218
|
dialog.querySelector('[data-event="cancel"]').addEventListener("click", () => dialog.close());
|
|
762
1219
|
dialog.addEventListener("close", () => dialog.remove());
|
|
1220
|
+
const syncRiskSteps = () => {
|
|
1221
|
+
const selectedRisk = dialog.querySelector('[name="riskLevel"]')?.value || "";
|
|
1222
|
+
dialog.querySelectorAll("[data-risk-levels]").forEach((step) => {
|
|
1223
|
+
const levels = step.dataset.riskLevels.split(",").filter(Boolean);
|
|
1224
|
+
step.hidden = levels.length > 0 && !levels.includes(selectedRisk);
|
|
1225
|
+
});
|
|
1226
|
+
};
|
|
1227
|
+
dialog.querySelector('[name="riskLevel"]')?.addEventListener("change", syncRiskSteps);
|
|
1228
|
+
syncRiskSteps();
|
|
763
1229
|
dialog.querySelector("form").addEventListener("submit", async (event) => {
|
|
764
1230
|
event.preventDefault();
|
|
765
1231
|
const form = event.currentTarget;
|
|
@@ -773,6 +1239,7 @@ function openObligationEventDialog(trigger) {
|
|
|
773
1239
|
eventType: trigger.eventType,
|
|
774
1240
|
occurredOn: form.elements.occurredOn?.value || undefined,
|
|
775
1241
|
occurredAt: form.elements.occurredAt?.value ? new Date(form.elements.occurredAt.value).toISOString() : undefined,
|
|
1242
|
+
riskLevel: form.elements.riskLevel?.value || undefined,
|
|
776
1243
|
subjectResourceIds: form.elements.subject?.value ? [form.elements.subject.value] : [],
|
|
777
1244
|
title: form.elements.title.value
|
|
778
1245
|
})
|
|
@@ -795,6 +1262,220 @@ function openObligationEventDialog(trigger) {
|
|
|
795
1262
|
dialog.querySelector('input[name="occurredOn"], input[name="occurredAt"]').focus();
|
|
796
1263
|
}
|
|
797
1264
|
|
|
1265
|
+
function openExternalReviewerGovernanceDialog() {
|
|
1266
|
+
const dialog = document.createElement("dialog");
|
|
1267
|
+
dialog.className = "commit-dialog event-dialog";
|
|
1268
|
+
dialog.setAttribute("aria-labelledby", "external-reviewer-dialog-title");
|
|
1269
|
+
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>';
|
|
1270
|
+
document.body.append(dialog);
|
|
1271
|
+
dialog.showModal();
|
|
1272
|
+
const form = dialog.querySelector("form");
|
|
1273
|
+
const close = () => dialog.close();
|
|
1274
|
+
dialog.querySelector(".icon-button").addEventListener("click", close);
|
|
1275
|
+
dialog.querySelector('[data-event="cancel"]').addEventListener("click", close);
|
|
1276
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
1277
|
+
let previewedPayload = null;
|
|
1278
|
+
form.addEventListener("input", () => {
|
|
1279
|
+
previewedPayload = null;
|
|
1280
|
+
dialog.querySelector(".workflow-preview").innerHTML = "";
|
|
1281
|
+
dialog.querySelector("[data-preview-governance]").textContent = "Preview bundle";
|
|
1282
|
+
});
|
|
1283
|
+
form.addEventListener("submit", async (event) => {
|
|
1284
|
+
event.preventDefault();
|
|
1285
|
+
if (!form.reportValidity()) return;
|
|
1286
|
+
const payload = Object.fromEntries(new FormData(form).entries());
|
|
1287
|
+
const error = dialog.querySelector(".dialog-error");
|
|
1288
|
+
error.textContent = "";
|
|
1289
|
+
form.querySelectorAll("button,input,textarea").forEach((control) => { control.disabled = true; });
|
|
1290
|
+
try {
|
|
1291
|
+
if (!previewedPayload) {
|
|
1292
|
+
const response = await localFetch("/api/external-reviewer-governance/preview", {
|
|
1293
|
+
method: "POST",
|
|
1294
|
+
headers: { "content-type": "application/json" },
|
|
1295
|
+
body: JSON.stringify(payload)
|
|
1296
|
+
});
|
|
1297
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1298
|
+
const preview = await response.json();
|
|
1299
|
+
previewedPayload = payload;
|
|
1300
|
+
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>';
|
|
1301
|
+
dialog.querySelector("[data-preview-governance]").textContent = "Confirm and apply";
|
|
1302
|
+
} else {
|
|
1303
|
+
const response = await localFetch("/api/external-reviewer-governance", {
|
|
1304
|
+
method: "POST",
|
|
1305
|
+
headers: { "content-type": "application/json" },
|
|
1306
|
+
body: JSON.stringify({ ...previewedPayload, confirmed: true })
|
|
1307
|
+
});
|
|
1308
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1309
|
+
applyMutationState(await response.json());
|
|
1310
|
+
dialog.close();
|
|
1311
|
+
render();
|
|
1312
|
+
}
|
|
1313
|
+
} catch (requestError) {
|
|
1314
|
+
error.textContent = requestError.message;
|
|
1315
|
+
} finally {
|
|
1316
|
+
form.querySelectorAll("button,input,textarea").forEach((control) => { control.disabled = false; });
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
form.elements.reviewerName.focus();
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function openNextAuditCycleDialog(prior) {
|
|
1323
|
+
const priorEnd = coverageEnd(prior.coverage) || currentDate();
|
|
1324
|
+
const start = dateAfter(priorEnd);
|
|
1325
|
+
const end = start.slice(0, 4) + "-12-31";
|
|
1326
|
+
const dialog = document.createElement("dialog");
|
|
1327
|
+
dialog.className = "commit-dialog event-dialog";
|
|
1328
|
+
dialog.setAttribute("aria-labelledby", "audit-cycle-dialog-title");
|
|
1329
|
+
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>';
|
|
1330
|
+
document.body.append(dialog);
|
|
1331
|
+
dialog.showModal();
|
|
1332
|
+
const form = dialog.querySelector("form");
|
|
1333
|
+
dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
|
|
1334
|
+
dialog.querySelector('[data-event="cancel"]').addEventListener("click", () => dialog.close());
|
|
1335
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
1336
|
+
let previewedPayload = null;
|
|
1337
|
+
form.addEventListener("input", () => {
|
|
1338
|
+
previewedPayload = null;
|
|
1339
|
+
dialog.querySelector(".workflow-preview").innerHTML = "";
|
|
1340
|
+
dialog.querySelector("[data-preview-cycle]").textContent = "Preview carry-forward";
|
|
1341
|
+
});
|
|
1342
|
+
form.addEventListener("submit", async (event) => {
|
|
1343
|
+
event.preventDefault();
|
|
1344
|
+
if (!form.reportValidity()) return;
|
|
1345
|
+
const payload = {
|
|
1346
|
+
...Object.fromEntries(new FormData(form).entries()),
|
|
1347
|
+
priorAuditId: prior.id
|
|
1348
|
+
};
|
|
1349
|
+
const error = dialog.querySelector(".dialog-error");
|
|
1350
|
+
error.textContent = "";
|
|
1351
|
+
form.querySelectorAll("button,input").forEach((control) => { control.disabled = true; });
|
|
1352
|
+
try {
|
|
1353
|
+
if (!previewedPayload) {
|
|
1354
|
+
const response = await localFetch("/api/audit-cycle/preview", {
|
|
1355
|
+
method: "POST",
|
|
1356
|
+
headers: { "content-type": "application/json" },
|
|
1357
|
+
body: JSON.stringify(payload)
|
|
1358
|
+
});
|
|
1359
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1360
|
+
const preview = await response.json();
|
|
1361
|
+
previewedPayload = payload;
|
|
1362
|
+
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>';
|
|
1363
|
+
dialog.querySelector("[data-preview-cycle]").textContent = "Confirm and create";
|
|
1364
|
+
} else {
|
|
1365
|
+
const response = await localFetch("/api/audit-cycle", {
|
|
1366
|
+
method: "POST",
|
|
1367
|
+
headers: { "content-type": "application/json" },
|
|
1368
|
+
body: JSON.stringify({ ...previewedPayload, confirmed: true })
|
|
1369
|
+
});
|
|
1370
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1371
|
+
const result = await response.json();
|
|
1372
|
+
applyMutationState(result);
|
|
1373
|
+
dialog.close();
|
|
1374
|
+
history.replaceState(null, "", "#/resources/audit/" + encodeURIComponent(result.audit.id));
|
|
1375
|
+
render();
|
|
1376
|
+
}
|
|
1377
|
+
} catch (requestError) {
|
|
1378
|
+
error.textContent = requestError.message;
|
|
1379
|
+
} finally {
|
|
1380
|
+
form.querySelectorAll("button,input").forEach((control) => { control.disabled = false; });
|
|
1381
|
+
}
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function openApplicabilityReviewDialog(type, entries) {
|
|
1386
|
+
const definition = state.model.resources[type];
|
|
1387
|
+
const reviewPoints = definition?.guidance?.reviewPoints || [];
|
|
1388
|
+
const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
|
|
1389
|
+
const pending = entries.filter(({ record }) => (
|
|
1390
|
+
!record.applicabilityReview
|
|
1391
|
+
|| type === "requirement" && record.applicability === "undetermined"
|
|
1392
|
+
));
|
|
1393
|
+
const dialog = document.createElement("dialog");
|
|
1394
|
+
dialog.className = "commit-dialog applicability-dialog";
|
|
1395
|
+
dialog.setAttribute("aria-labelledby", "applicability-dialog-title");
|
|
1396
|
+
const options = type === "requirement"
|
|
1397
|
+
? '<option value="applicable">Applicable</option><option value="not-applicable">Not applicable</option>'
|
|
1398
|
+
: '<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>';
|
|
1399
|
+
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("");
|
|
1400
|
+
const reviewChecks = reviewPoints.length
|
|
1401
|
+
? '<section class="collection-review-checks"><strong>Before deciding</strong><ul>' + reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section>'
|
|
1402
|
+
: "";
|
|
1403
|
+
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>';
|
|
1404
|
+
document.body.append(dialog);
|
|
1405
|
+
dialog.showModal();
|
|
1406
|
+
const form = dialog.querySelector("form");
|
|
1407
|
+
dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
|
|
1408
|
+
dialog.querySelector('[data-event="cancel"]').addEventListener("click", () => dialog.close());
|
|
1409
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
1410
|
+
let previewedPayload = null;
|
|
1411
|
+
form.addEventListener("input", () => {
|
|
1412
|
+
previewedPayload = null;
|
|
1413
|
+
dialog.querySelector(".workflow-preview").innerHTML = "";
|
|
1414
|
+
dialog.querySelector("[data-preview-review]").textContent = "Preview decisions";
|
|
1415
|
+
});
|
|
1416
|
+
form.addEventListener("submit", async (event) => {
|
|
1417
|
+
event.preventDefault();
|
|
1418
|
+
if (!form.reportValidity()) return;
|
|
1419
|
+
const error = dialog.querySelector(".dialog-error");
|
|
1420
|
+
error.textContent = "";
|
|
1421
|
+
let missingRationale = false;
|
|
1422
|
+
const decisions = [...dialog.querySelectorAll("[data-review-id]")].flatMap((row) => {
|
|
1423
|
+
const decision = row.querySelector('[name="decision"]').value;
|
|
1424
|
+
if (!decision) return [];
|
|
1425
|
+
const rationale = row.querySelector('[name="rationale"]').value.trim();
|
|
1426
|
+
if (!rationale) missingRationale = true;
|
|
1427
|
+
return [{
|
|
1428
|
+
id: row.dataset.reviewId,
|
|
1429
|
+
decision,
|
|
1430
|
+
rationale
|
|
1431
|
+
}];
|
|
1432
|
+
});
|
|
1433
|
+
if (missingRationale) {
|
|
1434
|
+
error.textContent = "Add a rationale for every selected decision.";
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
if (!decisions.length) {
|
|
1438
|
+
error.textContent = "Select at least one decision.";
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
const payload = {
|
|
1442
|
+
decisions,
|
|
1443
|
+
reviewedByIds: [form.elements.reviewerId.value],
|
|
1444
|
+
reviewedOn: form.elements.reviewedOn.value,
|
|
1445
|
+
expectedRevisions: Object.fromEntries(entries.map((entry) => [entry.record.id, entry.revision]))
|
|
1446
|
+
};
|
|
1447
|
+
form.querySelectorAll("button,input,select").forEach((control) => { control.disabled = true; });
|
|
1448
|
+
try {
|
|
1449
|
+
if (!previewedPayload) {
|
|
1450
|
+
const response = await localFetch("/api/applicability-review/preview", {
|
|
1451
|
+
method: "POST",
|
|
1452
|
+
headers: { "content-type": "application/json" },
|
|
1453
|
+
body: JSON.stringify(payload)
|
|
1454
|
+
});
|
|
1455
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1456
|
+
const preview = await response.json();
|
|
1457
|
+
previewedPayload = payload;
|
|
1458
|
+
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>';
|
|
1459
|
+
dialog.querySelector("[data-preview-review]").textContent = "Confirm and save";
|
|
1460
|
+
} else {
|
|
1461
|
+
const response = await localFetch("/api/applicability-review", {
|
|
1462
|
+
method: "POST",
|
|
1463
|
+
headers: { "content-type": "application/json" },
|
|
1464
|
+
body: JSON.stringify({ ...previewedPayload, confirmed: true })
|
|
1465
|
+
});
|
|
1466
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1467
|
+
applyMutationState(await response.json());
|
|
1468
|
+
dialog.close();
|
|
1469
|
+
render();
|
|
1470
|
+
}
|
|
1471
|
+
} catch (requestError) {
|
|
1472
|
+
error.textContent = requestError.message;
|
|
1473
|
+
} finally {
|
|
1474
|
+
form.querySelectorAll("button,input,select").forEach((control) => { control.disabled = false; });
|
|
1475
|
+
}
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
|
|
798
1479
|
function renderAuditPacket(main, params = new URLSearchParams()) {
|
|
799
1480
|
const audits = resourcesOfType("audit");
|
|
800
1481
|
const evidence = resourcesOfType("evidence");
|
|
@@ -803,7 +1484,10 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
|
|
|
803
1484
|
.flatMap((family) => family.operationRecordTypes || []));
|
|
804
1485
|
const filegrcRecords = state.resources.filter(({ record }) => filegrcRecordTypes.has(record.type));
|
|
805
1486
|
const requestedAudit = params.get("auditId");
|
|
806
|
-
const
|
|
1487
|
+
const requestedEntry = audits.find(({ record }) => record.id === requestedAudit);
|
|
1488
|
+
const openEntry = audits.find(({ record }) => record.status !== "complete");
|
|
1489
|
+
const soleEntry = audits.length === 1 ? audits[0] : null;
|
|
1490
|
+
const selected = (requestedEntry || openEntry || soleEntry)?.record || null;
|
|
807
1491
|
const today = currentDate();
|
|
808
1492
|
const start = coverageStart(selected?.coverage) || today.slice(0, 4) + "-01-01";
|
|
809
1493
|
const end = coverageEnd(selected?.coverage) || today;
|
|
@@ -815,13 +1499,13 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
|
|
|
815
1499
|
["Engagement", selected ? selected.title : "No audit record", "#/resources/audit", selected ? "good" : "warn"],
|
|
816
1500
|
["filegrc Evidence", filegrcRecords.length + " operating " + pluralize("record", filegrcRecords.length), "#/stage/run", "neutral"],
|
|
817
1501
|
["External Evidence", evidence.length + " " + pluralize("record", evidence.length), "#/resources/evidence", evidence.length ? "good" : "neutral"],
|
|
818
|
-
["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"]
|
|
1502
|
+
["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"]
|
|
819
1503
|
];
|
|
820
|
-
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>
|
|
1504
|
+
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>';
|
|
821
1505
|
const dateFields = typeOne
|
|
822
1506
|
? '<label><span>As-of date</span><input type="date" name="start" required value="' + esc(start) + '"></label>'
|
|
823
1507
|
: '<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>';
|
|
824
|
-
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>
|
|
1508
|
+
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>';
|
|
825
1509
|
main.querySelector('select[name="auditId"]').addEventListener("change", (event) => {
|
|
826
1510
|
const next = event.currentTarget.value;
|
|
827
1511
|
location.hash = "#/audit-packet" + (next ? "?auditId=" + encodeURIComponent(next) : "");
|
|
@@ -925,7 +1609,7 @@ function renderPacketResults(container, result) {
|
|
|
925
1609
|
}
|
|
926
1610
|
|
|
927
1611
|
function obligationPreview(items) {
|
|
928
|
-
return items.length ? '<div class="obligation-preview">' + items.map((item) => '<a href="#/stage/run"><span class="status-dot ' + (
|
|
1612
|
+
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.");
|
|
929
1613
|
}
|
|
930
1614
|
|
|
931
1615
|
function distinctObligationPreviews(items, limit) {
|
|
@@ -961,6 +1645,7 @@ function windowText(item) {
|
|
|
961
1645
|
function timingText(item) {
|
|
962
1646
|
if (item.canceledAction) return "Action canceled; resolve or cancel the event";
|
|
963
1647
|
if (item.missingCompletion) return "Link required completion proof";
|
|
1648
|
+
if (item.status === "blocked") return item.blockingReason || "Blocked";
|
|
964
1649
|
if (item.status === "proposed") return "Starter proposal";
|
|
965
1650
|
if (item.status === "overdue" && Number.isInteger(item.hoursOverdue)) {
|
|
966
1651
|
return item.hoursOverdue === 0 ? "Overdue less than 1 hour" : item.hoursOverdue + " hour" + (item.hoursOverdue === 1 ? "" : "s") + " overdue";
|
|
@@ -1015,11 +1700,21 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
1015
1700
|
return { name, label: field.label || humanize(name), values };
|
|
1016
1701
|
}).filter(({ values }) => values.length > 1);
|
|
1017
1702
|
const createButton = !state.readOnly && !definition.singleton ? '<button class="button primary" id="new-resource">New ' + esc(definition.title.toLowerCase()) + '</button>' : "";
|
|
1703
|
+
const hasPendingApplicability = entries.some(({ record }) => (
|
|
1704
|
+
!record.applicabilityReview
|
|
1705
|
+
|| type === "requirement" && record.applicability === "undetermined"
|
|
1706
|
+
));
|
|
1707
|
+
const applicabilityButton = !state.readOnly
|
|
1708
|
+
&& ["requirement", "control", "commitment", "complementary-control"].includes(type)
|
|
1709
|
+
&& hasPendingApplicability
|
|
1710
|
+
? '<button class="button" id="review-applicability">Review applicability</button>'
|
|
1711
|
+
: "";
|
|
1018
1712
|
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>';
|
|
1019
1713
|
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>' +
|
|
1020
|
-
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>';
|
|
1714
|
+
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>';
|
|
1021
1715
|
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) +
|
|
1022
|
-
|
|
1716
|
+
collectionReviewPanel(type) +
|
|
1717
|
+
'<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>' +
|
|
1023
1718
|
'<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>';
|
|
1024
1719
|
resourceGuideCleanup = setupResourceGuide(main);
|
|
1025
1720
|
const pagination = main.querySelector(".list-pagination");
|
|
@@ -1035,7 +1730,7 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
1035
1730
|
const start = (pageNumber - 1) * LIST_PAGE_SIZE;
|
|
1036
1731
|
const visible = filtered.slice(start, start + LIST_PAGE_SIZE);
|
|
1037
1732
|
main.querySelector("#result-count").textContent = filtered.length + (filtered.length === 1 ? " record" : " records");
|
|
1038
|
-
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="Git file"><code>' + esc(entry.relativePath.replace(/^data\//, "")) + '</code></td></tr>').join("") : '<tr><td colspan="' + (fields.length +
|
|
1733
|
+
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>';
|
|
1039
1734
|
pagination.hidden = totalPages === 1;
|
|
1040
1735
|
previous.disabled = pageNumber === 1;
|
|
1041
1736
|
next.disabled = pageNumber === totalPages;
|
|
@@ -1075,7 +1770,15 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
1075
1770
|
renderRows();
|
|
1076
1771
|
syncRoute();
|
|
1077
1772
|
main.querySelector("#new-resource")?.addEventListener("click", () => openEditor(type));
|
|
1773
|
+
main.querySelector("#review-applicability")?.addEventListener("click", () => openApplicabilityReviewDialog(type, entries));
|
|
1774
|
+
main.querySelector("[data-review-collection]")?.addEventListener("click", () => openCollectionReviewDialog(type));
|
|
1078
1775
|
if (params.get("new") === "1" && !state.readOnly && !definition.singleton) queueMicrotask(() => openEditor(type));
|
|
1776
|
+
if (params.get("review") === "1" && !state.readOnly) {
|
|
1777
|
+
queueMicrotask(() => main.querySelector("#review-applicability")?.click());
|
|
1778
|
+
}
|
|
1779
|
+
if (params.get("review-collection") === "1" && !state.readOnly) {
|
|
1780
|
+
queueMicrotask(() => main.querySelector("[data-review-collection]")?.click());
|
|
1781
|
+
}
|
|
1079
1782
|
}
|
|
1080
1783
|
|
|
1081
1784
|
function renderDetail(main, type, id) {
|
|
@@ -1113,12 +1816,33 @@ function renderDetail(main, type, id) {
|
|
|
1113
1816
|
? ""
|
|
1114
1817
|
: (FINDING_SOURCE_TYPES.has(type) ? '<button class="button" type="button" data-record-finding>Record finding</button>' : "")
|
|
1115
1818
|
+ (ACTION_ITEM_SOURCE_TYPES.has(type) ? '<button class="button" type="button" data-add-action-item>Add task</button>' : "");
|
|
1819
|
+
const lifecycleActions = state.readOnly
|
|
1820
|
+
? ""
|
|
1821
|
+
: type === "action-item" && !["done", "canceled"].includes(entry.record.status) && entry.record.obligationId
|
|
1822
|
+
? '<button class="button primary" type="button" data-complete-action-detail>Complete task</button>'
|
|
1823
|
+
: type === "obligation-event" && entry.record.status !== "complete"
|
|
1824
|
+
? '<button class="button primary" type="button" data-complete-event>Complete event</button>'
|
|
1825
|
+
: "";
|
|
1826
|
+
const governanceActions = !state.readOnly
|
|
1827
|
+
&& type === "appointment"
|
|
1828
|
+
&& entry.record.appointmentKind === "independent-policy-reviewer"
|
|
1829
|
+
&& entry.record.status === "planned"
|
|
1830
|
+
? '<button class="button" type="button" data-external-reviewer-governance>Use external reviewer</button>'
|
|
1831
|
+
: "";
|
|
1832
|
+
const auditCycleAction = !state.readOnly
|
|
1833
|
+
&& type === "audit"
|
|
1834
|
+
&& entry.record.status === "complete"
|
|
1835
|
+
? '<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>'
|
|
1836
|
+
: "";
|
|
1116
1837
|
const detailMain = hasRecordBody
|
|
1117
1838
|
? '<section class="panel detail-main">' + narrativeContent + markdownContent + addRecordContent + '</section>'
|
|
1118
1839
|
: "";
|
|
1119
|
-
|
|
1120
|
-
|
|
1840
|
+
const attachmentPanel = type === "evidence" ? evidenceAttachmentPanel(entry) : "";
|
|
1841
|
+
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 +
|
|
1842
|
+
'<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>';
|
|
1121
1843
|
main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry));
|
|
1844
|
+
main.querySelector("[data-external-reviewer-governance]")?.addEventListener("click", openExternalReviewerGovernanceDialog);
|
|
1845
|
+
main.querySelector("[data-next-audit-cycle]")?.addEventListener("click", () => openNextAuditCycleDialog(entry.record));
|
|
1122
1846
|
main.querySelector("[data-record-finding]")?.addEventListener("click", () => openEditor("finding", null, {
|
|
1123
1847
|
seed: issueSeed("finding", entry.record),
|
|
1124
1848
|
description: "Record only a confirmed gap that needs separate remediation tracking. Keep the report details in this source record’s Markdown."
|
|
@@ -1127,6 +1851,68 @@ function renderDetail(main, type, id) {
|
|
|
1127
1851
|
seed: issueSeed("action-item", entry.record),
|
|
1128
1852
|
description: "Create a separate task only when this follow-up needs its own assignee, deadline, and completion proof. It will appear in Work Queue."
|
|
1129
1853
|
}));
|
|
1854
|
+
main.querySelector("[data-complete-action-detail]")?.addEventListener("click", () => {
|
|
1855
|
+
const item = state.obligations.items.find(({ actionItemId }) => actionItemId === entry.record.id);
|
|
1856
|
+
if (item) openActionCompletion(item);
|
|
1857
|
+
else showError("This Action Item is not available in the current Work Queue calculation.");
|
|
1858
|
+
});
|
|
1859
|
+
main.querySelector("[data-complete-event]")?.addEventListener("click", async () => {
|
|
1860
|
+
try {
|
|
1861
|
+
const response = await localFetch("/api/obligation-event-completions", {
|
|
1862
|
+
method: "POST",
|
|
1863
|
+
headers: { "content-type": "application/json" },
|
|
1864
|
+
body: JSON.stringify({
|
|
1865
|
+
eventId: entry.record.id,
|
|
1866
|
+
completedOn: currentDate(),
|
|
1867
|
+
revision: entry.revision
|
|
1868
|
+
})
|
|
1869
|
+
});
|
|
1870
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1871
|
+
applyMutationState(await response.json());
|
|
1872
|
+
render();
|
|
1873
|
+
} catch (error) {
|
|
1874
|
+
showError(error.message);
|
|
1875
|
+
}
|
|
1876
|
+
});
|
|
1877
|
+
main.querySelector("[data-attach-evidence]")?.addEventListener("click", () => {
|
|
1878
|
+
main.querySelector("[data-evidence-file]")?.click();
|
|
1879
|
+
});
|
|
1880
|
+
main.querySelector("[data-evidence-file]")?.addEventListener("change", async (event) => {
|
|
1881
|
+
const file = event.currentTarget.files?.[0];
|
|
1882
|
+
if (!file) return;
|
|
1883
|
+
if (!confirm('Attach "' + file.name + '" to this Evidence record? Confirm that its classification, retention, and repository access are appropriate.')) {
|
|
1884
|
+
event.currentTarget.value = "";
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
try {
|
|
1888
|
+
const response = await localFetch(
|
|
1889
|
+
"/api/evidence-attachments/" + encodeURIComponent(entry.record.id) + "/" + encodeURIComponent(file.name)
|
|
1890
|
+
+ "?revision=" + encodeURIComponent(entry.revision),
|
|
1891
|
+
{ method: "POST", body: file }
|
|
1892
|
+
);
|
|
1893
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1894
|
+
applyMutationState(await response.json());
|
|
1895
|
+
render();
|
|
1896
|
+
} catch (error) {
|
|
1897
|
+
showError(error.message);
|
|
1898
|
+
}
|
|
1899
|
+
});
|
|
1900
|
+
main.querySelectorAll("[data-detach-evidence]").forEach((button) => button.addEventListener("click", async () => {
|
|
1901
|
+
const attachment = button.dataset.detachEvidence;
|
|
1902
|
+
if (!confirm('Remove "' + attachment + '" from this Evidence record and delete its local file?')) return;
|
|
1903
|
+
try {
|
|
1904
|
+
const response = await localFetch(
|
|
1905
|
+
"/api/evidence-attachments/" + encodeURIComponent(entry.record.id) + "/" + encodeURIComponent(attachment)
|
|
1906
|
+
+ "?revision=" + encodeURIComponent(entry.revision),
|
|
1907
|
+
{ method: "DELETE" }
|
|
1908
|
+
);
|
|
1909
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1910
|
+
applyMutationState(await response.json());
|
|
1911
|
+
render();
|
|
1912
|
+
} catch (error) {
|
|
1913
|
+
showError(error.message);
|
|
1914
|
+
}
|
|
1915
|
+
}));
|
|
1130
1916
|
main.querySelector("#add-record-content")?.addEventListener("click", () => openEditor(type, entry, { addRecordContent: true }));
|
|
1131
1917
|
main.querySelectorAll("[data-edit-content]").forEach((button) => button.addEventListener("click", () => openContentEditor(entry, button.dataset.editContent)));
|
|
1132
1918
|
main.querySelector("#delete-resource")?.addEventListener("click", async () => {
|
|
@@ -1142,6 +1928,23 @@ function renderDetail(main, type, id) {
|
|
|
1142
1928
|
});
|
|
1143
1929
|
}
|
|
1144
1930
|
|
|
1931
|
+
function evidenceAttachmentPanel(entry) {
|
|
1932
|
+
const attachments = (entry.record.filePaths || []).map((path) => ({
|
|
1933
|
+
path,
|
|
1934
|
+
name: path.split("/").at(-1)
|
|
1935
|
+
}));
|
|
1936
|
+
const rows = attachments.length
|
|
1937
|
+
? attachments.map(({ path, name }) => (
|
|
1938
|
+
'<li><span><strong>' + esc(name) + '</strong><small>' + esc(path) + '</small></span>'
|
|
1939
|
+
+ (!state.readOnly ? '<button class="text-button danger-text" type="button" data-detach-evidence="' + esc(name) + '">Remove</button>' : "")
|
|
1940
|
+
+ '</li>'
|
|
1941
|
+
)).join("")
|
|
1942
|
+
: '<li class="attachment-empty">No local attachments. An approved external reference may be used when the source cannot be stored here.</li>';
|
|
1943
|
+
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>'
|
|
1944
|
+
+ (!state.readOnly ? '<button class="button" type="button" data-attach-evidence>Attach file</button><input type="file" data-evidence-file hidden>' : "")
|
|
1945
|
+
+ '</div><ul>' + rows + '</ul></section>';
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1145
1948
|
async function loadResourceDetail(type, id) {
|
|
1146
1949
|
const key = type + "\0" + id;
|
|
1147
1950
|
if (resourceDetailRequests.has(key)) return resourceDetailRequests.get(key);
|
|
@@ -1479,14 +2282,18 @@ function resourceGuide(type) {
|
|
|
1479
2282
|
const definition = state.model.resources[type];
|
|
1480
2283
|
const guidance = definition?.guidance;
|
|
1481
2284
|
if (!definition || !guidance) return "";
|
|
1482
|
-
const instructions =
|
|
2285
|
+
const instructions = RESOURCE_GUIDE_INSTRUCTIONS[type] || definition.description;
|
|
1483
2286
|
const sources = (guidance.sourceResourceIds || [])
|
|
1484
2287
|
.map((id) => state.resources.find(({ record }) => record.id === id))
|
|
1485
2288
|
.filter(Boolean);
|
|
1486
2289
|
const sourceLinks = sources.length
|
|
1487
2290
|
? '<div class="guide-links">' + sources.map(({ record }) => '<a href="#/resource/' + encodeURIComponent(record.type) + '/' + encodeURIComponent(record.id) + '">' + esc(record.title) + '</a>').join("") + '</div>'
|
|
1488
2291
|
: "";
|
|
1489
|
-
|
|
2292
|
+
const reviewPoints = guidance.reviewPoints || [];
|
|
2293
|
+
const reviewGuide = reviewPoints.length
|
|
2294
|
+
? '<div class="guide-review"><span>When reviewing</span><ul>' + reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></div>'
|
|
2295
|
+
: "";
|
|
2296
|
+
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>';
|
|
1490
2297
|
}
|
|
1491
2298
|
|
|
1492
2299
|
function setupResourceGuide(main) {
|
|
@@ -1629,91 +2436,62 @@ function onboardingSteps() {
|
|
|
1629
2436
|
points: [
|
|
1630
2437
|
"Use the UI, an editor, the CLI, or an agent; every path changes the same files.",
|
|
1631
2438
|
"JSON holds structured records. Markdown holds policies, plans, minutes, and other long-form work.",
|
|
1632
|
-
"In trunk mode,
|
|
1633
|
-
"Record status represents approval. Draft, proposed, approved, and retired records all stay on the authoritative branch.",
|
|
1634
|
-
"Agents and terminal users continue to manage Git explicitly.",
|
|
1635
|
-
"The dashboard derives program status from the current repository state."
|
|
2439
|
+
"Record status represents approval. In trunk mode, the browser validates, commits, and pushes each save; agents and terminal users manage Git explicitly."
|
|
1636
2440
|
]
|
|
1637
2441
|
};
|
|
1638
2442
|
const path = {
|
|
1639
2443
|
target: ".readiness-map",
|
|
1640
2444
|
kicker: "Program model",
|
|
1641
2445
|
title: "Follow the audit chain",
|
|
1642
|
-
body: "
|
|
2446
|
+
body: "Follow the five program steps in order. Each step reveals the records and decisions needed next.",
|
|
1643
2447
|
points: [
|
|
1644
|
-
"
|
|
1645
|
-
"
|
|
1646
|
-
"
|
|
1647
|
-
"The CPA firm, formal report period, fieldwork, and final report are the last stage. Engage earlier only when timing or scope needs outside input."
|
|
2448
|
+
"Define the people, criteria, service, Systems, and providers in scope.",
|
|
2449
|
+
"Approve the policies, implement the controls, then operate them and retain dated proof.",
|
|
2450
|
+
"Create the audit engagement when a CPA firm is involved or a real customer deadline requires it."
|
|
1648
2451
|
]
|
|
1649
2452
|
};
|
|
1650
|
-
const
|
|
2453
|
+
const operation = {
|
|
1651
2454
|
target: ".obligation-panel",
|
|
1652
|
-
kicker: "
|
|
1653
|
-
title: "Work the policy
|
|
1654
|
-
body: "Recurring
|
|
1655
|
-
points: [
|
|
1656
|
-
"Quarterly means any date in that cycle is valid unless the policy sets a narrower window.",
|
|
1657
|
-
"Link a dated completion record and its evidence to satisfy one occurrence.",
|
|
1658
|
-
"The UI and filegrc CLI use the same calculation."
|
|
1659
|
-
]
|
|
1660
|
-
};
|
|
1661
|
-
const events = {
|
|
1662
|
-
target: ".event-reminder-panel",
|
|
1663
|
-
kicker: "Triggered work",
|
|
1664
|
-
title: "Complete a checklist when key events occur",
|
|
1665
|
-
body: "Use an event reminder for a new worker, job or responsibility 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.",
|
|
2455
|
+
kicker: "Program operation",
|
|
2456
|
+
title: "Work the queue and trigger policy events",
|
|
2457
|
+
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.",
|
|
1666
2458
|
points: [
|
|
1667
|
-
"
|
|
1668
|
-
"
|
|
1669
|
-
"
|
|
1670
|
-
"Agents start the identical workflow with the filegrc CLI."
|
|
2459
|
+
"Complete each occurrence with the requested dated operating record and supporting evidence.",
|
|
2460
|
+
"Start Policy Events only after the real event occurs. FileGRC creates one owned Action Item for each applicable policy step.",
|
|
2461
|
+
"The browser and CLI use the same schedules, event rules, and completion checks."
|
|
1671
2462
|
]
|
|
1672
2463
|
};
|
|
1673
|
-
const
|
|
2464
|
+
const auditPath = {
|
|
1674
2465
|
target: null,
|
|
1675
|
-
kicker: "
|
|
1676
|
-
title: "Choose the report goal",
|
|
2466
|
+
kicker: "Report goal and audit",
|
|
2467
|
+
title: "Choose the report goal and plan fieldwork",
|
|
1677
2468
|
body: [
|
|
1678
2469
|
"SOC 2 is an independent CPA report on controls relevant to the selected Trust Services Criteria.",
|
|
1679
|
-
"Most customer requests focus on Security. Add
|
|
2470
|
+
"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."
|
|
1680
2471
|
],
|
|
1681
2472
|
sections: [
|
|
1682
2473
|
{
|
|
1683
2474
|
title: "Type 1",
|
|
1684
|
-
body: "
|
|
2475
|
+
body: "The CPA evaluates control design and implementation at a point in time. Type 1 is optional before Type 2."
|
|
1685
2476
|
},
|
|
1686
2477
|
{
|
|
1687
2478
|
title: "Type 2",
|
|
1688
|
-
body: "
|
|
2479
|
+
body: "The CPA evaluates operation across an agreed period. Dated evidence and complete populations must cover that period."
|
|
1689
2480
|
}
|
|
1690
2481
|
],
|
|
1691
|
-
afterSections: "
|
|
1692
|
-
};
|
|
1693
|
-
const audit = {
|
|
1694
|
-
target: ".audit-panel",
|
|
1695
|
-
kicker: "Final stage",
|
|
1696
|
-
title: "Engage the firm and prepare fieldwork",
|
|
1697
|
-
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.",
|
|
1698
|
-
points: [
|
|
1699
|
-
"The audit record holds the firm-agreed date or period; the workspace keeps management's earlier candidate dates.",
|
|
1700
|
-
"Audit Readiness identifies missing management documents, populations, exact-period evidence, and request work.",
|
|
1701
|
-
"The CPA firm selects samples, tests controls, evaluates exceptions, and issues the report."
|
|
1702
|
-
]
|
|
2482
|
+
afterSections: "FileGRC prepares the records and packet. The CPA firm selects samples, tests controls, evaluates exceptions, and issues the report."
|
|
1703
2483
|
};
|
|
1704
2484
|
const setup = {
|
|
1705
2485
|
target: null,
|
|
1706
2486
|
kicker: "Initial scope",
|
|
1707
2487
|
title: "Describe the service you plan to audit",
|
|
1708
|
-
body: "
|
|
2488
|
+
body: "Create the first in-scope System and record management’s goal. Step 1 will guide the rest of the scope."
|
|
1709
2489
|
};
|
|
1710
2490
|
return [
|
|
1711
2491
|
files,
|
|
1712
2492
|
path,
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
reportTypes,
|
|
1716
|
-
audit,
|
|
2493
|
+
operation,
|
|
2494
|
+
auditPath,
|
|
1717
2495
|
setup
|
|
1718
2496
|
];
|
|
1719
2497
|
}
|
|
@@ -1734,7 +2512,7 @@ function renderOnboardingStep() {
|
|
|
1734
2512
|
? onboardingSetupForm()
|
|
1735
2513
|
: description + explanation + afterSections;
|
|
1736
2514
|
const finalActions = onboardingStep === steps.length - 1
|
|
1737
|
-
? '<span class="onboarding-save-status" role="status" aria-live="polite"></span><button class="button" type="button" data-onboarding="draft">Save
|
|
2515
|
+
? '<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>'
|
|
1738
2516
|
: '<button class="button primary" type="button" data-onboarding="next">Next</button>';
|
|
1739
2517
|
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>';
|
|
1740
2518
|
onboardingDialog.querySelector('[data-onboarding="skip"]').addEventListener("click", cancelOnboarding);
|
|
@@ -1774,17 +2552,12 @@ function onboardingSetupForm() {
|
|
|
1774
2552
|
if (onboardingDraft.classificationId && !classifications.includes(onboardingDraft.classificationId)) {
|
|
1775
2553
|
classifications.push(onboardingDraft.classificationId);
|
|
1776
2554
|
}
|
|
1777
|
-
const currentSystem = onboardingDraft.systemId ? state.resources.find(({ record }) => record.id === onboardingDraft.systemId)?.record : null;
|
|
1778
|
-
const existing = [
|
|
1779
|
-
currentSystem ? "Updates system " + currentSystem.title + "." : "Creates a new in-scope system.",
|
|
1780
|
-
"Records a management program goal without creating an audit engagement."
|
|
1781
|
-
].filter(Boolean).join(" ");
|
|
1782
2555
|
const gitStatus = state.repository?.mode === "trunk"
|
|
1783
2556
|
? '<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>'
|
|
1784
2557
|
: state.git.available && state.git.branch
|
|
1785
2558
|
? '<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>'
|
|
1786
2559
|
: '<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>';
|
|
1787
|
-
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">
|
|
2560
|
+
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>';
|
|
1788
2561
|
}
|
|
1789
2562
|
|
|
1790
2563
|
function captureOnboardingForm() {
|
|
@@ -1904,22 +2677,6 @@ async function writeRendererSettingsResource(record, entry) {
|
|
|
1904
2677
|
return response.json();
|
|
1905
2678
|
}
|
|
1906
2679
|
|
|
1907
|
-
async function toggleStagePageCompletion(button) {
|
|
1908
|
-
const entry = rendererSettingsEntry();
|
|
1909
|
-
if (!entry) throw new Error("Renderer settings are unavailable.");
|
|
1910
|
-
const completed = new Set(entry.record.completedStagePageIds || []);
|
|
1911
|
-
const pageId = button.dataset.stagePageCompletion;
|
|
1912
|
-
if (button.dataset.complete === "true") {
|
|
1913
|
-
completed.delete(pageId);
|
|
1914
|
-
} else {
|
|
1915
|
-
completed.add(pageId);
|
|
1916
|
-
}
|
|
1917
|
-
applyMutationState(await writeRendererSettingsResource({
|
|
1918
|
-
...entry.record,
|
|
1919
|
-
completedStagePageIds: [...completed].sort()
|
|
1920
|
-
}, entry));
|
|
1921
|
-
}
|
|
1922
|
-
|
|
1923
2680
|
function setOnboardingBusy(busy, label = "") {
|
|
1924
2681
|
if (!onboardingDialog) return;
|
|
1925
2682
|
clearTimeout(onboardingStillWorkingTimer);
|
|
@@ -2081,7 +2838,7 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
2081
2838
|
const editorDescription = options.description
|
|
2082
2839
|
|| implementationEditorDescription(type)
|
|
2083
2840
|
|| "Fill the core fields below. Git will record the author, time, reason, and diff when you commit this file.";
|
|
2084
|
-
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + (entry ? "Edit record" : 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
|
|
2841
|
+
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>' +
|
|
2085
2842
|
activeMarkdown.map((markdown) => {
|
|
2086
2843
|
const generated = !entry?.content?.[markdown.name];
|
|
2087
2844
|
const source = entry?.content?.[markdown.name]?.source
|
|
@@ -2151,7 +2908,9 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
2151
2908
|
}
|
|
2152
2909
|
const url = entry
|
|
2153
2910
|
? "/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(entry.record.id)
|
|
2154
|
-
: options.
|
|
2911
|
+
: options.actionCompletion
|
|
2912
|
+
? "/api/action-completions"
|
|
2913
|
+
: options.obligationCompletion ? "/api/obligation-completions" : "/api/resources";
|
|
2155
2914
|
const contentRevisions = Object.fromEntries([
|
|
2156
2915
|
...activeMarkdown.map(({ name }) => [entry?.content?.[name]?.path, entry?.content?.[name]?.revision]),
|
|
2157
2916
|
[recordContentItem?.path, recordContentItem?.revision]
|
|
@@ -2163,9 +2922,11 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
2163
2922
|
body: JSON.stringify({
|
|
2164
2923
|
record: updated,
|
|
2165
2924
|
content,
|
|
2166
|
-
revision: entry?.revision || options.obligationCompletion?.revision,
|
|
2925
|
+
revision: entry?.revision || options.actionCompletion?.revision || options.obligationCompletion?.revision,
|
|
2167
2926
|
contentRevisions,
|
|
2168
|
-
obligationId: options.obligationCompletion?.obligationId
|
|
2927
|
+
obligationId: options.obligationCompletion?.obligationId,
|
|
2928
|
+
actionItemId: options.actionCompletion?.actionItemId,
|
|
2929
|
+
completedOn: options.actionCompletion?.completedOn
|
|
2169
2930
|
})
|
|
2170
2931
|
});
|
|
2171
2932
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
@@ -2506,21 +3267,6 @@ function openContentEditor(entry, name) {
|
|
|
2506
3267
|
}
|
|
2507
3268
|
|
|
2508
3269
|
function bindCommon() {
|
|
2509
|
-
root.querySelectorAll("[data-stage-page-completion]").forEach((button) => button.addEventListener("click", async (event) => {
|
|
2510
|
-
event.preventDefault();
|
|
2511
|
-
event.stopPropagation();
|
|
2512
|
-
const label = button.textContent;
|
|
2513
|
-
button.disabled = true;
|
|
2514
|
-
button.textContent = "Saving…";
|
|
2515
|
-
try {
|
|
2516
|
-
await toggleStagePageCompletion(button);
|
|
2517
|
-
render();
|
|
2518
|
-
} catch (error) {
|
|
2519
|
-
button.disabled = false;
|
|
2520
|
-
button.textContent = label;
|
|
2521
|
-
button.title = error.message;
|
|
2522
|
-
}
|
|
2523
|
-
}));
|
|
2524
3270
|
root.querySelectorAll(".nav-toggle, .nav-subgroup-toggle").forEach((button) => button.addEventListener("click", () => {
|
|
2525
3271
|
const group = button.closest(".nav-group");
|
|
2526
3272
|
const open = group.classList.toggle("open");
|
|
@@ -2656,6 +3402,11 @@ function currentDate() {
|
|
|
2656
3402
|
return new Date().toISOString().slice(0, 10);
|
|
2657
3403
|
}
|
|
2658
3404
|
}
|
|
3405
|
+
function dateAfter(value) {
|
|
3406
|
+
const date = new Date(value + "T00:00:00Z");
|
|
3407
|
+
date.setUTCDate(date.getUTCDate() + 1);
|
|
3408
|
+
return date.toISOString().slice(0, 10);
|
|
3409
|
+
}
|
|
2659
3410
|
function currentLocalDateTime() {
|
|
2660
3411
|
const now = new Date();
|
|
2661
3412
|
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
|
@@ -3026,7 +3777,9 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
3026
3777
|
.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}
|
|
3027
3778
|
.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)}
|
|
3028
3779
|
.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}
|
|
3029
|
-
.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-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
|
|
3780
|
+
.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}
|
|
3781
|
+
.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}
|
|
3782
|
+
.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)}
|
|
3030
3783
|
.button{text-decoration:none}
|
|
3031
3784
|
.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}
|
|
3032
3785
|
.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}
|
|
@@ -3037,8 +3790,8 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
3037
3790
|
.setup-banner{margin:14px 0;background:#eef1ff;border:1px solid #ccd4ff;border-radius:11px;padding:19px 22px;display:grid;grid-template-columns:1fr 1.3fr;gap:25px;align-items:center}.setup-banner h3{margin:5px 0 6px;font-size:18px}.setup-banner p:not(.kicker){margin:0;color:var(--muted);font-size:13.2px;line-height:1.5}.setup-banner ol{margin:0;padding-left:22px;display:grid;gap:7px}.setup-banner li{font-size:13.2px}.setup-banner a{color:var(--accent);font-weight:700}.due-list time.overdue{color:var(--red)}.content-label{display:flex;align-items:center;justify-content:space-between;gap:12px}.text-button{border:0;background:none;color:var(--accent);font-size:10.8px;text-transform:uppercase;letter-spacing:.06em;font-weight:750;cursor:pointer;white-space:nowrap}.tag{white-space:normal;overflow-wrap:anywhere;max-width:100%}.editor{max-height:calc(100vh - 30px);overflow:auto}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin:20px 0}.form-field>.field-label,.content-editor-field>span{display:flex;align-items:center;justify-content:space-between;gap:8px;color:#3e4557;font-size:12px;font-weight:700;margin-bottom:6px}.required-mark{font-size:9.6px;color:var(--accent);text-transform:uppercase;letter-spacing:.06em}.form-field input,.form-field select,.editor .form-field textarea{width:100%;height:auto;min-height:40px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);padding:9px 10px;font:14.4px/1.4 inherit}.editor .form-field textarea{height:82px}.form-field input[readonly]{background:#eef0f6;color:#5d6475}.form-field>small{display:block;color:#6a7181;font-size:10.8px;margin-top:5px}.checkbox-list{display:grid;gap:5px;max-height:145px;overflow:auto;border:1px solid var(--line);border-radius:7px;padding:7px}.checkbox-list label{display:flex;align-items:center;gap:8px;padding:5px;border-radius:5px}.checkbox-list input{width:16px;min-height:16px;padding:0;flex:0 0 auto}.checkbox-list label:hover{background:#f2f4fa}.checkbox-list span,.checkbox-list small{display:block;font-size:12px}.checkbox-list small{color:var(--muted);margin-top:2px}.missing-options{padding:11px;border:1px dashed #d7c8a9;background:#fbf5e9;color:#795b23;border-radius:7px;font-size:12px}.content-editor-field{display:block;margin:17px 0}.editor .content-editor-field textarea,.editor .markdown-source{height:260px;background:#10162b;color:#e8ebff;font:13.2px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.advanced-editor{border-top:1px solid var(--line);margin-top:18px;padding-top:13px}.advanced-editor summary{cursor:pointer;color:var(--accent);font-size:13.2px;font-weight:750}.advanced-editor p{font-size:12px;color:var(--muted)}.editor .advanced-editor>textarea{height:320px}.alert-dialog{width:min(520px,calc(100vw - 30px));border:0;border-radius:12px;padding:23px;box-shadow:0 25px 80px rgba(0,0,24,.28)}.alert-dialog>p{font-size:14.4px;line-height:1.55;color:var(--muted)}.metadata dd{overflow-wrap:anywhere}
|
|
3038
3791
|
.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}
|
|
3039
3792
|
.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}
|
|
3040
|
-
.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
|
|
3041
|
-
.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}
|
|
3793
|
+
.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 auto;gap:3px 8px;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>div>.badge{font-size:8.4px}.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}
|
|
3794
|
+
.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}
|
|
3042
3795
|
.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}
|
|
3043
3796
|
.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}
|
|
3044
3797
|
.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)}
|
|
@@ -3048,6 +3801,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
3048
3801
|
@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))}}
|
|
3049
3802
|
@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}}
|
|
3050
3803
|
@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}}
|
|
3804
|
+
@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}}
|
|
3051
3805
|
@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}}
|
|
3052
3806
|
@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}}
|
|
3053
3807
|
@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}}
|
|
@@ -3121,11 +3875,12 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
|
|
|
3121
3875
|
.commit-files code{font-size:10.8px;overflow-wrap:anywhere}
|
|
3122
3876
|
.onboarding-progress{grid-template-columns:repeat(var(--onboarding-step-count),1fr)}
|
|
3123
3877
|
.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}
|
|
3124
|
-
.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}
|
|
3878
|
+
.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}
|
|
3125
3879
|
.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}
|
|
3126
|
-
.obligation-board{display:grid;grid-template-columns:repeat(
|
|
3880
|
+
.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}
|
|
3127
3881
|
.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}
|
|
3128
|
-
.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}
|
|
3882
|
+
.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}
|
|
3883
|
+
.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}
|
|
3129
3884
|
.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}
|
|
3130
3885
|
.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}
|
|
3131
3886
|
.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}
|
|
@@ -3135,6 +3890,7 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
|
|
|
3135
3890
|
@media(max-width:900px){.overview-grid{grid-template-columns:1fr}.overview-grid>.audit-panel{grid-column:auto}}
|
|
3136
3891
|
@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}}
|
|
3137
3892
|
@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}}
|
|
3893
|
+
@media(max-width:760px){.workflow-findings{grid-template-columns:1fr}}
|
|
3138
3894
|
|
|
3139
3895
|
@media(prefers-color-scheme:dark){
|
|
3140
3896
|
: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)}
|
|
@@ -3146,9 +3902,19 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
|
|
|
3146
3902
|
.badge.status-active,.badge.status-approved,.badge.status-complete,.badge.status-passed,.badge.status-accepted{background:#173b2b;color:#a8edc4}
|
|
3147
3903
|
.badge.status-open,.badge.status-high,.badge.status-critical,.badge.status-failed{background:#4a252a;color:#ffb5ad}
|
|
3148
3904
|
.badge.status-draft,.badge.status-planned,.badge.status-in-progress,.badge.status-medium{background:#483714;color:#ffd991}
|
|
3149
|
-
.badge.status-overdue{background:#4a252a;color:#ffb5ad}
|
|
3905
|
+
.badge.status-overdue,.badge.status-blocked{background:#4a252a;color:#ffb5ad}
|
|
3150
3906
|
.badge.status-due{background:#483714;color:#ffd991}
|
|
3151
3907
|
.badge.status-complete{background:#173b2b;color:#a8edc4}
|
|
3908
|
+
.repository-override,.repository-sync-alert{border-color:#77612f;background:#382f19}
|
|
3909
|
+
.repository-override code{color:#ffe2a3}
|
|
3910
|
+
.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}
|
|
3911
|
+
.workflow-finding-status.overdue,.workflow-finding-status.blocked,.readiness-state.bad,.preparation-status.action{background:#4a252a;color:#ffb5ad}
|
|
3912
|
+
.workflow-finding-status.complete,.stage-page-completion-state.complete,.readiness-state.good,.preparation-status.complete{background:#173b2b;color:#a8edc4}
|
|
3913
|
+
.stage-page-card.complete,.evidence-map-card.complete{border-color:#315f48}
|
|
3914
|
+
.collection-review-panel.required{border-color:#77612f}
|
|
3915
|
+
.collection-review-panel.current{border-color:#315f48}
|
|
3916
|
+
.evidence-map-source.complete{border-color:#315f48;background:#183426}
|
|
3917
|
+
.operation-tracking.running strong{color:#a8edc4}
|
|
3152
3918
|
.policy-event-feedback{border-color:#315f48;background:#183426}.policy-event-feedback p{color:#b7d8c3}
|
|
3153
3919
|
.missing-options{border-color:#77612f;background:#382f19;color:#ffdc92}
|
|
3154
3920
|
.status-dot.neutral{background:#9aabff}
|