filegrc 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/model/index.js +9 -5
- package/model/v10.json +12122 -0
- package/model/v9.json +11647 -0
- package/package.json +1 -1
- package/src/agent.js +3 -0
- package/src/audit-populations.js +88 -0
- package/src/audit-preparation.js +7 -2
- package/src/cli.js +186 -13
- package/src/collection-review-integrity.js +118 -0
- package/src/collection-review.js +71 -7
- package/src/collection-scope.js +8 -0
- package/src/evidence-packet.js +328 -46
- package/src/files.js +364 -6
- package/src/git.js +1064 -149
- package/src/index.js +17 -1
- package/src/model-migration.js +221 -5
- package/src/obligations.js +834 -66
- package/src/policy-library/information-security-policy-v2.md +1 -1
- package/src/policy-library.js +85 -8
- package/src/program-path.js +11 -5
- package/src/program-readiness.js +84 -6
- package/src/reconciliation.js +332 -81
- package/src/reporting-route-integrity.js +542 -0
- package/src/reporting-route-sets.js +745 -0
- package/src/server.js +160 -47
- package/src/state.js +30 -8
- package/src/time.js +55 -0
- package/src/validate.js +978 -4
- package/src/web.js +491 -49
- package/src/workflow-history-integrity.js +872 -0
- package/src/workflow.js +36 -10
package/src/web.js
CHANGED
|
@@ -94,6 +94,7 @@ let repositorySyncPollTimer = null;
|
|
|
94
94
|
let repositorySyncPollInFlight = false;
|
|
95
95
|
let mutationStateRefreshInFlight = false;
|
|
96
96
|
let mutationStateRefreshTimer = null;
|
|
97
|
+
let programSelectionGeneration = 0;
|
|
97
98
|
|
|
98
99
|
start().catch((error) => {
|
|
99
100
|
root.innerHTML = '<main class="fatal"><h1>Could Not Load the Workspace</h1><pre></pre></main>';
|
|
@@ -197,7 +198,8 @@ function loadStateSection(section) {
|
|
|
197
198
|
if (stateSectionRequests.has(section)) return stateSectionRequests.get(section);
|
|
198
199
|
state.sections[section] = "loading";
|
|
199
200
|
const token = state.stateToken;
|
|
200
|
-
const
|
|
201
|
+
const programQuery = state.selectedProgramId ? "&programId=" + encodeURIComponent(state.selectedProgramId) : "";
|
|
202
|
+
const request = fetchJson("/api/state/" + encodeURIComponent(section) + "?token=" + encodeURIComponent(token) + programQuery)
|
|
201
203
|
.then((result) => {
|
|
202
204
|
if (state.stateToken !== result.stateToken) return;
|
|
203
205
|
Object.assign(state, result.state);
|
|
@@ -300,7 +302,10 @@ function readinessStageForType(type) {
|
|
|
300
302
|
}
|
|
301
303
|
|
|
302
304
|
function activeProgram() {
|
|
303
|
-
return resourcesOfType("program").find(({ record }) =>
|
|
305
|
+
return resourcesOfType("program").find(({ record }) => record.id === state.selectedProgramId)
|
|
306
|
+
?.record
|
|
307
|
+
|| resourcesOfType("program").find(({ record }) => !["retired"].includes(record.status))?.record
|
|
308
|
+
|| state.workspace;
|
|
304
309
|
}
|
|
305
310
|
|
|
306
311
|
function reviewedRequirementIds() {
|
|
@@ -353,7 +358,11 @@ function topbar(route) {
|
|
|
353
358
|
const validationLoading = state.sections?.repository !== "complete";
|
|
354
359
|
const validationTone = repositoryError ? "warn" : validationLoading ? "neutral" : state.validation.ok ? "good" : "bad";
|
|
355
360
|
const validationLabel = repositoryError ? "Data check failed" : validationLoading ? "Checking data" : state.validation.ok ? "Data valid" : state.validation.counts.errors + " validation errors";
|
|
356
|
-
|
|
361
|
+
const programs = resourcesOfType("program").filter(({ record }) => record.status !== "retired");
|
|
362
|
+
const programSelect = programs.length > 1 && !state.readOnly
|
|
363
|
+
? '<label class="program-select"><span class="sr-only">Current Program</span><select data-program-select>' + programs.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (record.id === state.selectedProgramId ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label>'
|
|
364
|
+
: "";
|
|
365
|
+
return '<button class="mobile-nav" type="button" aria-label="Open navigation" aria-controls="sidebar-navigation" aria-expanded="false">☰</button><div><small class="eyebrow">' + esc(state.workspace.organizationName) + '</small><h1>' + esc(titleCase(title)) + '</h1></div><div class="topbar-status">' + programSelect + topbarProgramReadiness() + '<label class="search topbar-search"><span aria-hidden="true">⌕</span><input data-global-search type="search" placeholder="Search records" aria-label="Search records"><kbd>/</kbd></label><a class="validation-chip" href="#/repository"><span class="status-dot ' + validationTone + '"></span>' + validationLabel + '</a><a class="repo-chip" href="#/repository"><span class="status-dot ' + repositoryTone + '"></span>' + esc(repositoryLabel) + '</a></div>';
|
|
357
366
|
}
|
|
358
367
|
|
|
359
368
|
function topbarProgramReadiness() {
|
|
@@ -1270,8 +1279,10 @@ function stageProgress(stage) {
|
|
|
1270
1279
|
detail: "Create an Audit only after a real engagement or customer deadline exists."
|
|
1271
1280
|
};
|
|
1272
1281
|
}
|
|
1273
|
-
const
|
|
1274
|
-
|
|
1282
|
+
const pageStates = pages.map((destination) => derivedStagePageState(stage, destination));
|
|
1283
|
+
const applicable = pageStates.filter((current) => current.countsTowardProgress !== false);
|
|
1284
|
+
const complete = applicable.filter((current) => current.complete).length;
|
|
1285
|
+
return progressFromCounts(complete, applicable.length, "page");
|
|
1275
1286
|
}
|
|
1276
1287
|
|
|
1277
1288
|
function derivedStagePageState(stage, destination) {
|
|
@@ -1282,24 +1293,29 @@ function derivedStagePageState(stage, destination) {
|
|
|
1282
1293
|
) {
|
|
1283
1294
|
return { complete: false, label: "No engagement" };
|
|
1284
1295
|
}
|
|
1285
|
-
const
|
|
1296
|
+
const items = stagePageItems(stage, destination);
|
|
1297
|
+
const deferredStates = new Set(["later", "scheduled", "upcoming", "waiting-external"]);
|
|
1298
|
+
const blocking = items.filter(({ state }) => !deferredStates.has(state));
|
|
1286
1299
|
if (blocking.length) {
|
|
1287
1300
|
return { complete: false, label: blocking.length + " " + pluralize("item", blocking.length) + (blocking.length === 1 ? " needs work" : " need work") };
|
|
1288
1301
|
}
|
|
1302
|
+
if (items.some(({ state }) => deferredStates.has(state))) {
|
|
1303
|
+
return { complete: false, countsTowardProgress: false, label: "Later" };
|
|
1304
|
+
}
|
|
1305
|
+
const collectionReview = destination.type ? state.collectionReviews?.[destination.type] : null;
|
|
1306
|
+
if (collectionReview) {
|
|
1307
|
+
return collectionReview.status === "current"
|
|
1308
|
+
? { complete: true, label: "Reviewed" }
|
|
1309
|
+
: { complete: false, label: "Review scope" };
|
|
1310
|
+
}
|
|
1289
1311
|
if (destination.type && resourcesOfType(destination.type).length === 0) {
|
|
1290
|
-
|
|
1291
|
-
if (collectionReview) {
|
|
1292
|
-
return collectionReview.status === "current"
|
|
1293
|
-
? { complete: true, label: "Reviewed" }
|
|
1294
|
-
: { complete: false, label: "Review scope" };
|
|
1295
|
-
}
|
|
1296
|
-
return { complete: true, label: "Conditional" };
|
|
1312
|
+
return { complete: false, countsTowardProgress: false, label: "Only if needed" };
|
|
1297
1313
|
}
|
|
1298
1314
|
return { complete: true, label: "Ready" };
|
|
1299
1315
|
}
|
|
1300
1316
|
|
|
1301
1317
|
function stagePageItems(stage, destination) {
|
|
1302
|
-
const activeStates = new Set(["blocked", "due", "open", "overdue", "ready"]);
|
|
1318
|
+
const activeStates = new Set(["blocked", "due", "later", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
|
|
1303
1319
|
const items = [
|
|
1304
1320
|
...(state.workflow?.findings || []),
|
|
1305
1321
|
...(state.workflow?.workItems || []),
|
|
@@ -1541,6 +1557,9 @@ function renderObligations(main, params = new URLSearchParams()) {
|
|
|
1541
1557
|
const trigger = plan.triggers.find((item) => item.eventType === button.dataset.startEvent);
|
|
1542
1558
|
if (trigger) openObligationEventDialog(trigger);
|
|
1543
1559
|
}));
|
|
1560
|
+
main.querySelectorAll("[data-activate-obligation-rule]").forEach((button) => button.addEventListener("click", () => {
|
|
1561
|
+
openObligationRuleActivation(button.dataset.activateObligationRule);
|
|
1562
|
+
}));
|
|
1544
1563
|
main.querySelectorAll("[data-expand-policy-events]").forEach((button) => button.addEventListener("click", (event) => {
|
|
1545
1564
|
const button = event.currentTarget;
|
|
1546
1565
|
const expanded = button.getAttribute("aria-expanded") === "true";
|
|
@@ -1566,6 +1585,10 @@ function renderObligations(main, params = new URLSearchParams()) {
|
|
|
1566
1585
|
const item = plan.items.find((candidate) => candidate.key === button.dataset.recordObligation);
|
|
1567
1586
|
if (item) openObligationCompletion(item);
|
|
1568
1587
|
}));
|
|
1588
|
+
main.querySelectorAll("[data-reconcile-obligation]").forEach((button) => button.addEventListener("click", () => {
|
|
1589
|
+
const item = plan.items.find((candidate) => candidate.key === button.dataset.reconcileObligation);
|
|
1590
|
+
if (item) openObligationReconciliation(item);
|
|
1591
|
+
}));
|
|
1569
1592
|
main.querySelectorAll("[data-complete-action]").forEach((button) => button.addEventListener("click", () => {
|
|
1570
1593
|
const item = plan.items.find((candidate) => candidate.key === button.dataset.completeAction);
|
|
1571
1594
|
if (item) openActionCompletion(item);
|
|
@@ -1642,7 +1665,11 @@ function policyEventTrigger(trigger, index, collapsed = false, scope = "events")
|
|
|
1642
1665
|
: state.readOnly
|
|
1643
1666
|
? "Open this workspace in writable mode to trigger the workflow."
|
|
1644
1667
|
: trigger.steps.length + " " + pluralize("task", trigger.steps.length) + " will be added to the Work Queue.";
|
|
1645
|
-
|
|
1668
|
+
const proposedRule = trigger.steps.find(({ ruleStatus }) => ["proposed", "approved"].includes(ruleStatus));
|
|
1669
|
+
const action = proposedRule && !state.readOnly
|
|
1670
|
+
? '<button class="button primary" type="button" data-activate-obligation-rule="' + esc(proposedRule.ruleId) + '">Review schedule</button>'
|
|
1671
|
+
: '<button class="button primary" type="button" data-start-event="' + esc(trigger.eventType) + '"' + (unavailable ? " disabled" : "") + '>Trigger Work</button>';
|
|
1672
|
+
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>' + action + '</article>';
|
|
1646
1673
|
}
|
|
1647
1674
|
|
|
1648
1675
|
function policyEventName(eventType) {
|
|
@@ -1673,20 +1700,32 @@ function defaultClassificationId() {
|
|
|
1673
1700
|
}
|
|
1674
1701
|
|
|
1675
1702
|
function obligationCard(item, collapsed = false) {
|
|
1676
|
-
const type = item.actionItemId ? "action-item" : "obligation";
|
|
1677
|
-
const id = item.actionItemId || item.obligationId;
|
|
1703
|
+
const type = item.actionItemId ? "action-item" : item.occurrenceId ? "obligation-occurrence" : "obligation";
|
|
1704
|
+
const id = item.actionItemId || item.occurrenceId || item.obligationId;
|
|
1678
1705
|
const completion = item.actionItemId ? actionCompletionPlan(item) : obligationCompletionPlan(item);
|
|
1706
|
+
const proposedAction = item.status === "proposed" && item.ruleId && ["proposed", "approved"].includes(item.ruleStatus)
|
|
1707
|
+
? state.readOnly
|
|
1708
|
+
? '<a class="obligation-action" href="#/resource/obligation-rule/' + encodeURIComponent(item.ruleId) + '">Review schedule</a>'
|
|
1709
|
+
: '<button class="obligation-action" type="button" data-activate-obligation-rule="' + esc(item.ruleId) + '">Review and activate</button>'
|
|
1710
|
+
: item.status === "proposed" && item.programBlocker?.id
|
|
1711
|
+
? '<a class="obligation-action blocked" href="#/resource/' + encodeURIComponent(item.programBlocker.type) + '/' + encodeURIComponent(item.programBlocker.id) + '">' + esc(item.programBlocker.label) + '</a>'
|
|
1712
|
+
: "";
|
|
1679
1713
|
const canAct = completion?.blocked === "Assign current owner"
|
|
1680
1714
|
|| !["upcoming", "proposed"].includes(item.status);
|
|
1681
|
-
const action = !state.readOnly && canAct && completion
|
|
1715
|
+
const action = proposedAction || (!state.readOnly && canAct && completion
|
|
1682
1716
|
? completion.blocked
|
|
1683
1717
|
? '<a class="obligation-action blocked" href="' + completion.href + '">' + esc(completion.blocked) + '</a>'
|
|
1684
1718
|
: item.actionItemId
|
|
1685
1719
|
? '<button class="obligation-action" type="button" data-complete-action="' + esc(item.key) + '">Complete task</button>'
|
|
1686
|
-
:
|
|
1687
|
-
|
|
1720
|
+
: item.ruleId
|
|
1721
|
+
? '<button class="obligation-action" type="button" data-reconcile-obligation="' + esc(item.key) + '">' + (item.reconciliationStatus === "reconciled" ? "Correct result" : "Review population") + '</button>'
|
|
1722
|
+
: '<button class="obligation-action" type="button" data-record-obligation="' + esc(item.key) + '">Record work</button>'
|
|
1723
|
+
: "");
|
|
1688
1724
|
const kind = item.kind === "event" ? "Policy Event Task" : item.kind === "action" ? "Assigned Follow-up" : properCase(item.activityType || "Recurring");
|
|
1689
|
-
|
|
1725
|
+
const population = item.ruleId
|
|
1726
|
+
? '<p><strong>' + esc(String(item.completedCount || 0)) + ' of ' + esc(String(item.expectedCount || 0)) + '</strong> expected members passed' + (item.membershipFinal ? '' : '; population still open') + '.</p>'
|
|
1727
|
+
: '';
|
|
1728
|
+
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>' + population + '<div class="obligation-card-foot"><div class="obligation-links">' + (item.policyIds || []).map(formatReference).join("") + '</div>' + action + '</div></article>';
|
|
1690
1729
|
}
|
|
1691
1730
|
|
|
1692
1731
|
function actionCompletionPlan(item) {
|
|
@@ -1773,6 +1812,103 @@ function openObligationCompletion(item) {
|
|
|
1773
1812
|
});
|
|
1774
1813
|
}
|
|
1775
1814
|
|
|
1815
|
+
async function openObligationReconciliation(item) {
|
|
1816
|
+
try {
|
|
1817
|
+
const response = await localFetch("/api/obligation-occurrences/scaffold", {
|
|
1818
|
+
method: "POST",
|
|
1819
|
+
headers: { "content-type": "application/json" },
|
|
1820
|
+
body: JSON.stringify({
|
|
1821
|
+
obligationId: item.obligationId,
|
|
1822
|
+
windowStart: item.dueWindowStart,
|
|
1823
|
+
asOf: currentDate(),
|
|
1824
|
+
correctFinalized: item.reconciliationStatus === "reconciled"
|
|
1825
|
+
})
|
|
1826
|
+
});
|
|
1827
|
+
const scaffold = await response.json();
|
|
1828
|
+
if (!response.ok) throw new Error(scaffold.error || "Could not scaffold the occurrence reconciliation.");
|
|
1829
|
+
const current = scaffold.operation === "update"
|
|
1830
|
+
? state.resources.find(({ record }) => record.id === scaffold.record.id)
|
|
1831
|
+
: null;
|
|
1832
|
+
const entry = current ? { ...current, record: scaffold.record } : null;
|
|
1833
|
+
openEditor("obligation-occurrence", entry, {
|
|
1834
|
+
seed: entry ? null : scaffold.record,
|
|
1835
|
+
description: "Review each member and its proof. Counts and the conclusion are calculated from the rows below.",
|
|
1836
|
+
occurrenceReview: true,
|
|
1837
|
+
membershipFinal: scaffold.membershipFinal,
|
|
1838
|
+
occurrenceReconciliation: { revision: scaffold.revision },
|
|
1839
|
+
saveLabel: scaffold.operation === "update" ? "Save reconciliation" : scaffold.operation === "supersede" ? "Create correction" : "Create population review"
|
|
1840
|
+
});
|
|
1841
|
+
} catch (error) {
|
|
1842
|
+
showError(error.message);
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
async function openObligationRuleActivation(ruleId) {
|
|
1847
|
+
try {
|
|
1848
|
+
const response = await localFetch("/api/obligation-rule-activations/scaffold", {
|
|
1849
|
+
method: "POST",
|
|
1850
|
+
headers: { "content-type": "application/json" },
|
|
1851
|
+
body: JSON.stringify({ ruleId })
|
|
1852
|
+
});
|
|
1853
|
+
const scaffold = await response.json();
|
|
1854
|
+
if (!response.ok) throw new Error(scaffold.error || "Could not prepare this schedule for activation.");
|
|
1855
|
+
const dialog = document.createElement("dialog");
|
|
1856
|
+
dialog.className = "editor rule-activation";
|
|
1857
|
+
const recurrence = scaffold.review.recurrence;
|
|
1858
|
+
const selector = scaffold.review.selector;
|
|
1859
|
+
const scheduleReview = recurrence.mode === "event"
|
|
1860
|
+
? '<div><span>Trigger</span><strong>' + esc(policyEventName(recurrence.eventType)) + '</strong><small>Applies to events after the effective time</small></div>'
|
|
1861
|
+
: '<div><span>Cadence</span><strong>Every ' + esc(String(recurrence.interval)) + ' ' + esc(recurrence.unit) + (recurrence.interval === 1 ? "" : "s") + '</strong><small>Anchored ' + esc(recurrence.anchorDate) + ' · first affected <b data-first-affected>' + esc(scaffold.review.firstAffectedOn || "none") + '</b></small></div>';
|
|
1862
|
+
const reviewSummary = '<section class="activation-review">' + scheduleReview + '<div><span>Population</span><strong>' + esc(selector ? properCase(selector.resourceType) : "Obligation scope") + '</strong><small>' + esc(selector ? selector.cutoff && "cutoff " + properCase(selector.cutoff) : "No selector") + '</small></div><div class="full"><span>Rationale</span><strong>' + esc(scaffold.review.rationale) + '</strong></div></section>';
|
|
1863
|
+
const cutoverOption = (value, label) => '<option value="' + value + '" ' + (scaffold.payload.cutoverDecision === value ? "selected" : "") + '>' + label + '</option>';
|
|
1864
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Obligation schedule</p><h2>' + esc(scaffold.rule.title) + '</h2></div><button type="button" class="icon-button" data-dismiss aria-label="Close">×</button></div><p>Activation binds this reviewed schedule to ' + esc(scaffold.obligation.title) + ' in one write. <a href="#/resource/obligation-rule/' + encodeURIComponent(scaffold.rule.id) + '">Open the full rule</a>.</p>' + reviewSummary + (scaffold.openOccurrences.length ? '<p class="cutover-note">' + scaffold.openOccurrences.length + ' open ' + pluralize("occurrence", scaffold.openOccurrences.length) + ' need a cutover decision.</p>' : "") + '<div class="form-grid"><label class="field-group"><span>Approved on <span class="required-mark">Required</span></span><input name="approvedOn" type="date" required value="' + esc(scaffold.payload.approvedOn) + '"></label><label class="field-group"><span>Effective at <span class="required-mark">Required</span></span><input name="effectiveAt" type="datetime-local" required value="' + esc(scaffold.payload.effectiveLocal) + '"></label>' + (scaffold.priorRule ? '<label class="field-group"><span>Cutover</span><select name="cutoverDecision">' + cutoverOption("new-windows-only", "New windows only") + cutoverOption("keep-open-window", "Keep open occurrences") + cutoverOption("supersede-open-window", "Supersede open occurrences") + '</select></label>' : "") + '<fieldset class="field-group full"><legend>Approved by <span class="required-mark">Required</span></legend><div class="checkbox-list">' + scaffold.reviewerCandidates.map((person) => '<label><input type="checkbox" name="approver" value="' + esc(person.id) + '"><span>' + esc(person.title) + '<small>' + esc(person.id) + '</small></span></label>').join("") + '</div></fieldset><label class="field-group full confirmation"><input type="checkbox" name="confirmRevision" required><span>I reviewed revision <code>' + esc(scaffold.review.revision.slice(0, 12)) + '</code> and approve these terms.</span></label></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button type="button" class="button" data-dismiss>Cancel</button><button type="submit" class="button primary">Activate schedule</button></div></form>';
|
|
1865
|
+
document.body.append(dialog);
|
|
1866
|
+
dialog.showModal();
|
|
1867
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
1868
|
+
dialog.querySelectorAll("[data-dismiss]").forEach((button) => button.addEventListener("click", () => dialog.close()));
|
|
1869
|
+
const effectiveInput = dialog.querySelector('input[name="effectiveAt"]');
|
|
1870
|
+
effectiveInput.addEventListener("input", () => {
|
|
1871
|
+
const target = dialog.querySelector("[data-first-affected]");
|
|
1872
|
+
if (target) target.textContent = effectiveInput.value ? nextCalendarOccurrence(recurrence, effectiveInput.value.slice(0, 10)) : "none";
|
|
1873
|
+
});
|
|
1874
|
+
dialog.querySelector("form").addEventListener("submit", async (event) => {
|
|
1875
|
+
event.preventDefault();
|
|
1876
|
+
const form = event.currentTarget;
|
|
1877
|
+
const error = dialog.querySelector(".dialog-error");
|
|
1878
|
+
const approvedByIds = [...form.querySelectorAll('input[name="approver"]:checked')].map(({ value }) => value);
|
|
1879
|
+
if (!approvedByIds.length) return void (error.textContent = "Select at least one approver.");
|
|
1880
|
+
const button = form.querySelector('button[type="submit"]');
|
|
1881
|
+
button.disabled = true;
|
|
1882
|
+
button.textContent = "Activating…";
|
|
1883
|
+
error.textContent = "";
|
|
1884
|
+
try {
|
|
1885
|
+
const saved = await localFetch("/api/obligation-rule-activations", {
|
|
1886
|
+
method: "POST",
|
|
1887
|
+
headers: { "content-type": "application/json" },
|
|
1888
|
+
body: JSON.stringify({
|
|
1889
|
+
...scaffold.payload,
|
|
1890
|
+
confirmedRevision: scaffold.review.revision,
|
|
1891
|
+
approvedByIds,
|
|
1892
|
+
approvedOn: form.elements.approvedOn.value,
|
|
1893
|
+
effectiveLocal: form.elements.effectiveAt.value,
|
|
1894
|
+
...(form.elements.cutoverDecision ? { cutoverDecision: form.elements.cutoverDecision.value } : {})
|
|
1895
|
+
})
|
|
1896
|
+
});
|
|
1897
|
+
if (!saved.ok) throw new Error(await responseMessage(saved));
|
|
1898
|
+
applyMutationState(await saved.json());
|
|
1899
|
+
dialog.close();
|
|
1900
|
+
render();
|
|
1901
|
+
} catch (caught) {
|
|
1902
|
+
error.textContent = caught.message;
|
|
1903
|
+
button.disabled = false;
|
|
1904
|
+
button.textContent = "Activate schedule";
|
|
1905
|
+
}
|
|
1906
|
+
});
|
|
1907
|
+
} catch (error) {
|
|
1908
|
+
showError(error.message);
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1776
1912
|
function obligationCompletionSeed(type, item, obligation) {
|
|
1777
1913
|
const date = currentDate();
|
|
1778
1914
|
const timestamp = new Date().toISOString();
|
|
@@ -2172,7 +2308,9 @@ function openApplicabilityReviewDialog(type, entries) {
|
|
|
2172
2308
|
}
|
|
2173
2309
|
|
|
2174
2310
|
function renderAuditPacket(main, params = new URLSearchParams()) {
|
|
2175
|
-
const audits = resourcesOfType("audit")
|
|
2311
|
+
const audits = resourcesOfType("audit").filter(({ record }) => (
|
|
2312
|
+
!state.selectedProgramId || !record.programId || record.programId === state.selectedProgramId
|
|
2313
|
+
));
|
|
2176
2314
|
const evidence = resourcesOfType("evidence");
|
|
2177
2315
|
const filegrcRecordTypes = new Set((state.model.evidenceSourceFamilies || [])
|
|
2178
2316
|
.filter((family) => family.filegrcManaged === true)
|
|
@@ -2242,7 +2380,8 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
|
|
|
2242
2380
|
body: JSON.stringify({
|
|
2243
2381
|
start: form.elements.start.value,
|
|
2244
2382
|
end: form.elements.end?.value || form.elements.start.value,
|
|
2245
|
-
auditId: form.elements.auditId.value || undefined
|
|
2383
|
+
auditId: form.elements.auditId.value || undefined,
|
|
2384
|
+
programId: state.selectedProgramId || undefined
|
|
2246
2385
|
})
|
|
2247
2386
|
});
|
|
2248
2387
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
@@ -2416,7 +2555,8 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
2416
2555
|
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>';
|
|
2417
2556
|
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>' +
|
|
2418
2557
|
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>';
|
|
2419
|
-
|
|
2558
|
+
const pageSummary = STAGE_PAGE_SUMMARIES[type] || definition.description;
|
|
2559
|
+
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(listTitle)) + '</h2>' + guideTrigger + '</div><p>' + esc(pageSummary) + '</p></div>' + listTools + '</div>' + resourceGuide(type) +
|
|
2420
2560
|
collectionReviewPanel(type) +
|
|
2421
2561
|
'<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>' +
|
|
2422
2562
|
'<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>';
|
|
@@ -2573,6 +2713,21 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2573
2713
|
&& entry.record.status === "complete"
|
|
2574
2714
|
? '<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>'
|
|
2575
2715
|
: "";
|
|
2716
|
+
const auditPopulationCorrectionAction = !state.readOnly
|
|
2717
|
+
&& type === "audit-population"
|
|
2718
|
+
&& ["reconciled", "not-applicable"].includes(entry.record.status)
|
|
2719
|
+
? '<button class="button" type="button" data-correct-audit-population>Correct population</button>'
|
|
2720
|
+
: "";
|
|
2721
|
+
const reportingRouteSetActions = !state.readOnly && type === "reporting-route-set"
|
|
2722
|
+
? entry.record.status === "draft"
|
|
2723
|
+
? '<button class="button primary" type="button" data-propose-route-set>' + (state.repository?.mode === "trunk" && !state.repository?.developmentOverride ? "Commit proposal" : "Propose (commit required)") + '</button>'
|
|
2724
|
+
: entry.record.status === "proposed"
|
|
2725
|
+
? '<button class="button primary" type="button" data-approve-route-set ' + (!state.git.clean || !state.git.commit ? 'disabled title="Commit the proposal before approval"' : "") + '>' + (state.repository?.mode === "trunk" && !state.repository?.developmentOverride ? "Approve and commit" : "Approve (commit required)") + '</button>'
|
|
2726
|
+
: entry.record.status === "approved"
|
|
2727
|
+
? '<button class="button" type="button" data-cancel-route-set>Cancel route set</button>'
|
|
2728
|
+
: ""
|
|
2729
|
+
: "";
|
|
2730
|
+
const routeSetLocked = type === "reporting-route-set" && entry.record.status !== "draft";
|
|
2576
2731
|
const detailMain = hasRecordBody
|
|
2577
2732
|
? '<section class="panel detail-main">' + narrativeContent + markdownContent + addRecordContent + '</section>'
|
|
2578
2733
|
: "";
|
|
@@ -2590,10 +2745,33 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2590
2745
|
connectionsPanel: resourceConnections(entry),
|
|
2591
2746
|
historyPanel
|
|
2592
2747
|
});
|
|
2593
|
-
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><div class="detail-grid ' + (hasRecordBody ? "" : "detail-grid-structured") + '">' + detailMain + supportPanels + '</div></div>';
|
|
2748
|
+
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">' + reportingRouteSetActions + auditCycleAction + auditPopulationCorrectionAction + (type === "audit" ? '<a class="button primary" href="#/audit-packet?auditId=' + encodeURIComponent(entry.record.id) + '">Audit Evidence & Packet</a>' : "") + governanceActions + lifecycleActions + issueActions + addRecordContentAction + (!state.readOnly && !routeSetLocked ? '<button class="button" id="edit-resource">Edit</button>' + (!definition.singleton ? '<button class="button danger" id="delete-resource">Delete</button>' : "") : "") + '</div></div><div class="detail-grid ' + (hasRecordBody ? "" : "detail-grid-structured") + '">' + detailMain + supportPanels + '</div></div>';
|
|
2594
2749
|
main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry));
|
|
2595
2750
|
main.querySelector("[data-external-reviewer-governance]")?.addEventListener("click", openExternalReviewerGovernanceDialog);
|
|
2596
2751
|
main.querySelector("[data-next-audit-cycle]")?.addEventListener("click", () => openNextAuditCycleDialog(entry.record));
|
|
2752
|
+
main.querySelector("[data-correct-audit-population]")?.addEventListener("click", async () => {
|
|
2753
|
+
try {
|
|
2754
|
+
const scaffold = await fetchJson("/api/audit-population-corrections/scaffold", {
|
|
2755
|
+
method: "POST",
|
|
2756
|
+
headers: { "content-type": "application/json" },
|
|
2757
|
+
body: JSON.stringify({ populationId: entry.record.id })
|
|
2758
|
+
});
|
|
2759
|
+
openEditor("audit-population", null, {
|
|
2760
|
+
seed: scaffold.record,
|
|
2761
|
+
auditPopulationCorrection: scaffold,
|
|
2762
|
+
saveLabel: "Save correction",
|
|
2763
|
+
description: scaffold.instructions
|
|
2764
|
+
});
|
|
2765
|
+
} catch (error) {
|
|
2766
|
+
showError(error.message);
|
|
2767
|
+
}
|
|
2768
|
+
});
|
|
2769
|
+
main.querySelector("[data-propose-route-set]")?.addEventListener("click", () => mutateReportingRouteSet("propose", {
|
|
2770
|
+
routeSetId: entry.record.id,
|
|
2771
|
+
expectedRevision: entry.revision
|
|
2772
|
+
}));
|
|
2773
|
+
main.querySelector("[data-approve-route-set]")?.addEventListener("click", () => openReportingRouteApproval(entry));
|
|
2774
|
+
main.querySelector("[data-cancel-route-set]")?.addEventListener("click", () => openReportingRouteCancellation(entry));
|
|
2597
2775
|
main.querySelector("[data-record-finding]")?.addEventListener("click", () => openEditor("finding", null, {
|
|
2598
2776
|
seed: issueSeed("finding", entry.record),
|
|
2599
2777
|
description: "Record only a confirmed gap that needs separate remediation tracking. Keep the report details in this source record’s Markdown."
|
|
@@ -2925,7 +3103,7 @@ function renderRepository(main) {
|
|
|
2925
3103
|
? "Add a Git remote before pushing"
|
|
2926
3104
|
: "";
|
|
2927
3105
|
const pullButton = !state.readOnly && state.git.available && hasRemote
|
|
2928
|
-
? '<button class="button" type="button" data-git-action="pull" ' + (pullDisabled ? 'disabled title="' + esc(pullDisabled) + '"' : "") + '>
|
|
3106
|
+
? '<button class="button" type="button" data-git-action="pull" ' + (pullDisabled ? 'disabled title="' + esc(pullDisabled) + '"' : "") + '>Check incoming commits</button>'
|
|
2929
3107
|
: "";
|
|
2930
3108
|
const commitButton = !state.readOnly && state.git.available && !state.git.clean
|
|
2931
3109
|
? '<button class="button primary" type="button" id="commit-workspace" ' + (!state.git.branch
|
|
@@ -2938,7 +3116,7 @@ function renderRepository(main) {
|
|
|
2938
3116
|
const repositoryInstructions = !state.git.branch
|
|
2939
3117
|
? "This workspace is on a detached HEAD. Check out a branch before using browser commit, pull, or push."
|
|
2940
3118
|
: hasRemote
|
|
2941
|
-
? "
|
|
3119
|
+
? "Fetch remote refs without changing this branch. If commits are incoming, integrate them with Git, then reload FileGRC."
|
|
2942
3120
|
: "Review the workspace diff, then commit it locally. Add a Git remote when you want browser pull and push.";
|
|
2943
3121
|
const validationBody = state.validation.diagnostics.length
|
|
2944
3122
|
? '<div class="diagnostics">' + state.validation.diagnostics.map((item) => '<div><span class="badge ' + item.severity + '">' + esc(properCase(item.severity)) + '</span><code>' + esc(item.path) + '</code><p>' + esc(item.message) + '</p></div>').join("") + '</div>'
|
|
@@ -2996,14 +3174,14 @@ async function runRepositoryGitAction(action) {
|
|
|
2996
3174
|
const currentStatus = document.querySelector(".repository-sync-status");
|
|
2997
3175
|
if (currentStatus) currentStatus.textContent = action === "pull"
|
|
2998
3176
|
? result.updated
|
|
2999
|
-
? "
|
|
3177
|
+
? "No incoming commits from " + result.upstream + ". This branch remains at " + result.shortCommit + "."
|
|
3000
3178
|
: result.branch + " is current with " + result.upstream + "."
|
|
3001
3179
|
: action === "retry-sync"
|
|
3002
3180
|
? "Synchronized " + result.shortCommit + " with " + result.upstream + "."
|
|
3003
3181
|
: "Pushed " + result.shortCommit + " to " + result.upstream + ".";
|
|
3004
3182
|
} catch (cause) {
|
|
3005
3183
|
buttons.forEach((button, index) => { button.disabled = disabled[index]; });
|
|
3006
|
-
if (active) active.textContent = action === "pull" ? "
|
|
3184
|
+
if (active) active.textContent = action === "pull" ? "Check incoming commits" : action === "retry-sync" ? "Retry sync" : "Push";
|
|
3007
3185
|
if (status) {
|
|
3008
3186
|
status.textContent = cause.message;
|
|
3009
3187
|
status.classList.add("error");
|
|
@@ -3060,6 +3238,169 @@ function openCommitDialog() {
|
|
|
3060
3238
|
dialog.querySelector('input[name="message"]').focus();
|
|
3061
3239
|
}
|
|
3062
3240
|
|
|
3241
|
+
async function mutateReportingRouteSet(action, payload, dialog) {
|
|
3242
|
+
try {
|
|
3243
|
+
if (dialog) setMutationBusy(dialog, true, action === "approve" ? "Approving…" : action === "cancel" ? "Canceling…" : "Proposing…");
|
|
3244
|
+
const response = await localFetch("/api/reporting-route-sets/" + action, {
|
|
3245
|
+
method: "POST",
|
|
3246
|
+
headers: { "content-type": "application/json" },
|
|
3247
|
+
body: JSON.stringify(payload)
|
|
3248
|
+
});
|
|
3249
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
3250
|
+
applyMutationState(await response.json());
|
|
3251
|
+
dialog?.close();
|
|
3252
|
+
render();
|
|
3253
|
+
} catch (error) {
|
|
3254
|
+
if (dialog) {
|
|
3255
|
+
setMutationBusy(dialog, false, "");
|
|
3256
|
+
dialog.querySelector(".dialog-error").textContent = error.message;
|
|
3257
|
+
} else showError(error.message);
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
function reportingRouteAppointmentOptions(entry) {
|
|
3262
|
+
return resourcesOfType("appointment").filter(({ record }) => (
|
|
3263
|
+
["active", "ended"].includes(record.status)
|
|
3264
|
+
&& record.holderId
|
|
3265
|
+
&& record.appointmentKind === entry.record.approvalAppointmentKind
|
|
3266
|
+
&& record.scopeResourceIds?.some((id) => [entry.record.id, entry.record.programId, state.workspace.id].includes(id))
|
|
3267
|
+
));
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
function reportingRouteEvidenceField(subjectId, name, label) {
|
|
3271
|
+
const evidence = resourcesOfType("evidence").filter(({ record }) => (
|
|
3272
|
+
record.status === "verified"
|
|
3273
|
+
&& record.sourceResourceIds?.includes(subjectId)
|
|
3274
|
+
&& record.sourceDescription?.trim()
|
|
3275
|
+
&& record.collectedOn
|
|
3276
|
+
&& record.verifiedOn
|
|
3277
|
+
&& record.collectorIds?.length
|
|
3278
|
+
&& record.verifierIds?.length
|
|
3279
|
+
&& (
|
|
3280
|
+
(record.sourceKind === "file" && record.filePaths?.length)
|
|
3281
|
+
|| (record.sourceKind === "rendered-page" && record.artifactKind === "rendered-page" && record.capture && record.sourceCommit)
|
|
3282
|
+
)
|
|
3283
|
+
));
|
|
3284
|
+
if (!evidence.length) {
|
|
3285
|
+
return '<div class="field-group full"><span>' + esc(label) + ' <span class="required-mark">Required</span></span><p class="field-help">Add verified, fixed Evidence linked to this channel set before recording the event.</p><a class="button" href="#/resources/evidence">Open Evidence</a></div>';
|
|
3286
|
+
}
|
|
3287
|
+
return '<fieldset class="field-group full"><legend>' + esc(label) + ' <span class="required-mark">Required</span></legend>' + evidence.map(({ record }) => '<label class="confirmation"><input type="checkbox" name="' + esc(name) + '" value="' + esc(record.id) + '"><span>' + esc(record.title) + '</span></label>').join("") + '<p class="field-help">Only likely candidates are shown. FileGRC checks retained material and event-date coverage when you submit.</p></fieldset>';
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
function checkedFormValues(form, name) {
|
|
3291
|
+
return [...form.querySelectorAll('input[name="' + name + '"]:checked')].map((input) => input.value);
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
function openReportingRouteApproval(entry) {
|
|
3295
|
+
const appointments = reportingRouteAppointmentOptions(entry);
|
|
3296
|
+
const predecessor = entry.record.predecessorId
|
|
3297
|
+
? state.resources.find(({ record }) => record.id === entry.record.predecessorId && record.type === "reporting-route-set" && record.status === "approved")
|
|
3298
|
+
: null;
|
|
3299
|
+
const predecessorAppointments = predecessor ? reportingRouteAppointmentOptions(predecessor) : [];
|
|
3300
|
+
const nowLocal = localDateTimeInput(new Date(), state.workspace.timezone);
|
|
3301
|
+
const tomorrow = new Date(Date.now() + 86_400_000);
|
|
3302
|
+
const defaultEffective = predecessor
|
|
3303
|
+
? nowLocal
|
|
3304
|
+
: localDateTimeInput(tomorrow, state.workspace.timezone, true);
|
|
3305
|
+
const approvalActionLabel = state.repository?.mode === "trunk" && !state.repository?.developmentOverride
|
|
3306
|
+
? "Approve and commit"
|
|
3307
|
+
: "Approve (commit required)";
|
|
3308
|
+
const dialog = document.createElement("dialog");
|
|
3309
|
+
dialog.className = "editor";
|
|
3310
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Reporting channel approval</p><h2>' + esc(approvalActionLabel) + '</h2></div><button type="button" class="icon-button" data-dismiss aria-label="Close">×</button></div><p>Approve the exact normal and fallback channels proposed at <code>' + esc(state.git.shortCommit) + '</code>. FileGRC records the actual approval and effective times; Git records when this approval is committed.</p><div class="form-grid"><label class="field-group full"><span>Approval authority <span class="required-mark">Required</span></span><select name="approvalAppointmentId" required><option value="">Select an Appointment</option>' + appointments.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + '</option>').join("") + '</select></label>' + reportingRouteEvidenceField(entry.record.id, "evidenceIds", "Approval evidence") + '<label class="field-group"><span>Approved at <span class="required-mark">Required</span></span><input name="approvedAt" type="datetime-local" step="1" required value="' + esc(nowLocal) + '"></label><label class="field-group"><span>Effective at <span class="required-mark">Required</span></span><input name="effectiveAt" type="datetime-local" step="1" required value="' + esc(defaultEffective) + '"></label>' + (predecessor ? '<div class="field-group full"><span>Predecessor cutover</span><p class="field-help">Approving this successor also cancels ' + esc(predecessor.record.title) + ' at the effective time.</p></div><label class="field-group full"><span>Predecessor cancellation authority <span class="required-mark">Required</span></span><select name="predecessorCancellationAppointmentId" required><option value="">Select an Appointment</option>' + predecessorAppointments.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + '</option>').join("") + '</select></label>' + reportingRouteEvidenceField(predecessor.record.id, "predecessorCancellationEvidenceIds", "Predecessor cancellation evidence") : "") + '<label class="field-group full confirmation"><input type="checkbox" name="confirm" required><span>I reviewed this proposal and confirm these are the actual approval and effective times in ' + esc(state.workspace.timezone) + '.</span></label></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status" role="status"></span><button type="button" class="button" data-dismiss>Cancel</button><button type="submit" class="button primary">' + esc(approvalActionLabel) + '</button></div></form>';
|
|
3311
|
+
document.body.append(dialog);
|
|
3312
|
+
dialog.showModal();
|
|
3313
|
+
dialog.querySelectorAll("[data-dismiss]").forEach((button) => button.addEventListener("click", () => dialog.close()));
|
|
3314
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
3315
|
+
dialog.querySelector("form").addEventListener("submit", (event) => {
|
|
3316
|
+
event.preventDefault();
|
|
3317
|
+
const form = event.currentTarget;
|
|
3318
|
+
if (!form.reportValidity()) return;
|
|
3319
|
+
const evidenceIds = checkedFormValues(form, "evidenceIds");
|
|
3320
|
+
const predecessorEvidenceIds = checkedFormValues(form, "predecessorCancellationEvidenceIds");
|
|
3321
|
+
if (!evidenceIds.length || (predecessor && !predecessorEvidenceIds.length)) {
|
|
3322
|
+
form.querySelector(".dialog-error").textContent = "Select fixed, verified Evidence for each approval or cancellation event.";
|
|
3323
|
+
return;
|
|
3324
|
+
}
|
|
3325
|
+
mutateReportingRouteSet("approve", {
|
|
3326
|
+
routeSetId: entry.record.id,
|
|
3327
|
+
proposalCommit: state.git.commit,
|
|
3328
|
+
approvalAppointmentId: form.elements.approvalAppointmentId.value,
|
|
3329
|
+
evidenceIds,
|
|
3330
|
+
approvedAt: zonedTimestampFromLocal(form.elements.approvedAt.value, state.workspace.timezone),
|
|
3331
|
+
effectiveAt: zonedTimestampFromLocal(form.elements.effectiveAt.value, state.workspace.timezone),
|
|
3332
|
+
timezone: state.workspace.timezone,
|
|
3333
|
+
expectedRevision: entry.revision,
|
|
3334
|
+
...(predecessor ? {
|
|
3335
|
+
predecessorCancellationAppointmentId: form.elements.predecessorCancellationAppointmentId.value,
|
|
3336
|
+
predecessorCancellationEvidenceIds: predecessorEvidenceIds,
|
|
3337
|
+
predecessorExpectedRevision: predecessor.revision
|
|
3338
|
+
} : {})
|
|
3339
|
+
}, dialog);
|
|
3340
|
+
});
|
|
3341
|
+
}
|
|
3342
|
+
|
|
3343
|
+
function openReportingRouteCancellation(entry) {
|
|
3344
|
+
const appointments = reportingRouteAppointmentOptions(entry);
|
|
3345
|
+
const dialog = document.createElement("dialog");
|
|
3346
|
+
dialog.className = "editor";
|
|
3347
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Reporting channel cancellation</p><h2>Cancel this revision</h2></div><button type="button" class="icon-button" data-dismiss aria-label="Close">×</button></div><p>Use cancellation for a stopped revision. Create a successor when either channel changes.</p><div class="form-grid"><label class="field-group full"><span>Cancellation authority <span class="required-mark">Required</span></span><select name="approvalAppointmentId" required><option value="">Select an Appointment</option>' + appointments.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + '</option>').join("") + '</select></label>' + reportingRouteEvidenceField(entry.record.id, "evidenceIds", "Cancellation evidence") + '<label class="field-group"><span>Canceled at <span class="required-mark">Required</span></span><input name="canceledAt" type="datetime-local" step="1" required value="' + esc(localDateTimeInput(new Date(), state.workspace.timezone)) + '"></label><label class="field-group full"><span>Reason <span class="required-mark">Required</span></span><textarea name="reason" required></textarea></label></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status" role="status"></span><button type="button" class="button" data-dismiss>Cancel</button><button type="submit" class="button danger">Cancel channel set</button></div></form>';
|
|
3348
|
+
document.body.append(dialog);
|
|
3349
|
+
dialog.showModal();
|
|
3350
|
+
dialog.querySelectorAll("[data-dismiss]").forEach((button) => button.addEventListener("click", () => dialog.close()));
|
|
3351
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
3352
|
+
dialog.querySelector("form").addEventListener("submit", (event) => {
|
|
3353
|
+
event.preventDefault();
|
|
3354
|
+
const form = event.currentTarget;
|
|
3355
|
+
if (!form.reportValidity()) return;
|
|
3356
|
+
const evidenceIds = checkedFormValues(form, "evidenceIds");
|
|
3357
|
+
if (!evidenceIds.length) {
|
|
3358
|
+
form.querySelector(".dialog-error").textContent = "Select fixed, verified Evidence for the cancellation event.";
|
|
3359
|
+
return;
|
|
3360
|
+
}
|
|
3361
|
+
mutateReportingRouteSet("cancel", {
|
|
3362
|
+
routeSetId: entry.record.id,
|
|
3363
|
+
approvalAppointmentId: form.elements.approvalAppointmentId.value,
|
|
3364
|
+
evidenceIds,
|
|
3365
|
+
canceledAt: zonedTimestampFromLocal(form.elements.canceledAt.value, state.workspace.timezone),
|
|
3366
|
+
timezone: state.workspace.timezone,
|
|
3367
|
+
reason: form.elements.reason.value,
|
|
3368
|
+
expectedRevision: entry.revision
|
|
3369
|
+
}, dialog);
|
|
3370
|
+
});
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
function localDateTimeInput(date, timezone, midnight = false) {
|
|
3374
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", {
|
|
3375
|
+
timeZone: timezone,
|
|
3376
|
+
year: "numeric",
|
|
3377
|
+
month: "2-digit",
|
|
3378
|
+
day: "2-digit",
|
|
3379
|
+
hour: "2-digit",
|
|
3380
|
+
minute: "2-digit",
|
|
3381
|
+
second: "2-digit",
|
|
3382
|
+
hourCycle: "h23"
|
|
3383
|
+
}).formatToParts(date).map(({ type, value }) => [type, value]));
|
|
3384
|
+
return parts.year + "-" + parts.month + "-" + parts.day + "T" + (midnight ? "00:00:00" : parts.hour + ":" + parts.minute + ":" + parts.second);
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
function zonedTimestampFromLocal(value, timezone) {
|
|
3388
|
+
const desired = new Date(value + "Z");
|
|
3389
|
+
if (Number.isNaN(desired.getTime())) throw new Error("A valid local date and time is required.");
|
|
3390
|
+
let instant = desired;
|
|
3391
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
3392
|
+
const represented = new Date(localDateTimeInput(instant, timezone) + "Z");
|
|
3393
|
+
instant = new Date(instant.getTime() + desired.getTime() - represented.getTime());
|
|
3394
|
+
}
|
|
3395
|
+
if (localDateTimeInput(instant, timezone) !== value) throw new Error("The selected local time does not exist in " + timezone + ".");
|
|
3396
|
+
const zoneLocalAsUtc = new Date(localDateTimeInput(instant, timezone) + "Z");
|
|
3397
|
+
const offsetMinutes = Math.round((zoneLocalAsUtc.getTime() - instant.getTime()) / 60_000);
|
|
3398
|
+
const sign = offsetMinutes < 0 ? "-" : "+";
|
|
3399
|
+
const absolute = Math.abs(offsetMinutes);
|
|
3400
|
+
const offset = sign + String(Math.floor(absolute / 60)).padStart(2, "0") + ":" + String(absolute % 60).padStart(2, "0");
|
|
3401
|
+
return value + offset;
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3063
3404
|
function resourceGuide(type) {
|
|
3064
3405
|
const definition = state.model.resources[type];
|
|
3065
3406
|
const guidance = definition?.guidance;
|
|
@@ -3593,7 +3934,7 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
3593
3934
|
const markdownDefinitions = dedicatedMarkdownDefinitions(type);
|
|
3594
3935
|
if (!entry && options.seed) {
|
|
3595
3936
|
Object.assign(record, options.seed);
|
|
3596
|
-
record.id
|
|
3937
|
+
record.id ||= createResourceId(type, record.title, state.resources.map(({ record: existing }) => existing.id));
|
|
3597
3938
|
}
|
|
3598
3939
|
const required = new Set([
|
|
3599
3940
|
...Object.entries(state.model.commonFields).filter(([, field]) => field.required).map(([name]) => name),
|
|
@@ -3614,7 +3955,11 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
3614
3955
|
...Object.entries(fields).filter(([, field]) => field.relation || field.relationGroup).map(([name]) => name),
|
|
3615
3956
|
...Object.entries(fields).filter(([, field]) => field.requiredWhen).map(([name]) => name),
|
|
3616
3957
|
...oneOf
|
|
3617
|
-
])].filter((name) =>
|
|
3958
|
+
])].filter((name) => (
|
|
3959
|
+
!["id", "type"].includes(name)
|
|
3960
|
+
&& fields[name]
|
|
3961
|
+
&& !(options.occurrenceReview && ["members", "expectedCount", "completedCount", "conclusion"].includes(name))
|
|
3962
|
+
));
|
|
3618
3963
|
const dialog = document.createElement("dialog");
|
|
3619
3964
|
dialog.className = "editor";
|
|
3620
3965
|
dialog.setAttribute("aria-labelledby", "resource-editor-title");
|
|
@@ -3626,8 +3971,8 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
3626
3971
|
const editorDescription = options.description
|
|
3627
3972
|
|| implementationEditorDescription(type)
|
|
3628
3973
|
|| conciseResourceDescription(definition)
|
|
3629
|
-
|| "
|
|
3630
|
-
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, true) + '<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>' +
|
|
3974
|
+
|| "Fill the core fields below. Git will record the author, time, reason, and diff when you commit this file.";
|
|
3975
|
+
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, true) + (options.occurrenceReview ? occurrenceMemberReview(record, options.membershipFinal) : "") + '<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), record.id)).join("") + '</div>' +
|
|
3631
3976
|
activeMarkdown.map((markdown) => {
|
|
3632
3977
|
const generated = !entry?.content?.[markdown.name];
|
|
3633
3978
|
const source = entry?.content?.[markdown.name]?.source
|
|
@@ -3680,6 +4025,7 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
3680
4025
|
const updated = advanced
|
|
3681
4026
|
? JSON.parse(dialog.querySelector(".advanced-editor textarea").value)
|
|
3682
4027
|
: readGuidedRecord(dialog, record, fields);
|
|
4028
|
+
if (!advanced && options.occurrenceReview) applyOccurrenceMemberReview(dialog, updated, record);
|
|
3683
4029
|
if (type === "policy" && updated.status === "active" && entry?.record.status !== "active") {
|
|
3684
4030
|
throw new Error("Approve the Policy here, then activate it from the Step 3 Controls-page cutover.");
|
|
3685
4031
|
}
|
|
@@ -3700,7 +4046,11 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
3700
4046
|
content[path] = recordContentSource.value;
|
|
3701
4047
|
}
|
|
3702
4048
|
}
|
|
3703
|
-
const url =
|
|
4049
|
+
const url = options.occurrenceReconciliation
|
|
4050
|
+
? "/api/obligation-occurrences"
|
|
4051
|
+
: options.auditPopulationCorrection
|
|
4052
|
+
? "/api/audit-population-corrections"
|
|
4053
|
+
: entry
|
|
3704
4054
|
? "/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(entry.record.id)
|
|
3705
4055
|
: options.actionCompletion
|
|
3706
4056
|
? "/api/action-completions"
|
|
@@ -3711,12 +4061,12 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
3711
4061
|
].filter(([path, revision]) => path && revision));
|
|
3712
4062
|
setMutationBusy(dialog, true, "Saving…", options.saveLabel || "Save file");
|
|
3713
4063
|
const response = await localFetch(url, {
|
|
3714
|
-
method: entry ? "PUT" : "POST",
|
|
4064
|
+
method: options.occurrenceReconciliation ? "POST" : entry ? "PUT" : "POST",
|
|
3715
4065
|
headers: { "content-type": "application/json" },
|
|
3716
4066
|
body: JSON.stringify({
|
|
3717
4067
|
record: updated,
|
|
3718
4068
|
content,
|
|
3719
|
-
revision: entry?.revision || options.actionCompletion?.revision || options.obligationCompletion?.revision,
|
|
4069
|
+
revision: entry?.revision || options.actionCompletion?.revision || options.obligationCompletion?.revision || options.occurrenceReconciliation?.revision || options.auditPopulationCorrection?.revision,
|
|
3720
4070
|
contentRevisions,
|
|
3721
4071
|
obligationId: options.obligationCompletion?.obligationId,
|
|
3722
4072
|
actionItemId: options.actionCompletion?.actionItemId,
|
|
@@ -3735,6 +4085,71 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
3735
4085
|
});
|
|
3736
4086
|
}
|
|
3737
4087
|
|
|
4088
|
+
function occurrenceMemberReview(record, membershipFinal = true) {
|
|
4089
|
+
const members = record.members || [];
|
|
4090
|
+
const pending = membershipFinal
|
|
4091
|
+
? ""
|
|
4092
|
+
: '<p class="occurrence-pending">Population remains open through ' + esc(record.membershipCutoffAt) + '. Review now, then reconcile after the cutoff.</p>';
|
|
4093
|
+
if (!members.length) {
|
|
4094
|
+
return '<section class="occurrence-review"><div class="occurrence-review-head"><strong>Population</strong><span>0 members</span></div>' + pending + '<p class="occurrence-empty">Confirm the zero population, then set the record to reconciled.</p></section>';
|
|
4095
|
+
}
|
|
4096
|
+
const rows = members.map((member) => {
|
|
4097
|
+
const source = state.resources.find(({ record: candidate }) => candidate.id === member.resourceId)?.record;
|
|
4098
|
+
const title = source?.title || member.resourceId;
|
|
4099
|
+
const result = member.disposition === "not-applicable"
|
|
4100
|
+
? "not-applicable"
|
|
4101
|
+
: member.disposition === "exception" ? "exception" : member.result || "pending";
|
|
4102
|
+
return '<div class="occurrence-member" data-occurrence-member="' + esc(member.resourceId) + '"><div class="occurrence-member-name"><strong>' + esc(title) + '</strong><small>' + esc(member.resourceId) + '</small></div>'
|
|
4103
|
+
+ '<label><span>Result</span><select data-member-result><option value="pending" ' + (result === "pending" ? "selected" : "") + '>Pending</option><option value="passed" ' + (result === "passed" ? "selected" : "") + '>Passed</option><option value="failed" ' + (result === "failed" ? "selected" : "") + '>Failed</option><option value="exception" ' + (result === "exception" ? "selected" : "") + '>Approved exception</option><option value="not-applicable" ' + (result === "not-applicable" ? "selected" : "") + '>Not applicable</option></select></label>'
|
|
4104
|
+
+ '<label class="occurrence-proof"><span>Completion IDs</span><input data-member-completions value="' + esc((member.completionResourceIds || []).join(", ")) + '" placeholder="attestation-id"></label>'
|
|
4105
|
+
+ '<label class="occurrence-proof"><span>Exception ID</span><input data-member-exception value="' + esc(member.exceptionId || "") + '" placeholder="exception-id"></label>'
|
|
4106
|
+
+ '<label class="occurrence-rationale"><span>Rationale</span><input data-member-rationale value="' + esc(member.rationale || "") + '" placeholder="Required when not applicable"></label></div>';
|
|
4107
|
+
}).join("");
|
|
4108
|
+
return '<section class="occurrence-review"><div class="occurrence-review-head"><strong>Population</strong><span>' + members.length + ' members</span></div>' + pending + '<div class="occurrence-members">' + rows + '</div></section>';
|
|
4109
|
+
}
|
|
4110
|
+
|
|
4111
|
+
function applyOccurrenceMemberReview(dialog, updated, original) {
|
|
4112
|
+
updated.members = [...dialog.querySelectorAll("[data-occurrence-member]")].map((row) => {
|
|
4113
|
+
const resourceId = row.dataset.occurrenceMember;
|
|
4114
|
+
const originalMember = (original.members || []).find((member) => member.resourceId === resourceId) || { resourceId };
|
|
4115
|
+
const selectedResult = row.querySelector("[data-member-result]").value;
|
|
4116
|
+
const completionResourceIds = row.querySelector("[data-member-completions]").value.split(",").map((value) => value.trim()).filter(Boolean);
|
|
4117
|
+
const exceptionId = row.querySelector("[data-member-exception]").value.trim();
|
|
4118
|
+
const rationale = row.querySelector("[data-member-rationale]").value.trim();
|
|
4119
|
+
if (selectedResult === "not-applicable" && !rationale) {
|
|
4120
|
+
throw new Error("A rationale is required when " + resourceId + " is not applicable.");
|
|
4121
|
+
}
|
|
4122
|
+
if (selectedResult === "exception" && !exceptionId) {
|
|
4123
|
+
throw new Error("An approved Exception ID is required for " + resourceId + ".");
|
|
4124
|
+
}
|
|
4125
|
+
const disposition = ["not-applicable", "exception"].includes(selectedResult) ? selectedResult : "expected";
|
|
4126
|
+
return {
|
|
4127
|
+
...originalMember,
|
|
4128
|
+
disposition,
|
|
4129
|
+
result: ["not-applicable", "exception"].includes(selectedResult) ? "pending" : selectedResult,
|
|
4130
|
+
...(completionResourceIds.length ? { completionResourceIds } : { completionResourceIds: [] }),
|
|
4131
|
+
...(exceptionId && disposition === "exception" ? { exceptionId } : { exceptionId: undefined }),
|
|
4132
|
+
...(rationale ? { rationale } : {})
|
|
4133
|
+
};
|
|
4134
|
+
});
|
|
4135
|
+
const expected = updated.members.filter(({ disposition }) => disposition === "expected");
|
|
4136
|
+
updated.expectedCount = expected.length;
|
|
4137
|
+
updated.completedCount = expected.filter(({ result }) => result === "passed").length;
|
|
4138
|
+
if (updated.status === "open") {
|
|
4139
|
+
delete updated.conclusion;
|
|
4140
|
+
delete updated.reconciledAt;
|
|
4141
|
+
delete updated.reviewedByIds;
|
|
4142
|
+
return;
|
|
4143
|
+
}
|
|
4144
|
+
updated.conclusion = updated.members.length === 0
|
|
4145
|
+
? "zero-population"
|
|
4146
|
+
: updated.completedCount !== updated.expectedCount
|
|
4147
|
+
? "incomplete"
|
|
4148
|
+
: updated.members.some(({ disposition }) => disposition !== "expected")
|
|
4149
|
+
? "complete-with-exceptions"
|
|
4150
|
+
: "complete";
|
|
4151
|
+
}
|
|
4152
|
+
|
|
3738
4153
|
function implementationEditorDescription(type) {
|
|
3739
4154
|
if (type === "person") {
|
|
3740
4155
|
return "Record someone who owns, reviews, approves, or performs program work.";
|
|
@@ -3874,7 +4289,7 @@ function recordContentPlaceholder(type) {
|
|
|
3874
4289
|
return "Document the work, results, decisions, and follow-up.";
|
|
3875
4290
|
}
|
|
3876
4291
|
|
|
3877
|
-
function editorField(type, name, field, value, required, editing, oneOfRequired = false, oneOfActive = oneOfRequired) {
|
|
4292
|
+
function editorField(type, name, field, value, required, editing, oneOfRequired = false, oneOfActive = oneOfRequired, currentRecordId = null) {
|
|
3878
4293
|
const label = fieldLabel(type, name);
|
|
3879
4294
|
const requiredMark = required || field.requiredWhen || oneOfRequired
|
|
3880
4295
|
? '<span class="required-mark" ' + (required || oneOfActive ? "" : "hidden") + '>' + (oneOfRequired ? "One Required" : "Required") + '</span>'
|
|
@@ -3898,7 +4313,9 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
|
|
|
3898
4313
|
return fieldWrap(name, "relation-array", label, requiredMark, control, help, required);
|
|
3899
4314
|
}
|
|
3900
4315
|
if (relation) {
|
|
3901
|
-
const candidates = relationCandidates(relationField, type, name)
|
|
4316
|
+
const candidates = relationCandidates(relationField, type, name).filter(({ record }) => (
|
|
4317
|
+
name !== "predecessorId" || record.id !== currentRecordId
|
|
4318
|
+
));
|
|
3902
4319
|
control = candidates.length
|
|
3903
4320
|
? '<select><option value="">Select ' + esc(relationTypeLabel(relationField).toLowerCase()) + '</option>' + candidates.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (value === record.id ? "selected" : "") + '>' + esc(record.title) + ' · ' + esc(state.model.resources[record.type].title) + '</option>').join("") + '</select>'
|
|
3904
4321
|
: required ? '<select><option value="">No matching ' + esc(relationTypeLabel(relationField, true).toLowerCase()) + ' yet</option></select>' : '<div class="missing-options">No matching ' + esc(relationTypeLabel(relationField, true).toLowerCase()) + ' yet.</div>';
|
|
@@ -3914,9 +4331,11 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
|
|
|
3914
4331
|
const values = field.values || (field.type === "rating" ? state.model.primitives.rating : state.model.primitives.outcome) || [];
|
|
3915
4332
|
const activationManagedType = ["policy", "document"].includes(type)
|
|
3916
4333
|
|| (type === "training" && Boolean(state.model.resources.training?.fields?.activatedContentRevisions));
|
|
3917
|
-
const availableValues =
|
|
3918
|
-
? values.filter((item) => item
|
|
3919
|
-
:
|
|
4334
|
+
const availableValues = type === "reporting-route-set" && name === "status" && editing
|
|
4335
|
+
? values.filter((item) => item === value)
|
|
4336
|
+
: activationManagedType && name === "status" && value !== "active"
|
|
4337
|
+
? values.filter((item) => item !== "active")
|
|
4338
|
+
: values;
|
|
3920
4339
|
control = '<select><option value="">Select</option>' + availableValues.map((item) => '<option value="' + esc(item) + '" ' + (value === item ? "selected" : "") + '>' + esc(properCase(item)) + '</option>').join("") + '</select>';
|
|
3921
4340
|
const enumHelp = activationManagedType && name === "status"
|
|
3922
4341
|
? type === "document"
|
|
@@ -4030,7 +4449,7 @@ function objectPropertyFields(schema, value = {}) {
|
|
|
4030
4449
|
? '<textarea>' + esc(value[name] || "") + '</textarea>'
|
|
4031
4450
|
: '<input type="' + (property.type === "date" ? "date" : property.type === "timestamp" ? "datetime-local" : ["integer", "number"].includes(property.type) ? "number" : "text") + '" value="' + esc(property.type === "timestamp" && value[name] ? String(value[name]).slice(0, 16) : value[name] ?? "") + '"' + (property.minimum !== undefined ? ' min="' + esc(property.minimum) + '"' : "") + '>';
|
|
4032
4451
|
}
|
|
4033
|
-
return '<div class="object-property' + (stringMap ? " string-map-property" : "") + '" data-object-field="' + esc(name) + '" data-object-kind="' + esc(property.type) + '" data-object-required="' + (required.has(name) || property.requiredWhen ? "true" : "false") + '"' + (property.requiredWhen ? ' data-object-required-when="' + esc(JSON.stringify(property.requiredWhen)) + '"' : "") + (property.allowedWhen ? ' data-object-allowed-when="' + esc(JSON.stringify(property.allowedWhen)) + '"' : "") + '><span class="object-property-label">' + esc(humanize(name)) + mark + '</span>' + input + '</div>';
|
|
4452
|
+
return '<div class="object-property' + (stringMap ? " string-map-property" : "") + '" data-object-field="' + esc(name) + '" data-object-kind="' + esc(property.type) + '" data-object-required="' + (required.has(name) || property.requiredWhen ? "true" : "false") + '"' + (property.requiredWhen ? ' data-object-required-when="' + esc(JSON.stringify(property.requiredWhen)) + '"' : "") + (property.allowedWhen ? ' data-object-allowed-when="' + esc(JSON.stringify(property.allowedWhen)) + '"' : "") + '><span class="object-property-label">' + esc(property.label || humanize(name)) + mark + '</span>' + input + '</div>';
|
|
4034
4453
|
}).join("");
|
|
4035
4454
|
}
|
|
4036
4455
|
|
|
@@ -4430,6 +4849,17 @@ function openContentEditor(entry, name) {
|
|
|
4430
4849
|
}
|
|
4431
4850
|
|
|
4432
4851
|
function bindCommon() {
|
|
4852
|
+
root.querySelector("[data-program-select]")?.addEventListener("change", async (event) => {
|
|
4853
|
+
const programId = event.currentTarget.value;
|
|
4854
|
+
const selectionGeneration = ++programSelectionGeneration;
|
|
4855
|
+
const response = await fetch("/api/state/bootstrap?programId=" + encodeURIComponent(programId));
|
|
4856
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
4857
|
+
if (selectionGeneration !== programSelectionGeneration) return;
|
|
4858
|
+
state = normalizeAppState(await response.json());
|
|
4859
|
+
stateSectionRequests.clear();
|
|
4860
|
+
render();
|
|
4861
|
+
loadStateForRoute();
|
|
4862
|
+
});
|
|
4433
4863
|
root.querySelectorAll(".nav-toggle, .nav-subgroup-toggle").forEach((button) => button.addEventListener("click", () => {
|
|
4434
4864
|
const group = button.closest(".nav-group");
|
|
4435
4865
|
const open = group.classList.toggle("open");
|
|
@@ -4766,7 +5196,7 @@ function formatObjectArray(items, objectType, compact = false) {
|
|
|
4766
5196
|
notes.push(String(value));
|
|
4767
5197
|
return [];
|
|
4768
5198
|
}
|
|
4769
|
-
const label = humanize(name.replace(/Ids?$/, ""));
|
|
5199
|
+
const label = property.label || humanize(name.replace(/Ids?$/, ""));
|
|
4770
5200
|
let display;
|
|
4771
5201
|
if (property.relation && property.type === "id") display = formatReference(value);
|
|
4772
5202
|
else if (property.relation && property.type === "array") display = value.map((id) => formatReference(id)).join(" ");
|
|
@@ -4930,7 +5360,8 @@ async function refreshMutationState() {
|
|
|
4930
5360
|
mutationStateRefreshInFlight = true;
|
|
4931
5361
|
let retry = false;
|
|
4932
5362
|
try {
|
|
4933
|
-
const
|
|
5363
|
+
const programQuery = state.selectedProgramId ? "?programId=" + encodeURIComponent(state.selectedProgramId) : "";
|
|
5364
|
+
const response = await fetch("/api/state/bootstrap" + programQuery);
|
|
4934
5365
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
4935
5366
|
state = normalizeAppState(await response.json());
|
|
4936
5367
|
stateSectionRequests.clear();
|
|
@@ -5066,19 +5497,24 @@ async function responseMessage(response) {
|
|
|
5066
5497
|
try { return JSON.parse(source).error || source; } catch { return source; }
|
|
5067
5498
|
}
|
|
5068
5499
|
async function localFetch(url, options) {
|
|
5500
|
+
const requestUrl = new URL(url, location.origin);
|
|
5501
|
+
if (state?.selectedProgramId && !requestUrl.searchParams.has("programId")) {
|
|
5502
|
+
requestUrl.searchParams.set("programId", state.selectedProgramId);
|
|
5503
|
+
}
|
|
5504
|
+
const scopedUrl = requestUrl.pathname + requestUrl.search + requestUrl.hash;
|
|
5069
5505
|
const method = String(options?.method || "GET").toUpperCase();
|
|
5070
5506
|
const synchronizing = state?.repository?.mode === "trunk"
|
|
5071
5507
|
&& ["POST", "PUT", "DELETE"].includes(method)
|
|
5072
|
-
&& !["/api/evidence-packet", "/api/git/prefetch"].includes(
|
|
5508
|
+
&& !["/api/evidence-packet", "/api/git/prefetch"].includes(requestUrl.pathname);
|
|
5073
5509
|
const chip = synchronizing ? document.querySelector(".repo-chip") : null;
|
|
5074
5510
|
const previousChip = chip?.innerHTML;
|
|
5075
5511
|
let repositoryRefreshed = false;
|
|
5076
5512
|
if (chip) chip.innerHTML = '<span class="status-dot neutral"></span>Syncing';
|
|
5077
5513
|
try {
|
|
5078
|
-
const response = await fetch(
|
|
5514
|
+
const response = await fetch(scopedUrl, options);
|
|
5079
5515
|
if (synchronizing && !response.ok) {
|
|
5080
5516
|
try {
|
|
5081
|
-
const stateResponse = await fetch("/api/state");
|
|
5517
|
+
const stateResponse = await fetch("/api/state?programId=" + encodeURIComponent(state.selectedProgramId || ""));
|
|
5082
5518
|
if (stateResponse.ok) {
|
|
5083
5519
|
state = await stateResponse.json();
|
|
5084
5520
|
if (chip?.isConnected) {
|
|
@@ -5104,7 +5540,7 @@ function esc(value) { return String(value ?? "").replace(/[&<>"']/g, (character)
|
|
|
5104
5540
|
export const APP_STYLES = String.raw`
|
|
5105
5541
|
:root{--ink:#151827;--muted:#5d6475;--line:#dfe3ef;--paper:#f6f7fb;--panel:#fff;--accent:#0000a5;--accent-soft:#eef1ff;--accent-light:#8aa1ff;--focus:#0000e0;--amber:#8a5200;--red:#a13a31;--sidebar:linear-gradient(135deg,#000070 0%,#000035 60%);--primary-gradient:linear-gradient(135deg,#000070 0%,#000035 60%);--surface-soft:#f2f4fa;--surface-muted:#eceff7;--field:#fff;--field-readonly:#eef0f6;--code-bg:#10162b;--code-ink:#e8ebff;--shadow:0 8px 28px rgba(0,0,53,.08);color-scheme:light dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
|
|
5106
5542
|
*{box-sizing:border-box}body{margin:0;min-width:320px;background:var(--paper)}button,input,select,textarea{font:inherit}a{color:inherit}.skip-link{position:fixed;left:1rem;top:-4rem;z-index:100;padding:.7rem 1rem;background:#fff}.skip-link:focus{top:1rem}.loading,.fatal{padding:3rem}.shell{display:grid;grid-template-columns:248px 1fr;min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:248px;background:var(--sidebar);color:#eef1ff;padding:25px 18px 18px;overflow:auto;z-index:20}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;margin:0 7px 27px}.brand .mark{display:block;width:39px;height:39px;border-radius:10px}.brand strong,.brand small{display:block}.brand strong{color:#fff;font-size:18px}.brand small{font-size:13.2px;color:#c5cae2;margin-top:2px}.nav-home,.nav-items a{display:flex;justify-content:space-between;align-items:center;text-decoration:none;border-radius:7px;padding:8px 10px;font-size:15.6px;color:#d5d9ed}.nav-home{margin-bottom:9px}.nav-home:hover,.nav-items a:hover,.nav-home.current,.nav-items a.current{background:#202066;color:#fff}.nav-heading{width:100%;border:0;background:none;color:#b4bbdc;text-transform:uppercase;letter-spacing:.11em;font-size:12px;font-weight:750;display:flex;align-items:center;justify-content:space-between;padding:13px 10px 5px;cursor:pointer}.chevron{display:grid;place-items:center;width:14px;height:22px;font-size:0;line-height:1;transform:none}.chevron:before{content:"";width:6px;height:6px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transform-origin:center;transition:transform .15s}.nav-items{display:none}.nav-group.open .nav-items{display:block}.nav-items small{font-size:12px;color:#b8bed7}.side-foot{position:sticky;bottom:-18px;margin:25px -18px -18px;padding:17px 25px;background:#000024;border-top:1px solid #34345f;color:#cbd0e5;font-size:13.2px;display:flex;align-items:center;gap:8px}.status-dot{width:8px;height:8px;border-radius:50%;background:#9aa39f;display:inline-block;flex:0 0 auto}.status-dot.good,.badge.good{background:#6abf8c}.status-dot.warn,.badge.warn{background:#e9a445}.status-dot.bad,.badge.bad{background:#dc6c5d}.status-dot.neutral{background:#9aabff}.workspace{grid-column:2;min-width:0}.topbar{height:86px;background:rgba(255,255,255,.88);backdrop-filter:blur(10px);border-bottom:1px solid var(--line);padding:0 32px;display:flex;align-items:center;gap:23px;position:sticky;top:0;z-index:10}.topbar>div:first-of-type{min-width:190px}.topbar h1{font-size:20.4px;line-height:1.1;margin:3px 0 0}.eyebrow,.kicker{color:var(--accent);text-transform:uppercase;letter-spacing:.12em;font-weight:760;font-size:10.8px;margin:0}.search{height:39px;max-width:240px;flex:1;margin-left:auto;display:flex;align-items:center;gap:9px;background:#f2f4fa;border:1px solid #dfe3ef;border-radius:8px;padding:0 10px;color:#5d6475}.search input{border:0;outline:0;background:none;min-width:0;flex:1;font-size:15.6px}.search kbd{background:#fff;border:1px solid #dfe3ef;border-radius:4px;padding:1px 5px;font-size:12px}.mobile-sidebar-search{display:none}.repo-chip{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:8px;padding:10px 12px;color:var(--muted);font-size:13.2px;white-space:nowrap;text-decoration:none}.mobile-nav{display:none}.page{padding:30px 34px 70px;max-width:1510px;margin:auto}.hero{color:#f8f9ff;background:linear-gradient(120deg,#000070,#000035);border-radius:13px;padding:28px 31px;display:flex;justify-content:space-between;align-items:end;min-height:158px;box-shadow:var(--shadow);position:relative;overflow:hidden}.hero:after{content:"";position:absolute;width:270px;height:270px;border:55px solid rgba(138,161,255,.1);border-radius:50%;right:-80px;top:-145px}.hero .kicker{color:#cbd3ff}.hero h2{font-family:Georgia,serif;font-weight:500;font-size:33.6px;margin:10px 0 8px;letter-spacing:-.02em}.hero p:not(.kicker){margin:0;color:#dde1f4;font-size:15.6px;max-width:650px}.hero-meta{display:flex;gap:15px;position:relative;z-index:1}.hero-meta span{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#e6e8f7;border-left:1px solid #6874ab;padding-left:15px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:14px 0}.metric{background:#fff;border:1px solid var(--line);border-radius:10px;padding:16px 18px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.metric-label{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);display:flex;align-items:center;gap:7px}.metric>strong{display:block;font-family:Georgia,serif;font-size:30px;font-weight:500;margin:8px 0 2px}.metric>small{font-size:12px;color:#697184}.dashboard-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.panel{background:#fff;border:1px solid var(--line);border-radius:11px;padding:21px;min-width:0;box-shadow:0 2px 8px rgba(21,40,33,.025)}.span-2{grid-column:span 2}.panel-head{display:flex;align-items:start;justify-content:space-between;gap:15px;margin-bottom:18px}.panel-head h3{font-size:16.8px;margin:4px 0 0}.panel-head>a{font-size:13.2px;color:var(--accent);font-weight:700}.audit-progress{display:grid;grid-template-columns:105px 1fr;gap:11px 20px;align-items:end}.progress-number strong{font-family:Georgia,serif;font-size:36px;font-weight:500;display:block}.progress-number span{font-size:12px;color:var(--muted)}.progress{height:9px;background:#eceff7;border-radius:9px;overflow:hidden}.progress span{display:block;height:100%;background:linear-gradient(90deg,#0000a5,var(--accent-light));border-radius:9px}.progress-meta{grid-column:2;display:flex;justify-content:space-between;font-size:10.8px;text-transform:uppercase;letter-spacing:.08em;color:#5d6475}.due-list{display:grid}.due-list a{display:grid;grid-template-columns:60px 1fr;text-decoration:none;border-top:1px solid #e8ebf3;padding:10px 0;align-items:center}.due-list a:first-child{border:0;padding-top:0}.due-list time{font-size:12px;color:var(--accent);font-weight:750}.due-list strong,.due-list small{display:block}.due-list strong{font-size:13.2px}.due-list small{font-size:10.8px;color:var(--muted);margin-top:3px}.resource-bars{display:grid;gap:11px}.resource-bars a{display:grid;grid-template-columns:105px 1fr 20px;gap:9px;align-items:center;text-decoration:none;font-size:12px}.resource-bars i{height:5px;background:#e8ebf3;border-radius:5px;overflow:hidden}.resource-bars b{display:block;height:100%;background:#6676dd;border-radius:5px}.resource-bars strong{text-align:right}.catalog{display:grid;grid-template-columns:repeat(4,1fr);gap:7px}.catalog a{display:flex;justify-content:space-between;text-decoration:none;padding:9px 11px;background:#f2f4fa;border-radius:6px;font-size:12px}.catalog a:hover{background:var(--accent-soft)}.page-intro{display:flex;justify-content:space-between;align-items:end;margin-bottom:25px}.page-intro h2,.detail-head h2{font-family:Georgia,serif;font-size:37.2px;font-weight:500;margin:7px 0}.page-intro p:not(.kicker){color:var(--muted);max-width:700px;font-size:15.6px;margin:0}.button{border:1px solid #d0d5e3;background:#fff;border-radius:7px;padding:9px 13px;cursor:pointer;font-size:14.4px;font-weight:650}.button.primary{background:var(--accent);border-color:var(--accent);color:#fff}.button.danger{color:var(--red)}.list-tools{display:flex;align-items:center;gap:10px;margin-bottom:12px}.list-tools label{flex:1}.list-tools input,.list-tools select{width:100%;border:1px solid var(--line);border-radius:7px;background:#fff;padding:10px 12px;font-size:14.4px}.list-tools select{width:auto}.list-tools>span{color:var(--muted);font-size:12px}.record-table-wrap{background:#fff;border:1px solid var(--line);border-radius:10px;overflow:auto}.record-table{width:100%;border-collapse:collapse;font-size:13.2px}.record-table th{background:#f2f4fa;text-align:left;text-transform:uppercase;letter-spacing:.08em;color:#75817b;font-size:10.8px;padding:11px 14px;border-bottom:1px solid var(--line)}.record-table td{padding:13px 14px;border-bottom:1px solid #e8ebf3;vertical-align:top}.record-table tr:last-child td{border-bottom:0}.record-table code{font-size:10.8px;color:#5d6475}.record-title{display:block;color:var(--ink);font-weight:700;text-decoration:none}.record-table td>small{display:block;color:#6a7181;margin-top:3px}.record-table td[data-label="Description"]{min-width:260px;max-width:520px;color:var(--muted);line-height:1.45}.badge,.tag,.type-pill{display:inline-block;border-radius:99px;background:#eceff7;padding:3px 7px;font-size:10.8px;text-transform:uppercase;letter-spacing:.05em;white-space:nowrap}.tag{text-transform:none;margin:1px}.badge.status-active,.badge.status-approved,.badge.status-complete,.badge.status-passed,.badge.status-accepted{background:#ddefe5;color:#176143}.badge.status-open,.badge.status-high,.badge.status-critical,.badge.status-failed{background:#f5ded9;color:#8d352c}.badge.status-draft,.badge.status-planned,.badge.status-in-progress,.badge.status-medium{background:#f7e9cf;color:#855717}.breadcrumbs{display:flex;gap:8px;color:var(--muted);font-size:13.2px;margin-bottom:20px}.detail-head{display:flex;justify-content:space-between;align-items:end;margin-bottom:22px}.detail-head h2{margin-bottom:4px}.detail-head>div>code{font-size:12px;color:var(--muted)}.actions{display:flex;gap:7px}.detail-grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(270px,1fr);gap:14px}.detail-grid aside{display:grid;gap:14px;align-content:start}.detail-main{padding:29px}.content-label{color:#75817b;text-transform:uppercase;letter-spacing:.08em;font-size:10.8px;border-bottom:1px solid var(--line);padding-bottom:13px;margin-bottom:23px}.markdown{max-width:790px}.markdown h1{font-family:Georgia,serif;font-size:34.8px;font-weight:500}.markdown h2{font-family:Georgia,serif;font-size:27.6px;font-weight:500;margin-top:1.8em}.markdown h3{font-size:18px;margin-top:1.7em}.markdown p,.markdown li{font-size:15.6px;line-height:1.65;color:#272c3b}.markdown code{background:#eef0f6;border-radius:3px;padding:1px 4px}.markdown pre{padding:15px;background:#10162b;color:#e8ebff;border-radius:7px;overflow:auto}.markdown blockquote{border-left:3px solid var(--accent-light);padding:4px 15px;color:var(--muted);margin-left:0}.table-wrap{overflow:auto}.markdown table{border-collapse:collapse;width:100%;font-size:13.2px}.markdown th,.markdown td{border:1px solid var(--line);padding:8px;text-align:left}.metadata{margin:0}.metadata>div{display:grid;grid-template-columns:105px 1fr;gap:10px;border-top:1px solid #e8ebf3;padding:10px 0}.metadata>div:first-child{border-top:0;padding-top:0}.metadata dt{font-size:10.8px;text-transform:uppercase;letter-spacing:.06em;color:#5d6475}.metadata dd{margin:0;font-size:13.2px;min-width:0}.compact-json{white-space:pre-wrap;font-size:10.8px}.git-panel>code{font-size:10.8px;word-break:break-all}.git-panel p{font-size:12px;color:var(--muted)}.relation{color:var(--accent);text-decoration:none}.history{display:grid}.history>div{display:grid;grid-template-columns:60px 1fr;gap:8px;padding:8px 0;border-top:1px solid #e8ebf3}.history>div:first-child{border-top:0}.history code{font-size:10.8px;color:var(--accent)}.history strong,.history small{display:block}.history strong{font-size:12px}.history small{font-size:10.8px;color:var(--muted);margin-top:2px}.empty{padding:25px;color:#697184;text-align:center;font-size:13.2px;background:#f4f5fa;border-radius:7px}.changes{padding-left:18px}.changes li{margin:8px 0}.diagnostics>div{display:grid;grid-template-columns:58px minmax(120px,180px) minmax(0,1fr);gap:10px;align-items:start;border-top:1px solid var(--line);padding:10px 0}.diagnostics p{margin:0;font-size:13.2px;overflow-wrap:anywhere}.diagnostics code{font-size:10.8px;overflow-wrap:anywhere}.editor,.search-results{width:min(760px,calc(100vw - 30px));border:0;border-radius:12px;padding:0;box-shadow:0 25px 80px rgba(0,0,24,.28)}dialog::backdrop{background:rgba(0,0,24,.55)}.editor form,.search-results{padding:23px}.dialog-head{display:flex;justify-content:space-between;align-items:start}.dialog-head h2{font-family:Georgia,serif;font-weight:500;margin:5px 0 0}.icon-button{border:0;background:#eceff7;width:32px;height:32px;border-radius:50%;font-size:26.4px;cursor:pointer}.editor form>p{font-size:13.2px;color:var(--muted)}.editor textarea{width:100%;height:440px;border:1px solid var(--line);border-radius:7px;background:#10162b;color:#e8ebff;padding:15px;font:13.2px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;tab-size:2}.dialog-actions{display:flex;justify-content:end;gap:8px;margin-top:14px}.dialog-error{color:var(--red);font-size:13.2px;min-height:18px;margin-top:7px}.result-list{display:grid;margin-top:17px;max-height:60vh;overflow:auto}.result-list a{display:block;text-decoration:none;padding:11px;border-top:1px solid var(--line)}.result-list strong,.result-list small{display:block}.result-list small{color:var(--muted);margin-top:3px}.muted{color:#737a8b}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
|
5107
|
-
.topbar-readiness{display:grid;grid-template-columns:minmax(0,1fr);gap:3px;flex:0 1 220px;min-width:155px;padding:5px 8px;border:1px solid transparent;border-radius:8px;color:var(--ink);text-decoration:none}.topbar-readiness:hover{border-color:var(--accent-light);background:var(--accent-soft)}.topbar-readiness-copy{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:center}.topbar-readiness-copy>span{overflow:hidden;color:var(--muted);font-size:9.6px;font-weight:700;text-overflow:ellipsis;text-transform:uppercase;letter-spacing:.08em;white-space:nowrap}.topbar-readiness-copy>strong{font-size:10.8px;line-height:1}.topbar-readiness>.progress{width:100%;height:5px}.topbar-status{display:flex;flex:1 1 auto;min-width:0;align-items:center;justify-content:flex-end;gap:8px;margin-left:auto}.topbar-status .topbar-search{flex:0 1 240px;width:240px;min-width:140px;margin-left:0}.repo-chip,.validation-chip{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:8px;padding:10px 12px;color:var(--muted);font-size:13.2px;white-space:nowrap;text-decoration:none}.repo-chip:hover,.validation-chip:hover{color:var(--ink);border-color:var(--accent-light)}
|
|
5543
|
+
.topbar-readiness{display:grid;grid-template-columns:minmax(0,1fr);gap:3px;flex:0 1 220px;min-width:155px;padding:5px 8px;border:1px solid transparent;border-radius:8px;color:var(--ink);text-decoration:none}.topbar-readiness:hover{border-color:var(--accent-light);background:var(--accent-soft)}.topbar-readiness-copy{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:center}.topbar-readiness-copy>span{overflow:hidden;color:var(--muted);font-size:9.6px;font-weight:700;text-overflow:ellipsis;text-transform:uppercase;letter-spacing:.08em;white-space:nowrap}.topbar-readiness-copy>strong{font-size:10.8px;line-height:1}.topbar-readiness>.progress{width:100%;height:5px}.topbar-status{display:flex;flex:1 1 auto;min-width:0;align-items:center;justify-content:flex-end;gap:8px;margin-left:auto}.program-select select{max-width:180px;border:1px solid var(--line);border-radius:8px;padding:9px 28px 9px 10px;background:var(--surface);color:var(--ink);font:inherit}.topbar-status .topbar-search{flex:0 1 240px;width:240px;min-width:140px;margin-left:0}.repo-chip,.validation-chip{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:8px;padding:10px 12px;color:var(--muted);font-size:13.2px;white-space:nowrap;text-decoration:none}.repo-chip:hover,.validation-chip:hover{color:var(--ink);border-color:var(--accent-light)}
|
|
5108
5544
|
.metadata>div{grid-template-columns:minmax(140px,1fr) minmax(0,2.5fr)}.metadata dt{min-width:0;overflow-wrap:anywhere}
|
|
5109
5545
|
.audit-progress-empty{padding:11px 13px;border-radius:8px;background:var(--surface-soft)}.audit-progress-empty strong,.audit-progress-empty span{display:block}.audit-progress-empty strong{font-size:13.2px}.audit-progress-empty span{margin-top:4px;color:var(--muted);font-size:10.8px}
|
|
5110
5546
|
.icon-button{position:relative;display:grid;place-items:center;padding:0;color:var(--ink);font-size:0}.icon-button:before,.icon-button:after{content:"";position:absolute;width:13px;height:2px;border-radius:2px;background:currentColor;transform:rotate(45deg)}.icon-button:after{transform:rotate(-45deg)}
|
|
@@ -5232,14 +5668,17 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
|
|
|
5232
5668
|
.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}
|
|
5233
5669
|
.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}.operation-gate{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:22px;border:1px solid var(--accent-light);border-radius:12px;background:var(--accent-wash)}.operation-gate h2{margin:5px 0 7px;font:500 25px Georgia,serif}.operation-gate p:not(.kicker){margin:0;max-width:680px;color:var(--muted)}.operation-setup-preview>summary{margin-top:18px;padding:14px 16px;border:1px solid var(--line);border-radius:9px;background:var(--panel);font-weight:700;cursor:pointer}.operation-setup-preview[open]>summary{margin-bottom:0}.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}
|
|
5234
5670
|
.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}
|
|
5671
|
+
.occurrence-review{margin:14px 0;border:1px solid var(--line);border-radius:8px;overflow:hidden}.occurrence-review-head{display:flex;justify-content:space-between;gap:12px;padding:10px 12px;background:var(--surface-soft);font-size:10.8px}.occurrence-review-head span,.occurrence-member small{color:var(--muted)}.occurrence-members{display:grid}.occurrence-member{display:grid;grid-template-columns:minmax(150px,1.4fr) minmax(110px,.7fr) minmax(170px,1.2fr) minmax(150px,1fr);gap:8px;align-items:end;padding:10px 12px;border-top:1px solid var(--line)}.occurrence-member:first-child{border-top:0}.occurrence-member-name{align-self:center;min-width:0}.occurrence-member-name strong,.occurrence-member-name small{display:block;overflow:hidden;text-overflow:ellipsis}.occurrence-member label span{display:block;margin-bottom:4px;color:var(--muted);font-size:9.6px}.occurrence-member input,.occurrence-member select{width:100%}.occurrence-empty,.occurrence-pending{margin:0;padding:12px;color:var(--muted);font-size:10.8px}.occurrence-pending{border-bottom:1px solid var(--line);background:#fff7df;color:#765410}
|
|
5672
|
+
.activation-review{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:14px 0}.activation-review>div{padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.activation-review .full{grid-column:1/-1}.activation-review span,.activation-review strong,.activation-review small{display:block}.activation-review span{color:var(--muted);font-size:9.6px;text-transform:uppercase;letter-spacing:.06em}.activation-review strong{margin-top:4px;font-size:12px}.activation-review small{margin-top:3px;color:var(--muted);font-size:9.6px}.confirmation{display:flex!important;align-items:flex-start;gap:8px}.confirmation input{flex:none;width:auto!important;margin-top:2px}.cutover-note{margin:12px 0;padding:9px 11px;border-radius:7px;background:#fff7df;color:#765410;font-size:10.8px}
|
|
5235
5673
|
.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}
|
|
5236
5674
|
.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}.applicability-dialog form>.applicability-baseline-note{padding:10px 12px;border-radius:7px;background:var(--accent-soft);color:var(--ink)}.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}.applicability-row .applicability-constraint{color:var(--accent);line-height:1.35}
|
|
5237
5675
|
.object-value-list{display:grid;gap:7px}.object-value{min-width:0;padding:7px 8px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.object-value-facts{display:flex;flex-wrap:wrap;gap:5px 10px}.object-value-facts>span{display:flex;align-items:baseline;gap:5px;min-width:0;font-size:11px}.object-value-facts b{color:var(--muted);font-size:8.8px;text-transform:uppercase;letter-spacing:.05em}.object-value>small{display:block;margin-top:6px;color:var(--muted);font-size:10px;line-height:1.45}.object-value-list.compact .object-value{padding:0;border:0;background:none}.object-value-list.compact .object-value-facts{display:grid;gap:5px}.object-value-list.compact .object-value-facts>span{display:grid;gap:2px;font-size:10px}
|
|
5238
5676
|
.resource-review-criteria.compact{margin:12px 0;padding:10px 12px;background:var(--surface-soft)}.resource-review-criteria.compact summary{cursor:pointer;font-size:11px;font-weight:750}.resource-review-criteria.compact ul{margin-bottom:0}.evidence-map-more{justify-self:center;margin:2px 0 8px}
|
|
5239
|
-
.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:
|
|
5677
|
+
.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:max-content 1fr;align-items:start;gap:10px;border-top:1px solid var(--line);padding:10px 0}.packet-gaps>div:first-child{border-top:0}.packet-gaps .badge{display:inline-flex;align-items:center;justify-content:center;line-height:1.2}.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}
|
|
5240
5678
|
.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}
|
|
5241
5679
|
.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}
|
|
5242
5680
|
@media(max-width:900px){.obligation-board{grid-template-columns:1fr}.policy-event-list{grid-template-columns:repeat(2,minmax(0,1fr))}.policy-event-row:nth-child(3n) .policy-event-tooltip{right:auto;left:0}.policy-event-row:nth-child(2n) .policy-event-tooltip{right:0;left:auto}.packet-builder form,.packet-preflight{grid-template-columns:1fr 1fr}.packet-builder .button{align-self:end}}
|
|
5681
|
+
@media(max-width:900px){.occurrence-member{grid-template-columns:1fr 1fr}.occurrence-member-name,.occurrence-proof{grid-column:1/-1}}
|
|
5243
5682
|
@media(max-width:1000px){.stage-overview-layout{grid-template-columns:1fr}.stage-page-grid,.group-destination-grid,.policy-activation-grid{grid-template-columns:1fr}}
|
|
5244
5683
|
@media(max-width:760px){.preparation-stages{grid-template-columns:1fr}}
|
|
5245
5684
|
@media(max-width:900px){.overview-grid{grid-template-columns:1fr}.overview-grid>.audit-panel{grid-column:auto}}
|
|
@@ -5280,6 +5719,9 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
|
|
|
5280
5719
|
.missing-options{border-color:#77612f;background:#382f19;color:#ffdc92}
|
|
5281
5720
|
.status-dot.neutral{background:#9aabff}
|
|
5282
5721
|
}
|
|
5722
|
+
.record-table td:has(.relation){min-width:150px}
|
|
5723
|
+
.record-table .relation{border-radius:7px;line-height:1.35;overflow-wrap:anywhere;word-break:normal}
|
|
5724
|
+
@media(max-width:760px){.record-table td:has(.relation){min-width:0}.record-table td[data-label]>.relation{grid-column:2}}
|
|
5283
5725
|
`;
|
|
5284
5726
|
|
|
5285
5727
|
function safeJson(value) {
|