diff --git a/classifier/css/layout.css b/classifier/css/layout.css index df324be..188d48e 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -584,3 +584,49 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o opacity: 0.5; cursor: wait; } + +/* ── By-tracking merged-cell table ──────────────────────────────────────── */ +#trackingTree { padding: 0; } /* table reaches the edges; cells carry padding */ +.ttable { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; } +.ttable th, .ttable td { + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + vertical-align: top; + padding: 0; +} +.ttable thead th { + position: sticky; top: 0; z-index: 3; + background: var(--bg-secondary, var(--bg)); + color: var(--text-muted); + font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; + text-align: left; padding: 0.3rem 0.5rem; white-space: nowrap; + border-top: 1px solid var(--border); +} +.ttable__rh { color: var(--primary); } +.ttable__fileh { width: 99%; } /* the files column soaks up remaining width */ +.ttable__cell--empty { background: var(--bg-secondary, var(--bg)); } +/* The merged-cell value stays pinned just under the header while you scroll the + group, so a tall rowspan never reads as a blank column. */ +.tcell__inner { + position: sticky; top: 1.6rem; + display: flex; align-items: center; gap: 0.3rem; + padding: 0.25rem 0.5rem; white-space: nowrap; +} +.tcell__name { font-weight: 600; } +.trev__inner .tcell__name { color: var(--primary); } +.ttable__cell:hover .tnode__actions, .ttable__rev:hover .tnode__actions { opacity: 1; } +.ttable .drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; } +.ttable__file { padding: 0.1rem 0.4rem; } +.ttable__drop { color: var(--text-muted); font-style: italic; font-size: 0.75rem; } +.ttable .tfile { gap: 0.3rem; align-items: center; } +.ttable .tfile__name { + flex: 1; min-width: 8rem; max-width: 24rem; + padding: 0.15rem 0.35rem; border: 1px solid transparent; border-radius: var(--radius); + background: transparent; color: var(--text); font-size: 0.8rem; +} +.ttable .tfile__name:hover, .ttable .tfile__name:focus { border-color: var(--border); background: var(--bg); } +.ttable .tfile__name--err { color: var(--danger); } +.ttable .tfile--err::before { content: none; } /* we render our own badge instead */ +.tfile__badge { font-size: 0.78rem; flex: 0 0 auto; } +.tfile__badge--ok { color: var(--success, #16a34a); } +.tfile__badge--err { color: var(--danger); } diff --git a/classifier/js/classify.js b/classifier/js/classify.js index 63381c3..73a793e 100644 --- a/classifier/js/classify.js +++ b/classifier/js/classify.js @@ -29,6 +29,26 @@ return 'n' + (Date.now().toString(36)) + '-' + (++_idSeq).toString(36); } + // Per-workspace tracking-number PATTERN config. Drives the By-tracking + // table columns + (later) revision-modifier menus. Editable by the user. + var DEFAULT_FIELDS = [ + { name: 'ORIG', optional: false }, + { name: 'PROJ', optional: false }, + { name: 'DISC', optional: false }, + { name: 'TYPE', optional: false }, + { name: 'SEQ', optional: false }, + { name: 'SUFFIX', optional: true }, + ]; + var DEFAULT_STATUSES = (window.zddc && window.zddc.STATUSES) ? window.zddc.STATUSES.slice() : ['---']; + var DEFAULT_MODIFIERS = ['B', 'C', 'N', 'Q']; + function defaultConfig() { + return { + trackingFields: DEFAULT_FIELDS.map(function (f) { return { name: f.name, optional: !!f.optional }; }), + statuses: DEFAULT_STATUSES.slice(), + modifiers: DEFAULT_MODIFIERS.slice(), + }; + } + // ── state ──────────────────────────────────────────────────────────────── var state = { enabled: false, // classify mode on/off @@ -36,6 +56,7 @@ trackingTree: [], // [ { id, name, children:[] } ] (leaf = no children) transmittalTree: [], // [ { id, kind:'party', name, children:[ slot ] } ] outputName: null, // remembered output directory display name + config: defaultConfig(), // tracking-number pattern (fields/statuses/modifiers) }; // id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent } @@ -385,6 +406,7 @@ trackingTree: state.trackingTree, transmittalTree: state.transmittalTree, outputName: state.outputName, + config: state.config, }; } function load(obj) { @@ -393,9 +415,12 @@ state.trackingTree = obj.trackingTree || []; state.transmittalTree = obj.transmittalTree || []; state.outputName = obj.outputName || null; + state.config = normalizeConfig(obj.config); rebuildIndex(); notify(); } + // Reset clears the CLASSIFICATION but keeps the pattern config — it's a + // per-project setting, not part of the data being cleared. function reset() { state.assignments = {}; state.trackingTree = []; state.transmittalTree = []; state.outputName = null; @@ -403,6 +428,23 @@ notify(); } + // ── pattern config ─────────────────────────────────────────────────────── + function normalizeConfig(c) { + var d = defaultConfig(); + if (!c || typeof c !== 'object') return d; + var fields = Array.isArray(c.trackingFields) && c.trackingFields.length + ? c.trackingFields.map(function (f) { return { name: String(f && f.name || '').trim() || '?', optional: !!(f && f.optional) }; }) + : d.trackingFields; + return { + trackingFields: fields, + statuses: Array.isArray(c.statuses) && c.statuses.length ? c.statuses.slice() : d.statuses, + modifiers: Array.isArray(c.modifiers) && c.modifiers.length ? c.modifiers.slice() : d.modifiers, + }; + } + function getConfig() { return state.config; } + function getTrackingFields() { return state.config.trackingFields; } + function setConfig(c) { state.config = normalizeConfig(c); notify(); } + // ── add-folder pattern expansion ───────────────────────────────────────── // Brace expansion for the add-folder box. Supports (non-nested) groups: // {a,b,c} → alternation: a | b | c @@ -575,6 +617,7 @@ trackingNodeComplete: trackingNodeComplete, trackingPathLabel: trackingPathLabel, transmittalRecord: transmittalRecord, findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin, + getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields, getNode: getNode, getTrackingTree: function () { return state.trackingTree; }, getTransmittalTree: function () { return state.transmittalTree; }, // derive + reverse diff --git a/classifier/js/target-tree.js b/classifier/js/target-tree.js index 92c8969..5958a6c 100644 --- a/classifier/js/target-tree.js +++ b/classifier/js/target-tree.js @@ -199,51 +199,153 @@ return rfHit(orig) || rfHit(C().deriveTarget(f).filename || ''); } - // Tracking tree (recursive, filter-aware — a match reveals its whole path). - function renderTrackingInto(container, nodes, placedMap) { - container.textContent = ''; - if (!nodes.length) { - container.appendChild(el('div', 'target-empty', 'No tracking folders yet — “+ Root folder” to start.')); - return; - } - nodes.forEach(function (n) { var e = trackingNode(n, placedMap, false); if (e) container.appendChild(e); }); - if (rfActive() && !container.children.length) { - container.appendChild(el('div', 'target-empty', 'No matches in the tracking tree.')); - } - } - function trackingNode(n, placedMap, ancMatched) { - var matched = ancMatched || rfHit(n.name); - var isLeaf = (n.children || []).length === 0; - var expanded = !collapsed[n.id] || rfActive(); // auto-expand to reveal matches - var childEls = []; - if (expanded || rfActive()) { - (n.children || []).forEach(function (c) { var ce = trackingNode(c, placedMap, matched); if (ce) childEls.push(ce); }); - } - var placed = placedMap[n.id] || []; - var shownFiles = (rfActive() && !matched) ? placed.filter(fileRowMatches) : placed; - if (rfActive() && !matched && !childEls.length && !shownFiles.length) return null; + // ── By-tracking: merged-cell table ────────────────────────────────────── + // The positional hierarchy reads left-to-right as columns (one per configured + // field), ancestor cells span their descendants' rows, and the revision (the + // leaf) gets its own aligned column. Each placed file is a row. - var wrap = el('div', 'tnode' + (isLeaf ? ' tnode--leaf' : '')); - wrap.dataset.id = n.id; - var row = el('div', 'tnode__row'); - var toggle = el('button', 'tnode__toggle', isLeaf ? '·' : (expanded ? '▾' : '▸')); - if (!isLeaf) toggle.dataset.act = 'toggle'; - row.appendChild(toggle); - row.appendChild(el('span', 'tnode__name', n.name)); - if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length))); - row.appendChild(nodeActions([ - { act: 'add', label: '+', title: 'Add child folder' }, + // A node is a revision leaf when its name ends in a "(STATUS)" we recognise — + // tracking field codes never carry a parenthesised status, so this cleanly + // separates "0001" (a SEQ field) from "A (IFR)" (a revision). + function revStatusOf(name) { + var m = /\(\s*([A-Za-z0-9-]{1,5})\s*\)\s*$/.exec(name || ''); + return (m && window.zddc.isValidStatus(m[1])) ? m[1] : null; + } + function isRevisionLeaf(node) { + return !(node.children || []).length && revStatusOf(node.name) != null; + } + // Flatten the tree into rows: { path:[fieldNodes], rev:revNode|null, file }. + function buildTrackingRows(nodes, placedMap) { + var rows = []; + function emit(path, rev, files) { + var fs = (files && files.length) ? files : [null]; + fs.forEach(function (f) { rows.push({ path: path, rev: rev, file: f }); }); + } + function walk(node, ancestors) { + var placed = placedMap[node.id] || []; + if (isRevisionLeaf(node)) { emit(ancestors, node, placed); return; } + var myPath = ancestors.concat(node); // node is a tracking field segment + if (placed.length) emit(myPath, null, placed); // files dropped on a partial number + var kids = node.children || []; + if (kids.length) kids.forEach(function (c) { walk(c, myPath); }); + else if (!placed.length) emit(myPath, null, []); // empty leaf = drop target + } + nodes.forEach(function (n) { walk(n, []); }); + return rows; + } + function rowMatches(row) { + if (!rfActive()) return true; + if (row.file && fileRowMatches(row.file)) return true; + if (row.rev && rfHit(row.rev.name)) return true; + for (var i = 0; i < row.path.length; i++) { if (rfHit(row.path[i].name)) return true; } + return false; + } + + function fieldCellContent(node) { + var inner = el('div', 'tcell__inner'); + inner.appendChild(el('span', 'tcell__name', node.name)); + inner.appendChild(nodeActions([ + { act: 'add', label: '+', title: 'Add child segment / revision' }, { act: 'rename', label: '✎', title: 'Rename' }, { act: 'del', label: '🗑', title: 'Delete' }, ])); - wrap.appendChild(row); - if (shownFiles.length) wrap.appendChild(fileList(shownFiles)); - if (!isLeaf && expanded && childEls.length) { - var kids = el('div', 'tnode__children'); - childEls.forEach(function (ce) { kids.appendChild(ce); }); - wrap.appendChild(kids); + return inner; + } + function revCellContent(node, placedMap) { + var inner = el('div', 'tcell__inner trev__inner'); + inner.appendChild(el('span', 'tcell__name', node.name)); + var n = (placedMap[node.id] || []).length; + if (n) inner.appendChild(el('span', 'tnode__badge', String(n))); + inner.appendChild(nodeActions([ + { act: 'rename', label: '✎', title: 'Rename revision' }, + { act: 'del', label: '🗑', title: 'Delete' }, + ])); + return inner; + } + // A placed-file cell: editable ZDDC name + validation badge; the original + // filename is on hover, not shown inline. Reuses .tfile/.tfile__name so the + // delegated preview + name-edit handlers apply. + function fileCellContent(f) { + var d = C().deriveTarget(f); + var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : '')); + row.dataset.key = d.key; + var orig = f.originalFilename + (f.extension ? '.' + f.extension : ''); + 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('; ') + ' · ' : '') + 'original: ' + orig; + row.appendChild(name); + row.appendChild(el('span', 'tfile__badge' + (d.errors.length ? ' tfile__badge--err' : ' tfile__badge--ok'), + d.errors.length ? '⚠' : '✓')); + return row; + } + + function renderTrackingInto(container, nodes, placedMap) { + container.textContent = ''; + if (!nodes.length) { + container.appendChild(el('div', 'target-empty', 'No tracking numbers yet — “+ Root folder” to start.')); + return; } - return wrap; + var rows = buildTrackingRows(nodes, placedMap).filter(rowMatches); + if (!rows.length) { + container.appendChild(el('div', 'target-empty', rfActive() ? 'No matches in the tracking tree.' : 'No tracking numbers yet.')); + return; + } + var fields = C().getTrackingFields(); + var maxPath = rows.reduce(function (m, r) { return Math.max(m, r.path.length); }, 0); + var nCols = Math.max(fields.length, maxPath); + + function cellId(row, col) { + if (col < nCols) { var n = row.path[col]; return n ? n.id : null; } + return row.rev ? row.rev.id : null; // col === nCols → revision + } + // Rowspan run starting at row i for column col (0 = covered from above). + function spanAt(col, i) { + var id = cellId(rows[i], col); + if (id == null) return 1; + if (i > 0 && cellId(rows[i - 1], col) === id) return 0; + var span = 1; + for (var j = i + 1; j < rows.length; j++) { if (cellId(rows[j], col) === id) span++; else break; } + return span; + } + + var table = el('table', 'ttable'); + var thead = el('thead'), htr = el('tr'); + for (var c = 0; c < nCols; c++) { + htr.appendChild(el('th', 'ttable__fh', fields[c] ? fields[c].name + (fields[c].optional ? ' ?' : '') : '·')); + } + htr.appendChild(el('th', 'ttable__rh', 'REVISION')); + htr.appendChild(el('th', 'ttable__fileh', 'Files')); + thead.appendChild(htr); table.appendChild(thead); + + var tbody = el('tbody'); + rows.forEach(function (row, i) { + var tr = el('tr'); + for (var col = 0; col < nCols; col++) { + var span = spanAt(col, i); + if (span === 0) continue; // merged from the row above + var node = row.path[col] || null; + var td = el('td', 'ttable__cell' + (node ? '' : ' ttable__cell--empty')); + if (span > 1) td.rowSpan = span; + if (node) { td.dataset.id = node.id; td.appendChild(fieldCellContent(node)); } + tr.appendChild(td); + } + var rspan = spanAt(nCols, i); + if (rspan !== 0) { + var rtd = el('td', 'ttable__rev' + (row.rev ? '' : ' ttable__cell--empty')); + if (rspan > 1) rtd.rowSpan = rspan; + if (row.rev) { rtd.dataset.id = row.rev.id; rtd.appendChild(revCellContent(row.rev, placedMap)); } + tr.appendChild(rtd); + } + var ftd = el('td', 'ttable__file'); + if (row.file) ftd.appendChild(fileCellContent(row.file)); + else ftd.appendChild(el('span', 'ttable__drop', 'drop a file here')); + tr.appendChild(ftd); + tbody.appendChild(tr); + }); + table.appendChild(tbody); + container.appendChild(table); } // Transmittal tree @@ -334,7 +436,7 @@ // ── events ───────────────────────────────────────────────────────────── function closestNodeId(target) { - var n = target.closest('.tnode'); + var n = target.closest('[data-id]'); return n ? n.dataset.id : null; } function fileByKey(key) { @@ -453,10 +555,14 @@ // tracking → any folder node (.tnode) // transmittal → a transmittal bin only (.tnode--bin) function dropTarget(target, axis) { - var sel = axis === 'transmittal' ? '.tnode--bin' : '.tnode'; - var node = target.closest(sel); - if (!node || !node.dataset.id) return null; - return { id: node.dataset.id, row: node.querySelector('.tnode__row') || node }; + 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'); @@ -515,7 +621,7 @@ } } function flashNode(container, id) { - var node = container.querySelector('.tnode[data-id="' + id + '"]'); + var node = container.querySelector('[data-id="' + id + '"]'); if (!node) return; node.scrollIntoView({ block: 'center' }); var row = node.querySelector('.tnode__row') || node; diff --git a/tests/classify.spec.js b/tests/classify.spec.js index a26b5b8..d14d43a 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -161,9 +161,9 @@ test('target tree renders structure and tabs switch', async ({ page }) => { const party = c.addParty('ClientCorp'); c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); }); - // Tracking panel visible by default with the nodes rendered. - await expect(page.locator('#trackingTree .tnode__name', { hasText: 'ACME-PROJ' })).toBeVisible(); - await expect(page.locator('#trackingTree .tnode--leaf .tnode__name', { hasText: 'A (IFR)' })).toBeVisible(); + // Tracking panel visible by default with the table rendered. + await expect(page.locator('#trackingTree .ttable__cell .tcell__name', { hasText: 'ACME-PROJ' })).toBeVisible(); + await expect(page.locator('#trackingTree .ttable__rev .tcell__name', { hasText: 'A (IFR)' })).toBeVisible(); // Switch to transmittal tab. await page.click('#transmittalTab'); expect(await page.locator('#transmittalPanel').isHidden()).toBe(false); @@ -174,10 +174,10 @@ test('"+ Root folder" button (prompt) parses a name into nested levels', async ( await page.click('#modeClassifyBtn'); page.once('dialog', (d) => d.accept('CPO-0001_0 (IFU)')); await page.click('#addTrackingRootBtn'); - // "CPO-0001_0 (IFU)" → CPO / 0001 / 0 (IFU) (three nested levels). - await expect(page.locator('#trackingTree .tnode__name', { hasText: 'CPO' })).toBeVisible(); - await expect(page.locator('#trackingTree .tnode__name', { hasText: '0001' })).toBeVisible(); - await expect(page.locator('#trackingTree .tnode__name', { hasText: '0 (IFU)' })).toBeVisible(); + // "CPO-0001_0 (IFU)" → CPO / 0001 columns + "0 (IFU)" revision cell. + await expect(page.locator('#trackingTree .tcell__name', { hasText: 'CPO' })).toBeVisible(); + await expect(page.locator('#trackingTree .tcell__name', { hasText: '0001' })).toBeVisible(); + await expect(page.locator('#trackingTree .ttable__rev .tcell__name', { hasText: '0 (IFU)' })).toBeVisible(); }); // ── Phase 3: drag-and-drop assignment (drop handler) ─────────────────────── @@ -188,7 +188,7 @@ test('dropping a file onto a tracking leaf assigns it', async ({ page }) => { const c = window.app.modules.classify; const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)'); window.app.modules.targetTree.render(); - const row = document.querySelector('#trackingTree .tnode--leaf .tnode__row'); + const row = document.querySelector('#trackingTree .ttable__rev[data-id]'); const key = 'Sub/foundation.pdf'; window.app.modules.dnd.setDrag([key]); row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); @@ -739,7 +739,7 @@ test('tracking-tree filter reveals matching nodes and hides the rest', async ({ c.addTrackingPath(null, c.parseFolderLevels('XYZ-0009_A (IFR)')); window.app.modules.targetTree.render(); window.app.modules.targetTree.setNameFilter('CPO'); - return Array.from(document.querySelectorAll('#trackingTree .tnode__name')).map((e) => e.textContent); + return Array.from(document.querySelectorAll('#trackingTree .tcell__name')).map((e) => e.textContent); }); expect(names).toContain('CPO'); expect(names).toContain('0001'); @@ -960,3 +960,27 @@ test('a fully-excluded folder is struck through like its files', async ({ page } expect(r.before).toBe(false); // not struck through while active expect(r.after).toBe(true); // struck through once the whole subtree is excluded }); + +test('By-tracking table merges shared ancestors and aligns revisions', async ({ page }) => { + await page.click('#modeClassifyBtn'); + const r = await page.evaluate(() => { + const c = window.app.modules.classify, tt = window.app.modules.targetTree; + c.reset(); + c.addTrackingPath(null, c.parseFolderLevels('LKU-123456-PM-SCH-0001_2025-11-17 (IFI)')); + c.addTrackingPath(null, c.parseFolderLevels('LKU-123456-PM-SCH-0001_A (IFR)')); + c.addTrackingPath(null, c.parseFolderLevels('CPO-0001_0 (IFU)')); + tt.render(); + const cellByName = (n) => Array.from(document.querySelectorAll('#trackingTree .ttable__cell .tcell__name')) + .filter((e) => e.textContent === n).map((e) => e.closest('td'))[0]; + const lku = cellByName('LKU'), cpo = cellByName('CPO'); + return { + lkuSpan: lku ? lku.rowSpan : 0, + cpoSpan: cpo ? cpo.rowSpan : 0, + revs: Array.from(document.querySelectorAll('#trackingTree .ttable__rev .tcell__name')).map((e) => e.textContent), + }; + }); + expect(r.lkuSpan).toBe(2); // the LKU ancestor cell spans its two revisions (merged) + expect(r.cpoSpan).toBe(1); + // The revisions live in one aligned column; the date revision stays intact. + expect(r.revs).toEqual(['2025-11-17 (IFI)', 'A (IFR)', '0 (IFU)']); +});