Compare commits

..

No commits in common. "60678e552d77cf5118efb28645f693ced246686c" and "605f4ab3e02b6b06329d6681519ca250d7ebc1ee" have entirely different histories.

12 changed files with 280 additions and 454 deletions

View file

@ -579,18 +579,18 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
.target-tabs__group { display: flex; gap: 0.25rem; }
.target-tabs__divider { width: 1px; align-self: stretch; margin: 0.2rem 0.6rem 0; background: var(--border); }
/* The "By existing" catalog is now a normal in-flow tab panel. */
#worklistTable { flex: 1; min-height: 0; }
#worklistTable .seltable { height: 100%; }
#mdlTree { flex: 1; min-height: 0; }
#mdlTree .seltable { height: 100%; }
/* Editable "From a list" cells fill the column (the table is width:auto, so
the column sizes to its header/content, and the input never widens it). */
.worklist-rev__input, .worklist-tn__input, .worklist-title__input {
.mdl-rev__input, .fromlist-tn__input, .fromlist-title__input {
width: 100%; min-width: 4rem; box-sizing: border-box;
padding: 0.15rem 0.35rem; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 0.8rem;
}
.worklist-tn__input { font-family: var(--mono, monospace); }
.worklist-rev__input.is-warn, .worklist-tn__input.is-warn { border-color: var(--warning, #b8860b); }
.worklist-src { white-space: nowrap; }
.fromlist-tn__input { font-family: var(--mono, monospace); }
.mdl-rev__input.is-warn, .fromlist-tn__input.is-warn { border-color: var(--warning, #b8860b); }
.fromlist-src { white-space: nowrap; }
.src-badge {
display: inline-block; margin-right: 0.25rem; padding: 0 0.3rem; border-radius: 0.7rem;
font-size: 0.64rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em;
@ -603,10 +603,10 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
.target-toggle { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--text-muted); cursor: pointer; }
.seltable__extra { white-space: normal; }
.mdlfile__name { font-size: 0.78rem; }
#worklistPanel .tfile { gap: 0.3rem; align-items: center; padding: 0.05rem 0; cursor: grab; }
#worklistPanel .tfile--err .mdlfile__name { color: var(--danger); }
#worklistPanel .tfile__remove { opacity: 0.6; }
#worklistPanel .tfile:hover .tfile__remove { opacity: 1; }
#mdlPanel .tfile { gap: 0.3rem; align-items: center; padding: 0.05rem 0; cursor: grab; }
#mdlPanel .tfile--err .mdlfile__name { color: var(--danger); }
#mdlPanel .tfile__remove { opacity: 0.6; }
#mdlPanel .tfile:hover .tfile__remove { opacity: 1; }
/* Paste + Match dialogs (inside the .copy-choice modal shell) */
.scratch-modal__body { margin: 0 0 1rem; }
@ -623,17 +623,14 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
.scratch-preview__more { color: var(--text-muted); font-size: 0.76rem; padding: 0.2rem 0; }
.scratch-match__fuzzy { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--text-muted); }
.scratch-match__row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8rem; padding: 0.15rem 0; cursor: pointer; }
.scratch-match__row--review { opacity: 0.85; } /* not an exact 1:1 — needs a look */
.scratch-match__row--review .scratch-match__conf { color: var(--warning, #b8860b); }
.scratch-match__file { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scratch-match__arrow { color: var(--text-muted); }
.scratch-match__tn { font-family: var(--mono, monospace); }
.scratch-match__conf { color: var(--text-muted); font-size: 0.72rem; min-width: 6rem; text-align: right; white-space: nowrap; }
.worklist-cur { font-family: var(--mono, monospace); color: var(--text-muted); }
.scratch-match__conf { color: var(--text-muted); font-size: 0.72rem; width: 3rem; text-align: right; }
/* The base seltable rules live in shared/seltable.css (bundled by build.sh and
shared with the tables tool); only the classifier-specific catalog bits
(.seltable__extra, .worklist-rev__input, .worklist-*, .src-badge, #worklistTable) are
(.seltable__extra, .mdl-rev__input, .fromlist-*, .src-badge, #mdlTree) are
here. */
/* ── Copy destination dialog ────────────────────────────────────────────── */

View file

@ -59,7 +59,7 @@
transmittalTree: [], // [ { id, kind:'party', name, children:[ slot ] } ]
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 } ]
mdlList: [], // loaded MDL deliverables (drop targets): [ { id, party, trackingNumber, title, revisionCell } ]
};
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
@ -417,7 +417,7 @@
config: state.config,
// Strip the transient row→keys hint (`placed`) — it's rebuilt as
// drops happen and would otherwise bloat every autosave.
worklist: state.worklist.map(function (r) {
mdlList: state.mdlList.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 };
}),
};
@ -429,9 +429,9 @@
state.transmittalTree = obj.transmittalTree || [];
state.outputName = obj.outputName || null;
state.config = normalizeConfig(obj.config);
state.worklist = (Array.isArray(obj.worklist) ? obj.worklist : []).map(normalizeRow);
state.mdlList = (Array.isArray(obj.mdlList) ? obj.mdlList : []).map(normalizeRow);
rebuildIndex();
migrateLegacyMdl(obj.worklist); // BEFORE anything can prune; materializes old mdl placements
migrateLegacyMdl(obj.mdlList); // BEFORE anything can prune; materializes old mdl placements
notify();
}
// Pre-"From a list" workspaces stored a separate `mdlNodeId` axis pointing at
@ -499,17 +499,15 @@
id: r.id || uid(), party: r.party || '',
trackingNumber: (r.trackingNumber || '').trim(), title: r.title || '',
revisionCell: r.revisionCell || '',
// The file's existing name (pasted col 4) — a join key for name-match.
currentName: (r.currentName || '').trim(),
source: rowSource(r),
archiveRevisions: Array.isArray(r.archiveRevisions) ? r.archiveRevisions : [],
placed: Object.create(null),
};
}
function setWorklist(rows) { state.worklist = (rows || []).map(normalizeRow); notify(); }
function appendWorklist(rows) {
function setMdlList(rows) { state.mdlList = (rows || []).map(normalizeRow); notify(); }
function appendMdlRows(rows) {
var byTn = Object.create(null);
state.worklist.forEach(function (r) { if (r.trackingNumber) byTn[r.trackingNumber] = r; });
state.mdlList.forEach(function (r) { if (r.trackingNumber) byTn[r.trackingNumber] = r; });
(rows || []).forEach(function (raw) {
var r = normalizeRow(raw), ex = r.trackingNumber ? byTn[r.trackingNumber] : null;
if (ex) {
@ -520,15 +518,15 @@
ex.source.pasted = ex.source.pasted || r.source.pasted;
if (r.archiveRevisions.length && !ex.archiveRevisions.length) ex.archiveRevisions = r.archiveRevisions;
} else {
state.worklist.push(r);
state.mdlList.push(r);
if (r.trackingNumber) byTn[r.trackingNumber] = r;
}
});
notify();
}
function clearWorklist() { state.worklist = []; notify(); } // rows only — assignments survive
function getWorklist() { return state.worklist; }
function getWorklistRow(id) { return state.worklist.filter(function (r) { return r.id === id; })[0] || null; }
function clearMdlList() { state.mdlList = []; notify(); } // rows only — assignments survive
function getMdlList() { return state.mdlList; }
function getMdlRow(id) { return state.mdlList.filter(function (r) { return r.id === id; })[0] || null; }
// Build (creating folders as needed) the tracking-tree leaf a row points at:
// "<tracking>_<rev (status)>". A tracking number's last segment is an
@ -595,12 +593,12 @@
if (old) pruneEmptyTrackingChain(old);
}
function setRowTracking(rowId, tn) {
var r = getWorklistRow(rowId); if (!r) return;
var r = getMdlRow(rowId); if (!r) return;
r.trackingNumber = (tn == null ? '' : String(tn)).trim();
restampRow(r); notify();
}
function setRowTitle(rowId, title) {
var r = getWorklistRow(rowId); if (!r) return;
var r = getMdlRow(rowId); if (!r) return;
r.title = (title == null ? '' : String(title));
Object.keys(r.placed || {}).forEach(function (k) { if (state.assignments[k]) setTitleOverride(k, r.title); });
notify();
@ -609,7 +607,7 @@
function setRevisionCells(rowIds, value) {
var set = Object.create(null); (rowIds || []).forEach(function (i) { set[i] = true; });
var changed = false;
state.worklist.forEach(function (r) {
state.mdlList.forEach(function (r) {
if (set[r.id]) { r.revisionCell = (value == null ? '' : String(value)); restampRow(r); changed = true; }
});
if (changed) notify();
@ -619,9 +617,6 @@
// Parse Excel/TSV text into scratch rows. Columns: Tracking ⇥ Rev(Status) ⇥
// Title; a 4th bare-status column merges into the revision; a lone cell that
// parses as a full ZDDC filename is split; a header row is skipped.
// FIXED schema, by column position (no variant detection): a header row is
// skipped, then each line is tracking_number ⇥ rev (status) ⇥ title ⇥
// current name. Trailing columns may be omitted (currentName/title blank).
function parsePastedRows(text) {
function unq(s) {
s = (s == null ? '' : String(s)).trim();
@ -634,82 +629,43 @@
if (!raw.trim()) return;
var cells = raw.split('\t').map(unq);
var c0 = cells[0] || '';
// Skip a leading header row (first cell is a header word, not a tn).
if (!sawData && /^(tracking|number|no\.?|doc(ument)?|drawing|item)\b/i.test(c0) && c0.indexOf('-') === -1) return;
if (!c0) { skipped.push({ line: i + 1, reason: 'no tracking number', text: raw }); return; }
if (!sawData && cells.length > 1 && /^(tracking|number|no\.?|doc(ument)?|drawing|item)\b/i.test(c0) && c0.indexOf('-') === -1) {
return; // header row
}
var tracking = '', rev = '', title = '';
if (cells.length === 1) {
var p = window.zddc.parseFilename(c0);
if (p && p.valid && p.trackingNumber) { tracking = p.trackingNumber; rev = p.revision + (p.status ? ' (' + p.status + ')' : ''); title = p.title || ''; }
else tracking = c0;
} else {
tracking = c0;
if (cells.length >= 4 && cells[2] && window.zddc.isValidStatus(cells[2])) { rev = (cells[1] + ' (' + cells[2] + ')').trim(); title = cells[3] || ''; }
else { rev = cells[1] || ''; title = cells[2] || ''; }
}
if (!tracking) { skipped.push({ line: i + 1, reason: 'no tracking number', text: raw }); return; }
sawData = true;
rows.push({
trackingNumber: c0,
revisionCell: (cells[1] || '').trim(),
title: cells[2] || '',
currentName: cells[3] || '',
source: { pasted: true },
});
rows.push({ trackingNumber: tracking, revisionCell: rev.trim(), title: title, source: { pasted: true } });
});
return { rows: rows, skipped: skipped };
}
function normTok(s) { return String(s == null ? '' : s).toUpperCase().replace(/[^A-Z0-9]/g, ''); }
function dropExt(s) { return String(s == null ? '' : s).replace(/\.[^.\/\\]+$/, ''); }
function nameKey(s) { return dropExt(s).toLowerCase().replace(/[^a-z0-9]+/g, ''); }
function nameTokens(s) { return dropExt(s).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); }
// Score a pasted "current name" against a file's name: 1 = exact (normalized,
// extension dropped), 0.60.95 = token coverage, 0.7 = a clean substring,
// 0 = no match. Token-set beats raw substring (survives reordering).
function nameScore(rowName, fileFull) {
var rk = nameKey(rowName); if (!rk) return 0;
var fk = nameKey(fileFull);
if (rk === fk) return 1;
var rt = nameTokens(rowName);
if (rt.length) {
var ft = Object.create(null); nameTokens(fileFull).forEach(function (t) { ft[t] = true; });
var hit = 0; rt.forEach(function (t) { if (ft[t]) hit++; });
var cov = hit / rt.length;
if (cov >= 0.6) return Math.min(0.95, 0.6 + 0.35 * cov);
}
var a = rk.length <= fk.length ? rk : fk, b = rk.length <= fk.length ? fk : rk;
if (a.length >= 4 && b.indexOf(a) !== -1) return 0.7;
return 0;
}
// Propose file↔row matches. PRIMARY signal is the pasted "current name"
// column (nameScore); FALLBACK is the tracking number embedded in the
// filename (opts.fuzzy also tries the digit-run). Each proposal carries a
// confidence and an `auto` flag — true only for an exact 1:1 match (conf 1,
// the unique conf-1 match for BOTH its file and its row), the only kind safe
// to assign without confirmation.
// Propose row matches for source files by finding a row whose tracking number
// appears in the filename. opts.fuzzy also matches on the digit-run.
function proposeMatches(files, rows, opts) {
opts = opts || {};
var named = (rows || []).filter(function (r) { return (r.currentName || '').trim(); });
var out = [];
(files || []).forEach(function (f) {
var full = zddc.joinExtension(f.originalFilename, f.extension);
var best = null;
named.forEach(function (r) {
var s = nameScore(r.currentName, full);
if (s > 0 && (!best || s > best.confidence)) best = { row: r, confidence: s, via: 'name' };
var nameNorm = normTok(full), nameDigits = nameNorm.replace(/[^0-9]/g, ''), best = null;
(rows || []).forEach(function (r) {
var tn = r.trackingNumber || ''; if (!tn) return;
var tnNorm = normTok(tn), conf = 0;
if (full.indexOf(tn) !== -1) conf = 1;
else if (tnNorm && nameNorm.indexOf(tnNorm) !== -1) conf = 0.8;
else if (opts.fuzzy) { var d = tnNorm.replace(/[^0-9]/g, ''); if (d && nameDigits.indexOf(d) !== -1) conf = 0.5; }
if (conf && (!best || conf > best.confidence)) best = { row: r, confidence: conf };
});
if (!best) { // fallback: tracking number in the filename
var nameNorm = normTok(full), nameDigits = nameNorm.replace(/[^0-9]/g, '');
(rows || []).forEach(function (r) {
var tn = r.trackingNumber || ''; if (!tn) return;
var tnNorm = normTok(tn), conf = 0;
if (full.indexOf(tn) !== -1) conf = 1;
else if (tnNorm && nameNorm.indexOf(tnNorm) !== -1) conf = 0.8;
else if (opts.fuzzy) { var d = tnNorm.replace(/[^0-9]/g, ''); if (d && nameDigits.indexOf(d) !== -1) conf = 0.5; }
if (conf && (!best || conf > best.confidence)) best = { row: r, confidence: conf, via: 'tracking' };
});
}
if (best) out.push({ file: f, row: best.row, confidence: best.confidence, via: best.via, auto: false });
});
// Auto-assignable = exact + unambiguous both ways (so duplicate names
// never silently grab the wrong file).
var rowEx = Object.create(null), fileEx = Object.create(null);
out.forEach(function (p) {
if (p.confidence !== 1) return;
rowEx[p.row.id || p.row.trackingNumber] = (rowEx[p.row.id || p.row.trackingNumber] || 0) + 1;
fileEx[srcKeyForFile(p.file)] = (fileEx[srcKeyForFile(p.file)] || 0) + 1;
});
out.forEach(function (p) {
if (p.confidence === 1) p.auto = rowEx[p.row.id || p.row.trackingNumber] === 1 && fileEx[srcKeyForFile(p.file)] === 1;
if (best) out.push({ file: f, row: best.row, confidence: best.confidence });
});
return out;
}
@ -888,8 +844,8 @@
transmittalRecord: transmittalRecord,
findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin,
getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields,
setWorklist: setWorklist, appendWorklist: appendWorklist, clearWorklist: clearWorklist,
getWorklist: getWorklist, getWorklistRow: getWorklistRow,
setMdlList: setMdlList, appendMdlRows: appendMdlRows, clearMdlList: clearMdlList,
getMdlList: getMdlList, getMdlRow: getMdlRow,
assignFromRow: assignFromRow, unassignRowFile: unassignRowFile,
setRowTracking: setRowTracking, setRowTitle: setRowTitle,
setRevisionCell: setRevisionCell, setRevisionCells: setRevisionCells,

View file

@ -19,9 +19,9 @@
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 currentTab = 'tracking'; // 'tracking' | 'existing' | 'transmittal' — active tab
var mdlTable = null; // the seltable controller for the "From a list" tab
var mdlPlaced = {}; // 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)
@ -30,15 +30,15 @@
initialized = true;
els = {
trackingTab: document.getElementById('trackingTab'),
worklistTab: document.getElementById('worklistTab'),
existingTab: document.getElementById('existingTab'),
transmittalTab: document.getElementById('transmittalTab'),
trackingPanel: document.getElementById('trackingPanel'),
transmittalPanel: document.getElementById('transmittalPanel'),
worklistPanel: document.getElementById('worklistPanel'),
mdlPanel: document.getElementById('mdlPanel'),
trackingTree: document.getElementById('trackingTree'),
transmittalTree: document.getElementById('transmittalTree'),
worklistTable: document.getElementById('worklistTable'),
loadWorklistBtn: document.getElementById('loadWorklistBtn'),
mdlTree: document.getElementById('mdlTree'),
loadMdlBtn: document.getElementById('loadMdlBtn'),
pasteRowsBtn: document.getElementById('pasteRowsBtn'),
matchNamesBtn: document.getElementById('matchNamesBtn'),
clearListBtn: document.getElementById('clearListBtn'),
@ -49,13 +49,13 @@
};
els.trackingTab.addEventListener('click', function () { showTab('tracking'); });
if (els.worklistTab) els.worklistTab.addEventListener('click', function () { showTab('worklist'); });
if (els.existingTab) els.existingTab.addEventListener('click', function () { showTab('existing'); });
els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); });
if (els.loadWorklistBtn) els.loadWorklistBtn.addEventListener('click', loadWorklist);
if (els.loadMdlBtn) els.loadMdlBtn.addEventListener('click', loadMdl);
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();
var list = C().getMdlList();
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
@ -63,16 +63,16 @@
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();
C().clearMdlList();
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();
if (mdlTable) mdlTable.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 (els.mdlPanel) els.mdlPanel.addEventListener('paste', function (e) {
if (currentTab !== 'existing') 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') : '';
@ -135,12 +135,12 @@
}
function showTab(which) {
currentTab = (which === 'transmittal' || which === 'worklist') ? which : 'tracking';
currentTab = (which === 'transmittal' || which === 'existing') ? which : 'tracking';
els.trackingTab.classList.toggle('active', currentTab === 'tracking');
if (els.worklistTab) els.worklistTab.classList.toggle('active', currentTab === 'worklist');
if (els.existingTab) els.existingTab.classList.toggle('active', currentTab === 'existing');
els.transmittalTab.classList.toggle('active', currentTab === 'transmittal');
els.trackingPanel.hidden = currentTab !== 'tracking';
if (els.worklistPanel) els.worklistPanel.hidden = currentTab !== 'worklist';
if (els.mdlPanel) els.mdlPanel.hidden = currentTab !== 'existing';
els.transmittalPanel.hidden = currentTab !== 'transmittal';
render();
// The source-tree Show filters are per-axis, so the visible set changes
@ -174,7 +174,7 @@
var placed = buildPlaced(files);
renderTrackingInto(els.trackingTree, C().getTrackingTree(), placed.tracking);
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal);
renderWorklist(placed.byTracking);
renderMdlInto(placed.byTracking);
renderStats(files);
}
@ -509,52 +509,51 @@
}
// ── "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).'));
function renderMdlInto(placedByTracking) {
mdlPlaced = placedByTracking || {};
if (!C().getMdlList().length) {
mdlTable = null;
els.mdlTree.textContent = '';
els.mdlTree.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();
ensureMdlTable();
mdlTable.renderBody();
}
function rowPlaced(r) { var f = worklistPlaced[r.trackingNumber]; return f && f.length ? f : null; }
function ensureWorklistGrid() {
if (worklistGrid) return worklistGrid;
function rowPlaced(r) { var f = mdlPlaced[r.trackingNumber]; return f && f.length ? f : null; }
function ensureMdlTable() {
if (mdlTable) return mdlTable;
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(' '); },
{ key: 'tn', title: 'Tracking number', cls: 'fromlist-tn', get: function (r) { return r.trackingNumber || ''; },
render: function (r, td) { editCell(td, 'fromlist-tn__input', r.trackingNumber, 'ACME-…-0001', function (v) { c.setRowTracking(r.id, v); }, tnWarn(r)); } },
{ key: 'title', title: 'Title', cls: 'fromlist-title', get: function (r) { return r.title || ''; },
render: function (r, td) { editCell(td, 'fromlist-title__input', r.title, 'title', function (v) { c.setRowTitle(r.id, v); }); } },
{ key: 'src', title: 'Source', cls: 'fromlist-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); }); } },
{ key: 'rev', title: 'Revision', cls: 'mdl-rev', get: function (r) { return r.revisionCell; },
render: function (r, td) { editCell(td, 'mdl-rev__input', r.revisionCell, 'A (IFR)', function (v) { c.setRevisionCell(r.id, v); }); } },
];
worklistGrid = window.app.modules.seltable.create({
container: els.worklistTable,
mdlTable = window.app.modules.seltable.create({
container: els.mdlTree,
extraTitle: 'Files',
rows: function () {
var list = c.getWorklist();
var list = c.getMdlList();
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); },
onRowDrop: function (rowId, keys) { var row = c.getMdlRow(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); },
rowExtra: function (r, td) { renderMdlPlaced(r, td); },
});
worklistGrid.render();
return worklistGrid;
mdlTable.render();
return mdlTable;
}
// An editable seltable cell: an <input> that commits on change. `warn` is an
// optional tooltip that flags (without blocking) a questionable value.
@ -585,7 +584,7 @@
td.appendChild(el('span', 'src-badge src-badge--pasted', 'pasted'));
}
}
function renderWorklistFiles(row, td) {
function renderMdlPlaced(row, td) {
var c = C(), files = rowPlaced(row) || [];
files.forEach(function (f) {
var d = c.deriveTarget(f);
@ -656,7 +655,7 @@
}
return [{ label: scope.one, handle: archiveOf('/' + scope.one + '/') }];
}
async function loadWorklist() {
async function loadMdl() {
var roots = await buildRoots();
if (!roots) return;
var picked = await window.app.modules.dirPicker.pick(roots);
@ -707,8 +706,8 @@
}
function finishLoad(rows) {
listScanned = true;
C().appendWorklist(rows); // APPEND — the list accumulates across batches
showTab('worklist');
C().appendMdlRows(rows); // APPEND — the list accumulates across batches
showTab('existing');
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');
@ -731,29 +730,12 @@
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 m = scratchModal('Paste rows from Excel', 'Columns: Tracking · Rev (Status) · Title — tab-separated, as Excel copies. A header row is skipped; a pasted full filename is split.');
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.placeholder = 'ACME-AR-DWG-0001\tA (IFR)\tFloor plan';
ta.value = prefill || '';
m.body.appendChild(ta);
var preview = el('div', 'scratch-paste__preview'); m.body.appendChild(preview);
@ -766,13 +748,12 @@
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);
var head = el('tr'); ['Tracking number', 'Revision', 'Title'].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);
@ -784,23 +765,23 @@
}
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');
c.appendMdlRows(parsed.rows);
m.close(); showTab('existing');
window.zddc.toast('Added ' + n + ' pasted row' + (n === 1 ? '' : 's') + '.', 'success');
});
ta.addEventListener('input', refresh);
refresh(); ta.focus();
}
function openMatchDialog() {
var c = C();
var rows = c.getWorklist();
var rows = c.getMdlList();
if (!rows.length) { window.zddc.toast('Load or paste some tracking numbers first.', 'warning'); return; }
var files = unassignedFiles();
var files = allFiles().filter(function (f) {
var a = c.getAssignment(c.srcKeyForFile(f));
return !(a && (a.trackingNodeId || a.excluded));
});
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 m = scratchModal('Match names', 'Files whose name contains a known tracking number. Review, then assign the checked matches.');
var opts = { fuzzy: false };
var fuzzyLbl = el('label', 'scratch-match__fuzzy');
var fuzzy = document.createElement('input'); fuzzy.type = 'checkbox';
@ -816,19 +797,16 @@
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;
var rowEl = el('label', 'scratch-match__row');
var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = true; 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__file', 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);
rowEl.appendChild(el('span', 'scratch-match__conf', Math.round(p.confidence * 100) + '%'));
list.appendChild(rowEl);
});
accept.disabled = false; accept.textContent = 'Assign checked';
accept.disabled = false; accept.textContent = 'Assign ' + proposals.length;
}
accept.addEventListener('click', function () {
var n = 0;
@ -837,7 +815,7 @@
var p = proposals[Number(cb.dataset.i)];
if (p) { c.assignFromRow([c.srcKeyForFile(p.file)], p.row); n++; }
});
m.close(); showTab('worklist');
m.close(); showTab('existing');
window.zddc.toast('Assigned ' + n + ' file' + (n === 1 ? '' : 's') + ' by name match.', n ? 'success' : 'info');
});
fuzzy.addEventListener('change', function () { opts.fuzzy = fuzzy.checked; refresh(); });

