ZDDC/classifier/js/target-tree.js
ZDDC 2b32aced6d feat(classifier): By Tracking Number is now a flat editable grid (one row per file)
Replace the merged-cell positional table (one column per tracking-number segment,
hierarchy via shared ancestors, built by creating folders) with a plain editable
spreadsheet: one row per file, with the tracking number, the rev (status), and
the title as three separate editable columns. Columns are hideable + resizable.

The storage model is unchanged — a file's tracking identity is still its
placement in the tracking-folder tree. The grid is a flat presentation + inline-
edit layer over it; editing a cell re-materializes the placement via the existing
path (addTrackingPath → place(…,'tracking') → setTitleOverride), generalized to
per-field.

- classify.js: `trackingWorkset` (serialized) so a dropped file is a row before
  it has a number; `addToTrackingGrid`/`removeFromTrackingGrid`/`trackingGridKeys`
  (union with files that have a tracking placement — incl. ones named via "From a
  list"); `setFileIdentity(key, {tracking, rev, title})` re-files + prunes the old
  leaf; blank tracking = an unfilled row, blank rev = a PENDING_REV leaf.
- target-tree.js: `renderTrackingGrid` (Status badge · Original name preview ·
  Tracking number · Rev (status) · Title · ✕); drag onto the grid adds rows and
  auto-fills any file whose own name already parses as ZDDC; a "Columns ▾" chooser
  + drag-resize (resize.js, now parameterized) persisted to localStorage. The
  status badge validates the NAME only (the transmittal is a different tab).
  Removed the merged-cell machinery + per-node CRUD (+ Root folder, ✎/🗑, brace
  expansion) and the now-dead drop-on-node path.
- template/css: tracking toolbar → Columns chooser + hint; flat-grid + chooser CSS.

Tests: replaced the merged-cell/+Root-folder/drop-on-leaf/filename-edit tests with
grid tests (render, drop+auto-fill, per-cell re-file, filter, hide/persist,
preview link). Suite 342 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:51:39 -05:00

1019 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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' | 'worklist' | 'transmittal' — active tab
var worklistGrid = null; // the seltable controller for the "From a list" tab
var worklistPlaced = {}; // trackingNumber -> placed files (read by the Files cell)
var hideAssigned = false; // "Hide assigned" toggle in the From-a-list toolbar
var listScanned = false; // a Load has run this session (drives the "new" badge)
function init() {
if (initialized) return;
initialized = true;
els = {
trackingTab: document.getElementById('trackingTab'),
worklistTab: document.getElementById('worklistTab'),
transmittalTab: document.getElementById('transmittalTab'),
trackingPanel: document.getElementById('trackingPanel'),
transmittalPanel: document.getElementById('transmittalPanel'),
worklistPanel: document.getElementById('worklistPanel'),
trackingTree: document.getElementById('trackingTree'),
transmittalTree: document.getElementById('transmittalTree'),
worklistTable: document.getElementById('worklistTable'),
loadWorklistBtn: document.getElementById('loadWorklistBtn'),
pasteRowsBtn: document.getElementById('pasteRowsBtn'),
matchNamesBtn: document.getElementById('matchNamesBtn'),
clearListBtn: document.getElementById('clearListBtn'),
hideAssignedToggle: document.getElementById('hideAssignedToggle'),
trackingColsBtn: document.getElementById('trackingColsBtn'),
addPartyBtn: document.getElementById('addPartyBtn'),
stats: document.getElementById('classifyStats'),
};
els.trackingTab.addEventListener('click', function () { showTab('tracking'); });
if (els.worklistTab) els.worklistTab.addEventListener('click', function () { showTab('worklist'); });
els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); });
if (els.loadWorklistBtn) els.loadWorklistBtn.addEventListener('click', loadWorklist);
if (els.pasteRowsBtn) els.pasteRowsBtn.addEventListener('click', function () { openPasteDialog(''); });
if (els.matchNamesBtn) els.matchNamesBtn.addEventListener('click', openMatchDialog);
if (els.clearListBtn) els.clearListBtn.addEventListener('click', function () {
var list = C().getWorklist();
if (!list.length) return;
// Warn before stranding files that still need a revision: they stay
// assigned (on a "pending" leaf under By tracking number), but the
// row you'd use to finish them here is about to disappear.
var pending = 0;
list.forEach(function (r) { if (!(r.revisionCell || '').trim()) pending += Object.keys(r.placed || {}).length; });
if (pending && !confirm(pending + ' file' + (pending === 1 ? '' : 's') + ' still need a revision. They stay assigned (a “pending” folder under By tracking number), but the list row to finish them here goes away. Clear anyway?')) return;
C().clearWorklist();
window.zddc.toast('List cleared — every assignment is kept (see By tracking number).', 'info');
});
if (els.hideAssignedToggle) els.hideAssignedToggle.addEventListener('change', function () {
hideAssigned = !!els.hideAssignedToggle.checked;
if (worklistGrid) worklistGrid.renderBody();
});
// Ctrl-V anywhere on the From-a-list panel opens the paste dialog prefilled.
if (els.worklistPanel) els.worklistPanel.addEventListener('paste', function (e) {
if (currentTab !== 'worklist') return;
if (e.target && e.target.closest('input, textarea')) return; // let real inputs paste
var t = (e.clipboardData || window.clipboardData);
var text = t ? t.getData('text') : '';
if (text) { e.preventDefault(); openPasteDialog(text); }
});
if (els.trackingColsBtn) els.trackingColsBtn.addEventListener('click', openColumnChooser);
els.addPartyBtn.addEventListener('click', function () {
var name = prompt('Party name (also the transmittal-number prefix):', '');
if (name && name.trim()) C().addParty(name.trim());
});
els.transmittalTree.addEventListener('click', onTransmittalClick);
els.transmittalTree.addEventListener('change', onFileNameChange);
setupGridDrop(els.trackingTree);
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 = {}, byTn = {};
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);
// Also index by tracking NUMBER so a "From a list" row can show
// the files placed under it (a row is a tracking number, not a node).
var tn = c.deriveTarget(f).tracking;
if (tn) (byTn[tn] = byTn[tn] || []).push(f);
}
if (a.transmittalNodeId) (byX[a.transmittalNodeId] = byX[a.transmittalNodeId] || []).push(f);
});
return { tracking: byT, transmittal: byX, byTracking: byTn };
}
function showTab(which) {
currentTab = (which === 'transmittal' || which === 'worklist') ? which : 'tracking';
els.trackingTab.classList.toggle('active', currentTab === 'tracking');
if (els.worklistTab) els.worklistTab.classList.toggle('active', currentTab === 'worklist');
els.transmittalTab.classList.toggle('active', currentTab === 'transmittal');
els.trackingPanel.hidden = currentTab !== 'tracking';
if (els.worklistPanel) els.worklistPanel.hidden = currentTab !== 'worklist';
els.transmittalPanel.hidden = currentTab !== 'transmittal';
render();
// The source-tree Show filters are per-axis, so the visible set changes
// with the active tab — re-render the left tree.
reRenderSource();
}
// "From a list" drops materialize tracking placements, so its axis is 'tracking'.
function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : 'tracking'; }
function reRenderSource() { if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); }
// ── render ───────────────────────────────────────────────────────────────
function render() {
if (!initialized || !C().isEnabled()) return;
var files = allFiles();
var placed = buildPlaced(files);
renderTrackingGrid(els.trackingTree);
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal);
renderWorklist(placed.byTracking);
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: flat editable grid (one row per file) ──────────────────
var GRID_COLS = [
{ id: 'status', title: '', cls: 'tg-status', fixed: true },
{ id: 'orig', title: 'Original name', cls: 'tg-orig' },
{ id: 'tn', title: 'Tracking number', cls: 'tg-tn' },
{ id: 'rev', title: 'Rev (status)', cls: 'tg-rev' },
{ id: 'title', title: 'Title', cls: 'tg-title' },
{ id: 'x', title: '', cls: 'tg-x', fixed: true },
];
var GRID_PREFS_KEY = 'zddc.classifier.trackingCols';
function gridPrefs() { try { return JSON.parse(localStorage.getItem(GRID_PREFS_KEY)) || {}; } catch (_) { return {}; } }
function saveGridPrefs(p) { try { localStorage.setItem(GRID_PREFS_KEY, JSON.stringify(p)); } catch (_) { /* private mode */ } }
// A file's current identity, read from the placement model so per-field edits
// keep the fields they didn't touch.
function currentIdent(f) {
var d = C().deriveTarget(f);
return { tracking: d.tracking || '', rev: (d.revision || '') + (d.status ? ' (' + d.status + ')' : ''), title: d.title || '' };
}
function gridTnWarn(tn) {
tn = (tn || '').trim(); if (!tn) return '';
var n = tn.split('-').length, want = C().getTrackingFields().length;
return (n < want - 1 || n > want) ? ('Has ' + n + ' segments; the pattern expects ' + want + '.') : '';
}
function previewKey(key) {
var f = fileByKey(key);
if (f && window.app.modules.preview && window.app.modules.preview.previewFile) window.app.modules.preview.previewFile(f);
}
// The By-tracking grid validates the NAME only — the transmittal (path) is a
// different tab, so its "not placed in a transmittal" error doesn't count here.
function nameErrors(d) { return (d.errors || []).filter(function (e) { return e.indexOf('transmittal') === -1; }); }
function renderTrackingGrid(container) {
container.textContent = '';
var c = C();
var files = c.trackingGridKeys().map(fileByKey).filter(Boolean)
.filter(function (f) { return !rfActive() || fileRowMatches(f); });
if (!files.length) {
container.appendChild(el('div', 'target-empty', rfActive() ? 'No matches.'
: 'No files yet — drag files here from the left, then type each ones tracking number, revision, and title. A file thats already ZDDC-named fills in automatically.'));
return;
}
var prefs = gridPrefs(), hidden = prefs.hidden || {}, widths = prefs.widths || {};
var cols = GRID_COLS.filter(function (col) { return !hidden[col.id]; });
var table = el('table', 'ttable ttable--grid');
var thead = el('thead'), htr = el('tr');
cols.forEach(function (col) {
var th = el('th', 'tg-th ' + col.cls, col.title);
th.dataset.col = col.id;
if (widths[col.id]) { th.style.width = th.style.minWidth = th.style.maxWidth = widths[col.id] + 'px'; }
htr.appendChild(th);
});
thead.appendChild(htr); table.appendChild(thead);
var tbody = el('tbody');
files.forEach(function (f) {
var key = c.srcKeyForFile(f), d = c.deriveTarget(f);
var bad = nameErrors(d).length || c.hasHashConflict(key);
var tr = el('tr', 'tg-row' + (bad ? ' tg-row--err' : ''));
tr.dataset.key = key;
cols.forEach(function (col) {
var td = el('td', 'tg-td ' + col.cls);
buildGridCell(col.id, td, f, key);
tr.appendChild(td);
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
container.appendChild(table);
if (window.app.modules.resize && window.app.modules.resize.init) window.app.modules.resize.init(table, persistColWidths);
}
function buildGridCell(colId, td, f, key) {
var c = C(), d = c.deriveTarget(f), conflict = c.hasHashConflict(key);
if (colId === 'status') {
var ne = nameErrors(d), ok = !ne.length && !conflict;
var badge = el('span', 'tfile__badge ' + (ok ? 'tfile__badge--ok' : 'tfile__badge--err'), conflict ? '≠' : (ne.length ? '⚠' : '✓'));
badge.title = (conflict ? 'Same tracking+revision as another file but DIFFERENT content. ' : '') + (ne.length ? ne.join('; ') : 'Complete');
td.appendChild(badge); return;
}
if (colId === 'orig') {
var orig = f.originalFilename + (f.extension ? '.' + f.extension : '');
var link = el('a', 'tg-orig__link', orig);
link.href = '#'; link.title = 'Preview ' + orig;
link.addEventListener('click', function (e) { e.preventDefault(); previewKey(key); });
td.appendChild(link); return;
}
if (colId === 'x') {
var rm = el('button', 'tnode__act tg-x__btn', '✕');
rm.title = 'Remove from the grid';
rm.addEventListener('click', function () { c.removeFromTrackingGrid(key); });
td.appendChild(rm); return;
}
// editable: tn / rev / title
var ident = currentIdent(f);
var value = colId === 'tn' ? ident.tracking : colId === 'rev' ? ident.rev : ident.title;
var ph = colId === 'tn' ? 'ACME-…-0001' : colId === 'rev' ? 'A (IFR)' : 'title';
var warn = colId === 'tn' ? gridTnWarn(ident.tracking) : '';
var inp = el('input', 'tg-input' + (warn ? ' is-warn' : ''));
inp.type = 'text'; inp.value = value || ''; inp.placeholder = ph; inp.spellcheck = false;
if (warn) inp.title = warn;
inp.addEventListener('change', function () {
var cur = currentIdent(f); // re-read so a prior edit isn't clobbered
if (colId === 'tn') cur.tracking = inp.value.trim();
else if (colId === 'rev') cur.rev = inp.value.trim();
else cur.title = inp.value;
c.setFileIdentity(key, cur);
});
td.appendChild(inp);
}
function persistColWidths(table) {
var p = gridPrefs(); p.widths = p.widths || {};
Array.prototype.forEach.call(table.querySelectorAll('thead th[data-col]'), function (th) { p.widths[th.dataset.col] = Math.round(th.offsetWidth); });
saveGridPrefs(p);
}
// Drag files onto the grid → add as rows; auto-fill any already ZDDC-named.
function setupGridDrop(container) {
container.addEventListener('dragover', function (e) {
if (!window.app.modules.dnd.active()) return;
e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; container.classList.add('tg-drop-hover');
});
container.addEventListener('dragleave', function (e) { if (e.target === container) container.classList.remove('tg-drop-hover'); });
container.addEventListener('drop', function (e) {
container.classList.remove('tg-drop-hover');
e.preventDefault();
var keys = window.app.modules.dnd.getDrag(); window.app.modules.dnd.clearDrag();
if (keys.length) onGridDrop(keys);
});
}
function onGridDrop(keys) {
var c = C();
c.addToTrackingGrid(keys);
keys.forEach(function (k) {
var f = fileByKey(k); if (!f) return;
var p = window.zddc.parseFilename(f.originalFilename + (f.extension ? '.' + f.extension : ''));
if (p && p.valid && p.trackingNumber) c.setFileIdentity(k, { tracking: p.trackingNumber, rev: p.revision + (p.status ? ' (' + p.status + ')' : ''), title: p.title || '' });
});
}
// "Columns ▾" chooser — hide/show grid columns (status + ✕ always shown).
function openColumnChooser() {
var open = document.querySelector('.col-chooser');
if (open) { open.remove(); return; }
var hidden = (gridPrefs().hidden || {});
var menu = el('div', 'col-chooser');
GRID_COLS.forEach(function (col) {
if (col.fixed) return;
var lbl = el('label', 'col-chooser__item');
var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !hidden[col.id];
cb.addEventListener('change', function () {
var p = gridPrefs(); p.hidden = p.hidden || {};
if (cb.checked) delete p.hidden[col.id]; else p.hidden[col.id] = true;
saveGridPrefs(p); render();
});
lbl.appendChild(cb); lbl.appendChild(document.createTextNode(' ' + col.title));
menu.appendChild(lbl);
});
var r = els.trackingColsBtn.getBoundingClientRect();
menu.style.top = (r.bottom + 2) + 'px'; menu.style.left = r.left + 'px';
document.body.appendChild(menu);
setTimeout(function () {
function off(e) { if (!menu.contains(e.target) && e.target !== els.trackingColsBtn) { menu.remove(); document.removeEventListener('mousedown', off); } }
document.addEventListener('mousedown', off);
}, 0);
}
// 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;
}
// ── "From a list" (scratch worklist via the shared seltable) ────────────
function renderWorklist(placedByTracking) {
worklistPlaced = placedByTracking || {};
if (!C().getWorklist().length) {
worklistGrid = null;
els.worklistTable.textContent = '';
els.worklistTable.appendChild(el('div', 'target-empty', 'Empty — “Load…” numbers from the archive/MDL, “Paste rows…” from Excel, or “⚡ Match names”. Then drag files onto a row to name them. The list is a scratch pad — clearing it keeps every assignment (see By tracking number).'));
return;
}
ensureWorklistGrid();
worklistGrid.renderBody();
}
function rowPlaced(r) { var f = worklistPlaced[r.trackingNumber]; return f && f.length ? f : null; }
function ensureWorklistGrid() {
if (worklistGrid) return worklistGrid;
var c = C();
var cols = [
{ key: 'tn', title: 'Tracking number', cls: 'worklist-tn', get: function (r) { return r.trackingNumber || ''; },
render: function (r, td) { editCell(td, 'worklist-tn__input', r.trackingNumber, 'ACME-…-0001', function (v) { c.setRowTracking(r.id, v); }, tnWarn(r)); } },
{ key: 'title', title: 'Title', cls: 'worklist-title', get: function (r) { return r.title || ''; },
render: function (r, td) { editCell(td, 'worklist-title__input', r.title, 'title', function (v) { c.setRowTitle(r.id, v); }); } },
{ key: 'cur', title: 'Current name', cls: 'worklist-cur', get: function (r) { return r.currentName || ''; } },
{ key: 'src', title: 'Source', cls: 'worklist-src', get: function (r) { var s = r.source || {}; return [s.mdl ? 'mdl' : '', s.archive ? 'arch' : '', s.pasted ? 'pasted' : ''].filter(Boolean).join(' '); },
render: function (r, td) { renderSource(r, td); } },
{ key: 'latest', title: 'Latest rev', get: function (r) { return latestRevOf(r.archiveRevisions); } },
{ key: 'rev', title: 'Revision', cls: 'worklist-rev', get: function (r) { return r.revisionCell; },
render: function (r, td) { editCell(td, 'worklist-rev__input', r.revisionCell, 'A (IFR)', function (v) { c.setRevisionCell(r.id, v); }); } },
];
worklistGrid = window.app.modules.seltable.create({
container: els.worklistTable,
extraTitle: 'Files',
rows: function () {
var list = c.getWorklist();
return hideAssigned ? list.filter(function (r) { return !rowPlaced(r); }) : list;
},
rowId: function (r) { return r.id; },
columns: cols,
onRowDrop: function (rowId, keys) { var row = c.getWorklistRow(rowId); if (row) c.assignFromRow(keys, row); },
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) { renderWorklistFiles(r, td); },
});
worklistGrid.render();
return worklistGrid;
}
// An editable seltable cell: an <input> that commits on change. `warn` is an
// optional tooltip that flags (without blocking) a questionable value.
function editCell(td, cls, value, placeholder, onCommit, warn) {
var inp = document.createElement('input');
inp.type = 'text'; inp.className = cls + (warn ? ' is-warn' : ''); inp.value = value || '';
inp.placeholder = placeholder || ''; inp.spellcheck = false; inp.setAttribute('data-no-select', '');
if (warn) inp.title = warn;
inp.addEventListener('change', function () { onCommit(inp.value.trim()); });
td.appendChild(inp);
}
function tnWarn(r) {
var tn = (r.trackingNumber || '').trim(); if (!tn) return '';
var n = tn.split('-').length, want = C().getTrackingFields().length;
return n < want - 1 || n > want ? ('Has ' + n + ' segments; the pattern expects ' + want + '.') : '';
}
function renderSource(row, td) {
var s = row.source || {};
if (s.mdl) td.appendChild(el('span', 'src-badge src-badge--mdl', 'MDL'));
if (s.archive) td.appendChild(el('span', 'src-badge src-badge--arch', 'arch'));
if (s.pasted && !s.mdl && !s.archive) {
// A pasted number matching nothing known: a likely typo / a brand-new number.
var isNew = listScanned;
var b = el('span', 'src-badge src-badge--new', isNew ? 'new' : 'unverified');
b.title = isNew ? 'This tracking number isnt in the scanned archive/MDL — youre inventing it.' : 'Not checked against the archive/MDL — Load a directory to verify.';
td.appendChild(b);
} else if (s.pasted) {
td.appendChild(el('span', 'src-badge src-badge--pasted', 'pasted'));
}
}
function renderWorklistFiles(row, td) {
var c = C(), files = rowPlaced(row) || [];
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 usingRow = a.titleOverride != null && row.title && a.titleOverride === row.title.trim();
var tgl = el('button', 'tnode__act', usingRow ? 'Title: row' : 'Title: file');
tgl.title = 'Use the rows title or the files own';
tgl.addEventListener('click', function () { c.setTitleOverride(d.key, usingRow ? '' : row.title); });
line.appendChild(tgl);
var rm = el('button', 'tnode__act tfile__remove', '✕');
rm.title = 'Remove this file from the row';
rm.addEventListener('click', function () { c.unassignRowFile(row, d.key); });
line.appendChild(rm);
td.appendChild(line);
});
}
// "From a list" loader: "Load…" opens a multi-select directory tree (scoped
// to the served context); every ticked directory is walked recursively into
// the union of existing files + MDL deliverables, deduped by tracking number
// to one row at the latest revision. 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'; }
// The newest combined "<rev> (<status>)" string in a set, by revision token.
function latestRevOf(revs) {
var best = null, bestTok = null;
(revs || []).forEach(function (r) {
var tok = String(r).replace(/\s*\([^)]*\)\s*$/, '').trim(); // "A (IFR)" → "A"
if (best == null || window.zddc.compareRevisions(tok, bestTok) > 0) { best = r; bestTok = tok; }
});
return best || '';
}
// Where is the classifier served? Decides the directory-tree roots.
// 'local' → offline (file://), pick a folder.
// 'all' → standalone /_apps/classifier.html, root at every accessible project.
// {one:p} → under <project>/…, root at just that project.
function detectScope(pathname, hasSource, protocol) {
if (!hasSource || protocol === 'file:') return 'local';
if (/^\/_apps\//.test(pathname || '')) return 'all';
var seg = (String(pathname || '').split('/').filter(Boolean)[0]) || '';
return seg ? { one: seg } : 'all';
}
async function buildRoots() {
var src = window.zddc && window.zddc.source;
var scope = detectScope(location.pathname, !!src, location.protocol);
if (scope === 'local') {
if (!window.showDirectoryPicker) { window.zddc.toast('Loading a local folder needs the File System Access API (Chromium).', 'error'); return null; }
try { var dir = await window.showDirectoryPicker({ mode: 'read' }); return [{ label: dir.name || 'Selected folder', handle: dir }]; }
catch (e) { if (e.name !== 'AbortError') window.zddc.toast('Could not open the folder — ' + (e.message || e), 'error'); return null; }
}
function archiveOf(rel) {
if (rel.charAt(rel.length - 1) !== '/') rel += '/';
return new src.HttpDirectoryHandle(new URL(rel + 'archive/', location.origin).href, 'archive');
}
if (scope === 'all') {
var projects = await window.app.modules.copy.fetchAccessProjects();
if (projects == null) { window.zddc.toast('Could not load your projects from the server.', 'error'); return null; }
if (!projects.length) { window.zddc.toast('No projects you can access on this server.', 'warning'); return null; }
return projects.map(function (p) { return { label: (p.title ? p.name + ' — ' + p.title : p.name), handle: archiveOf(p.url || ('/' + p.name + '/')) }; });
}
return [{ label: scope.one, handle: archiveOf('/' + scope.one + '/') }];
}
async function loadWorklist() {
var roots = await buildRoots();
if (!roots) return;
var picked = await window.app.modules.dirPicker.pick(roots);
if (!picked || !picked.length) return;
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 selected directories…', 'info', { durationMs: 4000 });
try { for (var i = 0; i < picked.length; i++) await walkDirInto(picked[i], ensure); }
catch (e) { window.zddc.toast('Reading the directories 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);
}
// Walk a ticked directory recursively. A dir named "mdl" (or the ticked dir
// itself being an mdl folder) yields *.yaml deliverables → inMdl + title;
// every other ZDDC-named file is an archive revision of its tracking number.
async function walkDirInto(dirH, ensure) {
var party = (dirH.name && String(dirH.name).replace(/\/$/, '')) || '';
if (party === 'mdl') return readMdlYamls(dirH, ensure);
for await (var entry of dirH.values()) {
var nm = String(entry.name).replace(/\/$/, '');
if (entry.kind === 'directory') {
if (nm.charAt(0) === '.' || nm.charAt(0) === '_' || nm === 'rsk') continue;
var child = entry.getDirectoryHandle ? entry : await dirH.getDirectoryHandle(nm);
if (nm === 'mdl') await readMdlYamls(child, ensure);
else await walkDirInto(child, ensure);
} 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;
}
}
}
}
async function readMdlYamls(mdlH, ensure) {
for await (var ye of mdlH.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;
}
}
function finishLoad(rows) {
listScanned = true;
C().appendWorklist(rows); // APPEND — the list accumulates across batches
showTab('worklist');
window.zddc.toast(rows.length
? ('Added ' + rows.length + ' tracking number' + (rows.length === 1 ? '' : 's') + ' from the selected directories. Drag files on, set revisions.')
: 'No files or deliverables in the selected directories.', rows.length ? 'success' : 'warning');
}
// ── paste + match dialogs (reuse the .copy-choice modal shell) ──────────
function scratchModal(titleText, hintText) {
var done = false;
function close() { if (done) return; done = true; document.removeEventListener('keydown', onKey); back.remove(); }
function onKey(e) { if (e.key === 'Escape') close(); }
var back = el('div', 'copy-choice__backdrop');
var box = el('div', 'copy-choice copy-choice--wide');
box.appendChild(el('h3', null, titleText));
if (hintText) box.appendChild(el('p', null, hintText));
var body = el('div', 'scratch-modal__body'); box.appendChild(body);
var foot = el('div', 'copy-choice__btns'); box.appendChild(foot);
back.appendChild(box);
// Close on a genuine backdrop click only — not when a drag that began in
// the paste textarea (selecting text) ends out on the backdrop.
var pressedBackdrop = false;
back.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === back); });
back.addEventListener('click', function (e) { if (e.target === back && pressedBackdrop) close(); });
document.addEventListener('keydown', onKey);
document.body.appendChild(back);
return { body: body, foot: foot, close: close };
}
function unassignedFiles() {
var c = C();
return allFiles().filter(function (f) {
var a = c.getAssignment(c.srcKeyForFile(f));
return !(a && (a.trackingNodeId || a.excluded));
});
}
// Assign every exact, unambiguous (1:1) current-name match without prompting;
// returns the count. Lower-confidence / ambiguous matches are left for the
// user to review via "Match names".
function autoAssignByName() {
var c = C(), n = 0;
c.proposeMatches(unassignedFiles(), c.getWorklist(), {}).forEach(function (p) {
if (p.auto) { c.assignFromRow([c.srcKeyForFile(p.file)], p.row); n++; }
});
return n;
}
function openPasteDialog(prefill) {
var c = C();
var m = scratchModal('Paste rows from Excel', 'Fixed columns, tab-separated as Excel copies: Tracking number · Rev (Status) · Title · Current name. A header row is skipped. The current name is matched against your files — exact matches are assigned automatically.');
var ta = document.createElement('textarea');
ta.className = 'scratch-paste__ta'; ta.rows = 6; ta.spellcheck = false;
ta.placeholder = 'ACME-AR-DWG-0001\tA (IFR)\tFloor plan\tIMG_4471.pdf';
ta.value = prefill || '';
m.body.appendChild(ta);
var preview = el('div', 'scratch-paste__preview'); m.body.appendChild(preview);
var add = el('button', 'btn btn-primary', 'Add rows'); add.disabled = true;
var cancel = el('button', 'btn btn-secondary', 'Cancel'); cancel.addEventListener('click', m.close);
m.foot.appendChild(add); m.foot.appendChild(cancel);
var parsed = { rows: [], skipped: [] };
function refresh() {
parsed = c.parsePastedRows(ta.value);
preview.textContent = '';
if (parsed.rows.length) {
var tbl = el('table', 'scratch-preview__table');
var head = el('tr'); ['Tracking number', 'Revision', 'Title', 'Current name'].forEach(function (h) { head.appendChild(el('th', null, h)); }); tbl.appendChild(head);
parsed.rows.slice(0, 50).forEach(function (r) {
var tr = el('tr');
tr.appendChild(el('td', null, r.trackingNumber));
tr.appendChild(el('td', null, r.revisionCell || ''));
tr.appendChild(el('td', null, r.title || ''));
tr.appendChild(el('td', null, r.currentName || ''));
tbl.appendChild(tr);
});
preview.appendChild(tbl);
if (parsed.rows.length > 50) preview.appendChild(el('div', 'scratch-preview__more', '…and ' + (parsed.rows.length - 50) + ' more'));
}
parsed.skipped.forEach(function (s) { preview.appendChild(el('div', 'scratch-preview__skip', 'Line ' + s.line + ' skipped — ' + s.reason)); });
add.disabled = !parsed.rows.length;
add.textContent = parsed.rows.length ? ('Add ' + parsed.rows.length + ' row' + (parsed.rows.length === 1 ? '' : 's')) : 'Add rows';
}
add.addEventListener('click', function () {
var n = parsed.rows.length;
c.appendWorklist(parsed.rows);
m.close(); showTab('worklist');
var assigned = autoAssignByName();
var msg = 'Added ' + n + ' pasted row' + (n === 1 ? '' : 's') + '.';
if (assigned) msg += ' Auto-assigned ' + assigned + ' file' + (assigned === 1 ? '' : 's') + ' by current name.';
window.zddc.toast(msg + (assigned ? ' Review the rest with ⚡ Match names.' : ''), 'success');
});
ta.addEventListener('input', refresh);
refresh(); ta.focus();
}
function openMatchDialog() {
var c = C();
var rows = c.getWorklist();
if (!rows.length) { window.zddc.toast('Load or paste some tracking numbers first.', 'warning'); return; }
var files = unassignedFiles();
if (!files.length) { window.zddc.toast('No unassigned files to match.', 'info'); return; }
var m = scratchModal('Match names', 'Each unassigned file matched to a row by its “Current name” (or the tracking number in its filename). Exact matches are pre-checked; review the rest, then Assign.');
var opts = { fuzzy: false };
var fuzzyLbl = el('label', 'scratch-match__fuzzy');
var fuzzy = document.createElement('input'); fuzzy.type = 'checkbox';
fuzzyLbl.appendChild(fuzzy); fuzzyLbl.appendChild(document.createTextNode(' Looser matching (digits only)'));
m.body.appendChild(fuzzyLbl);
var list = el('div', 'scratch-match__list'); m.body.appendChild(list);
var accept = el('button', 'btn btn-primary', 'Assign');
var cancel = el('button', 'btn btn-secondary', 'Cancel'); cancel.addEventListener('click', m.close);
m.foot.appendChild(accept); m.foot.appendChild(cancel);
var proposals = [];
function refresh() {
proposals = c.proposeMatches(files, rows, opts);
list.textContent = '';
if (!proposals.length) { list.appendChild(el('div', 'scratch-preview__skip', 'No matches found.')); accept.disabled = true; accept.textContent = 'Assign'; return; }
proposals.forEach(function (p, i) {
var rowEl = el('label', 'scratch-match__row' + (p.auto ? '' : ' scratch-match__row--review'));
var cb = document.createElement('input'); cb.type = 'checkbox';
cb.checked = !!p.auto; // pre-check only exact 1:1 matches; opt in to the rest
cb.dataset.i = i;
rowEl.appendChild(cb);
rowEl.appendChild(el('span', 'scratch-match__file', window.zddc.joinExtension(p.file.originalFilename, p.file.extension)));
rowEl.appendChild(el('span', 'scratch-match__arrow', '→'));
rowEl.appendChild(el('span', 'scratch-match__tn', p.row.trackingNumber));
var tag = el('span', 'scratch-match__conf', Math.round(p.confidence * 100) + '% · ' + (p.via === 'name' ? 'name' : 'tracking#'));
rowEl.appendChild(tag);
list.appendChild(rowEl);
});
accept.disabled = false; accept.textContent = 'Assign checked';
}
accept.addEventListener('click', function () {
var n = 0;
Array.prototype.forEach.call(list.querySelectorAll('input[type=checkbox]'), function (cb) {
if (!cb.checked) return;
var p = proposals[Number(cb.dataset.i)];
if (p) { c.assignFromRow([c.srcKeyForFile(p.file)], p.row); n++; }
});
m.close(); showTab('worklist');
window.zddc.toast('Assigned ' + n + ' file' + (n === 1 ? '' : 's') + ' by name match.', n ? 'success' : 'info');
});
fuzzy.addEventListener('change', function () { opts.fuzzy = fuzzy.checked; refresh(); });
refresh();
}
// ── 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.
}
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;
C().place(keys, t.id, axis);
});
}
// 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.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,
// test seams (pure)
_detectScope: detectScope,
_latestRevOf: latestRevOf,
_walkDirInto: walkDirInto,
};
})();