Reframes the By-MDL tab as a "⊞ Catalog" button that opens an overlay over the target pane (the left filetree stays the drag source). The catalog is the de-duplicated union of everything the project knows about a tracking number: its MDL deliverables AND its archive files, merged by tracking number, with an informational "Archive revs" column (which revisions already exist) and an "MDL" flag. Nothing is written or altered — the Revision column is classifier-local and starts blank (never pre-filled from an old archive rev). - Drag a source file onto a row → assigns the tracking number only (the mdl axis); set the revision in the bulk-editable Revision column (ctrl-shift select rows + ctrl-Enter). Per-file Title: MDL/file toggle + ✕ remove kept. - Columns split the tracking number into the configured pattern fields, each with its own autofilter (per-column, via the shared seltable). Default pattern is now the 8-field ORIG-PHASE-PROJECT-AREA-DISC-TYPE-SEQ-SUFFIX. - Server load merges every party's archive/<party>/mdl/*.yaml with a recursive walk of the archive documents; local load reads a folder of deliverable yamls. Test: catalog shows merged archive revisions; drop names a file (tracking only); bulk revision feeds the derived name. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
884 lines
44 KiB
JavaScript
884 lines
44 KiB
JavaScript
/**
|
||
* ZDDC Classifier — target-tree pane (Classify & Copy mode).
|
||
*
|
||
* Renders the two orthogonal target trees the user maps files onto:
|
||
* - "By tracking number": folders that join with "-" into the tracking
|
||
* number; the leaf folder ("A (IFR)") is the revision+status.
|
||
* - "By transmittal": <party>/{received,issued}/<transmittal folder>.
|
||
*
|
||
* Structure here, placements in classify.js. Drag-and-drop assignment is wired
|
||
* in source-dnd.js / phase 3; this module owns rendering + folder/bin CRUD and
|
||
* shows the derived filename for each placed file.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
var SLOTS = ['received', 'issued'];
|
||
|
||
var els = {};
|
||
var collapsed = {}; // nodeId -> true when collapsed (default expanded)
|
||
var openForm = null; // { partyId, slot } when a bin form is open
|
||
var initialized = false;
|
||
var currentTab = 'tracking'; // 'tracking' | 'transmittal' — the active tab
|
||
var catalogOpen = false; // the Catalog overlay (the 'mdl' axis) is open
|
||
var mdlTable = null; // the seltable controller for the catalog
|
||
var mdlPlaced = {}; // latest placed.mdl map (read by the placed-file cell)
|
||
|
||
function init() {
|
||
if (initialized) return;
|
||
initialized = true;
|
||
els = {
|
||
trackingTab: document.getElementById('trackingTab'),
|
||
transmittalTab: document.getElementById('transmittalTab'),
|
||
catalogBtn: document.getElementById('catalogBtn'),
|
||
trackingPanel: document.getElementById('trackingPanel'),
|
||
transmittalPanel: document.getElementById('transmittalPanel'),
|
||
mdlPanel: document.getElementById('mdlPanel'),
|
||
trackingTree: document.getElementById('trackingTree'),
|
||
transmittalTree: document.getElementById('transmittalTree'),
|
||
mdlTree: document.getElementById('mdlTree'),
|
||
loadMdlBtn: document.getElementById('loadMdlBtn'),
|
||
catalogCloseBtn: document.getElementById('catalogCloseBtn'),
|
||
addTrackingRootBtn: document.getElementById('addTrackingRootBtn'),
|
||
addPartyBtn: document.getElementById('addPartyBtn'),
|
||
stats: document.getElementById('classifyStats'),
|
||
};
|
||
|
||
els.trackingTab.addEventListener('click', function () { showTab('tracking'); });
|
||
els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); });
|
||
if (els.catalogBtn) els.catalogBtn.addEventListener('click', function () { catalogOpen ? closeCatalog() : openCatalog(); });
|
||
if (els.catalogCloseBtn) els.catalogCloseBtn.addEventListener('click', closeCatalog);
|
||
if (els.loadMdlBtn) els.loadMdlBtn.addEventListener('click', loadMdl);
|
||
els.addTrackingRootBtn.addEventListener('click', function () {
|
||
var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n'
|
||
+ 'Brace patterns expand: BMB-{PM,EL}-{0001-0002,0005}_A (IFR)', '');
|
||
addFoldersFromPattern(null, name);
|
||
});
|
||
els.addPartyBtn.addEventListener('click', function () {
|
||
var name = prompt('Party name (also the transmittal-number prefix):', '');
|
||
if (name && name.trim()) C().addParty(name.trim());
|
||
});
|
||
|
||
els.trackingTree.addEventListener('click', onTrackingClick);
|
||
els.transmittalTree.addEventListener('click', onTransmittalClick);
|
||
els.trackingTree.addEventListener('change', onFileNameChange);
|
||
els.transmittalTree.addEventListener('change', onFileNameChange);
|
||
|
||
setupDropZone(els.trackingTree, 'tracking');
|
||
setupDropZone(els.transmittalTree, 'transmittal');
|
||
|
||
C().on(render);
|
||
if (window.app.modules.store && window.app.modules.store.on) {
|
||
window.app.modules.store.on('files', render);
|
||
}
|
||
render();
|
||
}
|
||
|
||
function C() { return window.app.modules.classify; }
|
||
// Every scanned source file (classify mode reads the left tree, not the
|
||
// selection-scoped grid). Lazy folders contribute their files once scanned.
|
||
function allFiles() {
|
||
var out = [];
|
||
(function walk(nodes) {
|
||
(nodes || []).forEach(function (n) {
|
||
(n.files || []).forEach(function (f) { out.push(f); });
|
||
walk(n.children);
|
||
});
|
||
})(window.app.folderTree || []);
|
||
return out;
|
||
}
|
||
// One pass: group files by the node they're placed in, per axis.
|
||
function buildPlaced(files) {
|
||
var c = C(), byT = {}, byX = {}, byM = {};
|
||
files.forEach(function (f) {
|
||
var a = c.getAssignment(c.srcKeyForFile(f));
|
||
if (!a) return;
|
||
if (a.trackingNodeId) (byT[a.trackingNodeId] = byT[a.trackingNodeId] || []).push(f);
|
||
if (a.transmittalNodeId) (byX[a.transmittalNodeId] = byX[a.transmittalNodeId] || []).push(f);
|
||
if (a.mdlNodeId) (byM[a.mdlNodeId] = byM[a.mdlNodeId] || []).push(f);
|
||
});
|
||
return { tracking: byT, transmittal: byX, mdl: byM };
|
||
}
|
||
|
||
function showTab(which) {
|
||
currentTab = which === 'transmittal' ? 'transmittal' : 'tracking';
|
||
els.trackingTab.classList.toggle('active', currentTab === 'tracking');
|
||
els.transmittalTab.classList.toggle('active', currentTab === 'transmittal');
|
||
els.trackingPanel.hidden = currentTab !== 'tracking';
|
||
els.transmittalPanel.hidden = currentTab !== 'transmittal';
|
||
// The source-tree Show filters are per-axis, so the visible set changes
|
||
// with the active tab — re-render the left tree.
|
||
if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render();
|
||
}
|
||
// The active axis is the catalog ('mdl') while the overlay is open, else the tab.
|
||
function activeAxis() { return catalogOpen ? 'mdl' : (currentTab === 'transmittal' ? 'transmittal' : 'tracking'); }
|
||
function reRenderSource() { if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); }
|
||
function openCatalog() {
|
||
catalogOpen = true;
|
||
if (els.mdlPanel) els.mdlPanel.hidden = false;
|
||
if (els.catalogBtn) els.catalogBtn.classList.add('active');
|
||
render();
|
||
reRenderSource();
|
||
}
|
||
function closeCatalog() {
|
||
catalogOpen = false;
|
||
if (els.mdlPanel) els.mdlPanel.hidden = true;
|
||
if (els.catalogBtn) els.catalogBtn.classList.remove('active');
|
||
reRenderSource();
|
||
}
|
||
|
||
// Expand a brace pattern into folder names and create them (confirming a
|
||
// multi-create first). parentId null = root folders. See expandFolderPattern.
|
||
function addFoldersFromPattern(parentId, raw) {
|
||
if (!raw || !raw.trim()) return;
|
||
var names = C().expandFolderPattern(raw);
|
||
if (!names.length) return;
|
||
if (names.length > 1) {
|
||
var shown = names.slice(0, 8).join('\n');
|
||
if (names.length > 8) shown += '\n…and ' + (names.length - 8) + ' more';
|
||
if (!confirm('Create ' + names.length + ' folders?\n\n' + shown)) return;
|
||
}
|
||
// Each expanded name is parsed into nested tracking levels (split on
|
||
// "-", final "_" splits the leaf rev), reusing shared ancestors.
|
||
names.forEach(function (nm) { C().addTrackingPath(parentId, C().parseFolderLevels(nm)); });
|
||
}
|
||
|
||
// ── render ───────────────────────────────────────────────────────────────
|
||
function render() {
|
||
if (!initialized || !C().isEnabled()) return;
|
||
var files = allFiles();
|
||
var placed = buildPlaced(files);
|
||
renderTrackingInto(els.trackingTree, C().getTrackingTree(), placed.tracking);
|
||
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal);
|
||
renderMdlInto(placed.mdl);
|
||
renderStats(files);
|
||
}
|
||
|
||
function renderStats(files) {
|
||
var s = C().stats(files);
|
||
if (els.stats) {
|
||
els.stats.textContent = s.done + ' done · ' + s.partial + ' in progress · '
|
||
+ s.none + ' unassigned · ' + s.excluded + ' excluded';
|
||
}
|
||
var copyBtn = document.getElementById('copyOutputBtn');
|
||
if (copyBtn) {
|
||
copyBtn.disabled = s.done === 0;
|
||
copyBtn.textContent = s.done ? ('Copy ' + s.done + '…') : 'Copy…';
|
||
}
|
||
}
|
||
|
||
function el(tag, cls, text) {
|
||
var e = document.createElement(tag);
|
||
if (cls) e.className = cls;
|
||
if (text != null) e.textContent = text;
|
||
return e;
|
||
}
|
||
|
||
function nodeActions(extra) {
|
||
var wrap = el('span', 'tnode__actions');
|
||
(extra || []).forEach(function (a) {
|
||
var b = el('button', 'tnode__act', a.label);
|
||
b.dataset.act = a.act;
|
||
b.title = a.title || '';
|
||
wrap.appendChild(b);
|
||
});
|
||
return wrap;
|
||
}
|
||
|
||
// Placed files inside a transmittal bin. Each row is draggable (drag onto
|
||
// another bin to MOVE it) and carries an ✕ to remove it from the transmittal.
|
||
function fileList(files) {
|
||
var box = el('div', 'tnode__files');
|
||
files.forEach(function (f) {
|
||
var d = C().deriveTarget(f);
|
||
var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : ''));
|
||
row.dataset.key = d.key;
|
||
row.draggable = true;
|
||
row.addEventListener('dragstart', function (e) { window.app.modules.dnd.setDrag([d.key], e); });
|
||
var orig = el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : ''));
|
||
orig.title = 'Drag to another transmittal to move · click to preview';
|
||
row.appendChild(orig);
|
||
row.appendChild(el('span', 'tfile__arrow', '→'));
|
||
// Editable derived filename — edit it to re-file the item.
|
||
var name = el('input', 'tfile__name' + (d.errors.length ? ' tfile__name--err' : ''));
|
||
name.type = 'text';
|
||
name.value = d.filename || '';
|
||
name.placeholder = '(incomplete)';
|
||
name.title = d.errors.length ? d.errors.join('; ') : 'Edit this ZDDC filename to re-file the item';
|
||
row.appendChild(name);
|
||
var rm = el('button', 'tnode__act tfile__remove', '✕');
|
||
rm.dataset.act = 'untransmit';
|
||
rm.title = 'Remove from this transmittal';
|
||
row.appendChild(rm);
|
||
box.appendChild(row);
|
||
});
|
||
return box;
|
||
}
|
||
|
||
// ── name filter (the autofilter box above the target trees) ────────────
|
||
var rfTerms = [];
|
||
function setNameFilter(q) {
|
||
rfTerms = String(q || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||
render();
|
||
}
|
||
function rfActive() { return rfTerms.length > 0; }
|
||
function rfHit(text) {
|
||
if (!rfTerms.length) return true;
|
||
var t = String(text || '').toLowerCase();
|
||
for (var i = 0; i < rfTerms.length; i++) { if (t.indexOf(rfTerms[i]) === -1) return false; }
|
||
return true;
|
||
}
|
||
// A placed-file row matches on its original name or its derived ZDDC name.
|
||
function fileRowMatches(f) {
|
||
var orig = f.originalFilename + (f.extension ? '.' + f.extension : '');
|
||
return rfHit(orig) || rfHit(C().deriveTarget(f).filename || '');
|
||
}
|
||
|
||
// ── By-tracking: merged-cell table ──────────────────────────────────────
|
||
// The positional hierarchy reads left-to-right as columns (one per configured
|
||
// field), ancestor cells span their descendants' rows, and the revision (the
|
||
// leaf) gets its own aligned column. Each placed file is a row.
|
||
|
||
// A node is a revision leaf when its name ends in a "(STATUS)" we recognise —
|
||
// tracking field codes never carry a parenthesised status, so this cleanly
|
||
// separates "0001" (a SEQ field) from "A (IFR)" (a revision).
|
||
function revStatusOf(name) {
|
||
var m = /\(\s*([A-Za-z0-9-]{1,5})\s*\)\s*$/.exec(name || '');
|
||
return (m && window.zddc.isValidStatus(m[1])) ? m[1] : null;
|
||
}
|
||
function isRevisionLeaf(node) {
|
||
return !(node.children || []).length && revStatusOf(node.name) != null;
|
||
}
|
||
// Flatten the tree into rows: { path:[fieldNodes], rev:revNode|null, file }.
|
||
function buildTrackingRows(nodes, placedMap) {
|
||
var rows = [];
|
||
function emit(path, rev, files) {
|
||
var fs = (files && files.length) ? files : [null];
|
||
fs.forEach(function (f) { rows.push({ path: path, rev: rev, file: f }); });
|
||
}
|
||
function walk(node, ancestors) {
|
||
var placed = placedMap[node.id] || [];
|
||
if (isRevisionLeaf(node)) { emit(ancestors, node, placed); return; }
|
||
var myPath = ancestors.concat(node); // node is a tracking field segment
|
||
if (placed.length) emit(myPath, null, placed); // files dropped on a partial number
|
||
var kids = node.children || [];
|
||
if (kids.length) kids.forEach(function (c) { walk(c, myPath); });
|
||
else if (!placed.length) emit(myPath, null, []); // empty leaf = drop target
|
||
}
|
||
nodes.forEach(function (n) { walk(n, []); });
|
||
return rows;
|
||
}
|
||
function rowMatches(row) {
|
||
if (!rfActive()) return true;
|
||
if (row.file && fileRowMatches(row.file)) return true;
|
||
if (row.rev && rfHit(row.rev.name)) return true;
|
||
for (var i = 0; i < row.path.length; i++) { if (rfHit(row.path[i].name)) return true; }
|
||
return false;
|
||
}
|
||
|
||
function fieldCellContent(node) {
|
||
var inner = el('div', 'tcell__inner');
|
||
inner.appendChild(el('span', 'tcell__name', node.name));
|
||
inner.appendChild(nodeActions([
|
||
{ act: 'add', label: '+', title: 'Add child segment / revision' },
|
||
{ act: 'rename', label: '✎', title: 'Rename' },
|
||
{ act: 'del', label: '🗑', title: 'Delete' },
|
||
]));
|
||
return inner;
|
||
}
|
||
function revCellContent(node, placedMap) {
|
||
var inner = el('div', 'tcell__inner trev__inner');
|
||
// The revision name doubles as a preview link for its placed file (the
|
||
// common case is one file per revision). No count bubble.
|
||
var files = placedMap[node.id] || [];
|
||
if (files.length) {
|
||
var link = el('a', 'tcell__name tcell__preview', node.name);
|
||
link.href = '#';
|
||
link.dataset.previewKey = C().srcKeyForFile(files[0]);
|
||
link.title = 'Preview ' + files[0].originalFilename + (files[0].extension ? '.' + files[0].extension : '');
|
||
inner.appendChild(link);
|
||
} else {
|
||
inner.appendChild(el('span', 'tcell__name', node.name));
|
||
}
|
||
inner.appendChild(nodeActions([
|
||
{ act: 'rename', label: '✎', title: 'Rename revision' },
|
||
{ act: 'del', label: '🗑', title: 'Delete' },
|
||
]));
|
||
return inner;
|
||
}
|
||
// A placed-file cell: editable ZDDC name + validation badge; the original
|
||
// filename is on hover, not shown inline. Reuses .tfile/.tfile__name so the
|
||
// delegated preview + name-edit handlers apply.
|
||
function fileCellContent(f) {
|
||
var d = C().deriveTarget(f);
|
||
var conflict = C().hasHashConflict(d.key); // same name, different bytes
|
||
var bad = d.errors.length || conflict;
|
||
var row = el('div', 'tfile' + (bad ? ' tfile--err' : ''));
|
||
row.dataset.key = d.key;
|
||
var orig = f.originalFilename + (f.extension ? '.' + f.extension : '');
|
||
var name = el('input', 'tfile__name' + (bad ? ' tfile__name--err' : ''));
|
||
name.type = 'text';
|
||
name.value = d.filename || '';
|
||
name.placeholder = '(incomplete)';
|
||
name.title = (conflict ? 'Same tracking+revision as another file but DIFFERENT content — fix before copying · ' : '')
|
||
+ (d.errors.length ? d.errors.join('; ') + ' · ' : '') + 'original: ' + orig;
|
||
row.appendChild(name);
|
||
row.appendChild(el('span', 'tfile__badge' + (bad ? ' tfile__badge--err' : ' tfile__badge--ok'),
|
||
conflict ? '≠' : (d.errors.length ? '⚠' : '✓')));
|
||
return row;
|
||
}
|
||
|
||
function renderTrackingInto(container, nodes, placedMap) {
|
||
container.textContent = '';
|
||
if (!nodes.length) {
|
||
container.appendChild(el('div', 'target-empty', 'No tracking numbers yet — “+ Root folder” to start.'));
|
||
return;
|
||
}
|
||
var rows = buildTrackingRows(nodes, placedMap).filter(rowMatches);
|
||
if (!rows.length) {
|
||
container.appendChild(el('div', 'target-empty', rfActive() ? 'No matches in the tracking tree.' : 'No tracking numbers yet.'));
|
||
return;
|
||
}
|
||
var fields = C().getTrackingFields();
|
||
var maxPath = rows.reduce(function (m, r) { return Math.max(m, r.path.length); }, 0);
|
||
var nCols = Math.max(fields.length, maxPath);
|
||
|
||
function cellId(row, col) {
|
||
if (col < nCols) { var n = row.path[col]; return n ? n.id : null; }
|
||
return row.rev ? row.rev.id : null; // col === nCols → revision
|
||
}
|
||
// Rowspan run starting at row i for column col (0 = covered from above).
|
||
function spanAt(col, i) {
|
||
var id = cellId(rows[i], col);
|
||
if (id == null) return 1;
|
||
if (i > 0 && cellId(rows[i - 1], col) === id) return 0;
|
||
var span = 1;
|
||
for (var j = i + 1; j < rows.length; j++) { if (cellId(rows[j], col) === id) span++; else break; }
|
||
return span;
|
||
}
|
||
|
||
var table = el('table', 'ttable');
|
||
var thead = el('thead'), htr = el('tr');
|
||
for (var c = 0; c < nCols; c++) {
|
||
htr.appendChild(el('th', 'ttable__fh', fields[c] ? fields[c].name + (fields[c].optional ? ' ?' : '') : '·'));
|
||
}
|
||
htr.appendChild(el('th', 'ttable__rh', 'REVISION'));
|
||
htr.appendChild(el('th', 'ttable__fileh', 'Files'));
|
||
thead.appendChild(htr); table.appendChild(thead);
|
||
|
||
var tbody = el('tbody');
|
||
rows.forEach(function (row, i) {
|
||
var tr = el('tr');
|
||
for (var col = 0; col < nCols; col++) {
|
||
var span = spanAt(col, i);
|
||
if (span === 0) continue; // merged from the row above
|
||
var node = row.path[col] || null;
|
||
var td = el('td', 'ttable__cell' + (node ? '' : ' ttable__cell--empty'));
|
||
if (span > 1) td.rowSpan = span;
|
||
if (node) { td.dataset.id = node.id; td.appendChild(fieldCellContent(node)); }
|
||
tr.appendChild(td);
|
||
}
|
||
var rspan = spanAt(nCols, i);
|
||
if (rspan !== 0) {
|
||
var rtd = el('td', 'ttable__rev' + (row.rev ? '' : ' ttable__cell--empty'));
|
||
if (rspan > 1) rtd.rowSpan = rspan;
|
||
if (row.rev) { rtd.dataset.id = row.rev.id; rtd.appendChild(revCellContent(row.rev, placedMap)); }
|
||
tr.appendChild(rtd);
|
||
}
|
||
var ftd = el('td', 'ttable__file');
|
||
if (row.file) ftd.appendChild(fileCellContent(row.file));
|
||
else ftd.appendChild(el('span', 'ttable__drop', 'drop a file here'));
|
||
tr.appendChild(ftd);
|
||
tbody.appendChild(tr);
|
||
});
|
||
table.appendChild(tbody);
|
||
container.appendChild(table);
|
||
}
|
||
|
||
// Transmittal tree
|
||
function renderTransmittalInto(container, parties, placedMap) {
|
||
container.textContent = '';
|
||
if (!parties.length) {
|
||
container.appendChild(el('div', 'target-empty', 'No parties yet — “+ Party” to start.'));
|
||
return;
|
||
}
|
||
parties.forEach(function (p) { var e = partyNode(p, placedMap); if (e) container.appendChild(e); });
|
||
if (rfActive() && !container.children.length) {
|
||
container.appendChild(el('div', 'target-empty', 'No matches in the transmittal tree.'));
|
||
}
|
||
}
|
||
function partyNode(party, placedMap) {
|
||
var partyMatch = rfHit(party.name);
|
||
var slotEls = [], anyBin = false;
|
||
SLOTS.forEach(function (slot) {
|
||
var slotNode = (party.children || []).filter(function (s) { return s.slot === slot; })[0];
|
||
var sw = el('div', 'tslot');
|
||
sw.dataset.party = party.id;
|
||
sw.dataset.slot = slot;
|
||
var sr = el('div', 'tslot__row');
|
||
sr.appendChild(el('span', 'tslot__name', slot));
|
||
var addBtn = el('button', 'tnode__act', '+ Transmittal');
|
||
addBtn.dataset.act = 'addbin';
|
||
sr.appendChild(addBtn);
|
||
sw.appendChild(sr);
|
||
|
||
if (openForm && openForm.partyId === party.id && openForm.slot === slot) {
|
||
sw.appendChild(binForm(party.id, slot));
|
||
}
|
||
(slotNode ? slotNode.children : []).forEach(function (bin) {
|
||
var be = binNode(bin, placedMap, partyMatch);
|
||
if (be) { sw.appendChild(be); anyBin = true; }
|
||
});
|
||
slotEls.push(sw);
|
||
});
|
||
if (rfActive() && !partyMatch && !anyBin) return null;
|
||
|
||
var wrap = el('div', 'tnode tnode--party');
|
||
wrap.dataset.id = party.id;
|
||
var row = el('div', 'tnode__row');
|
||
row.appendChild(el('span', 'tnode__icon', '🏢'));
|
||
row.appendChild(el('span', 'tnode__name', party.name));
|
||
row.appendChild(nodeActions([
|
||
{ act: 'rename-party', label: '✎', title: 'Rename party' },
|
||
{ act: 'del-party', label: '🗑', title: 'Delete party' },
|
||
]));
|
||
wrap.appendChild(row);
|
||
slotEls.forEach(function (sw) { wrap.appendChild(sw); });
|
||
return wrap;
|
||
}
|
||
function binNode(bin, placedMap, ancMatched) {
|
||
var matched = ancMatched || rfHit(bin.name || '');
|
||
var placed = placedMap[bin.id] || [];
|
||
var shownFiles = (rfActive() && !matched) ? placed.filter(fileRowMatches) : placed;
|
||
if (rfActive() && !matched && !shownFiles.length) return null;
|
||
var wrap = el('div', 'tnode tnode--bin');
|
||
wrap.dataset.id = bin.id;
|
||
var row = el('div', 'tnode__row');
|
||
row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)'));
|
||
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
|
||
row.appendChild(nodeActions([
|
||
{ act: 'rename-bin', label: '✎', title: 'Rename transmittal' },
|
||
{ act: 'del', label: '🗑', title: 'Delete transmittal' },
|
||
]));
|
||
wrap.appendChild(row);
|
||
if (shownFiles.length) wrap.appendChild(fileList(shownFiles));
|
||
return wrap;
|
||
}
|
||
|
||
var STATUSES = ['---', 'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU', 'REC', 'RSA', 'RSB', 'RSC', 'RSD', 'RSI', 'TBD'];
|
||
function binForm(partyId, slot) {
|
||
var form = el('div', 'binform');
|
||
form.dataset.party = partyId;
|
||
form.dataset.slot = slot;
|
||
var date = el('input', 'binform__date'); date.type = 'date';
|
||
try { date.value = new Date().toISOString().slice(0, 10); } catch (_) { /* ok */ }
|
||
var type = document.createElement('select'); type.className = 'binform__type';
|
||
['TRN', 'SUB'].forEach(function (t) { var o = el('option', null, t); o.value = t; type.appendChild(o); });
|
||
var seq = el('input', 'binform__seq'); seq.type = 'text'; seq.placeholder = 'seq (e.g. 0007)';
|
||
var status = document.createElement('select'); status.className = 'binform__status';
|
||
STATUSES.forEach(function (s) { var o = el('option', null, s); o.value = s; status.appendChild(o); });
|
||
var title = el('input', 'binform__title'); title.type = 'text'; title.placeholder = 'title (optional)';
|
||
var add = el('button', 'btn btn-sm btn-primary', 'Add'); add.dataset.act = 'binadd';
|
||
var cancel = el('button', 'btn btn-sm btn-secondary', 'Cancel'); cancel.dataset.act = 'bincancel';
|
||
[date, type, seq, status, title, add, cancel].forEach(function (n) { form.appendChild(n); });
|
||
return form;
|
||
}
|
||
|
||
// ── By MDL (deliverables as drop targets via the shared seltable) ───────
|
||
function renderMdlInto(placedMdl) {
|
||
mdlPlaced = placedMdl || {};
|
||
if (!C().getMdlList().length) {
|
||
mdlTable = null;
|
||
els.mdlTree.textContent = '';
|
||
els.mdlTree.appendChild(el('div', 'target-empty', 'Nothing loaded yet — “Load…” to pull in the project’s MDL deliverables and archive tracking numbers.'));
|
||
return;
|
||
}
|
||
ensureMdlTable();
|
||
mdlTable.renderBody();
|
||
}
|
||
function ensureMdlTable() {
|
||
if (mdlTable) return mdlTable;
|
||
var c = C();
|
||
// One column per configured tracking-number field (split positionally),
|
||
// then Title, MDL (✓), Archive revisions (informational), and the editable
|
||
// classifier-local Revision. Each column has its own autofilter.
|
||
var cols = c.getTrackingFields().map(function (f, i) {
|
||
return { key: 'f' + i, title: f.name, get: function (r) { return (r.trackingNumber || '').split('-')[i] || ''; } };
|
||
});
|
||
cols.push({ key: 'title', title: 'Title', get: function (r) { return r.title || ''; } });
|
||
cols.push({ key: 'mdl', title: 'MDL', cls: 'catalog-mdl', get: function (r) { return r.inMdl ? '✓' : ''; } });
|
||
cols.push({ key: 'arev', title: 'Archive revs', get: function (r) { return (r.archiveRevisions || []).join(', '); } });
|
||
cols.push({
|
||
key: 'rev', title: 'Revision', cls: 'mdl-rev', get: function (r) { return r.revisionCell; },
|
||
render: function (r, td) {
|
||
var inp = document.createElement('input');
|
||
inp.type = 'text'; inp.className = 'mdl-rev__input'; inp.value = r.revisionCell || '';
|
||
inp.placeholder = 'A (IFR)'; inp.setAttribute('data-no-select', '');
|
||
inp.addEventListener('change', function () { c.setRevisionCell(r.id, inp.value.trim()); });
|
||
td.appendChild(inp);
|
||
},
|
||
});
|
||
mdlTable = window.app.modules.seltable.create({
|
||
container: els.mdlTree,
|
||
extraTitle: 'Files',
|
||
rows: function () { return c.getMdlList(); },
|
||
rowId: function (r) { return r.id; },
|
||
columns: cols,
|
||
onRowDrop: function (rowId, keys) { c.place(keys, rowId, 'mdl'); },
|
||
onActivate: function (ids) {
|
||
if (!ids.length) return;
|
||
var v = prompt('Set the revision on ' + ids.length + ' selected row(s) (e.g. "A (IFR)"):', '');
|
||
if (v != null) c.setRevisionCells(ids, v.trim());
|
||
},
|
||
rowExtra: function (r, td) { renderMdlPlaced(r, td); },
|
||
});
|
||
mdlTable.render();
|
||
return mdlTable;
|
||
}
|
||
function renderMdlPlaced(row, td) {
|
||
var c = C(), files = mdlPlaced[row.id] || [];
|
||
files.forEach(function (f) {
|
||
var d = c.deriveTarget(f);
|
||
var a = c.getAssignment(d.key) || {};
|
||
var line = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : ''));
|
||
line.dataset.key = d.key; line.draggable = true;
|
||
line.addEventListener('dragstart', function (e) { window.app.modules.dnd.setDrag([d.key], e); });
|
||
var nm = el('span', 'mdlfile__name', d.filename || '(set a revision)');
|
||
nm.title = 'from ' + f.originalFilename + (f.extension ? '.' + f.extension : '');
|
||
line.appendChild(nm);
|
||
var tgl = el('button', 'tnode__act', a.titleFromDeliverable === false ? 'Title: file' : 'Title: MDL');
|
||
tgl.title = 'Use the deliverable’s title or the file’s own';
|
||
tgl.addEventListener('click', function () { c.setTitleFromDeliverable(d.key, a.titleFromDeliverable === false); });
|
||
line.appendChild(tgl);
|
||
var rm = el('button', 'tnode__act tfile__remove', '✕');
|
||
rm.title = 'Remove from this deliverable';
|
||
rm.addEventListener('click', function () { c.place([d.key], null, 'mdl'); });
|
||
line.appendChild(rm);
|
||
td.appendChild(line);
|
||
});
|
||
}
|
||
|
||
// Load the catalog: the union of the project's MDL deliverables and its
|
||
// archive tracking numbers, deduped by tracking number. Server reads both;
|
||
// a local folder reads just its *.yaml deliverables. Writes/alters nothing —
|
||
// the revision cell is classifier-local and starts blank.
|
||
function isRowYaml(nm) { return /\.yaml$/i.test(nm) && nm !== 'table.yaml' && nm !== 'form.yaml'; }
|
||
async function loadMdl() {
|
||
if (window.zddc && window.zddc.source && location.protocol !== 'file:') return loadMdlServer();
|
||
return loadMdlLocal();
|
||
}
|
||
async function loadMdlLocal() {
|
||
if (!window.showDirectoryPicker) { window.zddc.toast('Loading a local MDL needs the File System Access API (Chromium).', 'error'); return; }
|
||
var dir;
|
||
try { dir = await window.showDirectoryPicker({ mode: 'read' }); }
|
||
catch (e) { if (e.name !== 'AbortError') window.zddc.toast('Could not open the folder — ' + (e.message || e), 'error'); return; }
|
||
var rows = [];
|
||
try {
|
||
for await (var entry of dir.values()) {
|
||
var nm = String(entry.name).replace(/\/$/, '');
|
||
if (entry.kind !== 'file' || !isRowYaml(nm)) continue;
|
||
var obj = null; try { obj = window.jsyaml.load(await (await entry.getFile()).text()); } catch (_) { /* skip */ }
|
||
var stem = nm.replace(/\.yaml$/i, '');
|
||
rows.push({ id: stem, party: dir.name || 'local', trackingNumber: stem, title: (obj && obj.title) || '', inMdl: true, archiveRevisions: [], revisionCell: '' });
|
||
}
|
||
} catch (e) { window.zddc.toast('Reading the MDL folder failed — ' + (e.message || e), 'error'); return; }
|
||
finishLoad(rows);
|
||
}
|
||
async function loadMdlServer() {
|
||
var copy = window.app.modules.copy, src = window.zddc.source;
|
||
var projects = await copy.fetchAccessProjects();
|
||
if (projects == null) { window.zddc.toast('Could not load your projects from the server.', 'error'); return; }
|
||
if (!projects.length) { window.zddc.toast('No projects you can access on this server.', 'warning'); return; }
|
||
var proj = await copy.chooseProject(projects);
|
||
if (!proj) return;
|
||
var rel = (proj.url || ('/' + proj.name + '/')); if (rel.charAt(rel.length - 1) !== '/') rel += '/';
|
||
var archive = new src.HttpDirectoryHandle(new URL(rel + 'archive/', location.origin).href, 'archive');
|
||
var byTn = Object.create(null);
|
||
function ensure(tn) { return byTn[tn] || (byTn[tn] = { tracking: tn, title: '', inMdl: false, party: '', revs: Object.create(null) }); }
|
||
window.zddc.toast('Scanning the project MDL + archive…', 'info', { durationMs: 4000 });
|
||
try {
|
||
for await (var party of archive.values()) {
|
||
if (party.kind !== 'directory') continue;
|
||
var pn = String(party.name).replace(/\/$/, '');
|
||
if (pn.charAt(0) === '.' || pn.charAt(0) === '_') continue;
|
||
for await (var slot of party.values()) {
|
||
if (slot.kind !== 'directory') continue;
|
||
var sn = String(slot.name).replace(/\/$/, '');
|
||
if (sn.charAt(0) === '.' || sn.charAt(0) === '_' || sn === 'rsk') continue;
|
||
if (sn === 'mdl') { // registered deliverables
|
||
for await (var ye of slot.values()) {
|
||
var ynm = String(ye.name).replace(/\/$/, '');
|
||
if (ye.kind !== 'file' || !isRowYaml(ynm)) continue;
|
||
var obj = null; try { obj = window.jsyaml.load(await (await ye.getFile()).text()); } catch (_) { /* skip */ }
|
||
var row = ensure(ynm.replace(/\.yaml$/i, ''));
|
||
row.inMdl = true; if (!row.title && obj && obj.title) row.title = obj.title; if (!row.party) row.party = pn;
|
||
}
|
||
} else { // archive documents → tracking + revisions
|
||
await walkArchiveInto(slot, ensure, pn);
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { window.zddc.toast('Reading the project failed — ' + (e.message || e), 'error'); return; }
|
||
var rows = Object.keys(byTn).map(function (tn) {
|
||
var x = byTn[tn];
|
||
return { id: tn, party: x.party, trackingNumber: tn, title: x.title, inMdl: x.inMdl, archiveRevisions: Object.keys(x.revs).sort(), revisionCell: '' };
|
||
});
|
||
finishLoad(rows);
|
||
}
|
||
// Recursively collect ZDDC-named archive files under a slot → tracking +
|
||
// the set of revisions seen for each.
|
||
async function walkArchiveInto(dirH, ensure, party) {
|
||
for await (var entry of dirH.values()) {
|
||
var nm = String(entry.name).replace(/\/$/, '');
|
||
if (entry.kind === 'directory') {
|
||
if (nm.charAt(0) === '.' || nm.charAt(0) === '_') continue;
|
||
await walkArchiveInto(await dirH.getDirectoryHandle(nm), ensure, party);
|
||
} else {
|
||
var p = window.zddc.parseFilename(nm);
|
||
if (p && p.valid && p.trackingNumber) {
|
||
var row = ensure(p.trackingNumber);
|
||
if (!row.title) row.title = p.title || '';
|
||
if (!row.party) row.party = party;
|
||
row.revs[(p.revision + (p.status ? ' (' + p.status + ')' : '')).trim()] = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
function finishLoad(rows) {
|
||
C().setMdlList(rows);
|
||
openCatalog();
|
||
window.zddc.toast(rows.length
|
||
? ('Catalog: ' + rows.length + ' tracking number' + (rows.length === 1 ? '' : 's') + ' (MDL ∪ archive). Filter, drag files on, set revisions.')
|
||
: 'Nothing found in the MDL or archive.', rows.length ? 'success' : 'warning');
|
||
}
|
||
|
||
// ── events ─────────────────────────────────────────────────────────────
|
||
function closestNodeId(target) {
|
||
var n = target.closest('[data-id]');
|
||
return n ? n.dataset.id : null;
|
||
}
|
||
function fileByKey(key) {
|
||
var files = allFiles();
|
||
for (var i = 0; i < files.length; i++) { if (C().srcKeyForFile(files[i]) === key) return files[i]; }
|
||
return null;
|
||
}
|
||
// Click a placed-file row (anywhere but its editable name) → preview it.
|
||
function previewFromTarget(e) {
|
||
// Preview link on a revision cell (its placed file).
|
||
var pl = e.target.closest('[data-preview-key]');
|
||
if (pl) {
|
||
e.preventDefault();
|
||
var pf = fileByKey(pl.dataset.previewKey);
|
||
if (pf && window.app.modules.preview && window.app.modules.preview.previewFile) {
|
||
window.app.modules.preview.previewFile(pf);
|
||
}
|
||
return true;
|
||
}
|
||
if (e.target.closest('[data-act]')) return false; // action button — not a preview
|
||
if (e.target.closest('.tfile__name')) return false;
|
||
var tf = e.target.closest('.tfile');
|
||
if (!tf || !tf.dataset.key) return false;
|
||
var f = fileByKey(tf.dataset.key);
|
||
if (f && window.app.modules.preview && window.app.modules.preview.previewFile) {
|
||
window.app.modules.preview.previewFile(f);
|
||
}
|
||
return true;
|
||
}
|
||
// Edited a placed-file's ZDDC filename → re-derive its tracking placement
|
||
// (creating the folder path if needed) + its title override.
|
||
function onFileNameChange(e) {
|
||
var input = e.target.closest('.tfile__name');
|
||
if (input) commitFilenameEdit(input);
|
||
}
|
||
function commitFilenameEdit(input) {
|
||
var tf = input.closest('.tfile');
|
||
if (!tf || !tf.dataset.key) return;
|
||
var parsed = window.zddc.parseFilename((input.value || '').trim());
|
||
if (!parsed || !parsed.valid) {
|
||
window.zddc.toast('Not a valid ZDDC filename — expected "TRACKING_REV (STATUS) - Title.ext".', 'warning');
|
||
render(); // restore the derived value
|
||
return;
|
||
}
|
||
var stem = parsed.trackingNumber + '_' + parsed.revision + ' (' + parsed.status + ')';
|
||
var leaf = C().addTrackingPath(null, C().parseFolderLevels(stem));
|
||
C().place([tf.dataset.key], leaf, 'tracking');
|
||
if (parsed.title != null) C().setTitleOverride(tf.dataset.key, parsed.title);
|
||
// place/setTitleOverride fire classify.notify → re-render.
|
||
}
|
||
// Collapse/expand a node and its whole subtree (ctrl/cmd-click a toggle).
|
||
function setSubtreeCollapsed(nodeId, collapse) {
|
||
var node = C().getNode(nodeId);
|
||
if (!node) return;
|
||
(function walk(n) {
|
||
if ((n.children || []).length) { if (collapse) collapsed[n.id] = true; else delete collapsed[n.id]; }
|
||
(n.children || []).forEach(walk);
|
||
})(node);
|
||
}
|
||
function onTrackingClick(e) {
|
||
if (previewFromTarget(e)) return;
|
||
var btn = e.target.closest('[data-act]');
|
||
if (!btn) return;
|
||
var act = btn.dataset.act;
|
||
var id = closestNodeId(btn);
|
||
if (act === 'toggle') {
|
||
var collapse = !collapsed[id];
|
||
if (e.ctrlKey || e.metaKey) setSubtreeCollapsed(id, collapse);
|
||
else if (collapse) collapsed[id] = true; else delete collapsed[id];
|
||
render();
|
||
return;
|
||
}
|
||
if (act === 'add') {
|
||
var name = prompt('Child folder name (next tracking segment, or a leaf revision like "A (IFR)").\n'
|
||
+ 'Brace patterns expand: {PM,EL,EM}-MOM-{0001-0002,0005}_A (IFR)', '');
|
||
addFoldersFromPattern(id, name);
|
||
} else if (act === 'rename') {
|
||
var node = C().getNode(id);
|
||
var nn = prompt('Rename folder:', node ? node.name : '');
|
||
if (nn && nn.trim()) C().renameNode(id, nn.trim());
|
||
} else if (act === 'del') {
|
||
if (confirm('Delete this folder and everything under it? Files placed here become unassigned.')) C().deleteNode(id);
|
||
}
|
||
}
|
||
function onTransmittalClick(e) {
|
||
if (previewFromTarget(e)) return;
|
||
var btn = e.target.closest('[data-act]');
|
||
if (!btn) return;
|
||
var act = btn.dataset.act;
|
||
|
||
if (act === 'addbin') {
|
||
var slotEl = btn.closest('.tslot');
|
||
openForm = { partyId: slotEl.dataset.party, slot: slotEl.dataset.slot };
|
||
render();
|
||
return;
|
||
}
|
||
if (act === 'untransmit') {
|
||
var tf = btn.closest('.tfile');
|
||
if (tf && tf.dataset.key) C().place([tf.dataset.key], null, 'transmittal');
|
||
return;
|
||
}
|
||
if (act === 'rename-bin') {
|
||
var bid = closestNodeId(btn);
|
||
var bn = C().getNode(bid);
|
||
var nn = prompt('Rename transmittal (this becomes its folder name):', bn ? bn.name : '');
|
||
if (nn && nn.trim()) C().renameNode(bid, nn.trim());
|
||
return;
|
||
}
|
||
if (act === 'bincancel') { openForm = null; render(); return; }
|
||
if (act === 'binadd') {
|
||
var form = btn.closest('.binform');
|
||
var meta = {
|
||
date: form.querySelector('.binform__date').value,
|
||
type: form.querySelector('.binform__type').value,
|
||
seq: form.querySelector('.binform__seq').value.trim(),
|
||
status: form.querySelector('.binform__status').value,
|
||
title: form.querySelector('.binform__title').value.trim(),
|
||
};
|
||
if (!meta.date || !meta.seq) { window.zddc.toast('Transmittal needs at least a date and a sequence number.', 'warning'); return; }
|
||
C().addTransmittalBin(form.dataset.party, form.dataset.slot, meta);
|
||
openForm = null; // render() fires from classify.notify()
|
||
return;
|
||
}
|
||
|
||
var id = closestNodeId(btn);
|
||
if (act === 'rename-party') {
|
||
var node = C().getNode(id);
|
||
var nn = prompt('Rename party (re-derives its transmittal numbers):', node ? node.name : '');
|
||
if (nn && nn.trim()) C().renameNode(id, nn.trim());
|
||
} else if (act === 'del-party') {
|
||
if (confirm('Delete this party and all its transmittals? Files placed there become unassigned.')) C().deleteNode(id);
|
||
} else if (act === 'del') {
|
||
if (confirm('Delete this transmittal? Files placed here become unassigned.')) C().deleteNode(id);
|
||
}
|
||
}
|
||
|
||
// ── drop targets ───────────────────────────────────────────────────────
|
||
// Resolve the drop target under an event:
|
||
// tracking → any folder node (.tnode)
|
||
// transmittal → a transmittal bin only (.tnode--bin)
|
||
function dropTarget(target, axis) {
|
||
if (axis === 'transmittal') {
|
||
var bin = target.closest('.tnode--bin');
|
||
if (!bin || !bin.dataset.id) return null;
|
||
return { id: bin.dataset.id, row: bin.querySelector('.tnode__row') || bin };
|
||
}
|
||
var cell = target.closest('.ttable__cell[data-id], .ttable__rev[data-id]');
|
||
if (!cell) return null;
|
||
return { id: cell.dataset.id, row: cell };
|
||
}
|
||
function clearHover(container) {
|
||
var hot = container.querySelectorAll('.drop-hover');
|
||
for (var i = 0; i < hot.length; i++) hot[i].classList.remove('drop-hover');
|
||
}
|
||
function setupDropZone(container, axis) {
|
||
container.addEventListener('dragover', function (e) {
|
||
if (!window.app.modules.dnd.active()) return;
|
||
var t = dropTarget(e.target, axis);
|
||
clearHover(container);
|
||
if (!t) return;
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'copy';
|
||
t.row.classList.add('drop-hover');
|
||
});
|
||
container.addEventListener('dragleave', function (e) {
|
||
if (e.target === container) clearHover(container);
|
||
});
|
||
container.addEventListener('drop', function (e) {
|
||
var t = dropTarget(e.target, axis);
|
||
clearHover(container);
|
||
if (!t) return;
|
||
e.preventDefault();
|
||
var keys = window.app.modules.dnd.getDrag();
|
||
window.app.modules.dnd.clearDrag();
|
||
if (!keys.length) return;
|
||
if (axis === 'tracking') placeTrackingDrop(keys, t.id);
|
||
else C().place(keys, t.id, axis);
|
||
});
|
||
}
|
||
|
||
// Tracking drop: if the target is already a complete leaf, assign directly;
|
||
// otherwise prompt for the remaining levels (parsed + nested under it) so a
|
||
// file can be dropped on an existing partial tracking number and completed.
|
||
function placeTrackingDrop(keys, nodeId) {
|
||
if (C().trackingNodeComplete(nodeId)) { C().place(keys, nodeId, 'tracking'); return; }
|
||
var label = C().trackingPathLabel(nodeId);
|
||
var input = prompt('Dropping under "' + label + '".\n'
|
||
+ 'Add the remaining tracking levels (e.g. "0001_0 (IFU)"), or leave blank to drop here:', '');
|
||
if (input === null) return; // cancelled
|
||
var levels = C().parseFolderLevels(input.trim());
|
||
var target = levels.length ? C().addTrackingPath(nodeId, levels) : nodeId;
|
||
C().place(keys, target, 'tracking');
|
||
}
|
||
|
||
// Reveal a source key's placement in the target pane (source → target).
|
||
function reveal(key) {
|
||
var a = C().getAssignment(key);
|
||
if (!a) return;
|
||
if (a.mdlNodeId) {
|
||
openCatalog();
|
||
if (mdlTable) { mdlTable.renderBody(); }
|
||
} else if (a.trackingNodeId) {
|
||
showTab('tracking'); collapsed = {}; render();
|
||
flashNode(els.trackingTree, a.trackingNodeId);
|
||
} else if (a.transmittalNodeId) {
|
||
showTab('transmittal'); render();
|
||
flashNode(els.transmittalTree, a.transmittalNodeId);
|
||
}
|
||
}
|
||
function flashNode(container, id) {
|
||
var node = container.querySelector('[data-id="' + id + '"]');
|
||
if (!node) return;
|
||
node.scrollIntoView({ block: 'center' });
|
||
var row = node.querySelector('.tnode__row') || node;
|
||
row.classList.add('reveal-flash');
|
||
setTimeout(function () { row.classList.remove('reveal-flash'); }, 1500);
|
||
}
|
||
|
||
window.app.modules.targetTree = {
|
||
init: init,
|
||
render: render,
|
||
showTab: showTab,
|
||
activeAxis: activeAxis,
|
||
setNameFilter: setNameFilter,
|
||
reveal: reveal,
|
||
};
|
||
})();
|