View file

@ -163,7 +163,7 @@
<div class="target-tabs" role="tablist">
<div class="target-tabs__group">
<button class="target-tab active" id="trackingTab" role="tab">By tracking number</button>
<button class="target-tab" id="worklistTab" role="tab" title="Drag files onto a list of tracking numbers — loaded from the archive/MDL, pasted from Excel, or auto-matched by name.">From a list</button>
<button class="target-tab" id="existingTab" role="tab" title="Drag files onto a list of tracking numbers — loaded from the archive/MDL, pasted from Excel, or auto-matched by name.">From a list</button>
</div>
<span class="target-tabs__divider" aria-hidden="true"></span>
<div class="target-tabs__group">
@ -205,16 +205,16 @@
Dragging a file onto a row MATERIALIZES a real "By tracking number"
placement, so Clear keeps every assignment. The left filetree is
the drag source. -->
<section id="worklistPanel" class="target-panel" hidden>
<section id="mdlPanel" class="target-panel" hidden>
<div class="target-panel__toolbar">
<button id="loadWorklistBtn" class="btn btn-sm btn-secondary" title="Add tracking numbers from the project archive/MDL (pick directories to scan).">⊞ Load…</button>
<button id="loadMdlBtn" class="btn btn-sm btn-secondary" title="Add tracking numbers from the project archive/MDL (pick directories to scan).">⊞ Load…</button>
<button id="pasteRowsBtn" class="btn btn-sm btn-secondary" title="Paste rows from Excel: Tracking · Rev (Status) · Title.">⎘ Paste rows…</button>
<button id="matchNamesBtn" class="btn btn-sm btn-secondary" title="Auto-suggest assignments by matching unassigned filenames against the list.">⚡ Match names</button>
<button id="clearListBtn" class="btn btn-sm btn-secondary" title="Empty the list. Every assignment is kept — see By tracking number.">Clear list</button>
<label class="target-toggle" title="Hide rows that already have files assigned."><input type="checkbox" id="hideAssignedToggle"> Hide assigned</label>
<span class="target-hint">Drag files onto a row to name them; edit the Tracking number / Revision inline (ctrl-shift + ctrl-Enter sets many). Clearing the list keeps every assignment.</span>
</div>
<div id="worklistTable" class="target-tree"></div>
<div id="mdlTree" class="target-tree"></div>
</section>
</div>
</main>

