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>
1019 lines
54 KiB
JavaScript
1019 lines
54 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' | '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 one’s tracking number, revision, and title. A file that’s 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 isn’t in the scanned archive/MDL — you’re 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 row’s title or the file’s 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,
|
||
};
|
||
})();
|