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>
This commit is contained in:
ZDDC 2026-06-15 16:51:39 -05:00
parent e58347d476
commit 2b32aced6d
6 changed files with 338 additions and 305 deletions

View file

@ -731,3 +731,33 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
.tfile__badge { font-size: 0.78rem; flex: 0 0 auto; }
.tfile__badge--ok { color: var(--success, #16a34a); }
.tfile__badge--err { color: var(--danger); }
/* ── By-tracking flat editable grid (one row per file) ──────────────────── */
.ttable--grid { width: auto; }
.ttable--grid td.tg-td { padding: 0.1rem 0.35rem; vertical-align: middle; }
.ttable--grid th.tg-th { white-space: nowrap; } /* .column-resizer (spreadsheet.css) sits in the sticky th */
.tg-input {
width: 100%; min-width: 4rem; box-sizing: border-box;
padding: 0.12rem 0.3rem; border: 1px solid transparent; border-radius: var(--radius);
background: transparent; color: var(--text); font: inherit; font-size: 0.8rem;
}
.tg-input:hover { border-color: var(--border); }
.tg-input:focus { border-color: var(--primary); background: var(--bg); outline: none; }
.tg-tn .tg-input { font-family: var(--mono, monospace); }
.tg-input.is-warn { border-color: var(--warning, #b8860b); }
.tg-orig__link { color: var(--text-muted); white-space: nowrap; text-decoration: none; cursor: pointer; }
.tg-orig__link:hover { text-decoration: underline; }
.tg-status, .tg-x { text-align: center; }
.tg-x__btn { opacity: 0.5; }
.tg-row:hover .tg-x__btn { opacity: 1; }
.tg-row--err .tg-status { color: var(--danger); }
.tg-drop-hover { outline: 2px dashed var(--primary); outline-offset: -3px; background: var(--primary-light); }
/* "Columns ▾" chooser menu */
.col-chooser {
position: fixed; z-index: 9600; background: var(--bg);
border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: 0 6px 18px rgba(0,0,0,0.18); padding: 0.3rem; min-width: 11rem;
}
.col-chooser__item { display: flex; align-items: center; gap: 0.4rem; padding: 0.25rem 0.4rem; font-size: 0.83rem; cursor: pointer; border-radius: var(--radius); }
.col-chooser__item:hover { background: var(--bg-hover); }

View file

@ -60,6 +60,7 @@
outputName: null, // remembered output directory display name
config: defaultConfig(), // tracking-number pattern (fields/statuses/modifiers)
worklist: [], // "From a list" scratch rows: [ { id, trackingNumber, title, revisionCell, source, archiveRevisions } ]
trackingWorkset: Object.create(null), // srcKeys shown as rows in the By-tracking grid (set: key->true)
};
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
@ -420,6 +421,7 @@
worklist: state.worklist.map(function (r) {
return { id: r.id, party: r.party, trackingNumber: r.trackingNumber, title: r.title, revisionCell: r.revisionCell, source: r.source, archiveRevisions: r.archiveRevisions };
}),
trackingWorkset: Object.keys(state.trackingWorkset),
};
}
function load(obj) {
@ -430,6 +432,8 @@
state.outputName = obj.outputName || null;
state.config = normalizeConfig(obj.config);
state.worklist = (Array.isArray(obj.worklist) ? obj.worklist : []).map(normalizeRow);
state.trackingWorkset = Object.create(null);
(Array.isArray(obj.trackingWorkset) ? obj.trackingWorkset : []).forEach(function (k) { state.trackingWorkset[k] = true; });
rebuildIndex();
migrateLegacyMdl(obj.worklist); // BEFORE anything can prune; materializes old mdl placements
notify();
@ -461,10 +465,57 @@
function reset() {
state.assignments = {}; state.trackingTree = []; state.transmittalTree = [];
state.outputName = null;
state.trackingWorkset = Object.create(null);
rebuildIndex();
notify();
}
// ── By-tracking grid (one editable row per file) ─────────────────────────
// The grid is a flat presentation over the tracking-tree placement model.
// `trackingWorkset` tracks files put on the grid so a dropped file shows as a
// row before it has a tracking number; a file with a real tracking placement
// (named here OR via the "From a list" tab) is always a row too.
function addToTrackingGrid(keys) {
var changed = false;
(keys || []).forEach(function (k) { if (!state.trackingWorkset[k]) { state.trackingWorkset[k] = true; changed = true; } });
if (changed) notify();
}
function removeFromTrackingGrid(key) {
var a = state.assignments[key], old = a ? a.trackingNodeId : null;
delete state.trackingWorkset[key];
place([key], null, 'tracking');
if (old) pruneEmptyTrackingChain(old);
notify();
}
function trackingGridKeys() {
var set = Object.create(null);
Object.keys(state.trackingWorkset).forEach(function (k) { set[k] = true; });
Object.keys(state.assignments).forEach(function (k) { if (state.assignments[k].trackingNodeId) set[k] = true; });
return Object.keys(set);
}
// Re-materialize a file's tracking placement from a full identity. The caller
// passes ALL three fields (current values for the ones it didn't edit), read
// from deriveTarget — so this module needs no file objects. A blank revision
// lands on the PENDING_REV placeholder leaf (incomplete until set); a blank
// tracking number clears the placement (the row stays, unfilled).
function setFileIdentity(key, ident) {
ident = ident || {};
var tracking = (ident.tracking == null ? '' : String(ident.tracking)).trim();
var rev = (ident.rev == null ? '' : String(ident.rev)).trim();
var a = state.assignments[key], old = a ? a.trackingNodeId : null;
if (tracking) {
var leaf = addTrackingPath(null, parseFolderLevels(tracking + '_' + (rev || PENDING_REV)));
place([key], leaf, 'tracking');
if (old && old !== leaf) pruneEmptyTrackingChain(old);
} else {
place([key], null, 'tracking');
if (old) pruneEmptyTrackingChain(old);
}
setTitleOverride(key, ident.title || '');
state.trackingWorkset[key] = true;
notify();
}
// ── pattern config ───────────────────────────────────────────────────────
function normalizeConfig(c) {
var d = defaultConfig();
@ -888,6 +939,9 @@
transmittalRecord: transmittalRecord,
findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin,
getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields,
// By-tracking grid
addToTrackingGrid: addToTrackingGrid, removeFromTrackingGrid: removeFromTrackingGrid,
trackingGridKeys: trackingGridKeys, setFileIdentity: setFileIdentity,
setWorklist: setWorklist, appendWorklist: appendWorklist, clearWorklist: clearWorklist,
getWorklist: getWorklist, getWorklistRow: getWorklistRow,
assignFromRow: assignFromRow, unassignRowFile: unassignRowFile,

View file

@ -8,32 +8,39 @@
let resizingColumn = null;
let startX = 0;
let startWidth = 0;
let activeTable = null;
let activeOnResize = null;
/**
* Initialize column resizing
* Initialize column resizing on a table. Defaults to the rename-in-place
* spreadsheet when no table is passed (back-compatible). onResize(table) is
* called after each drag ends, so a caller can persist the new widths.
*/
function init() {
const table = window.app.dom.spreadsheet;
function init(table, onResize) {
table = table || (window.app.dom && window.app.dom.spreadsheet);
if (!table) return;
const headers = table.querySelectorAll('thead th');
headers.forEach(th => {
// Skip if resize handle already exists
if (th.querySelector('.column-resizer')) return;
// Add resize handle
const resizer = document.createElement('div');
resizer.className = 'column-resizer';
th.appendChild(resizer);
// Mouse down on resizer
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
resizingColumn = th;
startX = e.pageX;
startWidth = th.offsetWidth;
activeTable = table;
activeOnResize = onResize || null;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
});
@ -61,6 +68,8 @@
resizingColumn = null;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
if (activeOnResize && activeTable) { try { activeOnResize(activeTable); } catch (_) { /* ignore */ } }
activeTable = null; activeOnResize = null;
}
// Export module

View file

@ -43,7 +43,7 @@
matchNamesBtn: document.getElementById('matchNamesBtn'),
clearListBtn: document.getElementById('clearListBtn'),
hideAssignedToggle: document.getElementById('hideAssignedToggle'),
addTrackingRootBtn: document.getElementById('addTrackingRootBtn'),
trackingColsBtn: document.getElementById('trackingColsBtn'),
addPartyBtn: document.getElementById('addPartyBtn'),
stats: document.getElementById('classifyStats'),
};
@ -78,22 +78,16 @@
var text = t ? t.getData('text') : '';
if (text) { e.preventDefault(); openPasteDialog(text); }
});
els.addTrackingRootBtn.addEventListener('click', function () {
var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n'
+ 'Brace patterns expand: BMB-{PM,EL}-{0001-0002,0005}_A (IFR)', '');
addFoldersFromPattern(null, name);
});
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.trackingTree.addEventListener('click', onTrackingClick);
els.transmittalTree.addEventListener('click', onTransmittalClick);
els.trackingTree.addEventListener('change', onFileNameChange);
els.transmittalTree.addEventListener('change', onFileNameChange);
setupDropZone(els.trackingTree, 'tracking');
setupGridDrop(els.trackingTree);
setupDropZone(els.transmittalTree, 'transmittal');
C().on(render);
@ -151,28 +145,13 @@
function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : 'tracking'; }
function reRenderSource() { if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); }
// Expand a brace pattern into folder names and create them (confirming a
// multi-create first). parentId null = root folders. See expandFolderPattern.
function addFoldersFromPattern(parentId, raw) {
if (!raw || !raw.trim()) return;
var names = C().expandFolderPattern(raw);
if (!names.length) return;
if (names.length > 1) {
var shown = names.slice(0, 8).join('\n');
if (names.length > 8) shown += '\n…and ' + (names.length - 8) + ' more';
if (!confirm('Create ' + names.length + ' folders?\n\n' + shown)) return;
}
// Each expanded name is parsed into nested tracking levels (split on
// "-", final "_" splits the leaf rev), reusing shared ancestors.
names.forEach(function (nm) { C().addTrackingPath(parentId, C().parseFolderLevels(nm)); });
}
// ── render ───────────────────────────────────────────────────────────────
function render() {
if (!initialized || !C().isEnabled()) return;
var files = allFiles();
var placed = buildPlaced(files);
renderTrackingInto(els.trackingTree, C().getTrackingTree(), placed.tracking);
renderTrackingGrid(els.trackingTree);
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal);
renderWorklist(placed.byTracking);
renderStats(files);
@ -258,165 +237,173 @@
return rfHit(orig) || rfHit(C().deriveTarget(f).filename || '');
}
// ── By-tracking: merged-cell table ──────────────────────────────────────
// The positional hierarchy reads left-to-right as columns (one per configured
// field), ancestor cells span their descendants' rows, and the revision (the
// leaf) gets its own aligned column. Each placed file is a row.
// ── 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 node is a revision leaf when its name ends in a "(STATUS)" we recognise —
// tracking field codes never carry a parenthesised status, so this cleanly
// separates "0001" (a SEQ field) from "A (IFR)" (a revision).
function revStatusOf(name) {
var m = /\(\s*([A-Za-z0-9-]{1,5})\s*\)\s*$/.exec(name || '');
return (m && window.zddc.isValidStatus(m[1])) ? m[1] : null;
}
function isRevisionLeaf(node) {
return !(node.children || []).length && revStatusOf(node.name) != null;
}
// Flatten the tree into rows: { path:[fieldNodes], rev:revNode|null, file }.
function buildTrackingRows(nodes, placedMap) {
var rows = [];
function emit(path, rev, files) {
var fs = (files && files.length) ? files : [null];
fs.forEach(function (f) { rows.push({ path: path, rev: rev, file: f }); });
}
function walk(node, ancestors) {
var placed = placedMap[node.id] || [];
if (isRevisionLeaf(node)) { emit(ancestors, node, placed); return; }
var myPath = ancestors.concat(node); // node is a tracking field segment
if (placed.length) emit(myPath, null, placed); // files dropped on a partial number
var kids = node.children || [];
if (kids.length) kids.forEach(function (c) { walk(c, myPath); });
else if (!placed.length) emit(myPath, null, []); // empty leaf = drop target
}
nodes.forEach(function (n) { walk(n, []); });
return rows;
}
function rowMatches(row) {
if (!rfActive()) return true;
if (row.file && fileRowMatches(row.file)) return true;
if (row.rev && rfHit(row.rev.name)) return true;
for (var i = 0; i < row.path.length; i++) { if (rfHit(row.path[i].name)) return true; }
return false;
}
function fieldCellContent(node) {
var inner = el('div', 'tcell__inner');
inner.appendChild(el('span', 'tcell__name', node.name));
inner.appendChild(nodeActions([
{ act: 'add', label: '', title: 'Add child segment / revision' },
{ act: 'rename', label: '✎', title: 'Rename' },
{ act: 'del', label: '🗑', title: 'Delete' },
]));
return inner;
}
function revCellContent(node, placedMap) {
var inner = el('div', 'tcell__inner trev__inner');
// The revision name doubles as a preview link for its placed file (the
// common case is one file per revision). No count bubble.
var files = placedMap[node.id] || [];
if (files.length) {
var link = el('a', 'tcell__name tcell__preview', node.name);
link.href = '#';
link.dataset.previewKey = C().srcKeyForFile(files[0]);
link.title = 'Preview ' + files[0].originalFilename + (files[0].extension ? '.' + files[0].extension : '');
inner.appendChild(link);
} else {
inner.appendChild(el('span', 'tcell__name', node.name));
}
inner.appendChild(nodeActions([
{ act: 'rename', label: '✎', title: 'Rename revision' },
{ act: 'del', label: '🗑', title: 'Delete' },
]));
return inner;
}
// A placed-file cell: editable ZDDC name + validation badge; the original
// filename is on hover, not shown inline. Reuses .tfile/.tfile__name so the
// delegated preview + name-edit handlers apply.
function fileCellContent(f) {
// 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);
var conflict = C().hasHashConflict(d.key); // same name, different bytes
var bad = d.errors.length || conflict;
var row = el('div', 'tfile' + (bad ? ' tfile--err' : ''));
row.dataset.key = d.key;
var orig = f.originalFilename + (f.extension ? '.' + f.extension : '');
var name = el('input', 'tfile__name' + (bad ? ' tfile__name--err' : ''));
name.type = 'text';
name.value = d.filename || '';
name.placeholder = '(incomplete)';
name.title = (conflict ? 'Same tracking+revision as another file but DIFFERENT content — fix before copying · ' : '')
+ (d.errors.length ? d.errors.join('; ') + ' · ' : '') + 'original: ' + orig;
row.appendChild(name);
row.appendChild(el('span', 'tfile__badge' + (bad ? ' tfile__badge--err' : ' tfile__badge--ok'),
conflict ? '≠' : (d.errors.length ? '⚠' : '✓')));
return row;
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 renderTrackingInto(container, nodes, placedMap) {
function renderTrackingGrid(container) {
container.textContent = '';
if (!nodes.length) {
container.appendChild(el('div', 'target-empty', 'No tracking numbers yet — “+ Root folder” to start.'));
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 rows = buildTrackingRows(nodes, placedMap).filter(rowMatches);
if (!rows.length) {
container.appendChild(el('div', 'target-empty', rfActive() ? 'No matches in the tracking tree.' : 'No tracking numbers yet.'));
return;
}
var fields = C().getTrackingFields();
var maxPath = rows.reduce(function (m, r) { return Math.max(m, r.path.length); }, 0);
var nCols = Math.max(fields.length, maxPath);
var prefs = gridPrefs(), hidden = prefs.hidden || {}, widths = prefs.widths || {};
var cols = GRID_COLS.filter(function (col) { return !hidden[col.id]; });
function cellId(row, col) {
if (col < nCols) { var n = row.path[col]; return n ? n.id : null; }
return row.rev ? row.rev.id : null; // col === nCols → revision
}
// Rowspan run starting at row i for column col (0 = covered from above).
function spanAt(col, i) {
var id = cellId(rows[i], col);
if (id == null) return 1;
if (i > 0 && cellId(rows[i - 1], col) === id) return 0;
var span = 1;
for (var j = i + 1; j < rows.length; j++) { if (cellId(rows[j], col) === id) span++; else break; }
return span;
}
var table = el('table', 'ttable');
var table = el('table', 'ttable ttable--grid');
var thead = el('thead'), htr = el('tr');
for (var c = 0; c < nCols; c++) {
htr.appendChild(el('th', 'ttable__fh', fields[c] ? fields[c].name + (fields[c].optional ? ' ?' : '') : '·'));
}
htr.appendChild(el('th', 'ttable__rh', 'REVISION'));
htr.appendChild(el('th', 'ttable__fileh', 'Files'));
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');
rows.forEach(function (row, i) {
var tr = el('tr');
for (var col = 0; col < nCols; col++) {
var span = spanAt(col, i);
if (span === 0) continue; // merged from the row above
var node = row.path[col] || null;
var td = el('td', 'ttable__cell' + (node ? '' : ' ttable__cell--empty'));
if (span > 1) td.rowSpan = span;
if (node) { td.dataset.id = node.id; td.appendChild(fieldCellContent(node)); }
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);
}
var rspan = spanAt(nCols, i);
if (rspan !== 0) {
var rtd = el('td', 'ttable__rev' + (row.rev ? '' : ' ttable__cell--empty'));
if (rspan > 1) rtd.rowSpan = rspan;
if (row.rev) { rtd.dataset.id = row.rev.id; rtd.appendChild(revCellContent(row.rev, placedMap)); }
tr.appendChild(rtd);
}
var ftd = el('td', 'ttable__file');
if (row.file) ftd.appendChild(fileCellContent(row.file));
else ftd.appendChild(el('span', 'ttable__drop', 'drop a file here'));
tr.appendChild(ftd);
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
container.appendChild(table);
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
@ -901,40 +888,6 @@
if (parsed.title != null) C().setTitleOverride(tf.dataset.key, parsed.title);
// place/setTitleOverride fire classify.notify → re-render.
}
// Collapse/expand a node and its whole subtree (ctrl/cmd-click a toggle).
function setSubtreeCollapsed(nodeId, collapse) {
var node = C().getNode(nodeId);
if (!node) return;
(function walk(n) {
if ((n.children || []).length) { if (collapse) collapsed[n.id] = true; else delete collapsed[n.id]; }
(n.children || []).forEach(walk);
})(node);
}
function onTrackingClick(e) {
if (previewFromTarget(e)) return;
var btn = e.target.closest('[data-act]');
if (!btn) return;
var act = btn.dataset.act;
var id = closestNodeId(btn);
if (act === 'toggle') {
var collapse = !collapsed[id];
if (e.ctrlKey || e.metaKey) setSubtreeCollapsed(id, collapse);
else if (collapse) collapsed[id] = true; else delete collapsed[id];
render();
return;
}
if (act === 'add') {
var name = prompt('Child folder name (next tracking segment, or a leaf revision like "A (IFR)").\n'
+ 'Brace patterns expand: {PM,EL,EM}-MOM-{0001-0002,0005}_A (IFR)', '');
addFoldersFromPattern(id, name);
} else if (act === 'rename') {
var node = C().getNode(id);
var nn = prompt('Rename folder:', node ? node.name : '');
if (nn && nn.trim()) C().renameNode(id, nn.trim());
} else if (act === 'del') {
if (confirm('Delete this folder and everything under it? Files placed here become unassigned.')) C().deleteNode(id);
}
}
function onTransmittalClick(e) {
if (previewFromTarget(e)) return;
var btn = e.target.closest('[data-act]');
@ -1026,25 +979,10 @@
var keys = window.app.modules.dnd.getDrag();
window.app.modules.dnd.clearDrag();
if (!keys.length) return;
if (axis === 'tracking') placeTrackingDrop(keys, t.id);
else C().place(keys, t.id, axis);
C().place(keys, t.id, axis);
});
}
// Tracking drop: if the target is already a complete leaf, assign directly;
// otherwise prompt for the remaining levels (parsed + nested under it) so a
// file can be dropped on an existing partial tracking number and completed.
function placeTrackingDrop(keys, nodeId) {
if (C().trackingNodeComplete(nodeId)) { C().place(keys, nodeId, 'tracking'); return; }
var label = C().trackingPathLabel(nodeId);
var input = prompt('Dropping under "' + label + '".\n'
+ 'Add the remaining tracking levels (e.g. "0001_0 (IFU)"), or leave blank to drop here:', '');
if (input === null) return; // cancelled
var levels = C().parseFolderLevels(input.trim());
var target = levels.length ? C().addTrackingPath(nodeId, levels) : nodeId;
C().place(keys, target, 'tracking');
}
// Reveal a source key's placement in the target pane (source → target).
function reveal(key) {
var a = C().getAssignment(key);

View file

@ -184,11 +184,11 @@
<div class="target-body">
<section id="trackingPanel" class="target-panel">
<div class="target-panel__toolbar">
<button id="addTrackingRootBtn" class="btn btn-sm btn-secondary">+ Root folder</button>
<span class="target-hint">Folders join with “-” into the tracking number; the leaf folder is the revision — name it like “A (IFR)”.</span>
<button id="trackingColsBtn" class="btn btn-sm btn-secondary" title="Show or hide columns">Columns ▾</button>
<span class="target-hint">Drag files in, then type each ones tracking number, revision (e.g. “A (IFR)”), and title. A file thats already ZDDC-named fills in automatically. Columns are hideable + resizable.</span>
</div>
<input type="search" id="trackingFilterInput" class="tree-filter target-filter" spellcheck="false"
placeholder="Filter the tracking tree…" aria-label="Filter tracking tree">
placeholder="Filter the grid…" aria-label="Filter the tracking grid">
<div id="trackingTree" class="target-tree"></div>
</section>
<section id="transmittalPanel" class="target-panel" hidden>

View file

@ -152,49 +152,51 @@ test('mode switch swaps the spreadsheet pane for the target pane', async ({ page
expect(await page.locator('#spreadsheetPane').isHidden()).toBe(false);
});
test('target tree renders structure and tabs switch', async ({ page }) => {
test('target tree renders the By-tracking grid and tabs switch', async ({ page }) => {
await page.click('#modeClassifyBtn');
await page.evaluate(() => {
const c = window.app.modules.classify;
const acme = c.addTrackingNode(null, 'ACME-PROJ');
c.addTrackingNode(acme, 'A (IFR)');
c.reset();
const f = { originalFilename: 'scan', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
c.setFileIdentity(c.srcKeyForFile(f), { tracking: 'ACME-PROJ-EL-DWG-0001', rev: 'A (IFR)', title: 'Spec' });
const party = c.addParty('ClientCorp');
c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
window.app.modules.targetTree.render();
});
// Tracking panel visible by default with the table rendered.
await expect(page.locator('#trackingTree .ttable__cell .tcell__name', { hasText: 'ACME-PROJ' })).toBeVisible();
await expect(page.locator('#trackingTree .ttable__rev .tcell__name', { hasText: 'A (IFR)' })).toBeVisible();
// The grid shows the file's tracking number in an editable cell.
await expect(page.locator('#trackingTree .ttable--grid')).toBeVisible();
await expect(page.locator('#trackingTree .tg-tn .tg-input')).toHaveValue('ACME-PROJ-EL-DWG-0001');
// Switch to transmittal tab.
await page.click('#transmittalTab');
expect(await page.locator('#transmittalPanel').isHidden()).toBe(false);
await expect(page.locator('#transmittalTree .tnode--bin .tnode__name', { hasText: 'ClientCorp-TRN-0007' })).toBeVisible();
});
test('"+ Root folder" button (prompt) parses a name into nested levels', async ({ page }) => {
await page.click('#modeClassifyBtn');
page.once('dialog', (d) => d.accept('CPO-0001_0 (IFU)'));
await page.click('#addTrackingRootBtn');
// "CPO-0001_0 (IFU)" → CPO / 0001 columns + "0 (IFU)" revision cell.
await expect(page.locator('#trackingTree .tcell__name', { hasText: 'CPO' })).toBeVisible();
await expect(page.locator('#trackingTree .tcell__name', { hasText: '0001' })).toBeVisible();
await expect(page.locator('#trackingTree .ttable__rev .tcell__name', { hasText: '0 (IFU)' })).toBeVisible();
});
// ── Phase 3: drag-and-drop assignment (drop handler) ───────────────────────
test('dropping a file onto a tracking leaf assigns it', async ({ page }) => {
test('dropping files onto the By-tracking grid adds rows and auto-fills ZDDC names', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
window.app.modules.targetTree.render();
const row = document.querySelector('#trackingTree .ttable__rev[data-id]');
const key = 'Sub/foundation.pdf';
window.app.modules.dnd.setDrag([key]);
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
return { assigned: c.assignmentFor(key).trackingNodeId, leaf };
const c = window.app.modules.classify; c.reset();
const plain = { originalFilename: 'messy scan', extension: 'pdf', folderPath: 'R' };
const named = { originalFilename: 'ACME-MECH-0001_A (IFR) - Pump', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [plain, named], children: [] }];
const tt = window.app.modules.targetTree; tt.render();
const grid = document.querySelector('#trackingTree');
function drop(key) { window.app.modules.dnd.setDrag([key]); grid.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); }
drop(c.srcKeyForFile(plain));
drop(c.srcKeyForFile(named));
return {
rows: c.trackingGridKeys().length,
namedTn: c.deriveTarget(named).tracking, namedRev: c.deriveTarget(named).revision,
plainTn: c.deriveTarget(plain).tracking,
};
});
expect(r.assigned).toBe(r.leaf);
expect(r.rows).toBe(2); // both files added as rows
expect(r.namedTn).toBe('ACME-MECH-0001'); // the ZDDC-named file auto-filled
expect(r.namedRev).toBe('A');
expect(r.plainTn).toBe(''); // the plain file is a blank row to fill in
});
test('dropping onto a transmittal bin assigns; dropping on a party row does not', async ({ page }) => {
@ -663,24 +665,26 @@ test('trackingNodeComplete: true only for a leaf with a valid status', async ({
expect(r).toEqual({ root: false, num: false, leaf: true, bare: false });
});
test('editing a placed files filename re-files it onto the parsed tracking path', async ({ page }) => {
test('editing grid cells re-files the file onto the new tracking path', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
c.reset();
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'OLD'), 'A (IFR)');
const file = { folderPath: 'Root/Sub', originalFilename: 'doc', extension: 'pdf' };
const key = c.srcKeyForFile(file);
const leaf = c.addTrackingPath(null, c.parseFolderLevels('OLD-0001_A (IFR)'));
c.place([key], leaf, 'tracking');
window.app.folderTree = [{
name: 'Sub', path: 'Sub', expanded: true, scanState: 'done', children: [], files: [file],
}];
window.app.modules.targetTree.render();
const input = document.querySelector('#trackingTree .tfile__name');
input.value = 'CPO-0002_0 (IFU) - New Title.pdf';
input.dispatchEvent(new Event('change', { bubbles: true }));
window.app.folderTree = [{ name: 'Sub', path: 'Root/Sub', expanded: true, scanState: 'done', children: [], files: [file] }];
function editCell(cls, val) {
tt.render(); // re-render so we edit the live input each time
const inp = document.querySelector('#trackingTree .' + cls + ' .tg-input');
inp.value = val; inp.dispatchEvent(new Event('change', { bubbles: true }));
}
editCell('tg-tn', 'CPO-0002');
editCell('tg-rev', '0 (IFU)');
editCell('tg-title', 'New Title');
const d = c.deriveTarget(file);
return { tracking: d.tracking, revision: d.revision, status: d.status, title: d.title, complete: d.complete };
return { tracking: d.tracking, revision: d.revision, status: d.status, title: d.title };
});
expect(r.tracking).toBe('CPO-0002');
expect(r.revision).toBe('0');
@ -760,20 +764,22 @@ test('source-tree filter hides non-matches in place; never changes expand state'
expect(r.civilStillCollapsed).toBe(true); // the filter did NOT expand it
});
test('tracking-tree filter reveals matching nodes and hides the rest', async ({ page }) => {
test('the By-tracking grid filter narrows rows by name/tracking', async ({ page }) => {
await page.click('#modeClassifyBtn');
const names = await page.evaluate(() => {
const c = window.app.modules.classify;
const r = await page.evaluate(() => {
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
c.reset();
c.addTrackingPath(null, c.parseFolderLevels('CPO-0001_0 (IFU)'));
c.addTrackingPath(null, c.parseFolderLevels('XYZ-0009_A (IFR)'));
window.app.modules.targetTree.render();
window.app.modules.targetTree.setNameFilter('CPO');
return Array.from(document.querySelectorAll('#trackingTree .tcell__name')).map((e) => e.textContent);
const a = { originalFilename: 'pump', extension: 'pdf', folderPath: 'R' };
const b = { originalFilename: 'valve', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [a, b], children: [] }];
c.setFileIdentity(c.srcKeyForFile(a), { tracking: 'CPO-0001', rev: 'A (IFR)', title: 'Pump' });
c.setFileIdentity(c.srcKeyForFile(b), { tracking: 'XYZ-0009', rev: 'A (IFR)', title: 'Valve' });
tt.render();
tt.setNameFilter('CPO');
return Array.from(document.querySelectorAll('#trackingTree .tg-tn .tg-input')).map((e) => e.value);
});
expect(names).toContain('CPO');
expect(names).toContain('0001');
expect(names).not.toContain('XYZ');
expect(r).toContain('CPO-0001');
expect(r).not.toContain('XYZ-0009');
});
test('Show Empty off hides folders that contain no files', async ({ page }) => {
@ -1023,51 +1029,47 @@ test('a fully-excluded folder is struck through like its files', async ({ page }
expect(r.after).toBe(true); // struck through once the whole subtree is excluded
});
test('By-tracking table merges shared ancestors and aligns revisions', async ({ page }) => {
test('grid: hiding a column drops its cells; a status badge reflects completeness', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
c.reset();
c.addTrackingPath(null, c.parseFolderLevels('LKU-123456-PM-SCH-0001_2025-11-17 (IFI)'));
c.addTrackingPath(null, c.parseFolderLevels('LKU-123456-PM-SCH-0001_A (IFR)'));
c.addTrackingPath(null, c.parseFolderLevels('CPO-0001_0 (IFU)'));
try { localStorage.removeItem('zddc.classifier.trackingCols'); } catch (_) {}
const f = { originalFilename: 'x', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
c.setFileIdentity(c.srcKeyForFile(f), { tracking: 'ACME-MECH-0001', rev: 'A (IFR)', title: 'X' });
tt.render();
const cellByName = (n) => Array.from(document.querySelectorAll('#trackingTree .ttable__cell .tcell__name'))
.filter((e) => e.textContent === n).map((e) => e.closest('td'))[0];
const lku = cellByName('LKU'), cpo = cellByName('CPO');
return {
lkuSpan: lku ? lku.rowSpan : 0,
cpoSpan: cpo ? cpo.rowSpan : 0,
revs: Array.from(document.querySelectorAll('#trackingTree .ttable__rev .tcell__name')).map((e) => e.textContent),
};
const titleBefore = !!document.querySelector('#trackingTree .tg-title');
const badge = document.querySelector('#trackingTree .tg-status .tfile__badge');
// Hide the Title column via the persisted prefs, then re-render.
localStorage.setItem('zddc.classifier.trackingCols', JSON.stringify({ hidden: { title: true } }));
tt.render();
const titleAfter = !!document.querySelector('#trackingTree .tg-title');
try { localStorage.removeItem('zddc.classifier.trackingCols'); } catch (_) {}
return { titleBefore, titleAfter, badge: badge && badge.textContent };
});
expect(r.lkuSpan).toBe(2); // the LKU ancestor cell spans its two revisions (merged)
expect(r.cpoSpan).toBe(1);
// The revisions live in one aligned column; the date revision stays intact.
expect(r.revs).toEqual(['2025-11-17 (IFI)', 'A (IFR)', '0 (IFU)']);
expect(r.titleBefore).toBe(true);
expect(r.titleAfter).toBe(false); // Title column hidden + persists across re-render
expect(r.badge).toBe('✓'); // complete (tracking + rev + status all set)
});
test('revision cell links to preview its file and shows no count bubble', async ({ page }) => {
test('grid: the original-name cell is a preview link', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
c.reset();
const f = { originalFilename: 'foundation', extension: 'pdf', folderPath: 'Root' };
window.app.folderTree = [{ name: 'Root', path: 'Root', files: [f], children: [] }];
const leaf = c.addTrackingPath(null, c.parseFolderLevels('ACME-MECH-0001_A (IFR)'));
c.place([c.srcKeyForFile(f)], leaf, 'tracking');
let previewed = null;
window.app.modules.preview.previewFile = (file) => { previewed = file.originalFilename; };
c.setFileIdentity(c.srcKeyForFile(f), { tracking: 'ACME-MECH-0001', rev: 'A (IFR)', title: 'Foundation' });
tt.render();
const rev = document.querySelector('#trackingTree .ttable__rev');
const link = rev.querySelector('.tcell__preview[data-preview-key]');
return {
hasPreview: !!link,
previewKey: link && link.dataset.previewKey,
hasBadge: !!rev.querySelector('.tnode__badge'),
};
const link = document.querySelector('#trackingTree .tg-orig .tg-orig__link');
if (link) link.click();
return { text: link && link.textContent, previewed };
});
expect(r.hasPreview).toBe(true); // revision name is a preview link
expect(r.previewKey).toBe('foundation.pdf');
expect(r.hasBadge).toBe(false); // no count bubble
expect(r.text).toBe('foundation.pdf');
expect(r.previewed).toBe('foundation'); // clicking the name previews the file
});
test('Show Partial surfaces files assigned in the other tab only', async ({ page }) => {