View file

@ -1251,8 +1251,8 @@ test('From a list: a drop materializes a real tracking placement; row revision +
const f = { originalFilename: 'messy scan 47', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
const key = c.srcKeyForFile(f);
c.setWorklist([{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Switchgear Spec' }]);
c.assignFromRow([key], c.getWorklistRow('m1')); // blank revision → partial
c.setMdlList([{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Switchgear Spec' }]);
c.assignFromRow([key], c.getMdlRow('m1')); // blank revision → partial
const placedTracking = !!(c.getAssignment(key) || {}).trackingNodeId; // a REAL tracking placement
const beforeRev = c.deriveTarget(f);
c.setRevisionCell('m1', 'A (IFR)'); // re-stamps onto the leaf
@ -1288,22 +1288,22 @@ test('From a list: clearing the list keeps classifications; the row drives the s
const f = { originalFilename: 'scan', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
const key = c.srcKeyForFile(f);
c.setWorklist([
c.setMdlList([
{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Spec', inMdl: true, archiveRevisions: ['A (IFR)', 'B (IFC)'], revisionCell: 'C (IFC)' },
{ id: 'm2', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2', archiveRevisions: ['0 (IFC)'] },
]);
tt.showTab('worklist');
const row = document.querySelector('#worklistTable .seltable__row[data-id="m1"]');
tt.showTab('existing');
const row = document.querySelector('#mdlTree .seltable__row[data-id="m1"]');
const latestShown = !!row && row.textContent.includes('B (IFC)'); // latest archive rev shown
window.app.modules.dnd.setDrag([key]);
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop on m1 (rev C set)
const named = c.deriveTarget(f).filename;
c.clearWorklist(); // list emptied — assignment must survive
c.clearMdlList(); // list emptied — assignment must survive
return {
hasRow: !!row, latestShown,
placedAfterDrop: !!(c.getAssignment(key) || {}).trackingNodeId,
named,
listLen: c.getWorklist().length,
listLen: c.getMdlList().length,
stillPlaced: !!(c.getAssignment(key) || {}).trackingNodeId,
stillNamed: c.deriveTarget(f).filename,
};
@ -1325,8 +1325,8 @@ test('From a list: editing the tracking number (bump sequence) re-stamps placed
const f = { originalFilename: 'plan', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
const key = c.srcKeyForFile(f);
c.setWorklist([{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-DWG-0007', title: 'Plan', revisionCell: 'A (IFR)' }]);
c.assignFromRow([key], c.getWorklistRow('m1'));
c.setMdlList([{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-DWG-0007', title: 'Plan', revisionCell: 'A (IFR)' }]);
c.assignFromRow([key], c.getMdlRow('m1'));
const before = c.deriveTarget(f).filename;
c.setRowTracking('m1', 'ACM-PRJ-EL-DWG-0008'); // it's the next drawing
const after = c.deriveTarget(f).filename;
@ -1351,7 +1351,7 @@ test('From a list: load() migrates a legacy mdlNodeId placement into a tracking
// A pre-"From a list" serialized workspace: the file points at an mdl row.
c.load({
assignments: { [key]: { mdlNodeId: 'old1', titleFromDeliverable: true, transmittalNodeId: null, excluded: false, titleOverride: null } },
worklist: [{ id: 'old1', trackingNumber: 'ACM-PRJ-EL-SPC-0009', title: 'Legacy', revisionCell: 'B (IFC)' }],
mdlList: [{ id: 'old1', trackingNumber: 'ACM-PRJ-EL-SPC-0009', title: 'Legacy', revisionCell: 'B (IFC)' }],
});
const a = c.getAssignment(key) || {};
return {
@ -1365,63 +1365,27 @@ test('From a list: load() migrates a legacy mdlNodeId placement into a tracking
expect(r.named).toBe('ACM-PRJ-EL-SPC-0009_B (IFC) - Legacy.pdf'); // classification preserved
});
test('parsePastedRows: fixed columns tracking · rev · title · current name', async ({ page }) => {
test('parsePastedRows handles 3-col, 4-col(status), a filename, a header, and bad rows', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
const text = [
'Tracking Number\tRev\tTitle\tCurrent name', // header → skipped
'ACM-PRJ-EL-SPC-0001\tA (IFR)\tFloor plan\tIMG_4471.pdf', // full 4 columns
'ACM-PRJ-EL-SPC-0002\tB (IFC)\tSection', // 3 cols → current name blank
'\tjust a rev\t', // no tracking → skipped
'Tracking Number\tRev\tTitle', // header → skipped
'ACM-PRJ-EL-SPC-0001\tA (IFR)\tFloor plan', // 3-col
'ACM-PRJ-EL-SPC-0002\tB\tIFC\tSection', // 4-col (status split)
'ACM-PRJ-EL-SPC-0003_C (IFA) - Detail.pdf', // single full filename
'\tjust a rev\t', // no tracking → skipped
].join('\n');
return c.parsePastedRows(text);
});
expect(r.rows.map(x => x.trackingNumber)).toEqual(['ACM-PRJ-EL-SPC-0001', 'ACM-PRJ-EL-SPC-0002']);
expect(r.rows[0]).toMatchObject({ revisionCell: 'A (IFR)', title: 'Floor plan', currentName: 'IMG_4471.pdf' });
expect(r.rows[1].currentName).toBe(''); // omitted trailing column
expect(r.rows.map(x => x.trackingNumber)).toEqual(['ACM-PRJ-EL-SPC-0001', 'ACM-PRJ-EL-SPC-0002', 'ACM-PRJ-EL-SPC-0003']);
expect(r.rows[0].revisionCell).toBe('A (IFR)');
expect(r.rows[1].revisionCell).toBe('B (IFC)'); // status column merged
expect(r.rows[2].revisionCell).toBe('C (IFA)'); // split from the filename
expect(r.rows[2].title).toBe('Detail');
expect(r.skipped.length).toBe(1); // the no-tracking row
});
test('proposeMatches: the current-name column drives exact (auto) + token matches', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
const files = [
{ originalFilename: 'IMG_4471', extension: 'pdf', folderPath: 'R' }, // exact (case+ext+sep differ)
{ originalFilename: 'site-survey-final-v2', extension: 'docx', folderPath: 'R' }, // token coverage
{ originalFilename: 'totally unrelated', extension: 'pdf', folderPath: 'R' }, // no match
];
const rows = [
{ id: 'm1', trackingNumber: 'ACM-AR-DWG-0001', currentName: 'img_4471.PDF' },
{ id: 'm2', trackingNumber: 'ACM-AR-DWG-0002', currentName: 'Site Survey final' },
];
const m = c.proposeMatches(files, rows, {});
return Object.fromEntries(m.map(p => [p.file.originalFilename, { tn: p.row.trackingNumber, conf: p.confidence, via: p.via, auto: p.auto }]));
});
expect(r['IMG_4471']).toMatchObject({ tn: 'ACM-AR-DWG-0001', conf: 1, via: 'name', auto: true }); // exact 1:1 → auto
expect(r['site-survey-final-v2'].tn).toBe('ACM-AR-DWG-0002');
expect(r['site-survey-final-v2'].via).toBe('name');
expect(r['site-survey-final-v2'].auto).toBe(false); // < exact → needs review
expect(r['totally unrelated']).toBeUndefined(); // no match dropped
});
test('proposeMatches: ambiguous duplicate current-name is not auto-assigned', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
const files = [
{ originalFilename: 'scan001', extension: 'pdf', folderPath: 'Root/A' },
{ originalFilename: 'scan001', extension: 'pdf', folderPath: 'Root/B' }, // same name, different folder
];
const rows = [{ id: 'm1', trackingNumber: 'ACM-AR-DWG-0009', currentName: 'scan001.pdf' }];
return c.proposeMatches(files, rows, {}).map(p => ({ conf: p.confidence, auto: p.auto }));
});
expect(r.length).toBe(2); // both files match the one row
expect(r.every(p => p.conf === 1)).toBe(true);
expect(r.every(p => p.auto === false)).toBe(true); // a row claimed by 2 files → neither auto-assigns
});
test('proposeMatches finds a row whose tracking number is in the filename', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {

View file

@ -2717,7 +2717,7 @@ td[data-field="trackingNumber"] {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-15 13:55:36 · 0847c7a</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>

View file

@ -2824,7 +2824,7 @@ li.CodeMirror-hint-active {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-15 13:55:36 · 0847c7a</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>

View file

@ -1797,18 +1797,18 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
.target-tabs__group { display: flex; gap: 0.25rem; }
.target-tabs__divider { width: 1px; align-self: stretch; margin: 0.2rem 0.6rem 0; background: var(--border); }
/* The "By existing" catalog is now a normal in-flow tab panel. */
#worklistTable { flex: 1; min-height: 0; }
#worklistTable .seltable { height: 100%; }
#mdlTree { flex: 1; min-height: 0; }
#mdlTree .seltable { height: 100%; }
/* Editable "From a list" cells — fill the column (the table is width:auto, so
the column sizes to its header/content, and the input never widens it). */
.worklist-rev__input, .worklist-tn__input, .worklist-title__input {
.mdl-rev__input, .fromlist-tn__input, .fromlist-title__input {
width: 100%; min-width: 4rem; box-sizing: border-box;
padding: 0.15rem 0.35rem; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 0.8rem;
}
.worklist-tn__input { font-family: var(--mono, monospace); }
.worklist-rev__input.is-warn, .worklist-tn__input.is-warn { border-color: var(--warning, #b8860b); }
.worklist-src { white-space: nowrap; }
.fromlist-tn__input { font-family: var(--mono, monospace); }
.mdl-rev__input.is-warn, .fromlist-tn__input.is-warn { border-color: var(--warning, #b8860b); }
.fromlist-src { white-space: nowrap; }
.src-badge {
display: inline-block; margin-right: 0.25rem; padding: 0 0.3rem; border-radius: 0.7rem;
font-size: 0.64rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em;
@ -1821,10 +1821,10 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
.target-toggle { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--text-muted); cursor: pointer; }
.seltable__extra { white-space: normal; }
.mdlfile__name { font-size: 0.78rem; }
#worklistPanel .tfile { gap: 0.3rem; align-items: center; padding: 0.05rem 0; cursor: grab; }
#worklistPanel .tfile--err .mdlfile__name { color: var(--danger); }
#worklistPanel .tfile__remove { opacity: 0.6; }
#worklistPanel .tfile:hover .tfile__remove { opacity: 1; }
#mdlPanel .tfile { gap: 0.3rem; align-items: center; padding: 0.05rem 0; cursor: grab; }
#mdlPanel .tfile--err .mdlfile__name { color: var(--danger); }
#mdlPanel .tfile__remove { opacity: 0.6; }
#mdlPanel .tfile:hover .tfile__remove { opacity: 1; }
/* Paste + Match dialogs (inside the .copy-choice modal shell) */
.scratch-modal__body { margin: 0 0 1rem; }
@ -1841,17 +1841,14 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
.scratch-preview__more { color: var(--text-muted); font-size: 0.76rem; padding: 0.2rem 0; }
.scratch-match__fuzzy { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--text-muted); }
.scratch-match__row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8rem; padding: 0.15rem 0; cursor: pointer; }
.scratch-match__row--review { opacity: 0.85; } /* not an exact 1:1 — needs a look */
.scratch-match__row--review .scratch-match__conf { color: var(--warning, #b8860b); }
.scratch-match__file { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scratch-match__arrow { color: var(--text-muted); }
.scratch-match__tn { font-family: var(--mono, monospace); }
.scratch-match__conf { color: var(--text-muted); font-size: 0.72rem; min-width: 6rem; text-align: right; white-space: nowrap; }
.worklist-cur { font-family: var(--mono, monospace); color: var(--text-muted); }
.scratch-match__conf { color: var(--text-muted); font-size: 0.72rem; width: 3rem; text-align: right; }
/* The base seltable rules live in shared/seltable.css (bundled by build.sh and
shared with the tables tool); only the classifier-specific catalog bits
(.seltable__extra, .worklist-rev__input, .worklist-*, .src-badge, #worklistTable) are
(.seltable__extra, .mdl-rev__input, .fromlist-*, .src-badge, #mdlTree) are
here. */
/* ── Copy destination dialog ────────────────────────────────────────────── */
@ -2421,7 +2418,7 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-15 13:55:36 · 0847c7a</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
@ -2558,7 +2555,7 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
<div class="target-tabs" role="tablist">
<div class="target-tabs__group">
<button class="target-tab active" id="trackingTab" role="tab">By tracking number</button>
<button class="target-tab" id="worklistTab" role="tab" title="Drag files onto a list of tracking numbers — loaded from the archive/MDL, pasted from Excel, or auto-matched by name.">From a list</button>
<button class="target-tab" id="existingTab" role="tab" title="Drag files onto a list of tracking numbers — loaded from the archive/MDL, pasted from Excel, or auto-matched by name.">From a list</button>
</div>
<span class="target-tabs__divider" aria-hidden="true"></span>
<div class="target-tabs__group">
@ -2600,16 +2597,16 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
Dragging a file onto a row MATERIALIZES a real "By tracking number"
placement, so Clear keeps every assignment. The left filetree is
the drag source. -->
<section id="worklistPanel" class="target-panel" hidden>
<section id="mdlPanel" class="target-panel" hidden>
<div class="target-panel__toolbar">
<button id="loadWorklistBtn" class="btn btn-sm btn-secondary" title="Add tracking numbers from the project archive/MDL (pick directories to scan).">⊞ Load…</button>
<button id="loadMdlBtn" class="btn btn-sm btn-secondary" title="Add tracking numbers from the project archive/MDL (pick directories to scan).">⊞ Load…</button>
<button id="pasteRowsBtn" class="btn btn-sm btn-secondary" title="Paste rows from Excel: Tracking · Rev (Status) · Title.">⎘ Paste rows…</button>
<button id="matchNamesBtn" class="btn btn-sm btn-secondary" title="Auto-suggest assignments by matching unassigned filenames against the list.">⚡ Match names</button>
<button id="clearListBtn" class="btn btn-sm btn-secondary" title="Empty the list. Every assignment is kept — see By tracking number.">Clear list</button>
<label class="target-toggle" title="Hide rows that already have files assigned."><input type="checkbox" id="hideAssignedToggle"> Hide assigned</label>
<span class="target-hint">Drag files onto a row to name them; edit the Tracking number / Revision inline (ctrl-shift + ctrl-Enter sets many). Clearing the list keeps every assignment.</span>
</div>
<div id="worklistTable" class="target-tree"></div>
<div id="mdlTree" class="target-tree"></div>
</section>
</div>
</main>
@ -7473,7 +7470,7 @@ X.B(E,Y);return E}return J}())
transmittalTree: [], // [ { id, kind:'party', name, children:[ slot ] } ]
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 } ]
mdlList: [], // loaded MDL deliverables (drop targets): [ { id, party, trackingNumber, title, revisionCell } ]
};
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
@ -7831,7 +7828,7 @@ X.B(E,Y);return E}return J}())
config: state.config,
// Strip the transient row→keys hint (`placed`) — it's rebuilt as
// drops happen and would otherwise bloat every autosave.
worklist: state.worklist.map(function (r) {
mdlList: state.mdlList.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 };
}),
};
@ -7843,9 +7840,9 @@ X.B(E,Y);return E}return J}())
state.transmittalTree = obj.transmittalTree || [];
state.outputName = obj.outputName || null;
state.config = normalizeConfig(obj.config);
state.worklist = (Array.isArray(obj.worklist) ? obj.worklist : []).map(normalizeRow);
state.mdlList = (Array.isArray(obj.mdlList) ? obj.mdlList : []).map(normalizeRow);
rebuildIndex();
migrateLegacyMdl(obj.worklist); // BEFORE anything can prune; materializes old mdl placements
migrateLegacyMdl(obj.mdlList); // BEFORE anything can prune; materializes old mdl placements
notify();
}
// Pre-"From a list" workspaces stored a separate `mdlNodeId` axis pointing at
@ -7913,17 +7910,15 @@ X.B(E,Y);return E}return J}())
id: r.id || uid(), party: r.party || '',
trackingNumber: (r.trackingNumber || '').trim(), title: r.title || '',
revisionCell: r.revisionCell || '',
// The file's existing name (pasted col 4) — a join key for name-match.
currentName: (r.currentName || '').trim(),
source: rowSource(r),
archiveRevisions: Array.isArray(r.archiveRevisions) ? r.archiveRevisions : [],
placed: Object.create(null),
};
}
function setWorklist(rows) { state.worklist = (rows || []).map(normalizeRow); notify(); }
function appendWorklist(rows) {
function setMdlList(rows) { state.mdlList = (rows || []).map(normalizeRow); notify(); }
function appendMdlRows(rows) {
var byTn = Object.create(null);
state.worklist.forEach(function (r) { if (r.trackingNumber) byTn[r.trackingNumber] = r; });
state.mdlList.forEach(function (r) { if (r.trackingNumber) byTn[r.trackingNumber] = r; });
(rows || []).forEach(function (raw) {
var r = normalizeRow(raw), ex = r.trackingNumber ? byTn[r.trackingNumber] : null;
if (ex) {
@ -7934,15 +7929,15 @@ X.B(E,Y);return E}return J}())
ex.source.pasted = ex.source.pasted || r.source.pasted;
if (r.archiveRevisions.length && !ex.archiveRevisions.length) ex.archiveRevisions = r.archiveRevisions;
} else {
state.worklist.push(r);
state.mdlList.push(r);
if (r.trackingNumber) byTn[r.trackingNumber] = r;
}
});
notify();
}
function clearWorklist() { state.worklist = []; notify(); } // rows only — assignments survive
function getWorklist() { return state.worklist; }
function getWorklistRow(id) { return state.worklist.filter(function (r) { return r.id === id; })[0] || null; }
function clearMdlList() { state.mdlList = []; notify(); } // rows only — assignments survive
function getMdlList() { return state.mdlList; }
function getMdlRow(id) { return state.mdlList.filter(function (r) { return r.id === id; })[0] || null; }
// Build (creating folders as needed) the tracking-tree leaf a row points at:
// "<tracking>_<rev (status)>". A tracking number's last segment is an
@ -8009,12 +8004,12 @@ X.B(E,Y);return E}return J}())
if (old) pruneEmptyTrackingChain(old);
}
function setRowTracking(rowId, tn) {
var r = getWorklistRow(rowId); if (!r) return;
var r = getMdlRow(rowId); if (!r) return;
r.trackingNumber = (tn == null ? '' : String(tn)).trim();
restampRow(r); notify();
}
function setRowTitle(rowId, title) {
var r = getWorklistRow(rowId); if (!r) return;
var r = getMdlRow(rowId); if (!r) return;
r.title = (title == null ? '' : String(title));
Object.keys(r.placed || {}).forEach(function (k) { if (state.assignments[k]) setTitleOverride(k, r.title); });
notify();
@ -8023,7 +8018,7 @@ X.B(E,Y);return E}return J}())
function setRevisionCells(rowIds, value) {
var set = Object.create(null); (rowIds || []).forEach(function (i) { set[i] = true; });
var changed = false;
state.worklist.forEach(function (r) {
state.mdlList.forEach(function (r) {
if (set[r.id]) { r.revisionCell = (value == null ? '' : String(value)); restampRow(r); changed = true; }
});
if (changed) notify();
@ -8033,9 +8028,6 @@ X.B(E,Y);return E}return J}())
// Parse Excel/TSV text into scratch rows. Columns: Tracking ⇥ Rev(Status) ⇥
// Title; a 4th bare-status column merges into the revision; a lone cell that
// parses as a full ZDDC filename is split; a header row is skipped.
// FIXED schema, by column position (no variant detection): a header row is
// skipped, then each line is tracking_number ⇥ rev (status) ⇥ title ⇥
// current name. Trailing columns may be omitted (currentName/title blank).
function parsePastedRows(text) {
function unq(s) {
s = (s == null ? '' : String(s)).trim();
@ -8048,82 +8040,43 @@ X.B(E,Y);return E}return J}())
if (!raw.trim()) return;
var cells = raw.split('\t').map(unq);
var c0 = cells[0] || '';
// Skip a leading header row (first cell is a header word, not a tn).
if (!sawData && /^(tracking|number|no\.?|doc(ument)?|drawing|item)\b/i.test(c0) && c0.indexOf('-') === -1) return;
if (!c0) { skipped.push({ line: i + 1, reason: 'no tracking number', text: raw }); return; }
if (!sawData && cells.length > 1 && /^(tracking|number|no\.?|doc(ument)?|drawing|item)\b/i.test(c0) && c0.indexOf('-') === -1) {
return; // header row
}
var tracking = '', rev = '', title = '';
if (cells.length === 1) {
var p = window.zddc.parseFilename(c0);
if (p && p.valid && p.trackingNumber) { tracking = p.trackingNumber; rev = p.revision + (p.status ? ' (' + p.status + ')' : ''); title = p.title || ''; }
else tracking = c0;
} else {
tracking = c0;
if (cells.length >= 4 && cells[2] && window.zddc.isValidStatus(cells[2])) { rev = (cells[1] + ' (' + cells[2] + ')').trim(); title = cells[3] || ''; }
else { rev = cells[1] || ''; title = cells[2] || ''; }
}
if (!tracking) { skipped.push({ line: i + 1, reason: 'no tracking number', text: raw }); return; }
sawData = true;
rows.push({
trackingNumber: c0,
revisionCell: (cells[1] || '').trim(),
title: cells[2] || '',
currentName: cells[3] || '',
source: { pasted: true },
});
rows.push({ trackingNumber: tracking, revisionCell: rev.trim(), title: title, source: { pasted: true } });
});
return { rows: rows, skipped: skipped };
}
function normTok(s) { return String(s == null ? '' : s).toUpperCase().replace(/[^A-Z0-9]/g, ''); }
function dropExt(s) { return String(s == null ? '' : s).replace(/\.[^.\/\\]+$/, ''); }
function nameKey(s) { return dropExt(s).toLowerCase().replace(/[^a-z0-9]+/g, ''); }
function nameTokens(s) { return dropExt(s).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); }
// Score a pasted "current name" against a file's name: 1 = exact (normalized,
// extension dropped), 0.60.95 = token coverage, 0.7 = a clean substring,
// 0 = no match. Token-set beats raw substring (survives reordering).
function nameScore(rowName, fileFull) {
var rk = nameKey(rowName); if (!rk) return 0;
var fk = nameKey(fileFull);
if (rk === fk) return 1;
var rt = nameTokens(rowName);
if (rt.length) {
var ft = Object.create(null); nameTokens(fileFull).forEach(function (t) { ft[t] = true; });
var hit = 0; rt.forEach(function (t) { if (ft[t]) hit++; });
var cov = hit / rt.length;
if (cov >= 0.6) return Math.min(0.95, 0.6 + 0.35 * cov);
}
var a = rk.length <= fk.length ? rk : fk, b = rk.length <= fk.length ? fk : rk;
if (a.length >= 4 && b.indexOf(a) !== -1) return 0.7;
return 0;
}
// Propose file↔row matches. PRIMARY signal is the pasted "current name"
// column (nameScore); FALLBACK is the tracking number embedded in the
// filename (opts.fuzzy also tries the digit-run). Each proposal carries a
// confidence and an `auto` flag — true only for an exact 1:1 match (conf 1,
// the unique conf-1 match for BOTH its file and its row), the only kind safe
// to assign without confirmation.
// Propose row matches for source files by finding a row whose tracking number
// appears in the filename. opts.fuzzy also matches on the digit-run.
function proposeMatches(files, rows, opts) {
opts = opts || {};
var named = (rows || []).filter(function (r) { return (r.currentName || '').trim(); });
var out = [];
(files || []).forEach(function (f) {
var full = zddc.joinExtension(f.originalFilename, f.extension);
var best = null;
named.forEach(function (r) {
var s = nameScore(r.currentName, full);
if (s > 0 && (!best || s > best.confidence)) best = { row: r, confidence: s, via: 'name' };
var nameNorm = normTok(full), nameDigits = nameNorm.replace(/[^0-9]/g, ''), best = null;
(rows || []).forEach(function (r) {
var tn = r.trackingNumber || ''; if (!tn) return;
var tnNorm = normTok(tn), conf = 0;
if (full.indexOf(tn) !== -1) conf = 1;
else if (tnNorm && nameNorm.indexOf(tnNorm) !== -1) conf = 0.8;
else if (opts.fuzzy) { var d = tnNorm.replace(/[^0-9]/g, ''); if (d && nameDigits.indexOf(d) !== -1) conf = 0.5; }
if (conf && (!best || conf > best.confidence)) best = { row: r, confidence: conf };
});
if (!best) { // fallback: tracking number in the filename
var nameNorm = normTok(full), nameDigits = nameNorm.replace(/[^0-9]/g, '');
(rows || []).forEach(function (r) {
var tn = r.trackingNumber || ''; if (!tn) return;
var tnNorm = normTok(tn), conf = 0;
if (full.indexOf(tn) !== -1) conf = 1;
else if (tnNorm && nameNorm.indexOf(tnNorm) !== -1) conf = 0.8;
else if (opts.fuzzy) { var d = tnNorm.replace(/[^0-9]/g, ''); if (d && nameDigits.indexOf(d) !== -1) conf = 0.5; }
if (conf && (!best || conf > best.confidence)) best = { row: r, confidence: conf, via: 'tracking' };
});
}
if (best) out.push({ file: f, row: best.row, confidence: best.confidence, via: best.via, auto: false });
});
// Auto-assignable = exact + unambiguous both ways (so duplicate names
// never silently grab the wrong file).
var rowEx = Object.create(null), fileEx = Object.create(null);
out.forEach(function (p) {
if (p.confidence !== 1) return;
rowEx[p.row.id || p.row.trackingNumber] = (rowEx[p.row.id || p.row.trackingNumber] || 0) + 1;
fileEx[srcKeyForFile(p.file)] = (fileEx[srcKeyForFile(p.file)] || 0) + 1;
});
out.forEach(function (p) {
if (p.confidence === 1) p.auto = rowEx[p.row.id || p.row.trackingNumber] === 1 && fileEx[srcKeyForFile(p.file)] === 1;
if (best) out.push({ file: f, row: best.row, confidence: best.confidence });
});
return out;
}
@ -8302,8 +8255,8 @@ X.B(E,Y);return E}return J}())
transmittalRecord: transmittalRecord,
findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin,
getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields,
setWorklist: setWorklist, appendWorklist: appendWorklist, clearWorklist: clearWorklist,
getWorklist: getWorklist, getWorklistRow: getWorklistRow,
setMdlList: setMdlList, appendMdlRows: appendMdlRows, clearMdlList: clearMdlList,
getMdlList: getMdlList, getMdlRow: getMdlRow,
assignFromRow: assignFromRow, unassignRowFile: unassignRowFile,
setRowTracking: setRowTracking, setRowTitle: setRowTitle,
setRevisionCell: setRevisionCell, setRevisionCells: setRevisionCells,
@ -11062,9 +11015,9 @@ X.B(E,Y);return E}return J}())
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 currentTab = 'tracking'; // 'tracking' | 'existing' | 'transmittal' — active tab
var mdlTable = null; // the seltable controller for the "From a list" tab
var mdlPlaced = {}; // 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)
@ -11073,15 +11026,15 @@ X.B(E,Y);return E}return J}())
initialized = true;
els = {
trackingTab: document.getElementById('trackingTab'),
worklistTab: document.getElementById('worklistTab'),
existingTab: document.getElementById('existingTab'),
transmittalTab: document.getElementById('transmittalTab'),
trackingPanel: document.getElementById('trackingPanel'),
transmittalPanel: document.getElementById('transmittalPanel'),
worklistPanel: document.getElementById('worklistPanel'),
mdlPanel: document.getElementById('mdlPanel'),
trackingTree: document.getElementById('trackingTree'),
transmittalTree: document.getElementById('transmittalTree'),
worklistTable: document.getElementById('worklistTable'),
loadWorklistBtn: document.getElementById('loadWorklistBtn'),
mdlTree: document.getElementById('mdlTree'),
loadMdlBtn: document.getElementById('loadMdlBtn'),
pasteRowsBtn: document.getElementById('pasteRowsBtn'),
matchNamesBtn: document.getElementById('matchNamesBtn'),
clearListBtn: document.getElementById('clearListBtn'),
@ -11092,13 +11045,13 @@ X.B(E,Y);return E}return J}())
};
els.trackingTab.addEventListener('click', function () { showTab('tracking'); });
if (els.worklistTab) els.worklistTab.addEventListener('click', function () { showTab('worklist'); });
if (els.existingTab) els.existingTab.addEventListener('click', function () { showTab('existing'); });
els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); });
if (els.loadWorklistBtn) els.loadWorklistBtn.addEventListener('click', loadWorklist);
if (els.loadMdlBtn) els.loadMdlBtn.addEventListener('click', loadMdl);
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();
var list = C().getMdlList();
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
@ -11106,16 +11059,16 @@ X.B(E,Y);return E}return J}())
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();
C().clearMdlList();
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();
if (mdlTable) mdlTable.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 (els.mdlPanel) els.mdlPanel.addEventListener('paste', function (e) {
if (currentTab !== 'existing') 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') : '';
@ -11178,12 +11131,12 @@ X.B(E,Y);return E}return J}())
}
function showTab(which) {
currentTab = (which === 'transmittal' || which === 'worklist') ? which : 'tracking';
currentTab = (which === 'transmittal' || which === 'existing') ? which : 'tracking';
els.trackingTab.classList.toggle('active', currentTab === 'tracking');
if (els.worklistTab) els.worklistTab.classList.toggle('active', currentTab === 'worklist');
if (els.existingTab) els.existingTab.classList.toggle('active', currentTab === 'existing');
els.transmittalTab.classList.toggle('active', currentTab === 'transmittal');
els.trackingPanel.hidden = currentTab !== 'tracking';
if (els.worklistPanel) els.worklistPanel.hidden = currentTab !== 'worklist';
if (els.mdlPanel) els.mdlPanel.hidden = currentTab !== 'existing';
els.transmittalPanel.hidden = currentTab !== 'transmittal';
render();
// The source-tree Show filters are per-axis, so the visible set changes
@ -11217,7 +11170,7 @@ X.B(E,Y);return E}return J}())
var placed = buildPlaced(files);
renderTrackingInto(els.trackingTree, C().getTrackingTree(), placed.tracking);
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal);
renderWorklist(placed.byTracking);
renderMdlInto(placed.byTracking);
renderStats(files);
}
@ -11552,52 +11505,51 @@ X.B(E,Y);return E}return J}())
}
// ── "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).'));
function renderMdlInto(placedByTracking) {
mdlPlaced = placedByTracking || {};
if (!C().getMdlList().length) {
mdlTable = null;
els.mdlTree.textContent = '';
els.mdlTree.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();
ensureMdlTable();
mdlTable.renderBody();
}
function rowPlaced(r) { var f = worklistPlaced[r.trackingNumber]; return f && f.length ? f : null; }
function ensureWorklistGrid() {
if (worklistGrid) return worklistGrid;
function rowPlaced(r) { var f = mdlPlaced[r.trackingNumber]; return f && f.length ? f : null; }
function ensureMdlTable() {
if (mdlTable) return mdlTable;
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(' '); },
{ key: 'tn', title: 'Tracking number', cls: 'fromlist-tn', get: function (r) { return r.trackingNumber || ''; },
render: function (r, td) { editCell(td, 'fromlist-tn__input', r.trackingNumber, 'ACME-…-0001', function (v) { c.setRowTracking(r.id, v); }, tnWarn(r)); } },
{ key: 'title', title: 'Title', cls: 'fromlist-title', get: function (r) { return r.title || ''; },
render: function (r, td) { editCell(td, 'fromlist-title__input', r.title, 'title', function (v) { c.setRowTitle(r.id, v); }); } },
{ key: 'src', title: 'Source', cls: 'fromlist-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); }); } },
{ key: 'rev', title: 'Revision', cls: 'mdl-rev', get: function (r) { return r.revisionCell; },
render: function (r, td) { editCell(td, 'mdl-rev__input', r.revisionCell, 'A (IFR)', function (v) { c.setRevisionCell(r.id, v); }); } },
];
worklistGrid = window.app.modules.seltable.create({
container: els.worklistTable,
mdlTable = window.app.modules.seltable.create({
container: els.mdlTree,
extraTitle: 'Files',
rows: function () {
var list = c.getWorklist();
var list = c.getMdlList();
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); },
onRowDrop: function (rowId, keys) { var row = c.getMdlRow(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); },
rowExtra: function (r, td) { renderMdlPlaced(r, td); },
});
worklistGrid.render();
return worklistGrid;
mdlTable.render();
return mdlTable;
}
// An editable seltable cell: an <input> that commits on change. `warn` is an
// optional tooltip that flags (without blocking) a questionable value.
@ -11628,7 +11580,7 @@ X.B(E,Y);return E}return J}())
td.appendChild(el('span', 'src-badge src-badge--pasted', 'pasted'));
}
}
function renderWorklistFiles(row, td) {
function renderMdlPlaced(row, td) {
var c = C(), files = rowPlaced(row) || [];
files.forEach(function (f) {
var d = c.deriveTarget(f);
@ -11699,7 +11651,7 @@ X.B(E,Y);return E}return J}())
}
return [{ label: scope.one, handle: archiveOf('/' + scope.one + '/') }];
}
async function loadWorklist() {
async function loadMdl() {
var roots = await buildRoots();
if (!roots) return;
var picked = await window.app.modules.dirPicker.pick(roots);
@ -11750,8 +11702,8 @@ X.B(E,Y);return E}return J}())
}
function finishLoad(rows) {
listScanned = true;
C().appendWorklist(rows); // APPEND — the list accumulates across batches
showTab('worklist');
C().appendMdlRows(rows); // APPEND — the list accumulates across batches
showTab('existing');
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');
@ -11774,29 +11726,12 @@ X.B(E,Y);return E}return J}())
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 m = scratchModal('Paste rows from Excel', 'Columns: Tracking · Rev (Status) · Title — tab-separated, as Excel copies. A header row is skipped; a pasted full filename is split.');
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.placeholder = 'ACME-AR-DWG-0001\tA (IFR)\tFloor plan';
ta.value = prefill || '';
m.body.appendChild(ta);
var preview = el('div', 'scratch-paste__preview'); m.body.appendChild(preview);
@ -11809,13 +11744,12 @@ X.B(E,Y);return E}return J}())
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);
var head = el('tr'); ['Tracking number', 'Revision', 'Title'].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);
@ -11827,23 +11761,23 @@ X.B(E,Y);return E}return J}())
}
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');
c.appendMdlRows(parsed.rows);
m.close(); showTab('existing');
window.zddc.toast('Added ' + n + ' pasted row' + (n === 1 ? '' : 's') + '.', 'success');
});
ta.addEventListener('input', refresh);
refresh(); ta.focus();
}
function openMatchDialog() {
var c = C();
var rows = c.getWorklist();
var rows = c.getMdlList();
if (!rows.length) { window.zddc.toast('Load or paste some tracking numbers first.', 'warning'); return; }
var files = unassignedFiles();
var files = allFiles().filter(function (f) {
var a = c.getAssignment(c.srcKeyForFile(f));
return !(a && (a.trackingNodeId || a.excluded));
});
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 m = scratchModal('Match names', 'Files whose name contains a known tracking number. Review, then assign the checked matches.');
var opts = { fuzzy: false };
var fuzzyLbl = el('label', 'scratch-match__fuzzy');
var fuzzy = document.createElement('input'); fuzzy.type = 'checkbox';
@ -11859,19 +11793,16 @@ X.B(E,Y);return E}return J}())
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;
var rowEl = el('label', 'scratch-match__row');
var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = true; 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__file', 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);
rowEl.appendChild(el('span', 'scratch-match__conf', Math.round(p.confidence * 100) + '%'));
list.appendChild(rowEl);
});
accept.disabled = false; accept.textContent = 'Assign checked';
accept.disabled = false; accept.textContent = 'Assign ' + proposals.length;
}
accept.addEventListener('click', function () {
var n = 0;
@ -11880,7 +11811,7 @@ X.B(E,Y);return E}return J}())
var p = proposals[Number(cb.dataset.i)];
if (p) { c.assignFromRow([c.srcKeyForFile(p.file)], p.row); n++; }
});
m.close(); showTab('worklist');
m.close(); showTab('existing');
window.zddc.toast('Assigned ' + n + ' file' + (n === 1 ? '' : 's') + ' by name match.', n ? 'success' : 'info');
});
fuzzy.addEventListener('change', function () { opts.fuzzy = fuzzy.checked; refresh(); });

View file

@ -1793,7 +1793,7 @@ body {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-15 13:55:36 · 0847c7a</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947</span></span>
</div>
</div>
<div class="header-right">

View file

@ -2770,7 +2770,7 @@ dialog.modal--narrow {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-15 13:55:36 · 0847c7a</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947</span></span>
</div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action;

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.27-beta · 2026-06-15 13:55:36 · 0847c7a
transmittal=v0.0.27-beta · 2026-06-15 13:55:36 · 0847c7a
classifier=v0.0.27-beta · 2026-06-15 13:55:36 · 0847c7a
landing=v0.0.27-beta · 2026-06-15 13:55:36 · 0847c7a
form=v0.0.27-beta · 2026-06-15 13:55:36 · 0847c7a
tables=v0.0.27-beta · 2026-06-15 13:55:36 · 0847c7a
browse=v0.0.27-beta · 2026-06-15 13:55:36 · 0847c7a
archive=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947
transmittal=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947
classifier=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947
landing=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947
form=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947
tables=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947
browse=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947

View file

@ -1770,7 +1770,7 @@ body.is-elevated::after {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-15 13:55:36 · 0847c7a</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947</span></span>
</div>
</div>
<div class="header-right">