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>
312 lines
15 KiB
JavaScript
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 ({
|
|
'&': '&', '<': '<', '>': '>',
|
|
'"': '"', "'": '''
|
|
})[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
|
|
};
|
|
})();
|