// 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//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'); } var util = window.app.modules.util; var escapeHtml = util.escapeHtml; var isoDateToday = util.isoDateToday; var isoDatePlus = util.isoDatePlus; // 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; } var fetchPeopleSuggestions = util.fetchAccessEmails; 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 = '
' + 'Non-conforming files detected:

Cancel and contact the sender to correct these before re-uploading.

'; } var planReviewFieldsHtml = ''; box.innerHTML = '

Accept Transmittal — ' + escapeHtml(initial.tracking) + '

' + '

' + 'This will file ' + initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + ' from ' + '' + escapeHtml(initial.folder) + ' into the immutable received archive at ' + 'archive/' + escapeHtml(initial.party) + '/received/' + escapeHtml(initial.tracking) + '/. ' + 'Once filed, only document-control can add new files there; nothing can be edited or deleted.' + '

' + violationsHtml + '
' + '' + '' + '
' + '' + planReviewFieldsHtml + '
' + '' + '' + '
'; 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); }); }); // Bind the Escape handler once and remove it in close() — every // dismissal path (cancel, overlay-click, submit, Escape) routes // through close(), so the document listener can't outlive the // modal. function onKeydown(e) { if (e.key === 'Escape') { close(); reject(new Error('cancelled')); } } function close() { document.removeEventListener('keydown', onKeydown); if (overlay.parentNode) overlay.parentNode.removeChild(overlay); } box.querySelector('#acc-cancel').addEventListener('click', function () { close(); reject(new Error('cancelled')); }); // Close on a genuine backdrop click only — not when a drag that began // inside the dialog (selecting text in an input) ends out here. var pressedBackdrop = false; overlay.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === overlay); }); overlay.addEventListener('click', function (e) { if (e.target === overlay && pressedBackdrop) { close(); reject(new Error('cancelled')); } }); document.addEventListener('keydown', onKeydown); 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); }); }); } var quote = util.yamlQuote; 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'); } var busy = false; async function invoke(node) { if (busy) return; 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///. 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; } busy = true; try { 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, 'success'); // Refresh the incoming/ listing so the now-moved folder drops out // of the tree — the stale entry was the main re-trigger hazard. var ev = window.app.modules.events; if (ev && typeof ev.refreshListing === 'function') ev.refreshListing(); } finally { busy = false; } } window.app.modules.acceptTransmittal = { isAcceptableTransmittalFolder: isAcceptableTransmittalFolder, invoke: invoke }; })();