// plan-review.js — the doc-controller "Plan Review" workflow modal. // // Surfaced by events.js as a right-click menu item on // archive//received// 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//.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//. 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// 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 = '

Plan Review — ' + escapeHtml(initial.tracking) + '

' + '
' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '
' + '

Planned dates seal at first submission — they become part of the canonical record (received//.zddc) and the WORM zone prevents further edits. Subsequent Plan Reviews can swap the review lead or approver without changing the dates.

' + '
' + '' + '' + '
'; 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//received// // 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 }; })();