ZDDC/browse/js/accept-transmittal.js
ZDDC f94defc8c1 feat(browse,tables): flat-peer clients + dual-mode cross-party aggregate
browse: the party picker reads the ssr/ registry (the authoritative party
list) and creates at physical peer paths <project>/<peer>/<party>/…;
"register new party" writes ssr/<party>.yaml first (party_source: ssr).
stage.js + accept-transmittal.js repointed to the top-level workspace peers
(working/staging/incoming) — received/issued + plan-review stay under the
WORM archive.

tables: mdl/ and rsk/ render the cross-party aggregate by recursing ONE
level into the party subdirs CLIENT-side (works online AND offline), with
$party from the server-injected row content (or derived from the subdir
offline). Rows carry the <party>/ prefix so reads/edits hit the real
per-party path. The server just lists the peer root normally (party subdirs
+ synthetic table.yaml/form.yaml) — the fs/tree flattening + ListRollupRows
are dropped in favour of this dual-mode client recursion.

Full Go suite + all 256 Playwright tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:35:31 -05:00

312 lines
15 KiB
JavaScript

// accept-transmittal.js — the doc-controller "Accept Transmittal"
// workflow modal.
//
// Surfaced by events.js as a right-click item on a transmittal folder
// inside archive/<their-party>/incoming/. The folder name must conform
// to the ZDDC transmittal grammar (date_tracking (status) - title);
// every file inside must conform to ZDDC filename grammar with the
// same tracking. Non-conformance is flagged in the modal and the user
// cancels to ask the sender to fix.
//
// On submit, the form assembles a YAML body (received_date plus an
// optional plan-review chain block) and POSTs it with
// X-ZDDC-Op: accept-transmittal to the transmittal-folder URL. The
// server validates everything, moves the folder into received/,
// renames it to tracking-only, and optionally chains Plan Review.
(function () {
'use strict';
var REVIEW_OFFSET_DAYS = 7;
var RESPONSE_OFFSET_DAYS = 14;
function status(msg, level) {
var t = window.zddc && window.zddc.toast;
if (t) t(msg, level || 'info');
}
function isoDateToday() {
var d = new Date();
return d.getFullYear()
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2)
+ '-' + ('0' + d.getDate()).slice(-2);
}
function isoDatePlus(days) {
var d = new Date();
d.setDate(d.getDate() + days);
return d.getFullYear()
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2)
+ '-' + ('0' + d.getDate()).slice(-2);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;'
})[c];
});
}
// Is this node a direct child of an incoming/ canonical folder
// AND a well-formed transmittal folder? The first half is the
// cascade-driven scope check (X-ZDDC-Canonical-Folder == 'incoming'
// on the current listing's parent context); the second is a
// structural folder-name parse against the ZDDC grammar.
function isAcceptableTransmittalFolder(node) {
if (!node || !node.isDir) return false;
if (node.virtual) return false;
// The cascade signal is on the PARENT directory's listing, which
// is the directory whose contents are currently shown — i.e.
// state.currentPath. When the listing's scope is incoming/,
// every direct child folder is a candidate (validated by name
// here and by the server again on POST).
if (window.app.state.scopeCanonicalFolder !== 'incoming') return false;
var parsed = window.zddc.parseFolder(node.name);
return !!(parsed && parsed.valid);
}
// Scan the listing's tree node for files inside the transmittal
// folder and classify each as conforming (tracking matches the
// folder) or violating. Returns { ok: [...], violations: [...] }.
// Best-effort — operates only on already-loaded children. The
// server is authoritative; this is a UX hint.
function classifyChildren(node, folderTracking) {
var out = { ok: [], violations: [] };
var children = (node && node.children) ? node.children : [];
children.forEach(function (c) {
if (c.virtual) return;
if (c.isDir) {
out.violations.push(c.name + ': nested directories are not permitted');
return;
}
if (c.name.charAt(0) === '.') return; // dotfiles ignored
var parsed = window.zddc.parseFilename(c.name);
if (!parsed || !parsed.valid) {
out.violations.push(c.name + ': does not conform to ZDDC filename grammar');
return;
}
if (parsed.trackingNumber !== folderTracking) {
out.violations.push(c.name + ': tracking "' + parsed.trackingNumber
+ '" does not match folder tracking "' + folderTracking + '"');
return;
}
out.ok.push(c.name);
});
return out;
}
function fetchPeopleSuggestions() {
return fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
}).then(function (r) {
if (!r.ok) return [];
return r.json().then(function (data) {
var out = [];
if (data && data.email) out.push(data.email);
return out;
});
}).catch(function () { return []; });
}
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:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);font-family:inherit;';
var violationsHtml = '';
if (initial.violations && initial.violations.length) {
violationsHtml = '<div style="margin:0.5rem 0;padding:0.5rem 0.75rem;background:#fff3cd;border-left:3px solid #d39e00;font-size:0.85rem;">'
+ '<strong>Non-conforming files detected:</strong><ul style="margin:0.25rem 0 0 1rem;padding:0;">'
+ initial.violations.map(function (v) { return '<li>' + escapeHtml(v) + '</li>'; }).join('')
+ '</ul><p style="margin:0.4rem 0 0 0;">Cancel and contact the sender to correct these before re-uploading.</p></div>';
}
var planReviewFieldsHtml =
'<div id="acc-pr-fields" style="display:none;margin-top:0.6rem;padding:0.5rem 0.75rem;background:rgba(0,0,0,0.03);border-radius:4px;">' +
'<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.4rem 0.75rem;align-items:center;font-size:0.9rem;">' +
'<label for="acc-review-lead">Review lead</label>' +
'<input id="acc-review-lead" type="email" list="acc-people" style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of reviewing/<…>">' +
'<label for="acc-review-date">Plan review complete date</label>' +
'<input id="acc-review-date" type="date">' +
'<label for="acc-approver">Approver</label>' +
'<input id="acc-approver" type="email" list="acc-people" style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of staging/<…>">' +
'<label for="acc-response-date">Plan response date</label>' +
'<input id="acc-response-date" type="date">' +
'<datalist id="acc-people"></datalist>' +
'</div>' +
'</div>';
box.innerHTML =
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Accept Transmittal — ' + escapeHtml(initial.tracking) + '</h2>' +
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
'This will file <strong>' + initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + '</strong> from ' +
'<code>' + escapeHtml(initial.folder) + '</code> into the immutable received archive at ' +
'<code>archive/' + escapeHtml(initial.party) + '/received/' + escapeHtml(initial.tracking) + '/</code>. ' +
'Once filed, only document-control can add new files there; nothing can be edited or deleted.' +
'</p>' +
violationsHtml +
'<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.5rem 0.75rem;align-items:center;font-size:0.9rem;">' +
'<label for="acc-received-date">Received date</label>' +
'<input id="acc-received-date" type="date" required>' +
'</div>' +
'<label style="display:flex;align-items:center;gap:0.4rem;margin-top:0.8rem;font-size:0.9rem;">' +
'<input type="checkbox" id="acc-setup-pr">' +
'<span>Set up Plan Review now — scaffold the reviewing/ and staging/ folders for the response</span>' +
'</label>' +
planReviewFieldsHtml +
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
'<button type="button" id="acc-cancel">Cancel</button>' +
'<button type="button" id="acc-submit" class="btn-primary"' +
(initial.violations && initial.violations.length ? ' disabled' : '') + '>Accept</button>' +
'</div>';
overlay.appendChild(box);
document.body.appendChild(overlay);
box.querySelector('#acc-received-date').value = isoDateToday();
box.querySelector('#acc-review-date').value = isoDatePlus(REVIEW_OFFSET_DAYS);
box.querySelector('#acc-response-date').value = isoDatePlus(RESPONSE_OFFSET_DAYS);
var prCheckbox = box.querySelector('#acc-setup-pr');
var prFields = box.querySelector('#acc-pr-fields');
prCheckbox.addEventListener('change', function () {
prFields.style.display = prCheckbox.checked ? '' : 'none';
});
fetchPeopleSuggestions().then(function (emails) {
var dl = box.querySelector('#acc-people');
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('#acc-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('#acc-submit').addEventListener('click', function () {
var values = {
receivedDate: box.querySelector('#acc-received-date').value,
setupPlanReview: prCheckbox.checked,
reviewLead: box.querySelector('#acc-review-lead').value.trim(),
approver: box.querySelector('#acc-approver').value.trim(),
planReviewDate: box.querySelector('#acc-review-date').value,
planResponseDate: box.querySelector('#acc-response-date').value
};
if (!values.receivedDate) { status('Received date is required.', 'error'); return; }
if (values.setupPlanReview) {
if (!values.reviewLead || !values.approver
|| !values.planReviewDate || !values.planResponseDate) {
status('Plan Review fields are required when the checkbox is on.', 'error');
return;
}
}
close(); resolve(values);
});
});
}
function quote(s) {
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
function buildBody(values) {
var lines = ['received_date: ' + values.receivedDate];
if (values.setupPlanReview) {
lines.push('setup_plan_review: true');
lines.push('review_lead: ' + quote(values.reviewLead));
lines.push('approver: ' + quote(values.approver));
lines.push('plan_review_complete_date: ' + values.planReviewDate);
lines.push('plan_response_date: ' + values.planResponseDate);
}
lines.push('');
return lines.join('\n');
}
async function invoke(node) {
var tree = window.app.modules.tree;
if (!tree) return;
var url = tree.pathFor(node);
if (!url.endsWith('/')) url += '/';
var parsedFolder = window.zddc.parseFolder(node.name);
if (!parsedFolder || !parsedFolder.valid) {
status('Folder name does not conform to ZDDC transmittal grammar.', 'error');
return;
}
// Derive the party from the path: incoming/<party>/<folder>/.
var parts = url.replace(/^\/+|\/+$/g, '').split('/');
var incIdx = parts.indexOf('incoming');
var party = (incIdx >= 0 && parts[incIdx + 1]) ? parts[incIdx + 1] : '';
var classification = classifyChildren(node, parsedFolder.trackingNumber);
var values;
try {
values = await openForm({
tracking: parsedFolder.trackingNumber,
folder: node.name,
party: party,
fileCount: classification.ok.length,
violations: classification.violations
});
} catch (_e) {
return;
}
status('Accept Transmittal — submitting…');
var resp;
try {
resp = await fetch(url, {
method: 'POST',
headers: {
'X-ZDDC-Op': 'accept-transmittal',
'Content-Type': 'application/yaml'
},
body: buildBody(values),
credentials: 'same-origin'
});
} catch (e) {
status('Accept failed: ' + (e && e.message ? e.message : e), 'error');
return;
}
if (!resp.ok) {
var text = '';
try { text = await resp.text(); } catch (_e) { /* ignore */ }
status('Accept failed (' + resp.status + '): ' + text, 'error');
return;
}
var data; try { data = await resp.json(); } catch (_e) { data = null; }
var msg = 'Accepted ' + (data && data.moved_files ? data.moved_files : '?') + ' file(s) into '
+ (data && data.received_path ? data.received_path : 'received/');
if (data && data.merged) msg += ' (merged with existing tracking)';
if (data && data.plan_review) msg += ' · Plan Review scaffolded';
status(msg + ' — reload to see the move.', 'success');
}
window.app.modules.acceptTransmittal = {
isAcceptableTransmittalFolder: isAcceptableTransmittalFolder,
invoke: invoke
};
})();