Two layers shipped together since the second builds on the first. LAYER 1 — reviewing/ + Plan Review scaffolding - reviewing/ is now a real folder under each project, populated by the Plan Review composite endpoint. The old reviewing/ virtual aggregator handler is retired. - POST /<project>/archive/<party>/received/<tracking>/ with X-ZDDC-Op: plan-review scaffolds physical workflow folders under reviewing_root and staging_root, each carrying .zddc.received_path pointing back at the canonical submittal. Idempotent re-runs match by received_path and re-converge the ACL. - Virtual received window: when listing or writing under <workflow>/received/, the server resolves through the canonical archive/<party>/received/<tracking>/ via the workflow's .zddc.received_path. Writes get rewritten to <workflow>/<base>+C<n><suffix> so review comments land in the workflow folder and never touch the WORM archive. - Cascade defaults declare on_plan_review per project so the reviewing_root and staging_root are configurable. LAYER 2 — browse context-menu workflows - Accept Transmittal: right-click a transmittal folder in archive/<party>/incoming/ → validates ZDDC folder + filename conformance, atomic-renames the folder to archive/<party>/received/<tracking>/ (WORM zone), and optionally chains into Plan Review in the same composite request. Re-acceptance with a different revision merges file-by-file; WORM forbids overwrite of an existing filename. - Stage / Unstage: right-click files in working/<…>/ → "Stage to…" with picker of existing staging transmittal folders + inline "New transmittal folder…" create; right-click files in staging/<…>/ → "Unstage to working/" defaulting to the user's working/<email>/ home. Reuses the file-API move primitive. - Create Transmittal folder: right-click the staging/ pane → prompts for a ZDDC-conforming folder name with live validation; mkdir, then navigate to the new folder URL where the transmittal tool serves the editor. - Supporting infrastructure: new CanonicalFolderAt cascade lookup + X-ZDDC-Canonical-Folder response header so the browse SPA can scope-gate menu items without re-implementing the cascade client-side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
276 lines
12 KiB
JavaScript
276 lines
12 KiB
JavaScript
// plan-review.js — the doc-controller "Plan Review" workflow modal.
|
|
//
|
|
// Surfaced by events.js as a right-click menu item on
|
|
// archive/<party>/received/<tracking>/ folders when the cascade above
|
|
// has an on_plan_review block (X-ZDDC-On-Plan-Review header on the
|
|
// listing).
|
|
//
|
|
// The modal collects four fields:
|
|
//
|
|
// - review_lead (becomes sub-admin of reviewing/<…>/)
|
|
// - plan_review_complete_date (the committed review-done date)
|
|
// - approver (becomes sub-admin of staging/<…>/)
|
|
// - plan_response_date (the committed response-issue date)
|
|
//
|
|
// The planned dates are immutable from the sub-admins' perspective —
|
|
// they live in the canonical submittal's .zddc
|
|
// (received/<tracking>/.zddc) where only the doc controller (via Plan
|
|
// Review re-run) can change them. The workflow folders' .zddc files
|
|
// carry only the back-link + per-folder ACL.
|
|
//
|
|
// Title is auto-derived server-side from the first ZDDC-parseable
|
|
// file in received/<tracking>/. Forecast dates default to the planned
|
|
// dates at scaffolding time; the user renames the workflow folder
|
|
// directly to update the forecast later.
|
|
//
|
|
// On submit, the form assembles a YAML body and POSTs it with
|
|
// X-ZDDC-Op: plan-review to the received/<tracking>/ URL.
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
var REVIEW_OFFSET_DAYS = 7;
|
|
var RESPONSE_OFFSET_DAYS = 14;
|
|
|
|
function statusInfo(msg) {
|
|
var el = document.getElementById('statusBar');
|
|
if (!el) return;
|
|
el.textContent = msg || '';
|
|
el.classList.remove('status-bar--error');
|
|
el.classList.add('status-bar--info');
|
|
}
|
|
function statusError(msg) {
|
|
var el = document.getElementById('statusBar');
|
|
if (!el) return;
|
|
el.textContent = msg || '';
|
|
el.classList.remove('status-bar--info');
|
|
el.classList.add('status-bar--error');
|
|
}
|
|
|
|
// Compute today + N days as a YYYY-MM-DD string.
|
|
function isoDatePlus(days) {
|
|
var d = new Date();
|
|
d.setDate(d.getDate() + days);
|
|
var y = d.getFullYear();
|
|
var m = ('0' + (d.getMonth() + 1)).slice(-2);
|
|
var dd = ('0' + d.getDate()).slice(-2);
|
|
return y + '-' + m + '-' + dd;
|
|
}
|
|
|
|
// Fetch suggestion emails from /.profile/access so the originator
|
|
// field has a datalist of likely values. Best-effort — silent on
|
|
// failure (the field still accepts free text).
|
|
async function fetchOriginatorSuggestions() {
|
|
try {
|
|
var resp = await fetch('/.profile/access', {
|
|
headers: { 'Accept': 'application/json' },
|
|
credentials: 'same-origin'
|
|
});
|
|
if (!resp.ok) return [];
|
|
var data = await resp.json();
|
|
var out = [];
|
|
// The endpoint exposes the current user + any role members
|
|
// visible to them. Pull anything that looks like an email
|
|
// for the datalist; the field is otherwise free text.
|
|
if (data && data.email) out.push(data.email);
|
|
return out;
|
|
} catch (_e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Build the YAML body for the plan-review POST. Quoting is minimal
|
|
// (just enough for emails with special chars).
|
|
function buildBody(values) {
|
|
function yamlString(s) {
|
|
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
}
|
|
return [
|
|
'review_lead: ' + yamlString(values.reviewLead),
|
|
'approver: ' + yamlString(values.approver),
|
|
'plan_review_complete_date: ' + values.planReviewDate,
|
|
'plan_response_date: ' + values.planResponseDate,
|
|
''
|
|
].join('\n');
|
|
}
|
|
|
|
// Render the modal. Returns a Promise that resolves on submit
|
|
// (with the collected values) or rejects on cancel.
|
|
function openForm(initial) {
|
|
return new Promise(function (resolve, reject) {
|
|
var overlay = document.createElement('div');
|
|
overlay.className = 'modal-overlay';
|
|
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
|
|
|
|
var box = document.createElement('div');
|
|
box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:24rem;max-width:32rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);font-family:inherit;';
|
|
|
|
box.innerHTML =
|
|
'<h2 style="margin:0 0 0.75rem 0;font-size:1.1rem;">Plan Review — ' + escapeHtml(initial.tracking) + '</h2>' +
|
|
'<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.5rem 0.75rem;align-items:center;font-size:0.9rem;">' +
|
|
'<label for="pr-review-lead">Review lead</label>' +
|
|
'<input id="pr-review-lead" type="email" list="pr-people-list" required style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of reviewing/<…>">' +
|
|
'<label for="pr-review-date">Plan review complete date</label>' +
|
|
'<input id="pr-review-date" type="date" required>' +
|
|
'<label for="pr-approver">Approver</label>' +
|
|
'<input id="pr-approver" type="email" list="pr-people-list" required style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of staging/<…>">' +
|
|
'<label for="pr-response-date">Plan response date</label>' +
|
|
'<input id="pr-response-date" type="date" required>' +
|
|
'<datalist id="pr-people-list"></datalist>' +
|
|
'</div>' +
|
|
'<p style="margin:0.75rem 0 0 0;font-size:0.8rem;color:#666;">Planned dates seal at first submission — they become part of the canonical record (received/<tracking>/.zddc) and the WORM zone prevents further edits. Subsequent Plan Reviews can swap the review lead or approver without changing the dates.</p>' +
|
|
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
|
|
'<button type="button" id="pr-cancel">Cancel</button>' +
|
|
'<button type="button" id="pr-submit" class="btn-primary">Plan Review</button>' +
|
|
'</div>';
|
|
|
|
overlay.appendChild(box);
|
|
document.body.appendChild(overlay);
|
|
|
|
var reviewLeadInput = box.querySelector('#pr-review-lead');
|
|
var approverInput = box.querySelector('#pr-approver');
|
|
var reviewDateInput = box.querySelector('#pr-review-date');
|
|
var responseDateInput = box.querySelector('#pr-response-date');
|
|
|
|
reviewDateInput.value = isoDatePlus(REVIEW_OFFSET_DAYS);
|
|
responseDateInput.value = isoDatePlus(RESPONSE_OFFSET_DAYS);
|
|
|
|
// Populate the datalist with people suggestions (best
|
|
// effort — silent on failure).
|
|
fetchOriginatorSuggestions().then(function (emails) {
|
|
var dl = box.querySelector('#pr-people-list');
|
|
if (!dl) return;
|
|
emails.forEach(function (e) {
|
|
var opt = document.createElement('option');
|
|
opt.value = e;
|
|
dl.appendChild(opt);
|
|
});
|
|
});
|
|
|
|
function close() {
|
|
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
|
}
|
|
|
|
box.querySelector('#pr-cancel').addEventListener('click', function () {
|
|
close();
|
|
reject(new Error('cancelled'));
|
|
});
|
|
overlay.addEventListener('click', function (e) {
|
|
if (e.target === overlay) {
|
|
close();
|
|
reject(new Error('cancelled'));
|
|
}
|
|
});
|
|
document.addEventListener('keydown', function escHandler(e) {
|
|
if (e.key === 'Escape') {
|
|
document.removeEventListener('keydown', escHandler);
|
|
close();
|
|
reject(new Error('cancelled'));
|
|
}
|
|
});
|
|
|
|
box.querySelector('#pr-submit').addEventListener('click', function () {
|
|
var values = {
|
|
reviewLead: reviewLeadInput.value.trim(),
|
|
approver: approverInput.value.trim(),
|
|
planReviewDate: reviewDateInput.value,
|
|
planResponseDate: responseDateInput.value
|
|
};
|
|
if (!values.reviewLead || !values.approver
|
|
|| !values.planReviewDate || !values.planResponseDate) {
|
|
statusError('All fields are required.');
|
|
return;
|
|
}
|
|
close();
|
|
resolve(values);
|
|
});
|
|
|
|
reviewLeadInput.focus();
|
|
});
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, function (c) {
|
|
return ({
|
|
'&': '&', '<': '<', '>': '>',
|
|
'"': '"', "'": '''
|
|
})[c];
|
|
});
|
|
}
|
|
|
|
// Detect whether a tree node is an archive/<party>/received/<tracking>/
|
|
// folder. The path is path-shaped, not content-based — tracking-number
|
|
// content is not inspected (per design).
|
|
function isReceivedTrackingFolder(node) {
|
|
if (!node || !node.isDir) return false;
|
|
var tree = window.app.modules.tree;
|
|
if (!tree) return false;
|
|
var p = tree.pathFor(node).replace(/\/$/, '');
|
|
var rel = p.replace(/^\/+/, '');
|
|
var parts = rel.split('/');
|
|
return parts.length === 5
|
|
&& parts[1].toLowerCase() === 'archive'
|
|
&& parts[3].toLowerCase() === 'received';
|
|
}
|
|
|
|
// Run the Plan Review flow: open the modal, POST the result.
|
|
async function invoke(node) {
|
|
var tree = window.app.modules.tree;
|
|
if (!tree) return;
|
|
var url = tree.pathFor(node);
|
|
if (!url.endsWith('/')) url += '/';
|
|
var parts = url.replace(/^\/+/, '').replace(/\/$/, '').split('/');
|
|
var tracking = parts[parts.length - 1];
|
|
|
|
var values;
|
|
try {
|
|
values = await openForm({ tracking: tracking });
|
|
} catch (_e) {
|
|
return; // cancelled
|
|
}
|
|
|
|
statusInfo('Plan Review — submitting…');
|
|
var body = buildBody(values);
|
|
var resp;
|
|
try {
|
|
resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-ZDDC-Op': 'plan-review',
|
|
'Content-Type': 'application/yaml'
|
|
},
|
|
body: body,
|
|
credentials: 'same-origin'
|
|
});
|
|
} catch (e) {
|
|
statusError('Plan Review failed: ' + (e && e.message ? e.message : e));
|
|
return;
|
|
}
|
|
if (!resp.ok) {
|
|
var text = '';
|
|
try { text = await resp.text(); } catch (_e) { /* ignore */ }
|
|
statusError('Plan Review failed (' + resp.status + '): ' + text);
|
|
return;
|
|
}
|
|
var data;
|
|
try { data = await resp.json(); } catch (_e) { data = null; }
|
|
if (data && data.reviewing && data.staging) {
|
|
var rPart = data.reviewing.created ? 'created' : 'updated';
|
|
var sPart = data.staging.created ? 'created' : 'updated';
|
|
var seal = (data.received && data.received.created)
|
|
? ' Canonical record sealed.'
|
|
: (data.received && !data.received.zddc_written)
|
|
? ' Canonical dates left untouched (already sealed).'
|
|
: '';
|
|
statusInfo('Plan Review: reviewing ' + rPart + ', staging ' + sPart + '.' + seal +
|
|
' Reload the relevant folder to see the new entries.');
|
|
} else {
|
|
statusInfo('Plan Review complete.');
|
|
}
|
|
}
|
|
|
|
window.app.modules.planReview = {
|
|
isReceivedTrackingFolder: isReceivedTrackingFolder,
|
|
invoke: invoke
|
|
};
|
|
})();
|