From 9ca24eb3f1cde152a42f738d5baf552289303e3e Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 18 Jun 2026 09:03:42 -0500 Subject: [PATCH] feat(classifier): By-transmittal is a per-file grid with a single folder-path input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standardize the two classify tabs: By-transmittal drops the party→slot→bin tree (and its multi-field "+ Transmittal" form) for a flat per-file grid that mirrors By-tracking. Each file gets ONE editable text input — its full transmittal folder path "//". Committing it find-or-creates the party/slot/bin; structure stays derived, never stored. Drag-and-drop (from the source tree onto the grid): - plain drop on a routed row → the dropped files JOIN that row's folder; - ⌘/Ctrl-drop on a routed row → a prompt prefilled with that folder's path lets you edit it into a NEW transmittal the files go to (the original is untouched; an unedited path dedups via find-or-create); - drop on empty space / an unrouted row → files are added as blank rows to fill. Model (classify.js): adds a `transmittalWorkset` (parallel to trackingWorkset) plus addToTransmittalGrid / removeFromTransmittalGrid / transmittalGridKeys and setTransmittalPath(keys, path) — the single parser for "//" that also prunes any bin a re-route empties. app.js importPaths now reuses setTransmittalPath for its route axis (one parser, less duplication). Removes the now-dead tree rendering/CRUD (party/bin nodes, binForm, the bin filename editor, the bin drop zone). Tests updated to the grid model: tab render shows the folder-path input; drop join/branch/empty; edit re-routes and prunes the emptied folder; ✕ removes. 71/71 classify specs pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- classifier/css/layout.css | 2 +- classifier/js/app.js | 28 +-- classifier/js/classify.js | 86 +++++++ classifier/js/target-tree.js | 443 +++++++++++------------------------ classifier/template.html | 5 +- tests/classify.spec.js | 125 ++++++---- 6 files changed, 300 insertions(+), 389 deletions(-) diff --git a/classifier/css/layout.css b/classifier/css/layout.css index d9edeb0..56c6a94 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -751,7 +751,7 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o } .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-tn .tg-input, .tx-path .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; } diff --git a/classifier/js/app.js b/classifier/js/app.js index 4d5148a..997aa0f 100644 --- a/classifier/js/app.js +++ b/classifier/js/app.js @@ -295,29 +295,11 @@ note('filename is not a valid ZDDC name "' + filename + '"'); } - // Axis 2 — // → transmittal tree (the route). - if (leading.length >= 3) { - var party = leading[0]; - var slot = leading[1].toLowerCase(); - var folder = leading.slice(2).join('/'); - if (slot !== 'issued' && slot !== 'received') { - note('direction must be "issued" or "received", got "' + leading[1] + '"'); - } else { - var pf = window.zddc.parseFolder(folder); - if (pf && pf.valid) { - var tnParts = pf.trackingNumber.split('-'); - var seq = tnParts.pop(), type = tnParts.pop(); - var bid = c.findOrAddTransmittalBin(c.findOrAddParty(party), slot, { - date: pf.date, type: type || 'TRN', seq: seq || '', status: pf.status, title: pf.title, - }); - if (bid) { c.place([oldPath], bid, 'transmittal'); didTransmittal = true; } - else note('could not create the transmittal folder'); - } else { - note('transmittal folder is not a valid ZDDC folder name "' + folder + '"'); - } - } - } else if (leading.length >= 1) { - note('to route a transmittal the new path needs ///'); + // Axis 2 — // → transmittal tree (the + // route). Same parser the By-transmittal grid uses. + if (leading.length >= 1) { + var terr = c.setTransmittalPath([oldPath], leading.join('/')); + if (terr) note(terr); else didTransmittal = true; } if (didTracking || didTransmittal) imported++; diff --git a/classifier/js/classify.js b/classifier/js/classify.js index a34f1e8..3de49ab 100644 --- a/classifier/js/classify.js +++ b/classifier/js/classify.js @@ -61,6 +61,7 @@ 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) + transmittalWorkset: Object.create(null), // srcKeys shown as rows in the By-transmittal grid (set: key->true) }; // id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent } @@ -422,6 +423,7 @@ 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), + transmittalWorkset: Object.keys(state.transmittalWorkset), }; } function load(obj) { @@ -434,6 +436,8 @@ 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; }); + state.transmittalWorkset = Object.create(null); + (Array.isArray(obj.transmittalWorkset) ? obj.transmittalWorkset : []).forEach(function (k) { state.transmittalWorkset[k] = true; }); rebuildIndex(); migrateLegacyMdl(obj.worklist); // BEFORE anything can prune; materializes old mdl placements notify(); @@ -466,6 +470,7 @@ state.assignments = {}; state.trackingTree = []; state.transmittalTree = []; state.outputName = null; state.trackingWorkset = Object.create(null); + state.transmittalWorkset = Object.create(null); rebuildIndex(); notify(); } @@ -529,6 +534,84 @@ notify(); } + // ── By-transmittal grid (one editable row per file) ────────────────────── + // The transmittal tab mirrors the By-tracking grid: a flat, per-file surface + // where each file carries ONE text input — its full transmittal folder path + // "//". The path is + // PARSED into the transmittal tree (find-or-create party/slot/bin); structure + // is still derived, never stored. `transmittalWorkset` keeps a file on the + // grid before (and after) it has a path, exactly like `trackingWorkset`. + function addToTransmittalGrid(keys) { + var changed = false; + (keys || []).forEach(function (k) { if (!state.transmittalWorkset[k]) { state.transmittalWorkset[k] = true; changed = true; } }); + if (changed) notify(); + } + function transmittalGridKeys() { + var set = Object.create(null); + Object.keys(state.transmittalWorkset).forEach(function (k) { set[k] = true; }); + Object.keys(state.assignments).forEach(function (k) { if (state.assignments[k].transmittalNodeId) set[k] = true; }); + return Object.keys(set); + } + function transmittalHasFiles(binId) { + for (var k in state.assignments) { if (state.assignments[k].transmittalNodeId === binId) return true; } + return false; + } + // Delete a transmittal bin once nothing points at it (so re-routing doesn't + // litter the tree); drop the party too if it has no remaining bins. + function pruneEmptyTransmittal(binId) { + var info = infoFor(binId); + if (!info || info.kind !== 'transmittal' || transmittalHasFiles(binId)) return; + var slotInfo = info.parent ? infoFor(info.parent.id) : null; + var party = slotInfo && slotInfo.parent ? slotInfo.parent : null; + deleteNode(binId); // rebuilds the index + clears danglers + if (party) { + var anyBin = (party.children || []).some(function (slot) { return (slot.children || []).length; }); + if (!anyBin) deleteNode(party.id); + } + } + function removeFromTransmittalGrid(key) { + var a = state.assignments[key], old = a ? a.transmittalNodeId : null; + delete state.transmittalWorkset[key]; + place([key], null, 'transmittal'); + if (old) pruneEmptyTransmittal(old); + notify(); + } + // Route keys to the transmittal named by a "//" path, + // creating party/slot/bin as needed. Blank path clears the placement (the row + // stays, unrouted). Returns '' on success or a short error message; on error + // nothing is changed. Empties out (and prunes) any bin a key leaves behind. + function setTransmittalPath(keys, path) { + keys = keys || []; + path = (path == null ? '' : String(path)).trim(); + var oldBins = Object.create(null); + keys.forEach(function (k) { var a = state.assignments[k]; if (a && a.transmittalNodeId) oldBins[a.transmittalNodeId] = true; }); + if (!path) { + place(keys, null, 'transmittal'); + keys.forEach(function (k) { state.transmittalWorkset[k] = true; }); + Object.keys(oldBins).forEach(pruneEmptyTransmittal); + notify(); + return ''; + } + var segs = path.split('/').filter(function (s) { return s !== ''; }); + if (segs.length < 3) return 'path must be //'; + var party = segs[0], slot = segs[1].toLowerCase(), folder = segs.slice(2).join('/'); + if (slot !== 'issued' && slot !== 'received') return 'direction must be "issued" or "received"'; + var pf = zddc.parseFolder(folder); + if (!pf || !pf.valid) return 'not a valid transmittal folder "YYYY-MM-DD_TN (STATUS) - Title"'; + var tnParts = pf.trackingNumber.split('-'); + var seq = tnParts.pop(), type = tnParts.pop(); + var bid = findOrAddTransmittalBin(findOrAddParty(party), slot, { + date: pf.date, type: type || 'TRN', seq: seq || '', status: pf.status, title: pf.title, + }); + if (!bid) return 'could not create the transmittal'; + place(keys, bid, 'transmittal'); + keys.forEach(function (k) { state.transmittalWorkset[k] = true; }); + delete oldBins[bid]; // keep the bin we just filled + Object.keys(oldBins).forEach(pruneEmptyTransmittal); + notify(); + return ''; + } + // ── pattern config ─────────────────────────────────────────────────────── function normalizeConfig(c) { var d = defaultConfig(); @@ -983,6 +1066,9 @@ // By-tracking grid addToTrackingGrid: addToTrackingGrid, removeFromTrackingGrid: removeFromTrackingGrid, trackingGridKeys: trackingGridKeys, setFileIdentity: setFileIdentity, forgetFile: forgetFile, + // By-transmittal grid + addToTransmittalGrid: addToTransmittalGrid, removeFromTransmittalGrid: removeFromTransmittalGrid, + transmittalGridKeys: transmittalGridKeys, setTransmittalPath: setTransmittalPath, setWorklist: setWorklist, appendWorklist: appendWorklist, clearWorklist: clearWorklist, removeWorklistRow: removeWorklistRow, getWorklist: getWorklist, getWorklistRow: getWorklistRow, diff --git a/classifier/js/target-tree.js b/classifier/js/target-tree.js index 8d8e732..3ab439a 100644 --- a/classifier/js/target-tree.js +++ b/classifier/js/target-tree.js @@ -1,23 +1,22 @@ /** * ZDDC Classifier — target-tree pane (Classify & Copy mode). * - * Renders the two orthogonal target trees the user maps files onto: - * - "By tracking number": folders that join with "-" into the tracking - * number; the leaf folder ("A (IFR)") is the revision+status. - * - "By transmittal": /{received,issued}/. + * Two orthogonal per-file grids (both on the shared seltable) the user maps + * files onto — one editable row per file: + * - "By tracking number": Tracking# / Rev (Status) / Title cells compose the + * ZDDC filename (the rename). + * - "By transmittal": one text input = the file's full transmittal folder path + * "//" (the route); + * committing it find-or-creates the party/slot/bin in classify.js. * - * Structure here, placements in classify.js. Drag-and-drop assignment is wired - * in source-dnd.js / phase 3; this module owns rendering + folder/bin CRUD and - * shows the derived filename for each placed file. + * Structure + placements live in classify.js; everything shown here is derived, + * never stored. Drops are handled per-grid (setupGridDrop / setupTransmittalDrop) + * so a ⌘/Ctrl transmittal drop can branch a new folder. */ (function () { 'use strict'; - var SLOTS = ['received', 'issued']; - var els = {}; - var collapsed = {}; // nodeId -> true when collapsed (default expanded) - var openForm = null; // { partyId, slot } when a bin form is open var initialized = false; var currentTab = 'tracking'; // 'tracking' | 'transmittal' — active tab var listScanned = false; // a Load has run this session (drives the "new" badge) @@ -39,7 +38,6 @@ addFilteredBtn: document.getElementById('addFilteredBtn'), renameBtn: document.getElementById('renameBtn'), trackingColsBtn: document.getElementById('trackingColsBtn'), - addPartyBtn: document.getElementById('addPartyBtn'), stats: document.getElementById('classifyStats'), }; @@ -70,16 +68,9 @@ if (text) { e.preventDefault(); openPasteDialog(text); } }); if (els.trackingColsBtn) els.trackingColsBtn.addEventListener('click', openColumnChooser); - els.addPartyBtn.addEventListener('click', function () { - var name = prompt('Party name (also the transmittal-number prefix):', ''); - if (name && name.trim()) C().addParty(name.trim()); - }); - - els.transmittalTree.addEventListener('click', onTransmittalClick); - els.transmittalTree.addEventListener('change', onFileNameChange); setupGridDrop(els.trackingTree); - setupDropZone(els.transmittalTree, 'transmittal'); + setupTransmittalDrop(els.transmittalTree); C().on(render); if (window.app.modules.store && window.app.modules.store.on) { @@ -101,24 +92,6 @@ })(window.app.folderTree || []); return out; } - // One pass: group files by the node they're placed in, per axis. - function buildPlaced(files) { - var c = C(), byT = {}, byX = {}, byTn = {}; - files.forEach(function (f) { - var a = c.getAssignment(c.srcKeyForFile(f)); - if (!a) return; - if (a.trackingNodeId) { - (byT[a.trackingNodeId] = byT[a.trackingNodeId] || []).push(f); - // Also index by tracking NUMBER so a "From a list" row can show - // the files placed under it (a row is a tracking number, not a node). - var tn = c.deriveTarget(f).tracking; - if (tn) (byTn[tn] = byTn[tn] || []).push(f); - } - if (a.transmittalNodeId) (byX[a.transmittalNodeId] = byX[a.transmittalNodeId] || []).push(f); - }); - return { tracking: byT, transmittal: byX, byTracking: byTn }; - } - function showTab(which) { currentTab = (which === 'transmittal') ? 'transmittal' : 'tracking'; els.trackingTab.classList.toggle('active', currentTab === 'tracking'); @@ -138,9 +111,8 @@ function render() { if (!initialized || !C().isEnabled()) return; var files = allFiles(); - var placed = buildPlaced(files); renderTrackingGrid(els.trackingTree); - renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal); + renderTransmittalGrid(els.transmittalTree); renderStats(files); } @@ -184,66 +156,13 @@ return e; } - function nodeActions(extra) { - var wrap = el('span', 'tnode__actions'); - (extra || []).forEach(function (a) { - var b = el('button', 'tnode__act', a.label); - b.dataset.act = a.act; - b.title = a.title || ''; - wrap.appendChild(b); - }); - return wrap; - } - - // Placed files inside a transmittal bin. Each row is draggable (drag onto - // another bin to MOVE it) and carries an ✕ to remove it from the transmittal. - function fileList(files) { - var box = el('div', 'tnode__files'); - files.forEach(function (f) { - var d = C().deriveTarget(f); - var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : '')); - row.dataset.key = d.key; - row.draggable = true; - row.addEventListener('dragstart', function (e) { window.app.modules.dnd.setDrag([d.key], e); }); - var orig = el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : '')); - orig.title = 'Drag to another transmittal to move · click to preview'; - row.appendChild(orig); - row.appendChild(el('span', 'tfile__arrow', '→')); - // Editable derived filename — edit it to re-file the item. - var name = el('input', 'tfile__name' + (d.errors.length ? ' tfile__name--err' : '')); - name.type = 'text'; - name.value = d.filename || ''; - name.placeholder = '(incomplete)'; - name.title = d.errors.length ? d.errors.join('; ') : 'Edit this ZDDC filename to re-file the item'; - row.appendChild(name); - var rm = el('button', 'tnode__act tfile__remove', '✕'); - rm.dataset.act = 'untransmit'; - rm.title = 'Remove from this transmittal'; - row.appendChild(rm); - box.appendChild(row); - }); - return box; - } - - // ── name filter (the autofilter box above the target trees) ──────────── + // ── name filter (the autofilter box above the target grids) ──────────── + // Mirrored into each grid's own global filter (seltable.setFilter) on render. var rfTerms = []; function setNameFilter(q) { rfTerms = String(q || '').trim().toLowerCase().split(/\s+/).filter(Boolean); render(); } - function rfActive() { return rfTerms.length > 0; } - function rfHit(text) { - if (!rfTerms.length) return true; - var t = String(text || '').toLowerCase(); - for (var i = 0; i < rfTerms.length; i++) { if (t.indexOf(rfTerms[i]) === -1) return false; } - return true; - } - // A placed-file row matches on its original name or its derived ZDDC name. - function fileRowMatches(f) { - var orig = f.originalFilename + (f.extension ? '.' + f.extension : ''); - return rfHit(orig) || rfHit(C().deriveTarget(f).filename || ''); - } - // ── By-tracking: flat editable grid (one row per file), on the shared // seltable — so it gets multi-sort + per-column autofilters + resizable, // persisted widths for free. Only `hidden` (the Columns ▾ chooser) is @@ -531,93 +450,85 @@ }, 0); } - // Transmittal tree - function renderTransmittalInto(container, parties, placedMap) { - container.textContent = ''; - if (!parties.length) { - container.appendChild(el('div', 'target-empty', 'No parties yet — “+ Party” to start.')); + // ── By-transmittal: flat editable grid (one row per file), mirroring the + // By-tracking grid. Each row's single text input is the file's full + // transmittal folder path "//"; committing + // it routes the file (classify.setTransmittalPath find-or-creates the + // party/slot/bin). Drops are handled at the container level so a ⌘/Ctrl + // drop can branch a new transmittal (setupTransmittalDrop). ────────────── + function txPath(f) { return C().deriveTarget(f).outPath || ''; } + function txStatusCell(td, f) { + var ok = !!txPath(f); + var badge = el('span', ok ? 'tfile__badge tfile__badge--ok' : 'tfile__badge tg-wanted', ok ? '✓' : '◇'); + badge.title = ok ? ('Routed to ' + txPath(f)) : 'No transmittal folder yet — type one, or drop onto a routed row.'; + td.appendChild(badge); + } + function txPathCell(td, f) { + var c = C(), key = c.srcKeyForFile(f); + editCell(td, 'tg-input', txPath(f), 'Acme/received/2026-06-18_Acme-TRN-0001 (IFC) - Title', function (v) { + var err = c.setTransmittalPath([key], v); + if (err) { window.zddc.toast('Transmittal not set — ' + err, 'warning'); render(); } + }); + } + function txRemoveCell(td, f) { + var c = C(), key = c.srcKeyForFile(f); + var rm = el('button', 'tnode__act tg-x__btn', '✕'); rm.title = 'Remove from the transmittal grid'; + rm.addEventListener('click', function () { c.removeFromTransmittalGrid(key); }); + td.appendChild(rm); + } + function transmittalGridRows() { + var out = []; + C().transmittalGridKeys().forEach(function (k) { + var f = fileByKey(k); if (f) out.push({ kind: 'file', file: f, id: 'f:' + k }); + }); + return out; + } + function transmittalColumns() { + return [ + { key: 'status', title: 'Status', cls: 'tg-status', filterable: false, + get: function (r) { return txPath(r.file) ? 'ok' : 'awaiting'; }, + render: function (r, td) { txStatusCell(td, r.file); } }, + { key: 'orig', title: 'Original name', cls: 'tg-orig', + get: function (r) { return joinName(r.file); }, + render: function (r, td) { gridOrigCell(td, r.file); } }, + { key: 'path', title: 'Transmittal folder', cls: 'tx-path', + get: function (r) { return txPath(r.file); }, + render: function (r, td) { txPathCell(td, r.file); } }, + { key: 'x', title: '', cls: 'tg-x', sortable: false, filterable: false, + render: function (r, td) { txRemoveCell(td, r.file); } }, + ]; + } + var transmittalGrid = null; + function ensureTransmittalGrid(container) { + if (transmittalGrid) return transmittalGrid; + transmittalGrid = window.app.modules.seltable.create({ + container: container, + rows: transmittalGridRows, + rowId: function (r) { return r.id; }, + columns: transmittalColumns(), + persistKey: 'zddc.classifier.transmittalCols', + }); + transmittalGrid.render(); + return transmittalGrid; + } + function renderTransmittalGrid(container) { + if (!transmittalGridRows().length) { + transmittalGrid = null; + container.textContent = ''; + container.classList.remove('seltable'); + container.appendChild(el('div', 'target-empty', + 'No files here yet — drag files in, then type each one’s transmittal folder ' + + '(//). Drop onto a routed ' + + 'row to put the file in that same folder; ⌘/Ctrl-drop to branch a new transmittal from it.')); return; } - parties.forEach(function (p) { var e = partyNode(p, placedMap); if (e) container.appendChild(e); }); - if (rfActive() && !container.children.length) { - container.appendChild(el('div', 'target-empty', 'No matches in the transmittal tree.')); - } - } - function partyNode(party, placedMap) { - var partyMatch = rfHit(party.name); - var slotEls = [], anyBin = false; - SLOTS.forEach(function (slot) { - var slotNode = (party.children || []).filter(function (s) { return s.slot === slot; })[0]; - var sw = el('div', 'tslot'); - sw.dataset.party = party.id; - sw.dataset.slot = slot; - var sr = el('div', 'tslot__row'); - sr.appendChild(el('span', 'tslot__name', slot)); - var addBtn = el('button', 'tnode__act', '+ Transmittal'); - addBtn.dataset.act = 'addbin'; - sr.appendChild(addBtn); - sw.appendChild(sr); - - if (openForm && openForm.partyId === party.id && openForm.slot === slot) { - sw.appendChild(binForm(party.id, slot)); - } - (slotNode ? slotNode.children : []).forEach(function (bin) { - var be = binNode(bin, placedMap, partyMatch); - if (be) { sw.appendChild(be); anyBin = true; } - }); - slotEls.push(sw); - }); - if (rfActive() && !partyMatch && !anyBin) return null; - - var wrap = el('div', 'tnode tnode--party'); - wrap.dataset.id = party.id; - var row = el('div', 'tnode__row'); - row.appendChild(el('span', 'tnode__icon', '🏢')); - row.appendChild(el('span', 'tnode__name', party.name)); - row.appendChild(nodeActions([ - { act: 'rename-party', label: '✎', title: 'Rename party' }, - { act: 'del-party', label: '🗑', title: 'Delete party' }, - ])); - wrap.appendChild(row); - slotEls.forEach(function (sw) { wrap.appendChild(sw); }); - return wrap; - } - function binNode(bin, placedMap, ancMatched) { - var matched = ancMatched || rfHit(bin.name || ''); - var placed = placedMap[bin.id] || []; - var shownFiles = (rfActive() && !matched) ? placed.filter(fileRowMatches) : placed; - if (rfActive() && !matched && !shownFiles.length) return null; - var wrap = el('div', 'tnode tnode--bin'); - wrap.dataset.id = bin.id; - var row = el('div', 'tnode__row'); - row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)')); - if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length))); - row.appendChild(nodeActions([ - { act: 'rename-bin', label: '✎', title: 'Rename transmittal' }, - { act: 'del', label: '🗑', title: 'Delete transmittal' }, - ])); - wrap.appendChild(row); - if (shownFiles.length) wrap.appendChild(fileList(shownFiles)); - return wrap; + ensureTransmittalGrid(container); + transmittalGrid.setFilter(rfTerms.join(' ')); } - var STATUSES = ['---', 'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU', 'REC', 'RSA', 'RSB', 'RSC', 'RSD', 'RSI', 'TBD']; - function binForm(partyId, slot) { - var form = el('div', 'binform'); - form.dataset.party = partyId; - form.dataset.slot = slot; - var date = el('input', 'binform__date'); date.type = 'date'; - try { date.value = new Date().toISOString().slice(0, 10); } catch (_) { /* ok */ } - var type = document.createElement('select'); type.className = 'binform__type'; - ['TRN', 'SUB'].forEach(function (t) { var o = el('option', null, t); o.value = t; type.appendChild(o); }); - var seq = el('input', 'binform__seq'); seq.type = 'text'; seq.placeholder = 'seq (e.g. 0007)'; - var status = document.createElement('select'); status.className = 'binform__status'; - STATUSES.forEach(function (s) { var o = el('option', null, s); o.value = s; status.appendChild(o); }); - var title = el('input', 'binform__title'); title.type = 'text'; title.placeholder = 'title (optional)'; - var add = el('button', 'btn btn-sm btn-primary', 'Add'); add.dataset.act = 'binadd'; - var cancel = el('button', 'btn btn-sm btn-secondary', 'Cancel'); cancel.dataset.act = 'bincancel'; - [date, type, seq, status, title, add, cancel].forEach(function (n) { form.appendChild(n); }); - return form; + function setGridStatus(text) { + var s = document.getElementById('scanStatus'); + if (s) { s.textContent = text; s.classList.toggle('scanning', !!text); } } function setGridStatus(text) { @@ -937,163 +848,75 @@ } // ── events ───────────────────────────────────────────────────────────── - function closestNodeId(target) { - var n = target.closest('[data-id]'); - return n ? n.dataset.id : null; - } function fileByKey(key) { var files = allFiles(); for (var i = 0; i < files.length; i++) { if (C().srcKeyForFile(files[i]) === key) return files[i]; } return null; } - // Click a placed-file row (anywhere but its editable name) → preview it. - function previewFromTarget(e) { - // Preview link on a revision cell (its placed file). - var pl = e.target.closest('[data-preview-key]'); - if (pl) { - e.preventDefault(); - var pf = fileByKey(pl.dataset.previewKey); - if (pf && window.app.modules.preview && window.app.modules.preview.previewFile) { - window.app.modules.preview.previewFile(pf); - } - return true; - } - if (e.target.closest('[data-act]')) return false; // action button — not a preview - if (e.target.closest('.tfile__name')) return false; - var tf = e.target.closest('.tfile'); - if (!tf || !tf.dataset.key) return false; - var f = fileByKey(tf.dataset.key); - if (f && window.app.modules.preview && window.app.modules.preview.previewFile) { - window.app.modules.preview.previewFile(f); - } - return true; - } - // Edited a placed-file's ZDDC filename → re-derive its tracking placement - // (creating the folder path if needed) + its title override. - function onFileNameChange(e) { - var input = e.target.closest('.tfile__name'); - if (input) commitFilenameEdit(input); - } - function commitFilenameEdit(input) { - var tf = input.closest('.tfile'); - if (!tf || !tf.dataset.key) return; - var parsed = window.zddc.parseFilename((input.value || '').trim()); - if (!parsed || !parsed.valid) { - window.zddc.toast('Not a valid ZDDC filename — expected "TRACKING_REV (STATUS) - Title.ext".', 'warning'); - render(); // restore the derived value - return; - } - var stem = parsed.trackingNumber + '_' + parsed.revision + ' (' + parsed.status + ')'; - var leaf = C().addTrackingPath(null, C().parseFolderLevels(stem)); - C().place([tf.dataset.key], leaf, 'tracking'); - if (parsed.title != null) C().setTitleOverride(tf.dataset.key, parsed.title); - // place/setTitleOverride fire classify.notify → re-render. - } - function onTransmittalClick(e) { - if (previewFromTarget(e)) return; - var btn = e.target.closest('[data-act]'); - if (!btn) return; - var act = btn.dataset.act; - if (act === 'addbin') { - var slotEl = btn.closest('.tslot'); - openForm = { partyId: slotEl.dataset.party, slot: slotEl.dataset.slot }; - render(); - return; + // ── By-transmittal drops ───────────────────────────────────────────────── + // Handled at the container level (not seltable's per-row onRowDrop) so the + // drop event's modifier key is available: + // plain drop on a routed row → the dropped files JOIN that row's folder. + // ⌘/Ctrl drop on a routed row → prompt, prefilled with that folder's path, + // so the user edits it into a NEW transmittal the files go to (the + // original folder is untouched — find-or-create dedups an unedited path). + // drop on empty space / an unrouted row → just add the files as grid rows. + function setupTransmittalDrop(container) { + function rowUnder(e) { var tr = e.target.closest && e.target.closest('.seltable__row'); return (tr && container.contains(tr)) ? tr : null; } + function clearHover() { + Array.prototype.forEach.call(container.querySelectorAll('.drop-hover'), function (n) { n.classList.remove('drop-hover'); }); + container.classList.remove('tg-drop-hover'); } - if (act === 'untransmit') { - var tf = btn.closest('.tfile'); - if (tf && tf.dataset.key) C().place([tf.dataset.key], null, 'transmittal'); - return; - } - if (act === 'rename-bin') { - var bid = closestNodeId(btn); - var bn = C().getNode(bid); - var nn = prompt('Rename transmittal (this becomes its folder name):', bn ? bn.name : ''); - if (nn && nn.trim()) C().renameNode(bid, nn.trim()); - return; - } - if (act === 'bincancel') { openForm = null; render(); return; } - if (act === 'binadd') { - var form = btn.closest('.binform'); - var meta = { - date: form.querySelector('.binform__date').value, - type: form.querySelector('.binform__type').value, - seq: form.querySelector('.binform__seq').value.trim(), - status: form.querySelector('.binform__status').value, - title: form.querySelector('.binform__title').value.trim(), - }; - if (!meta.date || !meta.seq) { window.zddc.toast('Transmittal needs at least a date and a sequence number.', 'warning'); return; } - C().addTransmittalBin(form.dataset.party, form.dataset.slot, meta); - openForm = null; // render() fires from classify.notify() - return; - } - - var id = closestNodeId(btn); - if (act === 'rename-party') { - var node = C().getNode(id); - var nn = prompt('Rename party (re-derives its transmittal numbers):', node ? node.name : ''); - if (nn && nn.trim()) C().renameNode(id, nn.trim()); - } else if (act === 'del-party') { - if (confirm('Delete this party and all its transmittals? Files placed there become unassigned.')) C().deleteNode(id); - } else if (act === 'del') { - if (confirm('Delete this transmittal? Files placed here become unassigned.')) C().deleteNode(id); - } - } - - // ── drop targets ─────────────────────────────────────────────────────── - // Resolve the drop target under an event: - // tracking → any folder node (.tnode) - // transmittal → a transmittal bin only (.tnode--bin) - function dropTarget(target, axis) { - if (axis === 'transmittal') { - var bin = target.closest('.tnode--bin'); - if (!bin || !bin.dataset.id) return null; - return { id: bin.dataset.id, row: bin.querySelector('.tnode__row') || bin }; - } - var cell = target.closest('.ttable__cell[data-id], .ttable__rev[data-id]'); - if (!cell) return null; - return { id: cell.dataset.id, row: cell }; - } - function clearHover(container) { - var hot = container.querySelectorAll('.drop-hover'); - for (var i = 0; i < hot.length; i++) hot[i].classList.remove('drop-hover'); - } - function setupDropZone(container, axis) { container.addEventListener('dragover', function (e) { if (!window.app.modules.dnd.active()) return; - var t = dropTarget(e.target, axis); - clearHover(container); - if (!t) return; - e.preventDefault(); - e.dataTransfer.dropEffect = 'copy'; - t.row.classList.add('drop-hover'); - }); - container.addEventListener('dragleave', function (e) { - if (e.target === container) clearHover(container); + e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; + clearHover(); + var tr = rowUnder(e); + if (tr) tr.classList.add('drop-hover'); else container.classList.add('tg-drop-hover'); }); + container.addEventListener('dragleave', function (e) { if (e.target === container) clearHover(); }); container.addEventListener('drop', function (e) { - var t = dropTarget(e.target, axis); - clearHover(container); - if (!t) return; e.preventDefault(); + var tr = rowUnder(e), meta = e.metaKey || e.ctrlKey; + clearHover(); var keys = window.app.modules.dnd.getDrag(); window.app.modules.dnd.clearDrag(); if (!keys.length) return; - C().place(keys, t.id, axis); + onTransmittalDrop(keys, tr ? tr.dataset.id : null, meta); }); } + function onTransmittalDrop(keys, rowId, meta) { + var c = C(), targetPath = ''; + if (rowId && rowId.indexOf('f:') === 0) { + var tf = fileByKey(rowId.slice(2)); + if (tf) targetPath = c.deriveTarget(tf).outPath || ''; + } + if (targetPath) { + if (meta) { + var edited = prompt('New transmittal folder — edit to branch a copy, or keep to join:', targetPath); + if (edited == null) return; // cancelled + var err = c.setTransmittalPath(keys, edited.trim()); + if (err) window.zddc.toast('Could not route — ' + err, 'warning'); + } else { + c.setTransmittalPath(keys, targetPath); // join the same folder + } + return; + } + c.addToTransmittalGrid(keys); // empty space / unrouted row → add blank rows to fill + } // Reveal a source key's placement in the target pane (source → target). function reveal(key) { var a = C().getAssignment(key); if (!a) return; + // Both tabs are per-file grids whose rows are keyed "f:". if (a.trackingNodeId) { - showTab('tracking'); collapsed = {}; render(); - flashNode(els.trackingTree, a.trackingNodeId); + showTab('tracking'); render(); + flashNode(els.trackingTree, 'f:' + key); } else if (a.transmittalNodeId) { showTab('transmittal'); render(); - flashNode(els.transmittalTree, a.transmittalNodeId); + flashNode(els.transmittalTree, 'f:' + key); } } function flashNode(container, id) { diff --git a/classifier/template.html b/classifier/template.html index 182885d..db08028 100644 --- a/classifier/template.html +++ b/classifier/template.html @@ -130,13 +130,12 @@ diff --git a/tests/classify.spec.js b/tests/classify.spec.js index d47361b..0c0ba3b 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -164,16 +164,18 @@ test('target tree renders the By-tracking grid and tabs switch', async ({ page } 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' }); + const bin = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); + c.place([c.srcKeyForFile(f)], bin, 'transmittal'); // route it so it's a transmittal-grid row window.app.modules.targetTree.render(); }); // The grid (now on the shared seltable) shows the tracking number in a cell. await expect(page.locator('#trackingTree .seltable__table')).toBeVisible(); await expect(page.locator('#trackingTree .tg-tn .tg-input')).toHaveValue('ACME-PROJ-EL-DWG-0001'); - // Switch to transmittal tab. + // Switch to transmittal tab — also a per-file grid, one folder-path input per file. 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(); + await expect(page.locator('#transmittalTree .tx-path .tg-input')) + .toHaveValue('ClientCorp/received/2026-03-14_ClientCorp-TRN-0007 (---) - Transmittal'); }); // ── Phase 3: drag-and-drop assignment (drop handler) ─────────────────────── @@ -202,33 +204,52 @@ test('dropping files onto the By-tracking grid adds rows and auto-fills ZDDC nam 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 }) => { +test('transmittal grid: plain drop on a row joins its folder, ⌘-drop branches, empty drop adds a row', async ({ page }) => { await page.evaluate(() => window.app.modules.app.setMode()); await page.click('#transmittalTab'); const r = await page.evaluate(() => { - const c = window.app.modules.classify; - const party = c.addParty('ClientCorp'); - const bin = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); - window.app.modules.targetTree.render(); - const key = 'Sub/foundation.pdf'; + const c = window.app.modules.classify, tt = window.app.modules.targetTree; + c.reset(); + const fA = { originalFilename: 'A', extension: 'pdf', folderPath: 'R' }; + window.app.folderTree = [{ name: 'R', path: 'R', files: [fA], children: [] }]; + const keyA = c.srcKeyForFile(fA); + // Route file A into a folder so its row is a drop target. + c.setTransmittalPath([keyA], 'ClientCorp/received/2026-03-14_ClientCorp-TRN-0007 (IFC) - Pkg'); + tt.render(); + const rowA = document.querySelector('#transmittalTree .seltable__row'); + const binA = c.getAssignment(keyA).transmittalNodeId; - // Drop on the bin → assigned. - const binRow = document.querySelector('#transmittalTree .tnode--bin .tnode__row'); - window.app.modules.dnd.setDrag([key]); - binRow.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); - const afterBin = c.assignmentFor(key).transmittalNodeId; + // Plain drop of B onto A's row → B JOINS the same folder. + const keyB = 'R/B.pdf'; + window.app.modules.dnd.setDrag([keyB]); + rowA.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); + const bJoined = c.getAssignment(keyB).transmittalNodeId; - // Reset, then drop on the party row → ignored (only bins are targets). - c.place([key], null, 'transmittal'); - const partyRow = document.querySelector('#transmittalTree .tnode--party > .tnode__row'); - window.app.modules.dnd.setDrag([key]); - partyRow.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); - const afterParty = c.assignmentFor(key).transmittalNodeId; + // ⌘-drop of D onto A's row → a NEW folder (prompt supplies the edited name). + window.prompt = function () { return 'ClientCorp/received/2026-03-15_ClientCorp-TRN-0008 (IFC) - New'; }; + const keyD = 'R/D.pdf'; + window.app.modules.dnd.setDrag([keyD]); + const metaEv = new Event('drop', { bubbles: true, cancelable: true }); metaEv.metaKey = true; + rowA.dispatchEvent(metaEv); + const dBin = c.getAssignment(keyD).transmittalNodeId; - return { afterBin, bin, afterParty }; + // Drop of C on empty grid space → added as a row, not routed. + const keyC = 'R/C.pdf'; + window.app.modules.dnd.setDrag([keyC]); + document.querySelector('#transmittalTree').dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); + const cAssign = c.getAssignment(keyC); + + return { + binA, bJoined, dBin, + cInGrid: c.transmittalGridKeys().indexOf(keyC) !== -1, + cRouted: !!(cAssign && cAssign.transmittalNodeId), + }; }); - expect(r.afterBin).toBe(r.bin); - expect(r.afterParty).toBe(null); + expect(r.bJoined).toBe(r.binA); // plain drop joined A's folder + expect(r.dBin).not.toBe(r.binA); // ⌘-drop branched a different folder + expect(r.dBin).toBeTruthy(); + expect(r.cInGrid).toBe(true); // empty drop added C as a grid row + expect(r.cRouted).toBe(false); // …but left it unrouted }); // ── Phase 4: left-tree markers, exclude, cross-tree find ─────────────────── @@ -1204,7 +1225,7 @@ test('copy: verifies copied bytes; a bad write fails verification and is removed expect(r.left).toBe(0); // …so a re-run re-copies it }); -test('transmittal: rename a bin (feeds the folder), remove and move a placed file', async ({ page }) => { +test('transmittal grid: editing the path re-routes (branching folders); ✕ removes the file', async ({ page }) => { await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(async () => { const c = window.app.modules.classify, tt = window.app.modules.targetTree; @@ -1212,40 +1233,40 @@ test('transmittal: rename a bin (feeds the folder), remove and move a placed fil const f = { originalFilename: 'doc', extension: 'pdf', folderPath: 'R' }; window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }]; const key = c.srcKeyForFile(f); - const party = c.addParty('CC'); - const bin1 = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); - const bin2 = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0008' }); - c.place([key], bin1, 'transmittal'); + const p1 = 'CC/received/2026-03-14_CC-TRN-0007 (IFC) - First'; + const p2 = 'CC/received/2026-03-14_CC-TRN-0008 (IFC) - Second'; + const err = c.setTransmittalPath([key], p1); tt.showTab('transmittal'); tt.render(); + const out1 = c.deriveTarget(f).outPath; + const bin1 = c.getAssignment(key).transmittalNodeId; - // Rename the bin → it becomes the copy folder name. - c.renameNode(bin1, 'My Custom Transmittal'); - const renamed = c.getNode(bin1).name === 'My Custom Transmittal'; - const folder = c.deriveTarget(f).transmittalFolder; + // Edit the single folder-path input → re-routes to a new folder. + const input = document.querySelector('#transmittalTree .tx-path .tg-input'); + const prefilled = input.value; + input.value = p2; input.dispatchEvent(new Event('change', { bubbles: true })); + const out2 = c.deriveTarget(f).outPath; + const rerouted = c.getAssignment(key).transmittalNodeId !== bin1; + // The now-empty first folder is pruned (re-routing doesn't litter the tree). + const oldPruned = !c.getNode(bin1); - // The placed-file row is draggable (move) and carries a remove button. + // ✕ removes the file from the transmittal grid entirely. tt.render(); - const row = document.querySelector('#transmittalTree .tfile[data-key]'); - const draggable = !!(row && row.draggable); - const hasRemove = !!(row && row.querySelector('.tfile__remove[data-act="untransmit"]')); - - // Remove from the transmittal (click ✕). - row.querySelector('.tfile__remove').click(); - const a1 = c.getAssignment(key); - const removed = !(a1 && a1.transmittalNodeId); - - // Move = re-place onto another bin (what dropping on bin2 does). - c.place([key], bin2, 'transmittal'); - const movedTo = (c.getAssignment(key) || {}).transmittalNodeId === bin2; - - return { renamed, folder, draggable, hasRemove, removed, movedTo }; + document.querySelector('#transmittalTree .tg-x .tg-x__btn').click(); + const a = c.getAssignment(key); + return { + err, prefilled, out1, out2, rerouted, oldPruned, + removed: !(a && a.transmittalNodeId), + gone: c.transmittalGridKeys().indexOf(key) === -1, + }; }); - expect(r.renamed).toBe(true); - expect(r.folder).toBe('My Custom Transmittal'); // rename drives the filing folder - expect(r.draggable).toBe(true); - expect(r.hasRemove).toBe(true); + expect(r.err).toBe(''); + expect(r.prefilled).toBe('CC/received/2026-03-14_CC-TRN-0007 (IFC) - First'); // input shows the current folder + expect(r.out1).toBe('CC/received/2026-03-14_CC-TRN-0007 (IFC) - First'); + expect(r.out2).toBe('CC/received/2026-03-14_CC-TRN-0008 (IFC) - Second'); // edit re-routed + expect(r.rerouted).toBe(true); + expect(r.oldPruned).toBe(true); expect(r.removed).toBe(true); - expect(r.movedTo).toBe(true); + expect(r.gone).toBe(true); }); test('seltable: autofilter + ctrl-shift selection builds complex sets', async ({ page }) => {