filegrc 0.15.0 → 0.15.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/package.json +1 -1
- package/src/audit-preparation.js +16 -4
- package/src/audit-selection.js +33 -0
- package/src/cli.js +6 -3
- package/src/index.js +1 -1
- package/src/program-readiness.js +201 -26
- package/src/retention.js +31 -11
- package/src/web.js +192 -96
- package/src/workflow.js +2 -0
package/package.json
CHANGED
package/src/audit-preparation.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { modelSupports } from "../model/index.js";
|
|
4
4
|
import { applicabilityReviewIsCurrent } from "./applicability-scope.js";
|
|
5
|
+
import { selectDefaultAudit } from "./audit-selection.js";
|
|
5
6
|
import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
|
|
6
7
|
import {
|
|
7
8
|
coverageContains,
|
|
@@ -63,20 +64,21 @@ export async function assessAuditPreparation(input, options = {}) {
|
|
|
63
64
|
const records = loaded.resources;
|
|
64
65
|
const byId = new Map(records.map((record) => [record.id, record]));
|
|
65
66
|
const audits = records.filter((record) => record.type === "audit");
|
|
67
|
+
const calendarAsOf = options.asOf || currentCalendarDate(loaded.workspace?.timezone || "UTC");
|
|
66
68
|
const audit = options.auditId
|
|
67
69
|
? audits.find((record) => record.id === options.auditId)
|
|
68
70
|
: options.selectDefault === false
|
|
69
71
|
? null
|
|
70
|
-
: audits
|
|
72
|
+
: selectDefaultAudit(audits, calendarAsOf);
|
|
71
73
|
if (options.auditId && !audit) throw new Error(`Audit "${options.auditId}" was not found.`);
|
|
72
74
|
|
|
73
|
-
const calendarAsOf = options.asOf || currentCalendarDate(loaded.workspace?.timezone || "UTC");
|
|
74
75
|
const formalPeriodEnd = audit?.auditKind === "soc-2-type-2" ? coverageEnd(audit.coverage) : null;
|
|
75
76
|
const readinessAsOf = formalPeriodEnd && formalPeriodEnd < calendarAsOf ? formalPeriodEnd : calendarAsOf;
|
|
76
77
|
const programReadiness = options.programReadiness || await assessProgramReadiness(loaded, {
|
|
77
78
|
asOf: readinessAsOf,
|
|
78
79
|
generatedAt: options.generatedAt,
|
|
79
|
-
programId: audit?.programId || options.programId
|
|
80
|
+
programId: audit?.programId || options.programId,
|
|
81
|
+
auditId: audit?.id
|
|
80
82
|
});
|
|
81
83
|
const documentActivations = audit && modelSupports(loaded.model, "governed-document-activation")
|
|
82
84
|
? await auditDocumentActivationAssessments(loaded, audit, byId, calendarAsOf)
|
|
@@ -746,6 +748,12 @@ function canonicalScopeValue(value) {
|
|
|
746
748
|
function programFoundationStage(programReadiness, workspace, audit) {
|
|
747
749
|
const needsOperatingPeriod = audit?.auditKind === "soc-2-type-2";
|
|
748
750
|
const ready = needsOperatingPeriod ? programReadiness.operating : programReadiness.evidenceReady;
|
|
751
|
+
const setupRemaining = programReadiness.setupProgress?.remaining
|
|
752
|
+
?? (programReadiness.progress?.mode === "setup-progress" ? programReadiness.progress.remaining : 0);
|
|
753
|
+
const activeWindow = programReadiness.progress?.mode === "operating-window";
|
|
754
|
+
const activeWindowLabel = programReadiness.progress?.source === "audit"
|
|
755
|
+
? "audit window"
|
|
756
|
+
: "candidate operating period";
|
|
749
757
|
return stage("program", "Program Readiness", "The management program can be prepared and operated without an audit record or CPA firm.", [
|
|
750
758
|
item(
|
|
751
759
|
"evidence-ready",
|
|
@@ -755,9 +763,13 @@ function programFoundationStage(programReadiness, workspace, audit) {
|
|
|
755
763
|
? needsOperatingPeriod
|
|
756
764
|
? `${programReadiness.target.label} is operating for the selected engagement.`
|
|
757
765
|
: `${programReadiness.target.label} is evidence-ready.`
|
|
766
|
+
: needsOperatingPeriod && activeWindow
|
|
767
|
+
? setupRemaining
|
|
768
|
+
? `The ${activeWindowLabel} is active, but ${setupRemaining} required setup ${setupRemaining === 1 ? "action remains" : "actions remain"} across scope, policies, controls, and evidence preparation.`
|
|
769
|
+
: `The ${activeWindowLabel} is active, but current operating work needs attention before treating the engagement as management-ready.`
|
|
758
770
|
: needsOperatingPeriod && programReadiness.evidenceReady
|
|
759
771
|
? "Start the candidate period and finish the current operating-readiness work before treating a Type 2 engagement as management-ready."
|
|
760
|
-
: `${
|
|
772
|
+
: `${setupRemaining} required setup ${setupRemaining === 1 ? "action remains" : "actions remain"} across scope, policies, controls, and evidence preparation.`,
|
|
761
773
|
workspace || { type: "workspace" }
|
|
762
774
|
)
|
|
763
775
|
]);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function selectDefaultAudit(audits = [], asOf = "") {
|
|
2
|
+
const terminal = new Set(["complete", "closed", "canceled", "delivered"]);
|
|
3
|
+
const coverageStart = (audit) => audit?.coverage?.startsOn
|
|
4
|
+
|| audit?.coverage?.periodStart
|
|
5
|
+
|| audit?.coverage?.on
|
|
6
|
+
|| "";
|
|
7
|
+
const statusOrder = new Map([
|
|
8
|
+
["planned", 0],
|
|
9
|
+
["in-progress", 1],
|
|
10
|
+
["fieldwork", 2],
|
|
11
|
+
["report-draft", 3],
|
|
12
|
+
["issued", 4],
|
|
13
|
+
["delivered", 5],
|
|
14
|
+
["complete", 6]
|
|
15
|
+
]);
|
|
16
|
+
const open = audits.filter((audit) => !terminal.has(audit.status));
|
|
17
|
+
const candidates = [...(open.length ? open : audits)];
|
|
18
|
+
candidates.sort((left, right) => {
|
|
19
|
+
const leftStart = coverageStart(left);
|
|
20
|
+
const rightStart = coverageStart(right);
|
|
21
|
+
const leftStarted = Boolean(leftStart && (!asOf || leftStart <= asOf));
|
|
22
|
+
const rightStarted = Boolean(rightStart && (!asOf || rightStart <= asOf));
|
|
23
|
+
return Number(rightStarted) - Number(leftStarted)
|
|
24
|
+
|| (leftStarted && rightStarted
|
|
25
|
+
? rightStart.localeCompare(leftStart)
|
|
26
|
+
: leftStart && rightStart
|
|
27
|
+
? leftStart.localeCompare(rightStart)
|
|
28
|
+
: Number(Boolean(rightStart)) - Number(Boolean(leftStart)))
|
|
29
|
+
|| (statusOrder.get(right.status) ?? -1) - (statusOrder.get(left.status) ?? -1)
|
|
30
|
+
|| String(left.id || "").localeCompare(String(right.id || ""));
|
|
31
|
+
});
|
|
32
|
+
return candidates[0] || null;
|
|
33
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -447,16 +447,17 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
447
447
|
const loaded = await loadWorkspace(root);
|
|
448
448
|
const result = await assessProgramReadiness(loaded, { asOf: flags["as-of"], programId: flags.program });
|
|
449
449
|
const output = flags.summary ? summarizeProgramReadiness(result) : result;
|
|
450
|
+
const headlineStatus = result.progress.status || result.status;
|
|
450
451
|
if (flags.json) console.log(JSON.stringify(output, null, 2));
|
|
451
452
|
else if (flags.summary) {
|
|
452
|
-
console.log(`${
|
|
453
|
+
console.log(`${headlineStatus.toUpperCase()}: ${result.progress.label} ${result.progress.percent}%. ${result.progress.detail}`);
|
|
453
454
|
for (const stage of output.stages) {
|
|
454
|
-
console.log(`${stage.status.toUpperCase()}\t${stage.title}\t${stage.counts.action}
|
|
455
|
+
console.log(`${stage.status.toUpperCase()}\t${stage.title}\t${stage.counts.action} checks need action`);
|
|
455
456
|
}
|
|
456
457
|
if (output.firstAction) console.log(`Next: ${output.firstAction.title}\t${output.firstAction.message}`);
|
|
457
458
|
}
|
|
458
459
|
else {
|
|
459
|
-
console.log(`${
|
|
460
|
+
console.log(`${headlineStatus.toUpperCase()}: ${result.progress.label} ${result.progress.percent}%. ${result.progress.detail}`);
|
|
460
461
|
console.log(
|
|
461
462
|
`${result.target.label}`
|
|
462
463
|
+ (result.target.candidateCoverage?.kind === "range"
|
|
@@ -2036,6 +2037,8 @@ function summarizeProgramReadiness(result) {
|
|
|
2036
2037
|
suggestedCandidatePeriodStart: result.suggestedCandidatePeriodStart,
|
|
2037
2038
|
target: result.target,
|
|
2038
2039
|
progress: result.progress,
|
|
2040
|
+
setupProgress: result.setupProgress,
|
|
2041
|
+
checkProgress: result.checkProgress,
|
|
2039
2042
|
counts: result.counts,
|
|
2040
2043
|
scopeCounts: Object.fromEntries(
|
|
2041
2044
|
Object.entries(result.scope).map(([name, ids]) => [name.replace(/Ids$/, ""), ids.length])
|
package/src/index.js
CHANGED
|
@@ -86,7 +86,7 @@ export {
|
|
|
86
86
|
scaffoldExternalReviewerGovernance,
|
|
87
87
|
setupExternalReviewerGovernance
|
|
88
88
|
} from "./external-reviewer.js";
|
|
89
|
-
export { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
|
|
89
|
+
export { assessEvidenceMap, assessProgramReadiness, calculateProgramProgress } from "./program-readiness.js";
|
|
90
90
|
export {
|
|
91
91
|
approveReportingRouteSet,
|
|
92
92
|
assessReportingRoutePeriod,
|
package/src/program-readiness.js
CHANGED
|
@@ -32,6 +32,94 @@ import { assessSourceCoverageReadiness } from "./source-coverage.js";
|
|
|
32
32
|
import { currentCalendarDate, timestampFromLocalDateTime } from "./time.js";
|
|
33
33
|
import { loadWorkspace } from "./workspace.js";
|
|
34
34
|
|
|
35
|
+
export function calculateProgramProgress({
|
|
36
|
+
stages = [],
|
|
37
|
+
target = {},
|
|
38
|
+
evidenceReady = false,
|
|
39
|
+
operating = false,
|
|
40
|
+
asOf,
|
|
41
|
+
window = null
|
|
42
|
+
} = {}) {
|
|
43
|
+
const typeTwo = target.goal === "soc-2-type-2";
|
|
44
|
+
const trackedWindow = normalizeProgressWindow(window || target.candidateCoverage, window ? "audit" : "candidate");
|
|
45
|
+
const windowStarted = Boolean(
|
|
46
|
+
typeTwo
|
|
47
|
+
&& trackedWindow
|
|
48
|
+
&& asOf
|
|
49
|
+
&& trackedWindow.start <= asOf
|
|
50
|
+
);
|
|
51
|
+
const setupProgress = calculateSetupActionProgress(stages, target, Boolean(trackedWindow));
|
|
52
|
+
if (windowStarted) {
|
|
53
|
+
const totalDays = calendarDayDistance(trackedWindow.start, trackedWindow.end) + 1;
|
|
54
|
+
const elapsedDays = Math.min(
|
|
55
|
+
totalDays,
|
|
56
|
+
Math.max(0, calendarDayDistance(trackedWindow.start, asOf) + 1)
|
|
57
|
+
);
|
|
58
|
+
const percent = totalDays ? Math.round((elapsedDays / totalDays) * 100) : 0;
|
|
59
|
+
const complete = elapsedDays >= totalDays;
|
|
60
|
+
const healthy = Boolean(operating);
|
|
61
|
+
return {
|
|
62
|
+
mode: "operating-window",
|
|
63
|
+
label: trackedWindow.source === "audit" ? "Audit window" : "Operating window",
|
|
64
|
+
status: healthy ? complete ? "Window complete" : "Operating" : "Needs attention",
|
|
65
|
+
complete: elapsedDays,
|
|
66
|
+
total: totalDays,
|
|
67
|
+
remaining: Math.max(0, totalDays - elapsedDays),
|
|
68
|
+
percent,
|
|
69
|
+
unit: "day",
|
|
70
|
+
start: trackedWindow.start,
|
|
71
|
+
end: trackedWindow.end,
|
|
72
|
+
source: trackedWindow.source,
|
|
73
|
+
detail: complete
|
|
74
|
+
? `${totalDays} of ${totalDays} days elapsed.`
|
|
75
|
+
: `Day ${elapsedDays} of ${totalDays}; ${Math.max(0, totalDays - elapsedDays)} days remain.`,
|
|
76
|
+
operating
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
mode: "setup-progress",
|
|
82
|
+
label: "Program setup",
|
|
83
|
+
status: !setupProgress.remaining
|
|
84
|
+
? evidenceReady && typeTwo && trackedWindow?.start > asOf
|
|
85
|
+
? "Scheduled"
|
|
86
|
+
: evidenceReady ? "Evidence ready" : "Ready"
|
|
87
|
+
: evidenceReady && typeTwo ? "Ready to start" : "Needs work",
|
|
88
|
+
...setupProgress,
|
|
89
|
+
unit: "action",
|
|
90
|
+
detail: setupProgress.remaining
|
|
91
|
+
? `${setupProgress.complete} of ${setupProgress.total} required actions complete.`
|
|
92
|
+
: `All ${setupProgress.total} required actions are complete.`,
|
|
93
|
+
operating
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function calculateSetupActionProgress(stages, target, windowMilestoneComplete) {
|
|
98
|
+
const units = new Map();
|
|
99
|
+
for (const currentStage of stages.filter(({ id }) => id !== "operation")) {
|
|
100
|
+
for (const current of currentStage.items || []) {
|
|
101
|
+
if (["info", "later"].includes(current.status) || current.progressUnit === false) continue;
|
|
102
|
+
const key = `${currentStage.id}:${current.progressUnitId || current.id}`;
|
|
103
|
+
const existing = units.get(key);
|
|
104
|
+
if (!existing || existing.status === "complete" && current.status !== "complete") {
|
|
105
|
+
units.set(key, current);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const setupUnits = [...units.values()];
|
|
111
|
+
const complete = setupUnits.filter(({ status }) => status === "complete").length;
|
|
112
|
+
const total = setupUnits.length + (target.goal === "soc-2-type-2" ? 1 : 0);
|
|
113
|
+
const completed = complete + (windowMilestoneComplete ? 1 : 0);
|
|
114
|
+
const remaining = Math.max(0, total - completed);
|
|
115
|
+
return {
|
|
116
|
+
complete: completed,
|
|
117
|
+
total,
|
|
118
|
+
remaining,
|
|
119
|
+
percent: total ? Math.round((completed / total) * 100) : 0
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
35
123
|
export async function assessProgramReadiness(input, options = {}) {
|
|
36
124
|
const loaded = input?.resources && input?.model && input?.entries
|
|
37
125
|
? input
|
|
@@ -41,6 +129,15 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
41
129
|
const workspace = loaded.workspace || records.find((record) => record.type === "workspace");
|
|
42
130
|
const program = resolveProgram(loaded, options.programId);
|
|
43
131
|
const asOf = options.asOf || currentCalendarDate(workspace?.timezone || "UTC");
|
|
132
|
+
const requestedAudit = options.auditId
|
|
133
|
+
? records.find((record) => record.type === "audit" && record.id === options.auditId)
|
|
134
|
+
: null;
|
|
135
|
+
const auditWindow = requestedAudit?.auditKind === "soc-2-type-2"
|
|
136
|
+
? requestedAudit.coverage
|
|
137
|
+
: selectedAuditWindow(records, program, asOf);
|
|
138
|
+
const progressWindow = requestedAudit?.auditKind === "soc-2-type-2"
|
|
139
|
+
? normalizeProgressWindow(auditWindow, "audit")
|
|
140
|
+
: selectedProgressWindow(program?.candidateCoverage, auditWindow, asOf);
|
|
44
141
|
const scope = programScope(program, records, byId, loaded.model, loaded);
|
|
45
142
|
const collectionReviews = assessCollectionReviews(loaded, { programId: program.id });
|
|
46
143
|
const markdown = new Map();
|
|
@@ -123,26 +220,54 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
123
220
|
const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0);
|
|
124
221
|
const stages = [
|
|
125
222
|
...evidenceGateStages,
|
|
126
|
-
await operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model)
|
|
223
|
+
await operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model, progressWindow)
|
|
127
224
|
];
|
|
128
225
|
finalizeStage(stages.at(-1));
|
|
129
|
-
const
|
|
226
|
+
const periodStarted = Boolean(
|
|
130
227
|
program?.assuranceGoal === "soc-2-type-2"
|
|
131
|
-
&&
|
|
132
|
-
&& coverageStart(program.candidateCoverage) <= asOf
|
|
228
|
+
&& progressWindow?.start <= asOf
|
|
133
229
|
);
|
|
134
230
|
const obligations = planObligations(records, { programId: program.id, asOf, through: asOf, model: loaded.model });
|
|
135
231
|
const policyLibrary = await assessPolicyLibraryUpgrades(loaded);
|
|
136
|
-
const operating =
|
|
232
|
+
const operating = Boolean(
|
|
233
|
+
evidenceReady
|
|
234
|
+
&& periodStarted
|
|
235
|
+
&& stages.at(-1).counts.action === 0
|
|
236
|
+
);
|
|
137
237
|
const canStartCandidatePeriod = Boolean(
|
|
138
238
|
evidenceReady
|
|
139
239
|
&& program?.assuranceGoal === "soc-2-type-2"
|
|
140
240
|
&& !program.candidateCoverage
|
|
241
|
+
&& !periodStarted
|
|
141
242
|
);
|
|
142
243
|
const items = stages.flatMap((current) => current.items);
|
|
143
244
|
const managedItems = items.filter((current) => !["info", "later"].includes(current.status));
|
|
144
245
|
const complete = managedItems.filter((current) => current.status === "complete").length;
|
|
145
246
|
const firstAction = items.find((current) => current.status === "action") || null;
|
|
247
|
+
const target = {
|
|
248
|
+
programId: program?.id || null,
|
|
249
|
+
goal: program?.assuranceGoal || "none",
|
|
250
|
+
label: assuranceGoalLabel(program?.assuranceGoal),
|
|
251
|
+
candidateCoverage: program?.candidateCoverage || null
|
|
252
|
+
};
|
|
253
|
+
const checkProgress = {
|
|
254
|
+
complete,
|
|
255
|
+
total: managedItems.length,
|
|
256
|
+
percent: managedItems.length ? Math.round((complete / managedItems.length) * 100) : 0
|
|
257
|
+
};
|
|
258
|
+
const progress = calculateProgramProgress({
|
|
259
|
+
stages,
|
|
260
|
+
target,
|
|
261
|
+
evidenceReady,
|
|
262
|
+
operating,
|
|
263
|
+
asOf,
|
|
264
|
+
window: progressWindow
|
|
265
|
+
});
|
|
266
|
+
const setupProgress = calculateSetupActionProgress(
|
|
267
|
+
stages,
|
|
268
|
+
target,
|
|
269
|
+
Boolean(progressWindow)
|
|
270
|
+
);
|
|
146
271
|
|
|
147
272
|
return {
|
|
148
273
|
program,
|
|
@@ -150,12 +275,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
150
275
|
dataModelVersion: String(loaded.model.modelVersion),
|
|
151
276
|
generatedAt: options.generatedAt || new Date().toISOString(),
|
|
152
277
|
asOf,
|
|
153
|
-
target
|
|
154
|
-
programId: program?.id || null,
|
|
155
|
-
goal: program?.assuranceGoal || "none",
|
|
156
|
-
label: assuranceGoalLabel(program?.assuranceGoal),
|
|
157
|
-
candidateCoverage: program?.candidateCoverage || null
|
|
158
|
-
},
|
|
278
|
+
target,
|
|
159
279
|
status: operating ? "operating" : evidenceReady ? "evidence-ready" : "needs-work",
|
|
160
280
|
evidenceReady,
|
|
161
281
|
operating,
|
|
@@ -168,11 +288,9 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
168
288
|
...policyLibrary.proposals,
|
|
169
289
|
...legacyPolicyLibraryProposals(records)
|
|
170
290
|
],
|
|
171
|
-
progress
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
percent: managedItems.length ? Math.round((complete / managedItems.length) * 100) : 0
|
|
175
|
-
},
|
|
291
|
+
progress,
|
|
292
|
+
setupProgress,
|
|
293
|
+
checkProgress,
|
|
176
294
|
counts: countStatuses(items),
|
|
177
295
|
firstAction,
|
|
178
296
|
scope: {
|
|
@@ -724,6 +842,7 @@ async function policiesStage(scope, records, byId, readMarkdown, model) {
|
|
|
724
842
|
: "Appoint a reviewer who is separate from the policy owner. The reviewer may be another person in the organization or an external person, and is separate from the CPA firm that may later perform the audit.",
|
|
725
843
|
appointedReviewer || (reviewerNeedsAssignment ? governedRecords[0] : { type: "person" }),
|
|
726
844
|
{
|
|
845
|
+
progressUnit: false,
|
|
727
846
|
commands: [
|
|
728
847
|
"npx filegrc list appointment --workflow --json",
|
|
729
848
|
"npx filegrc guide appointment --json",
|
|
@@ -1468,7 +1587,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
|
1468
1587
|
return stage("sources", "Control Evidence Sources", `Complete the authoritative ${modelSupports(model, "component-sources") ? "Components" : "Systems"} for every selected control family before marking the Controls implemented.`, items);
|
|
1469
1588
|
}
|
|
1470
1589
|
|
|
1471
|
-
async function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
|
|
1590
|
+
async function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model, progressWindow = null) {
|
|
1472
1591
|
const goal = workspace?.assuranceGoal || "none";
|
|
1473
1592
|
if (!evidenceReady) {
|
|
1474
1593
|
return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence after the Evidence Ready gate passes.", [
|
|
@@ -1500,12 +1619,9 @@ async function operationStage(loaded, workspace, scope, records, byId, asOf, evi
|
|
|
1500
1619
|
}
|
|
1501
1620
|
|
|
1502
1621
|
const obligations = planObligations(records, { programId: workspace.id, asOf, through: asOf, model });
|
|
1503
|
-
const start =
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
const end = workspace.candidateCoverage?.kind === "range"
|
|
1507
|
-
? coverageEnd(workspace.candidateCoverage)
|
|
1508
|
-
: null;
|
|
1622
|
+
const start = progressWindow?.start || null;
|
|
1623
|
+
const end = progressWindow?.end || null;
|
|
1624
|
+
const formalWindow = progressWindow?.source === "audit";
|
|
1509
1625
|
const startStatus = !start ? "action" : start <= asOf ? "complete" : "later";
|
|
1510
1626
|
const sourceCoverage = await assessSourceCoverageReadiness(loaded, scope.controls.map(({ id }) => id), workspace);
|
|
1511
1627
|
const incompleteSourceCoverage = sourceCoverage.filter(({ complete }) => !complete);
|
|
@@ -1517,16 +1633,20 @@ async function operationStage(loaded, workspace, scope, records, byId, asOf, evi
|
|
|
1517
1633
|
!start
|
|
1518
1634
|
? "Set the management candidate period start when the Evidence Ready gate passes. Do not backdate it."
|
|
1519
1635
|
: start <= asOf
|
|
1520
|
-
?
|
|
1636
|
+
? formalWindow
|
|
1637
|
+
? `The auditor-agreed Type 2 period began on ${start}. Keep evidence collection and operating work current through the period.`
|
|
1638
|
+
: `Management began the candidate Type 2 evidence period on ${start}. This is not the auditor-agreed report period.`
|
|
1521
1639
|
: `Evidence collection is scheduled to begin on ${start}.`,
|
|
1522
1640
|
workspace
|
|
1523
1641
|
),
|
|
1524
1642
|
item(
|
|
1525
1643
|
"candidate-period-end",
|
|
1526
1644
|
end ? "complete" : start ? "later" : "info",
|
|
1527
|
-
"Plan the candidate period end",
|
|
1645
|
+
formalWindow ? "Confirm the auditor-agreed period end" : "Plan the candidate period end",
|
|
1528
1646
|
end
|
|
1529
|
-
?
|
|
1647
|
+
? formalWindow
|
|
1648
|
+
? `Auditor-agreed period: ${start || "start not set"} through ${end}.`
|
|
1649
|
+
: `Management candidate period: ${start || "start not set"} through ${end}. The CPA firm may agree to different dates.`
|
|
1530
1650
|
: "Add the management target end when useful. Starting reliable evidence collection is the immediate milestone.",
|
|
1531
1651
|
workspace
|
|
1532
1652
|
),
|
|
@@ -1784,6 +1904,61 @@ function countStatuses(items) {
|
|
|
1784
1904
|
return counts;
|
|
1785
1905
|
}
|
|
1786
1906
|
|
|
1907
|
+
export function selectedAuditWindow(records, program, asOf) {
|
|
1908
|
+
if (program?.assuranceGoal !== "soc-2-type-2") return null;
|
|
1909
|
+
const candidates = records.filter((record) => (
|
|
1910
|
+
record.type === "audit"
|
|
1911
|
+
&& record.auditKind === "soc-2-type-2"
|
|
1912
|
+
&& (!record.programId || record.programId === program.id)
|
|
1913
|
+
&& !["canceled", "closed", "complete", "delivered"].includes(record.status)
|
|
1914
|
+
&& normalizeProgressWindow(record.coverage)
|
|
1915
|
+
&& coverageStart(record.coverage) <= asOf
|
|
1916
|
+
));
|
|
1917
|
+
const statusOrder = new Map([
|
|
1918
|
+
["planned", 0],
|
|
1919
|
+
["in-progress", 1],
|
|
1920
|
+
["fieldwork", 2],
|
|
1921
|
+
["report-draft", 3],
|
|
1922
|
+
["issued", 4]
|
|
1923
|
+
]);
|
|
1924
|
+
candidates.sort((left, right) => (
|
|
1925
|
+
coverageStart(right.coverage).localeCompare(coverageStart(left.coverage))
|
|
1926
|
+
|| (statusOrder.get(right.status) ?? -1) - (statusOrder.get(left.status) ?? -1)
|
|
1927
|
+
|| coverageEnd(right.coverage).localeCompare(coverageEnd(left.coverage))
|
|
1928
|
+
|| left.id.localeCompare(right.id)
|
|
1929
|
+
));
|
|
1930
|
+
return candidates[0]?.coverage || null;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
export function selectedProgressWindow(candidateCoverage, auditCoverage, asOf) {
|
|
1934
|
+
const candidates = [
|
|
1935
|
+
normalizeProgressWindow(candidateCoverage, "candidate"),
|
|
1936
|
+
normalizeProgressWindow(auditCoverage, "audit")
|
|
1937
|
+
].filter((window) => window && (!asOf || window.start <= asOf));
|
|
1938
|
+
candidates.sort((left, right) => (
|
|
1939
|
+
right.start.localeCompare(left.start)
|
|
1940
|
+
|| Number(right.source === "audit") - Number(left.source === "audit")
|
|
1941
|
+
|| right.end.localeCompare(left.end)
|
|
1942
|
+
));
|
|
1943
|
+
if (candidates.length) return candidates[0];
|
|
1944
|
+
return normalizeProgressWindow(candidateCoverage, "candidate")
|
|
1945
|
+
|| normalizeProgressWindow(auditCoverage, "audit");
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
function normalizeProgressWindow(coverage, source = "candidate") {
|
|
1949
|
+
const start = coverage?.start || coverageStart(coverage);
|
|
1950
|
+
const end = coverage?.end || coverageEnd(coverage);
|
|
1951
|
+
if (!start || !end || end < start) return null;
|
|
1952
|
+
return { start, end, source: coverage?.source || source };
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
function calendarDayDistance(start, end) {
|
|
1956
|
+
const startTime = Date.parse(`${start}T00:00:00Z`);
|
|
1957
|
+
const endTime = Date.parse(`${end}T00:00:00Z`);
|
|
1958
|
+
if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) return 0;
|
|
1959
|
+
return Math.round((endTime - startTime) / 86_400_000);
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1787
1962
|
function shiftYear(value, offset) {
|
|
1788
1963
|
const date = new Date(`${value}T00:00:00Z`);
|
|
1789
1964
|
date.setUTCFullYear(date.getUTCFullYear() + offset);
|
package/src/retention.js
CHANGED
|
@@ -32,22 +32,42 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
|
32
32
|
]
|
|
33
33
|
}
|
|
34
34
|
));
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
const usesByInformationType = new Map();
|
|
36
|
+
for (const use of uses) {
|
|
37
|
+
const grouped = usesByInformationType.get(use.informationTypeId) || [];
|
|
38
|
+
grouped.push(use);
|
|
39
|
+
usesByInformationType.set(use.informationTypeId, grouped);
|
|
40
|
+
}
|
|
41
|
+
items.push(...[...usesByInformationType].map(([informationTypeId, informationTypeUses]) => {
|
|
42
|
+
const uncoveredUses = informationTypeUses.filter((use) => (
|
|
43
|
+
!usableRules.some((rule) => ruleCoversUse(rule, use, program))
|
|
44
|
+
));
|
|
45
|
+
const matches = usableRules.filter((rule) => (
|
|
46
|
+
informationTypeUses.some((use) => ruleCoversUse(rule, use, program))
|
|
47
|
+
));
|
|
48
|
+
const informationTypeTitle = byId.get(informationTypeId)?.title || informationTypeId;
|
|
49
|
+
const affectedTitles = uncoveredUses.map(({ resource }) => resource.title);
|
|
50
|
+
const affectedSummary = affectedTitles.length > 3
|
|
51
|
+
? `${affectedTitles.slice(0, 3).join(", ")}, and ${affectedTitles.length - 3} more`
|
|
52
|
+
: affectedTitles.join(", ");
|
|
37
53
|
return readinessItem(
|
|
38
|
-
`retention-use-${
|
|
39
|
-
|
|
40
|
-
`Decide retention for ${
|
|
41
|
-
|
|
42
|
-
? `${
|
|
43
|
-
:
|
|
44
|
-
|
|
54
|
+
`retention-use-${informationTypeId}`,
|
|
55
|
+
uncoveredUses.length ? "action" : "complete",
|
|
56
|
+
`Decide retention for ${informationTypeTitle}`,
|
|
57
|
+
uncoveredUses.length
|
|
58
|
+
? `${affectedTitles.length} in-scope ${affectedTitles.length === 1 ? "resource uses" : "resources use"} this Information Type without an active, current retention decision, including ${affectedSummary}. Management must choose the cutoff, period, disposition, and whether one program-wide rule or separate scoped rules apply.`
|
|
59
|
+
: `All ${informationTypeUses.length} in-scope ${informationTypeUses.length === 1 ? "use is" : "uses are"} covered by ${matches.map(({ title }) => title).join(", ")}.`,
|
|
60
|
+
{ type: "retention-schedule-item" },
|
|
45
61
|
{
|
|
46
|
-
informationTypeId
|
|
62
|
+
informationTypeId,
|
|
63
|
+
affectedResourceIds: uncoveredUses.map(({ resource }) => resource.id),
|
|
64
|
+
retentionScopeResourceIds: uncoveredUses.length === informationTypeUses.length
|
|
65
|
+
? [program.id]
|
|
66
|
+
: uncoveredUses.map(({ resource }) => resource.id),
|
|
47
67
|
retentionScheduleItemIds: matches.map(({ id }) => id),
|
|
48
68
|
commands: [
|
|
49
69
|
"npx filegrc guide retention-schedule-item --json",
|
|
50
|
-
`npx filegrc scaffold retention-schedule-item --title ${shellArgument(`Retention for ${
|
|
70
|
+
`npx filegrc scaffold retention-schedule-item --title ${shellArgument(`Retention for ${informationTypeTitle}`)}`
|
|
51
71
|
]
|
|
52
72
|
}
|
|
53
73
|
);
|
package/src/web.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createResourceId } from "./id.js";
|
|
2
|
+
import { selectDefaultAudit } from "./audit-selection.js";
|
|
2
3
|
import { MODEL_CAPABILITY_VERSIONS } from "../model/index.js";
|
|
3
4
|
import {
|
|
4
5
|
calendarOccurrence,
|
|
@@ -14,12 +15,20 @@ import { formatCalendarDate, formatLocalDateTime } from "./time.js";
|
|
|
14
15
|
|
|
15
16
|
export function dashboardProgramReadiness(programReadiness = {}) {
|
|
16
17
|
const progress = programReadiness.progress || {};
|
|
17
|
-
const lifecycle =
|
|
18
|
-
? {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
const lifecycle = progress.mode === "operating-window"
|
|
19
|
+
? {
|
|
20
|
+
status: progress.status || (programReadiness.operating ? "Operating" : "Needs attention"),
|
|
21
|
+
tone: programReadiness.operating ? "good" : "warn"
|
|
22
|
+
}
|
|
23
|
+
: programReadiness.operating
|
|
24
|
+
? { status: "Operating", tone: "good" }
|
|
25
|
+
: programReadiness.evidenceReady
|
|
26
|
+
? { status: progress.status || "Evidence ready", tone: "good" }
|
|
27
|
+
: { status: progress.status || "Needs work", tone: "warn" };
|
|
22
28
|
return {
|
|
29
|
+
mode: progress.mode || "setup-progress",
|
|
30
|
+
label: progress.label || "Program setup",
|
|
31
|
+
detail: progress.detail || `${progress.complete ?? 0} of ${progress.total ?? 0} required actions complete.`,
|
|
23
32
|
percent: progress.percent ?? 0,
|
|
24
33
|
complete: progress.complete ?? 0,
|
|
25
34
|
total: progress.total ?? 0,
|
|
@@ -27,6 +36,23 @@ export function dashboardProgramReadiness(programReadiness = {}) {
|
|
|
27
36
|
};
|
|
28
37
|
}
|
|
29
38
|
|
|
39
|
+
export function workflowHrefBelongsToPage(destination = {}, href = "") {
|
|
40
|
+
const route = String(href).split("?")[0];
|
|
41
|
+
if (!route) return false;
|
|
42
|
+
if (destination.href === "#/policies") {
|
|
43
|
+
return ["policy", "document", "training"].some((type) => route.startsWith(`#/resource/${type}/`));
|
|
44
|
+
}
|
|
45
|
+
if (destination.type) {
|
|
46
|
+
return route === `#/resources/${destination.type}` || route.startsWith(`#/resource/${destination.type}/`);
|
|
47
|
+
}
|
|
48
|
+
return route === String(destination.href || "").split("?")[0];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function pageActionIdentity(href = "", fallback = "") {
|
|
52
|
+
const route = String(href).split("?")[0];
|
|
53
|
+
return route.startsWith("#/resource/") ? route : fallback || href;
|
|
54
|
+
}
|
|
55
|
+
|
|
30
56
|
export function renderIndex(state = null) {
|
|
31
57
|
const snapshot = state
|
|
32
58
|
? `<script id="filegrc-data" type="application/json">${safeJson(state)}</script>`
|
|
@@ -71,6 +97,9 @@ let READINESS_STAGES = SHARED_PROGRAM_STAGES.map((stage) => ({
|
|
|
71
97
|
}));
|
|
72
98
|
const RESOURCE_GUIDE_INSTRUCTIONS = ${JSON.stringify(RESOURCE_INSTRUCTIONS)};
|
|
73
99
|
const dashboardProgramReadiness = ${dashboardProgramReadiness.toString()};
|
|
100
|
+
const workflowHrefBelongsToPage = ${workflowHrefBelongsToPage.toString()};
|
|
101
|
+
const pageActionIdentity = ${pageActionIdentity.toString()};
|
|
102
|
+
const selectDefaultAudit = ${selectDefaultAudit.toString()};
|
|
74
103
|
const STAGE_PAGE_SUMMARIES = ${JSON.stringify({
|
|
75
104
|
...RESOURCE_PAGE_SUMMARIES,
|
|
76
105
|
"utility:evidence-sources": "Check that each Control has an authoritative, retrievable evidence source.",
|
|
@@ -93,6 +122,7 @@ let onboardingPendingDraft = false;
|
|
|
93
122
|
const resourceDetailRequests = new Map();
|
|
94
123
|
const stateSectionRequests = new Map();
|
|
95
124
|
let resourceGuideCleanup = null;
|
|
125
|
+
let recordOverflowCleanup = null;
|
|
96
126
|
let repositorySyncPollTimer = null;
|
|
97
127
|
let repositorySyncPollInFlight = false;
|
|
98
128
|
let mutationStateRefreshInFlight = false;
|
|
@@ -165,6 +195,8 @@ function handleRouteChange() {
|
|
|
165
195
|
function render() {
|
|
166
196
|
resourceGuideCleanup?.();
|
|
167
197
|
resourceGuideCleanup = null;
|
|
198
|
+
recordOverflowCleanup?.();
|
|
199
|
+
recordOverflowCleanup = null;
|
|
168
200
|
const previousNavigation = root.querySelector(".sidebar-nav");
|
|
169
201
|
if (previousNavigation) navigationScrollTop = previousNavigation.scrollTop;
|
|
170
202
|
const route = parseRoute();
|
|
@@ -460,8 +492,16 @@ function topbarProgramReadiness() {
|
|
|
460
492
|
return '<a class="topbar-readiness' + (loading ? ' is-loading' : '') + '" href="#/"' + (loading ? ' aria-busy="true"' : '') + '><span class="topbar-readiness-copy"><span>Program readiness</span><strong>' + label + '</strong></span><span class="progress" aria-hidden="true"><span style="width:0%"></span></span></a>';
|
|
461
493
|
}
|
|
462
494
|
const progress = dashboardProgramReadiness(state.programReadiness);
|
|
463
|
-
const
|
|
464
|
-
|
|
495
|
+
const setupRemaining = state.programReadiness.setupProgress?.remaining || 0;
|
|
496
|
+
const setupRegression = progress.mode === "operating-window" && setupRemaining > 0;
|
|
497
|
+
const href = progress.mode === "operating-window" && !setupRegression ? "#/stage/run" : nextProgramStageHref();
|
|
498
|
+
const label = setupRegression
|
|
499
|
+
? progress.label + " · " + setupRemaining + " setup " + (setupRemaining === 1 ? "action" : "actions")
|
|
500
|
+
: progress.label;
|
|
501
|
+
const detail = setupRegression
|
|
502
|
+
? progress.detail + " " + setupRemaining + " required setup " + (setupRemaining === 1 ? "action also needs" : "actions also need") + " attention."
|
|
503
|
+
: progress.detail;
|
|
504
|
+
return '<a class="topbar-readiness" href="' + href + '" aria-label="' + esc(label + ": " + progress.percent + "%. " + detail + " " + progress.status + ".") + '"><span class="topbar-readiness-copy"><span>' + esc(label) + '</span><strong>' + progress.percent + '%</strong></span><span class="progress" aria-hidden="true"><span style="width:' + progress.percent + '%"></span></span></a>';
|
|
465
505
|
}
|
|
466
506
|
|
|
467
507
|
function repositoryStatusTone(status) {
|
|
@@ -649,7 +689,7 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
|
|
|
649
689
|
const seed = {
|
|
650
690
|
...(params.get("title") ? { title: params.get("title") } : {}),
|
|
651
691
|
...(params.get("informationTypeId") ? { informationTypeIds: [params.get("informationTypeId")] } : {}),
|
|
652
|
-
...(params.
|
|
692
|
+
...(params.getAll("scopeResourceId").length ? { scopeResourceIds: params.getAll("scopeResourceId") } : {}),
|
|
653
693
|
...(params.get("scheduleDocumentId") ? { scheduleDocumentId: params.get("scheduleDocumentId") } : {})
|
|
654
694
|
};
|
|
655
695
|
history.replaceState(null, "", "#/retention-schedule");
|
|
@@ -704,18 +744,9 @@ function wireRetentionScheduleTable(total) {
|
|
|
704
744
|
}
|
|
705
745
|
|
|
706
746
|
function renderRetentionScheduleIssues() {
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
.map(({ record }) => ({
|
|
711
|
-
id: "planned-" + record.id,
|
|
712
|
-
title: "Complete or retire " + record.title,
|
|
713
|
-
message: "Planned rows are excluded from the approved schedule. Finish this decision or retire the row before schedule approval.",
|
|
714
|
-
href: '#/resource/retention-schedule-item/' + encodeURIComponent(record.id) + '?stage=policies'
|
|
715
|
-
}));
|
|
716
|
-
const actions = [...plannedRows, ...items.filter((item) => (
|
|
717
|
-
item.status === "action" && item.id !== "collection-review-retention-schedule-item"
|
|
718
|
-
))];
|
|
747
|
+
const actions = retentionSchedulePageItems().filter((item) => (
|
|
748
|
+
item.id !== "collection-review-retention-schedule-item"
|
|
749
|
+
));
|
|
719
750
|
if (!actions.length) return "";
|
|
720
751
|
return '<section class="retention-issues"><div class="section-head"><div><p class="kicker">Before approval</p><h2>Items to resolve</h2></div></div><div class="retention-issue-list">' + actions.map((item) => {
|
|
721
752
|
const href = item.href || (item.id?.startsWith("retention-use-")
|
|
@@ -888,7 +919,7 @@ function renderDocumentActivationAssessments() {
|
|
|
888
919
|
|
|
889
920
|
function renderAuditDocumentActivationAssessments() {
|
|
890
921
|
const audits = resourcesOfType("audit").map(({ record }) => record);
|
|
891
|
-
const audit = audits.
|
|
922
|
+
const audit = selectDefaultAudit(audits, state.programReadiness?.asOf || currentDate());
|
|
892
923
|
const preparation = audit ? state.auditPreparations?.[audit.id] : null;
|
|
893
924
|
const assessments = preparation?.documentActivations || [];
|
|
894
925
|
if (!audit || !assessments.length) return "";
|
|
@@ -1276,8 +1307,7 @@ function recordWorkflowItems(type, id) {
|
|
|
1276
1307
|
const activeStates = new Set(["blocked", "due", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
|
|
1277
1308
|
return [
|
|
1278
1309
|
...(state.workflow?.findings || []),
|
|
1279
|
-
...(state.workflow?.workItems || [])
|
|
1280
|
-
...programReadinessWorkflowItems()
|
|
1310
|
+
...(state.workflow?.workItems || [])
|
|
1281
1311
|
].filter((item) => (
|
|
1282
1312
|
activeStates.has(item.state)
|
|
1283
1313
|
&& (
|
|
@@ -1499,7 +1529,10 @@ function workflowItemHref(item) {
|
|
|
1499
1529
|
function retentionScheduleItemHref(item) {
|
|
1500
1530
|
const params = new URLSearchParams({ new: "1" });
|
|
1501
1531
|
if (item.informationTypeId) params.set("informationTypeId", item.informationTypeId);
|
|
1502
|
-
|
|
1532
|
+
for (const id of item.retentionScopeResourceIds || []) params.append("scopeResourceId", id);
|
|
1533
|
+
if (!params.has("scopeResourceId") && (item.resourceId || item.subject?.id)) {
|
|
1534
|
+
params.set("scopeResourceId", item.resourceId || item.subject.id);
|
|
1535
|
+
}
|
|
1503
1536
|
const informationTypeTitle = String(item.title || "").replace(/^Decide retention for\s+/i, "").trim();
|
|
1504
1537
|
if (informationTypeTitle && informationTypeTitle !== item.title) params.set("title", "Retention for " + informationTypeTitle);
|
|
1505
1538
|
const scheduleDocuments = resourcesOfType("document").filter(({ record }) => (
|
|
@@ -1611,16 +1644,50 @@ function isRetentionScheduleWorkflowItem(item) {
|
|
|
1611
1644
|
});
|
|
1612
1645
|
}
|
|
1613
1646
|
|
|
1614
|
-
function
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
.
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1647
|
+
function retentionSchedulePageItems() {
|
|
1648
|
+
const plannedRows = resourcesOfType("retention-schedule-item")
|
|
1649
|
+
.filter(({ record }) => record.status === "planned")
|
|
1650
|
+
.map(({ record }) => ({
|
|
1651
|
+
id: "planned-" + record.id,
|
|
1652
|
+
key: "planned-" + record.id,
|
|
1653
|
+
status: "action",
|
|
1621
1654
|
state: "ready",
|
|
1622
|
-
|
|
1655
|
+
title: "Complete or retire " + record.title,
|
|
1656
|
+
message: "Planned rows are excluded from the approved schedule. Finish this decision or retire the row before schedule approval.",
|
|
1657
|
+
resourceType: record.type,
|
|
1658
|
+
resourceId: record.id,
|
|
1659
|
+
href: '#/resource/retention-schedule-item/' + encodeURIComponent(record.id) + '?stage=policies'
|
|
1623
1660
|
}));
|
|
1661
|
+
return distinctPageActionItems([
|
|
1662
|
+
...plannedRows,
|
|
1663
|
+
...retentionReviewItems("policies", false)
|
|
1664
|
+
.filter(({ status }) => status === "action")
|
|
1665
|
+
.map((item) => ({
|
|
1666
|
+
...item,
|
|
1667
|
+
key: item.id,
|
|
1668
|
+
state: "ready",
|
|
1669
|
+
href: item.id === "collection-review-retention-schedule-item"
|
|
1670
|
+
? "#/retention-schedule"
|
|
1671
|
+
: item.id?.startsWith("retention-use-")
|
|
1672
|
+
? retentionScheduleItemHref(item)
|
|
1673
|
+
: item.resourceId && item.resourceType
|
|
1674
|
+
? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
|
|
1675
|
+
: workflowItemHref(item) || "#/retention-schedule",
|
|
1676
|
+
subject: item.resourceId && item.resourceType
|
|
1677
|
+
? { type: item.resourceType, id: item.resourceId }
|
|
1678
|
+
: item.subject
|
|
1679
|
+
}))
|
|
1680
|
+
]);
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
function distinctPageActionItems(items) {
|
|
1684
|
+
const distinct = new Map();
|
|
1685
|
+
for (const item of items) {
|
|
1686
|
+
const href = item.href || workflowItemHref(item);
|
|
1687
|
+
const key = pageActionIdentity(href, item.key || item.id);
|
|
1688
|
+
if (!distinct.has(key)) distinct.set(key, item);
|
|
1689
|
+
}
|
|
1690
|
+
return [...distinct.values()];
|
|
1624
1691
|
}
|
|
1625
1692
|
|
|
1626
1693
|
function evidenceSourceCheckLabel(name) {
|
|
@@ -1660,11 +1727,11 @@ function stagePageCard(stage, destination, index) {
|
|
|
1660
1727
|
const stepLabel = "Step " + stage.number + "." + String.fromCharCode(97 + index);
|
|
1661
1728
|
const derived = derivedStagePageState(stage, destination);
|
|
1662
1729
|
const complete = derived.complete;
|
|
1663
|
-
const items =
|
|
1730
|
+
const items = stagePageActionItems(stage, destination);
|
|
1664
1731
|
const completionState = '<span class="stage-page-completion-state ' + (complete ? "complete" : "") + '">' + esc(derived.label) + '</span>';
|
|
1665
1732
|
const taskPreview = items.length
|
|
1666
1733
|
? '<div class="stage-page-tasks">' + items.slice(0, 3).map((item) => {
|
|
1667
|
-
const href = destination.utility === "evidence-sources" ? destination.href : workflowItemHref(item) || destination.href;
|
|
1734
|
+
const href = destination.utility === "evidence-sources" ? destination.href : item.href || workflowItemHref(item) || destination.href;
|
|
1668
1735
|
return '<a href="' + href + '"><span class="workflow-finding-status ' + esc(item.state) + '">' + esc(properCase(item.state)) + '</span><span><strong>' + esc(item.title) + '</strong><small>' + esc(stagePageItemDetail(item)) + '</small></span></a>';
|
|
1669
1736
|
}).join("") + (items.length > 3 ? '<small class="stage-page-tasks-more">+' + (items.length - 3) + ' more on this page</small>' : "") + '</div>'
|
|
1670
1737
|
: "";
|
|
@@ -1699,6 +1766,11 @@ function stageProgress(stage) {
|
|
|
1699
1766
|
return progressFromCounts(complete, applicable.length, "page");
|
|
1700
1767
|
}
|
|
1701
1768
|
|
|
1769
|
+
function stagePageActionItems(stage, destination) {
|
|
1770
|
+
const deferredStates = new Set(["later", "scheduled", "upcoming", "waiting-external"]);
|
|
1771
|
+
return stagePageItems(stage, destination).filter(({ state }) => !deferredStates.has(state));
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1702
1774
|
function derivedStagePageState(stage, destination) {
|
|
1703
1775
|
if (
|
|
1704
1776
|
stage.id === "audit"
|
|
@@ -1716,15 +1788,14 @@ function derivedStagePageState(stage, destination) {
|
|
|
1716
1788
|
: { complete: evidenceItems.length > 0, label: evidenceItems.length ? "Ready" : "Not configured", countsTowardProgress: evidenceItems.length > 0 };
|
|
1717
1789
|
}
|
|
1718
1790
|
if (destination.utility === "retention-schedule") {
|
|
1719
|
-
const
|
|
1720
|
-
|
|
1721
|
-
return assessment?.status === "current" && !incomplete
|
|
1791
|
+
const incomplete = retentionSchedulePageItems().length;
|
|
1792
|
+
return !incomplete
|
|
1722
1793
|
? { complete: true, label: "Approved" }
|
|
1723
1794
|
: { complete: false, label: incomplete ? incomplete + " " + pluralize("item", incomplete) + " need work" : "Approval needed" };
|
|
1724
1795
|
}
|
|
1725
1796
|
const items = stagePageItems(stage, destination);
|
|
1726
1797
|
const deferredStates = new Set(["later", "scheduled", "upcoming", "waiting-external"]);
|
|
1727
|
-
const blocking =
|
|
1798
|
+
const blocking = stagePageActionItems(stage, destination);
|
|
1728
1799
|
if (blocking.length) {
|
|
1729
1800
|
return { complete: false, label: blocking.length + " " + pluralize("item", blocking.length) + (blocking.length === 1 ? " needs work" : " need work") };
|
|
1730
1801
|
}
|
|
@@ -1749,8 +1820,7 @@ function stagePageItems(stage, destination) {
|
|
|
1749
1820
|
const activeStates = new Set(["blocked", "due", "later", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
|
|
1750
1821
|
const items = [
|
|
1751
1822
|
...(state.workflow?.findings || []),
|
|
1752
|
-
...(state.workflow?.workItems || [])
|
|
1753
|
-
...programReadinessWorkflowItems()
|
|
1823
|
+
...(state.workflow?.workItems || [])
|
|
1754
1824
|
].filter((item) => (
|
|
1755
1825
|
activeStates.has(item.state)
|
|
1756
1826
|
&& (
|
|
@@ -1766,18 +1836,9 @@ function stagePageItems(stage, destination) {
|
|
|
1766
1836
|
.map((item) => ({ ...item, key: item.id, state: item.status, subject: { type: "source-coverage" } }));
|
|
1767
1837
|
}
|
|
1768
1838
|
if (destination.utility === "retention-schedule") {
|
|
1769
|
-
return
|
|
1770
|
-
.filter(({ status }) => status !== "complete")
|
|
1771
|
-
.map((item) => ({ ...item, key: item.id, state: item.status, subject: item.resourceId && item.resourceType ? { type: item.resourceType, id: item.resourceId } : { type: item.resourceType || "retention-schedule-item" } }));
|
|
1772
|
-
}
|
|
1773
|
-
if (stage.id === "policies" && destination.href === "#/policies") {
|
|
1774
|
-
return items.filter((item) => !isRetentionScheduleWorkflowItem(item)).sort((left, right) => (
|
|
1775
|
-
workflowItemStatePriority(left) - workflowItemStatePriority(right)
|
|
1776
|
-
|| (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
|
|
1777
|
-
|| left.key.localeCompare(right.key)
|
|
1778
|
-
));
|
|
1839
|
+
return retentionSchedulePageItems();
|
|
1779
1840
|
}
|
|
1780
|
-
return items.filter((item) => {
|
|
1841
|
+
return distinctPageActionItems(items.filter((item) => {
|
|
1781
1842
|
if (destination.type === "program" && item.key === "program.scope.criteria") return false;
|
|
1782
1843
|
if (
|
|
1783
1844
|
destination.type === "requirement"
|
|
@@ -1789,22 +1850,10 @@ function stagePageItems(stage, destination) {
|
|
|
1789
1850
|
&& ["source-coverage", "system"].includes(item.subject?.type)
|
|
1790
1851
|
&& item.key?.startsWith("evidence-source.")
|
|
1791
1852
|
) return false;
|
|
1792
|
-
if (
|
|
1793
|
-
stage.id === "policies"
|
|
1794
|
-
&& destination.type === "policy"
|
|
1795
|
-
&& item.code === "governance.appointment.independent-policy-reviewer"
|
|
1796
|
-
) return true;
|
|
1797
|
-
if (
|
|
1798
|
-
destination.type
|
|
1799
|
-
&& (item.subject?.type === destination.type || item.source?.type === destination.type)
|
|
1800
|
-
) return true;
|
|
1801
1853
|
const href = workflowItemHref(item);
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|| href.startsWith(destinationHref + "?")
|
|
1806
|
-
|| Boolean(destination.type && href.startsWith("#/resource/" + destination.type + "/"));
|
|
1807
|
-
}).sort((left, right) => (
|
|
1854
|
+
return workflowHrefBelongsToPage(destination, href)
|
|
1855
|
+
&& !(destination.href === "#/policies" && isRetentionScheduleWorkflowItem(item));
|
|
1856
|
+
})).sort((left, right) => (
|
|
1808
1857
|
workflowItemStatePriority(left) - workflowItemStatePriority(right)
|
|
1809
1858
|
|| (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
|
|
1810
1859
|
|| left.key.localeCompare(right.key)
|
|
@@ -1820,7 +1869,7 @@ function operationProgress() {
|
|
|
1820
1869
|
const goal = program?.target?.goal || activeProgram().assuranceGoal || "none";
|
|
1821
1870
|
const asOf = program?.asOf || currentDate();
|
|
1822
1871
|
const candidateStarted = goal === "soc-2-type-2"
|
|
1823
|
-
? Boolean(program?.
|
|
1872
|
+
? Boolean(program?.progress?.mode === "operating-window")
|
|
1824
1873
|
: goal === "soc-2-type-1"
|
|
1825
1874
|
? Boolean(program?.target?.candidateCoverage?.kind === "as-of")
|
|
1826
1875
|
: Boolean(program?.evidenceReady);
|
|
@@ -3092,7 +3141,7 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
3092
3141
|
const seed = type === "retention-schedule-item" ? {
|
|
3093
3142
|
...(params.get("title") ? { title: params.get("title") } : {}),
|
|
3094
3143
|
...(params.get("informationTypeId") ? { informationTypeIds: [params.get("informationTypeId")] } : {}),
|
|
3095
|
-
...(params.
|
|
3144
|
+
...(params.getAll("scopeResourceId").length ? { scopeResourceIds: params.getAll("scopeResourceId") } : {}),
|
|
3096
3145
|
...(params.get("scheduleDocumentId") ? { scheduleDocumentId: params.get("scheduleDocumentId") } : {})
|
|
3097
3146
|
} : type === "document" ? {
|
|
3098
3147
|
...(params.get("title") ? { title: params.get("title") } : {}),
|
|
@@ -3155,12 +3204,11 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
3155
3204
|
const narrative = recordNarrative(entry.record, fields);
|
|
3156
3205
|
const narrativeNames = new Set(narrative.map(([name]) => name));
|
|
3157
3206
|
const visible = Object.entries(entry.record).filter(([name]) => (
|
|
3158
|
-
!["id", "type", "title"].includes(name)
|
|
3207
|
+
!["id", "type", "title", "extensions"].includes(name)
|
|
3159
3208
|
&& !fields[name]?.content
|
|
3160
3209
|
&& !narrativeNames.has(name)
|
|
3161
3210
|
));
|
|
3162
3211
|
const content = Object.entries(entry.content);
|
|
3163
|
-
const sourceMetadata = '<div><dt>Source file</dt><dd><code>' + esc(entry.relativePath) + '</code></dd></div><div><dt>Workspace revision</dt><dd>' + (state.git.available ? '<code>' + esc(state.git.shortCommit) + '</code>' : "Unavailable until the workspace is committed.") + '</dd></div>';
|
|
3164
3212
|
const narrativeContent = narrative.length
|
|
3165
3213
|
? '<div class="content-label"><span>Record</span></div><div class="record-prose">' + narrative.map(([name, value]) => '<section><h3>' + esc(titleCase(fields[name]?.label || humanize(name))) + '</h3><p>' + esc(value) + '</p></section>').join("") + '</div>'
|
|
3166
3214
|
: "";
|
|
@@ -3213,26 +3261,41 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
3213
3261
|
? '<section class="panel detail-main">' + narrativeContent + markdownContent + addRecordContent + '</section>'
|
|
3214
3262
|
: "";
|
|
3215
3263
|
const attachmentPanel = type === "evidence" ? evidenceAttachmentPanel(entry) : "";
|
|
3216
|
-
const
|
|
3217
|
-
? '<section class="panel detail-
|
|
3264
|
+
const recordFieldsPanel = visible.length
|
|
3265
|
+
? '<section class="panel detail-support-panel detail-metadata-panel"><div class="panel-head"><h3>Record details</h3></div><dl class="metadata">' + visible.map(([name, value]) => '<div><dt>' + esc(fields[name]?.label || humanize(name)) + '</dt><dd>' + formatValue(name === "status" ? displayStatus(entry.record) : value, name, type) + '</dd></div>').join("") + '</dl></section>'
|
|
3218
3266
|
: "";
|
|
3219
|
-
const participationPanel = entry.historyLoaded === false
|
|
3220
|
-
? '<section class="panel detail-support-panel" role="status" aria-live="polite" aria-busy="true"><div class="panel-head"><h3>Participation</h3></div><p class="muted is-loading">' + loadingIndicator("Loading participation and file history…") + '</p></section>'
|
|
3221
|
-
: entry.historyError
|
|
3222
|
-
? '<section class="panel detail-support-panel" role="status" aria-live="polite"><div class="panel-head"><h3>Participation</h3></div><p class="muted">Participation and file history are unavailable. Reload the record to try again.</p></section>'
|
|
3223
|
-
: personParticipation(entry);
|
|
3224
3267
|
const supportPanels = renderDetailSupport({
|
|
3225
3268
|
hasRecordBody,
|
|
3226
3269
|
workflowPanel: workflowGuidance({ type, id, title: "Next steps", workflow: entry.workflow }) || recordCompletionState(entry.record),
|
|
3227
|
-
metadataPanel:
|
|
3228
|
-
attachmentPanel
|
|
3229
|
-
participationPanel,
|
|
3230
|
-
connectionsPanel: resourceConnections(entry),
|
|
3231
|
-
historyPanel
|
|
3270
|
+
metadataPanel: recordFieldsPanel,
|
|
3271
|
+
attachmentPanel
|
|
3232
3272
|
});
|
|
3233
|
-
|
|
3273
|
+
const mayDelete = !state.readOnly && !routeSetLocked && !definition.singleton;
|
|
3274
|
+
const recordMenu = '<details class="record-overflow"><summary aria-label="More record actions" title="More record actions"><svg viewBox="0 0 18 4" aria-hidden="true"><circle cx="2" cy="2" r="2"></circle><circle cx="9" cy="2" r="2"></circle><circle cx="16" cy="2" r="2"></circle></svg></summary><div class="record-overflow-menu" aria-label="Record actions"><button type="button" data-record-modal="info">Record info</button><button type="button" data-record-modal="connections">Connections</button><button type="button" data-record-modal="history"' + (entry.historyLoaded === false ? ' disabled' : '') + '>File history' + (entry.historyLoaded === false ? ' (loading)' : '') + '</button>' + (mayDelete ? '<div class="record-overflow-separator"></div><button class="danger-text" type="button" data-delete-resource>Delete record</button>' : '') + '</div></details>';
|
|
3275
|
+
main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="' + collectionHref + '">' + esc(collectionLabel) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div><div class="actions">' + reportingRouteSetActions + auditCycleAction + auditPopulationCorrectionAction + (type === "audit" ? '<a class="button primary" href="#/audit-packet?auditId=' + encodeURIComponent(entry.record.id) + '">Audit Evidence & Packet</a>' : "") + governanceActions + lifecycleActions + issueActions + addRecordContentAction + (!state.readOnly && !routeSetLocked ? '<button class="button" id="edit-resource">Edit</button>' : "") + recordMenu + '</div></div><div class="detail-grid ' + (hasRecordBody ? "" : "detail-grid-structured") + '">' + detailMain + supportPanels + '</div></div>';
|
|
3234
3276
|
main.querySelectorAll("[data-completion-requirements]").forEach((button) => button.addEventListener("click", () => openCompletionRequirementsDialog(button.dataset.completionRequirements)));
|
|
3235
3277
|
main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry));
|
|
3278
|
+
main.querySelectorAll("[data-record-modal]").forEach((button) => button.addEventListener("click", () => {
|
|
3279
|
+
button.closest("details")?.removeAttribute("open");
|
|
3280
|
+
openRecordUtilityDialog(entry, button.dataset.recordModal);
|
|
3281
|
+
}));
|
|
3282
|
+
const overflowMenu = main.querySelector(".record-overflow");
|
|
3283
|
+
const stopOutsideDismissal = () => document.removeEventListener("click", dismissOnOutsideClick);
|
|
3284
|
+
const dismissOnOutsideClick = (event) => {
|
|
3285
|
+
if (overflowMenu.contains(event.target)) return;
|
|
3286
|
+
overflowMenu.removeAttribute("open");
|
|
3287
|
+
stopOutsideDismissal();
|
|
3288
|
+
};
|
|
3289
|
+
overflowMenu?.addEventListener("toggle", () => {
|
|
3290
|
+
stopOutsideDismissal();
|
|
3291
|
+
if (overflowMenu.open) queueMicrotask(() => document.addEventListener("click", dismissOnOutsideClick));
|
|
3292
|
+
});
|
|
3293
|
+
overflowMenu?.addEventListener("keydown", (event) => {
|
|
3294
|
+
if (event.key !== "Escape" || !overflowMenu.open) return;
|
|
3295
|
+
overflowMenu.removeAttribute("open");
|
|
3296
|
+
overflowMenu.querySelector("summary")?.focus();
|
|
3297
|
+
});
|
|
3298
|
+
recordOverflowCleanup = stopOutsideDismissal;
|
|
3236
3299
|
main.querySelector("[data-external-reviewer-governance]")?.addEventListener("click", openExternalReviewerGovernanceDialog);
|
|
3237
3300
|
main.querySelector("[data-next-audit-cycle]")?.addEventListener("click", () => openNextAuditCycleDialog(entry.record));
|
|
3238
3301
|
main.querySelector("[data-correct-audit-population]")?.addEventListener("click", async () => {
|
|
@@ -3343,7 +3406,8 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
3343
3406
|
}));
|
|
3344
3407
|
main.querySelector("#add-record-content")?.addEventListener("click", () => openEditor(type, entry, { addRecordContent: true }));
|
|
3345
3408
|
main.querySelectorAll("[data-edit-content]").forEach((button) => button.addEventListener("click", () => openContentEditor(entry, button.dataset.editContent)));
|
|
3346
|
-
main.querySelector("
|
|
3409
|
+
main.querySelector("[data-delete-resource]")?.addEventListener("click", async () => {
|
|
3410
|
+
main.querySelector(".record-overflow")?.removeAttribute("open");
|
|
3347
3411
|
const repositoryPrefetch = prefetchRepositoryForReview();
|
|
3348
3412
|
if (!await confirmAction({
|
|
3349
3413
|
kicker: "Delete record",
|
|
@@ -3369,17 +3433,50 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
3369
3433
|
}
|
|
3370
3434
|
}
|
|
3371
3435
|
|
|
3372
|
-
function renderDetailSupport({ hasRecordBody, workflowPanel, metadataPanel, attachmentPanel
|
|
3373
|
-
const panels = workflowPanel + metadataPanel + attachmentPanel
|
|
3436
|
+
function renderDetailSupport({ hasRecordBody, workflowPanel, metadataPanel, attachmentPanel }) {
|
|
3437
|
+
const panels = workflowPanel + metadataPanel + attachmentPanel;
|
|
3374
3438
|
if (hasRecordBody) return '<aside>' + panels + '</aside>';
|
|
3375
3439
|
const stacks = [
|
|
3376
3440
|
{ name: "guidance", content: workflowPanel },
|
|
3377
|
-
{ name: "record", content: metadataPanel + attachmentPanel
|
|
3378
|
-
{ name: "relationships", content: participationPanel + connectionsPanel }
|
|
3441
|
+
{ name: "record", content: metadataPanel + attachmentPanel }
|
|
3379
3442
|
].filter(({ content }) => content);
|
|
3380
3443
|
return '<aside class="detail-support-columns">' + stacks.map(({ name, content }) => '<div class="detail-support-stack ' + name + '">' + content + '</div>').join("") + '</aside>';
|
|
3381
3444
|
}
|
|
3382
3445
|
|
|
3446
|
+
function openRecordUtilityDialog(entry, view) {
|
|
3447
|
+
const dialog = document.createElement("dialog");
|
|
3448
|
+
dialog.className = "alert-dialog record-utility-dialog";
|
|
3449
|
+
const definitions = {
|
|
3450
|
+
info: {
|
|
3451
|
+
kicker: "Record info",
|
|
3452
|
+
title: entry.record.title,
|
|
3453
|
+
body: '<dl class="metadata record-info"><div><dt>Record ID</dt><dd><code>' + esc(entry.record.id) + '</code></dd></div><div><dt>Type</dt><dd>' + esc(state.model.resources[entry.record.type].title) + '</dd></div><div><dt>Source file</dt><dd><code>' + esc(entry.relativePath) + '</code></dd></div><div><dt>Workspace revision</dt><dd>' + (state.git.available ? '<code>' + esc(state.git.shortCommit) + '</code>' : "Unavailable until the workspace is committed.") + '</dd></div>' + (entry.record.extensions ? '<div><dt>Extensions</dt><dd>' + formatValue(entry.record.extensions, "extensions", entry.record.type) + '</dd></div>' : '') + '</dl><details class="record-json"><summary>View raw JSON</summary><pre class="compact-json">' + esc(JSON.stringify(entry.record, null, 2)) + '</pre></details>'
|
|
3454
|
+
},
|
|
3455
|
+
connections: {
|
|
3456
|
+
kicker: "Connections",
|
|
3457
|
+
title: entry.record.title,
|
|
3458
|
+
body: (personParticipation(entry, true) + resourceConnections(entry, true)) || '<p class="muted">This record has no connected records.</p>'
|
|
3459
|
+
},
|
|
3460
|
+
history: {
|
|
3461
|
+
kicker: "File history",
|
|
3462
|
+
title: entry.record.title,
|
|
3463
|
+
body: entry.historyError
|
|
3464
|
+
? '<p class="muted">File history is unavailable. Reload the record to try again.</p>'
|
|
3465
|
+
: entry.history?.length
|
|
3466
|
+
? '<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>'
|
|
3467
|
+
: '<p class="muted">No committed history is available for this record.</p>'
|
|
3468
|
+
}
|
|
3469
|
+
};
|
|
3470
|
+
const definition = definitions[view];
|
|
3471
|
+
if (!definition) return;
|
|
3472
|
+
dialog.setAttribute("aria-labelledby", "record-utility-title");
|
|
3473
|
+
dialog.innerHTML = '<div class="dialog-head"><div><p class="kicker">' + esc(definition.kicker) + '</p><h2 id="record-utility-title">' + esc(titleCase(definition.title)) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><div class="record-utility-body">' + definition.body + '</div><div class="dialog-actions"><button type="button" class="button primary">Close</button></div>';
|
|
3474
|
+
document.body.append(dialog);
|
|
3475
|
+
dialog.querySelectorAll("button").forEach((button) => button.addEventListener("click", () => dialog.close()));
|
|
3476
|
+
dialog.addEventListener("close", () => dialog.remove(), { once: true });
|
|
3477
|
+
dialog.showModal();
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3383
3480
|
function evidenceAttachmentPanel(entry) {
|
|
3384
3481
|
const attachments = (entry.record.filePaths || []).map((path) => ({
|
|
3385
3482
|
path,
|
|
@@ -3563,7 +3660,7 @@ function recordContentDefinition(type) {
|
|
|
3563
3660
|
};
|
|
3564
3661
|
}
|
|
3565
3662
|
|
|
3566
|
-
function personParticipation(entry) {
|
|
3663
|
+
function personParticipation(entry, expanded = false) {
|
|
3567
3664
|
if (entry.record.type !== "person") return "";
|
|
3568
3665
|
const personId = entry.record.id;
|
|
3569
3666
|
const appointments = resourcesOfType("appointment")
|
|
@@ -3603,8 +3700,8 @@ function personParticipation(entry) {
|
|
|
3603
3700
|
const count = affiliations.length + assignments.length;
|
|
3604
3701
|
if (!count) return "";
|
|
3605
3702
|
return '<section class="panel connections-panel"><div class="panel-head"><h3>Participation</h3><span>' + count + '</span></div>'
|
|
3606
|
-
+ personParticipationGroup("Appointments and teams", affiliations, 8, "more appointments or teams")
|
|
3607
|
-
+ personParticipationGroup("Assigned records", assignments, 10, "more assigned records")
|
|
3703
|
+
+ personParticipationGroup("Appointments and teams", affiliations, expanded ? affiliations.length : 8, "more appointments or teams")
|
|
3704
|
+
+ personParticipationGroup("Assigned records", assignments, expanded ? assignments.length : 10, "more assigned records")
|
|
3608
3705
|
+ '</section>';
|
|
3609
3706
|
}
|
|
3610
3707
|
|
|
@@ -3617,7 +3714,7 @@ function personParticipationGroup(title, rows, limit, moreLabel) {
|
|
|
3617
3714
|
+ '</div>';
|
|
3618
3715
|
}
|
|
3619
3716
|
|
|
3620
|
-
function resourceConnections(entry) {
|
|
3717
|
+
function resourceConnections(entry, expanded = false) {
|
|
3621
3718
|
const relatedPeopleOnly = entry.record.type === "person";
|
|
3622
3719
|
const connections = new Map();
|
|
3623
3720
|
const entriesById = new Map(state.resources.map((item) => [item.record.id, item]));
|
|
@@ -3652,7 +3749,7 @@ function resourceConnections(entry) {
|
|
|
3652
3749
|
return firstType - secondType || first.entry.record.title.localeCompare(second.entry.record.title);
|
|
3653
3750
|
});
|
|
3654
3751
|
if (!sorted.length) return "";
|
|
3655
|
-
const visible = sorted.slice(0, 14);
|
|
3752
|
+
const visible = expanded ? sorted : sorted.slice(0, 14);
|
|
3656
3753
|
return '<section class="panel connections-panel"><div class="panel-head"><h3>' + (relatedPeopleOnly ? "Related people" : "Connections") + '</h3><span>' + sorted.length + '</span></div><div class="connections">' + visible.map(({ entry: connected, reasons }) => '<a href="#/resource/' + encodeURIComponent(connected.record.type) + '/' + encodeURIComponent(connected.record.id) + '"><strong>' + esc(connected.record.title) + '</strong><small>' + esc([...reasons].join(" · ")) + '</small></a>').join("") + '</div>' + (sorted.length > visible.length ? '<p class="connections-more">' + (sorted.length - visible.length) + ' more connections are available through the linked records.</p>' : "") + '</section>';
|
|
3657
3754
|
}
|
|
3658
3755
|
|
|
@@ -6385,7 +6482,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
6385
6482
|
.reconciliation-panel>p{margin:0 0 12px;color:var(--muted);font-size:12px;line-height:1.5}.reconciliation-candidates{display:grid;gap:9px}.reconciliation-candidate{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,.8fr);gap:16px;padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.reconciliation-candidate strong{margin-left:8px;font-size:13.2px}.reconciliation-candidate p{margin:7px 0;color:var(--muted);font-size:12px;line-height:1.5}.reconciliation-candidate small{display:block;overflow-wrap:anywhere;color:var(--muted);font-size:9.6px}.reconciliation-actions{display:grid;gap:8px;justify-items:end;align-content:center}.reconciliation-actions>code{max-width:100%;overflow-wrap:anywhere;color:var(--muted);font-size:9.6px;text-align:right}
|
|
6386
6483
|
.onboarding-save-status{color:var(--muted);font-size:10.8px;line-height:1.35}.onboarding-save-status:empty{display:none}.onboarding-save-status:not(:empty){order:-1;flex-basis:100%;margin-bottom:4px}.onboarding-retry-sync{margin-top:9px}.detail-loading{color:var(--muted)}
|
|
6387
6484
|
.save-status{min-height:16px;color:var(--muted);font-size:10.8px;line-height:1.35}
|
|
6388
|
-
.page-intro,.detail-head{align-items:center;margin-bottom:12px}.actions{align-items:center}.detail-head>div:first-child{min-width:0}.detail-head h2{margin:7px 0}.detail-head .header-breadcrumbs{margin:0;font-size:10.8px;line-height:normal;min-height:11px;align-items:center}.header-breadcrumbs span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60ch}
|
|
6485
|
+
.page-intro,.detail-head{align-items:center;margin-bottom:12px}.actions{align-items:center;justify-content:flex-end;flex-wrap:wrap}.detail-head>div:first-child{min-width:0}.detail-head h2{margin:7px 0}.detail-head .header-breadcrumbs{margin:0;font-size:10.8px;line-height:normal;min-height:11px;align-items:center}.header-breadcrumbs span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60ch}.record-overflow{position:relative;flex:0 0 auto;margin-left:auto}.record-overflow>summary{display:grid;place-items:center;width:38px;height:38px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--ink);cursor:pointer;list-style:none}.record-overflow>summary::-webkit-details-marker{display:none}.record-overflow>summary svg{display:block;width:18px;height:4px;fill:currentColor}.record-overflow>summary:hover,.record-overflow[open]>summary{border-color:var(--accent-light);background:var(--accent-soft);color:var(--accent)}.record-overflow-menu{position:absolute;z-index:12;top:calc(100% + 6px);right:0;display:grid;width:190px;padding:6px;border:1px solid var(--line);border-radius:9px;background:var(--panel);box-shadow:0 14px 36px rgba(0,0,53,.16)}.record-overflow-menu button{width:100%;border:0;border-radius:6px;padding:9px 10px;background:none;color:var(--ink);font-size:13.2px;text-align:left;cursor:pointer}.record-overflow-menu button:hover{background:var(--surface-soft)}.record-overflow-menu button:disabled{cursor:wait;color:var(--muted)}.record-overflow-menu .danger-text{color:var(--red)}.record-overflow-separator{height:1px;margin:5px 4px;background:var(--line)}.record-utility-dialog{width:min(680px,calc(100vw - 30px));max-height:min(760px,calc(100vh - 30px));overflow:auto}.record-utility-body{display:grid;gap:12px;margin:19px 0}.record-utility-body>.panel{box-shadow:none}.record-info>div:first-child{border-top:0}.record-json{border-top:1px solid var(--line);padding-top:13px}.record-json summary{color:var(--accent);font-size:13.2px;font-weight:750;cursor:pointer}.record-json pre{max-height:360px;overflow:auto;margin:12px 0 0}.record-utility-dialog .history{max-height:none}
|
|
6389
6486
|
.not-found{max-width:620px;padding:28px}.not-found h2{font-family:Georgia,serif;font-size:37.2px;font-weight:500;margin:7px 0}.not-found>p:not(.kicker){margin:0;color:var(--muted);font-size:15.6px}.not-found .page-actions{justify-content:flex-start;margin-top:22px}
|
|
6390
6487
|
.button.danger-action{background:var(--red);border-color:var(--red);color:#fff}
|
|
6391
6488
|
@media(max-width:1200px){.readiness-flow{grid-template-columns:repeat(3,minmax(0,1fr))}.audit-engagement{grid-template-columns:1fr 1fr}.audit-engagement .button{grid-column:1/-1;justify-self:start}}
|
|
@@ -6396,13 +6493,12 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
6396
6493
|
.detail-grid aside .panel{width:100%;max-height:min(520px,65vh);overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable}.detail-grid aside .workflow-guidance,.detail-grid aside .resource-review-criteria{margin:0}.detail-grid aside .resource-review-criteria{padding:21px}.detail-grid aside .detail-workflow-panel{max-height:min(440px,60vh)}.detail-grid aside .detail-review-panel{max-height:min(300px,50vh)}.detail-grid aside .workflow-findings,.detail-grid aside .resource-review-criteria ul{grid-template-columns:1fr}.detail-support-stack{display:grid;min-width:0;gap:14px;align-content:start}
|
|
6397
6494
|
@media(max-width:760px){.detail-grid aside{order:-1}}
|
|
6398
6495
|
@media(max-width:760px){.reconciliation-candidate{grid-template-columns:1fr}.reconciliation-actions{justify-items:start}.reconciliation-actions>code{text-align:left}}
|
|
6399
|
-
@media(max-width:760px){.detail-grid-structured .detail-support-columns{display:flex;flex-direction:column}.detail-grid-structured .detail-support-stack{display:contents}
|
|
6496
|
+
@media(max-width:760px){.detail-grid-structured .detail-support-columns{display:flex;flex-direction:column}.detail-grid-structured .detail-support-stack{display:contents}}
|
|
6400
6497
|
@media(max-width:760px){.guide-review ul,.resource-review-criteria ul{grid-template-columns:1fr}.collection-review-head,.collection-review-foot{align-items:stretch;flex-direction:column}.record-workflow-action{min-width:0}}
|
|
6401
6498
|
@media(max-width:760px){.policy-content-table>.section-head{display:block}.policy-content-tools{justify-content:flex-start;margin-top:15px}.policy-content-tools>label:first-child{flex:1 1 100%;width:100%}.policy-content-tools input[type="search"]{width:100%;min-width:0}}
|
|
6402
6499
|
@media(max-width:520px){.onboarding-form,.onboarding-sections,.setup-steps{grid-template-columns:1fr}.onboarding-form label.wide{grid-column:auto}.onboarding-actions{flex-wrap:wrap}.onboarding-skip{width:100%;order:3;margin:3px 0 0}.readiness-flow{grid-template-columns:1fr}.obligation-card-foot{align-items:flex-start;flex-direction:column}.obligation-action{align-self:flex-start}}
|
|
6403
6500
|
@media(min-width:761px){.detail-grid{grid-template-columns:minmax(270px,1fr) minmax(0,2fr)}.detail-grid aside{grid-column:1;grid-row:1}.detail-main{grid-column:2;grid-row:1}}
|
|
6404
6501
|
@media(min-width:761px){.detail-grid.detail-grid-structured{grid-template-columns:1fr}.detail-grid-structured aside{grid-column:1;grid-row:1}.detail-grid-structured .detail-support-columns{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
|
6405
|
-
@media(min-width:1300px){.detail-grid-structured .detail-support-columns{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
|
6406
6502
|
@media(max-width:760px){.sidebar{visibility:hidden;transition:transform .2s,visibility 0s .2s}.sidebar.shown{visibility:visible;transition-delay:0s}.nav-close{display:grid;place-items:center;position:absolute;top:25px;right:18px;width:34px;height:34px;border:1px solid #5966a4;border-radius:50%;background:#11174a;color:#eef1ff;font-size:24px;cursor:pointer}.nav-scrim{display:block;position:fixed;inset:0;border:0;background:rgba(0,0,24,.38);opacity:0;pointer-events:none;transition:opacity .2s;z-index:15}.sidebar.shown+.nav-scrim{opacity:1;pointer-events:auto}.pagination{justify-content:space-between;gap:8px}.page-status{min-width:0}}
|
|
6407
6503
|
@media(max-width:760px){.topbar{height:56px}.topbar>div:first-of-type{display:none}.topbar-readiness{flex:0 1 220px;min-width:120px}.nav-close{font-size:0}.nav-close:before,.nav-close:after{content:"";position:absolute;width:13px;height:2px;border-radius:2px;background:currentColor;transform:rotate(45deg)}.nav-close:after{transform:rotate(-45deg)}}
|
|
6408
6504
|
|
package/src/workflow.js
CHANGED
|
@@ -1125,6 +1125,8 @@ function normalizeFinding(code, item, context) {
|
|
|
1125
1125
|
...(item.createResourceType ? { createResourceType: item.createResourceType } : {}),
|
|
1126
1126
|
...(item.sourceResourceIds ? { sourceResourceIds: item.sourceResourceIds } : {}),
|
|
1127
1127
|
...(item.informationTypeId ? { informationTypeId: item.informationTypeId } : {}),
|
|
1128
|
+
...(item.affectedResourceIds ? { affectedResourceIds: item.affectedResourceIds } : {}),
|
|
1129
|
+
...(item.retentionScopeResourceIds ? { retentionScopeResourceIds: item.retentionScopeResourceIds } : {}),
|
|
1128
1130
|
...(item.retentionScheduleItemIds ? { retentionScheduleItemIds: item.retentionScheduleItemIds } : {}),
|
|
1129
1131
|
...(item.staleResourceIds ? { staleResourceIds: item.staleResourceIds } : {}),
|
|
1130
1132
|
...(subject ? { subject } : {}),
|