diff --git a/classifier/css/layout.css b/classifier/css/layout.css index 7b8a833..98b6dcf 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -731,3 +731,33 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o .tfile__badge { font-size: 0.78rem; flex: 0 0 auto; } .tfile__badge--ok { color: var(--success, #16a34a); } .tfile__badge--err { color: var(--danger); } + +/* ── By-tracking flat editable grid (one row per file) ──────────────────── */ +.ttable--grid { width: auto; } +.ttable--grid td.tg-td { padding: 0.1rem 0.35rem; vertical-align: middle; } +.ttable--grid th.tg-th { white-space: nowrap; } /* .column-resizer (spreadsheet.css) sits in the sticky th */ +.tg-input { + width: 100%; min-width: 4rem; box-sizing: border-box; + padding: 0.12rem 0.3rem; border: 1px solid transparent; border-radius: var(--radius); + background: transparent; color: var(--text); font: inherit; font-size: 0.8rem; +} +.tg-input:hover { border-color: var(--border); } +.tg-input:focus { border-color: var(--primary); background: var(--bg); outline: none; } +.tg-tn .tg-input { font-family: var(--mono, monospace); } +.tg-input.is-warn { border-color: var(--warning, #b8860b); } +.tg-orig__link { color: var(--text-muted); white-space: nowrap; text-decoration: none; cursor: pointer; } +.tg-orig__link:hover { text-decoration: underline; } +.tg-status, .tg-x { text-align: center; } +.tg-x__btn { opacity: 0.5; } +.tg-row:hover .tg-x__btn { opacity: 1; } +.tg-row--err .tg-status { color: var(--danger); } +.tg-drop-hover { outline: 2px dashed var(--primary); outline-offset: -3px; background: var(--primary-light); } + +/* "Columns ▾" chooser menu */ +.col-chooser { + position: fixed; z-index: 9600; background: var(--bg); + border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: 0 6px 18px rgba(0,0,0,0.18); padding: 0.3rem; min-width: 11rem; +} +.col-chooser__item { display: flex; align-items: center; gap: 0.4rem; padding: 0.25rem 0.4rem; font-size: 0.83rem; cursor: pointer; border-radius: var(--radius); } +.col-chooser__item:hover { background: var(--bg-hover); } diff --git a/classifier/js/classify.js b/classifier/js/classify.js index 3f9661e..80999f6 100644 --- a/classifier/js/classify.js +++ b/classifier/js/classify.js @@ -60,6 +60,7 @@ outputName: null, // remembered output directory display name config: defaultConfig(), // tracking-number pattern (fields/statuses/modifiers) worklist: [], // "From a list" scratch rows: [ { id, trackingNumber, title, revisionCell, source, archiveRevisions } ] + trackingWorkset: Object.create(null), // srcKeys shown as rows in the By-tracking grid (set: key->true) }; // id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent } @@ -420,6 +421,7 @@ worklist: state.worklist.map(function (r) { return { id: r.id, party: r.party, trackingNumber: r.trackingNumber, title: r.title, revisionCell: r.revisionCell, source: r.source, archiveRevisions: r.archiveRevisions }; }), + trackingWorkset: Object.keys(state.trackingWorkset), }; } function load(obj) { @@ -430,6 +432,8 @@ state.outputName = obj.outputName || null; state.config = normalizeConfig(obj.config); state.worklist = (Array.isArray(obj.worklist) ? obj.worklist : []).map(normalizeRow); + state.trackingWorkset = Object.create(null); + (Array.isArray(obj.trackingWorkset) ? obj.trackingWorkset : []).forEach(function (k) { state.trackingWorkset[k] = true; }); rebuildIndex(); migrateLegacyMdl(obj.worklist); // BEFORE anything can prune; materializes old mdl placements notify(); @@ -461,10 +465,57 @@ function reset() { state.assignments = {}; state.trackingTree = []; state.transmittalTree = []; state.outputName = null; + state.trackingWorkset = Object.create(null); rebuildIndex(); notify(); } + // ── By-tracking grid (one editable row per file) ───────────────────────── + // The grid is a flat presentation over the tracking-tree placement model. + // `trackingWorkset` tracks files put on the grid so a dropped file shows as a + // row before it has a tracking number; a file with a real tracking placement + // (named here OR via the "From a list" tab) is always a row too. + function addToTrackingGrid(keys) { + var changed = false; + (keys || []).forEach(function (k) { if (!state.trackingWorkset[k]) { state.trackingWorkset[k] = true; changed = true; } }); + if (changed) notify(); + } + function removeFromTrackingGrid(key) { + var a = state.assignments[key], old = a ? a.trackingNodeId : null; + delete state.trackingWorkset[key]; + place([key], null, 'tracking'); + if (old) pruneEmptyTrackingChain(old); + notify(); + } + function trackingGridKeys() { + var set = Object.create(null); + Object.keys(state.trackingWorkset).forEach(function (k) { set[k] = true; }); + Object.keys(state.assignments).forEach(function (k) { if (state.assignments[k].trackingNodeId) set[k] = true; }); + return Object.keys(set); + } + // Re-materialize a file's tracking placement from a full identity. The caller + // passes ALL three fields (current values for the ones it didn't edit), read + // from deriveTarget — so this module needs no file objects. A blank revision + // lands on the PENDING_REV placeholder leaf (incomplete until set); a blank + // tracking number clears the placement (the row stays, unfilled). + function setFileIdentity(key, ident) { + ident = ident || {}; + var tracking = (ident.tracking == null ? '' : String(ident.tracking)).trim(); + var rev = (ident.rev == null ? '' : String(ident.rev)).trim(); + var a = state.assignments[key], old = a ? a.trackingNodeId : null; + if (tracking) { + var leaf = addTrackingPath(null, parseFolderLevels(tracking + '_' + (rev || PENDING_REV))); + place([key], leaf, 'tracking'); + if (old && old !== leaf) pruneEmptyTrackingChain(old); + } else { + place([key], null, 'tracking'); + if (old) pruneEmptyTrackingChain(old); + } + setTitleOverride(key, ident.title || ''); + state.trackingWorkset[key] = true; + notify(); + } + // ── pattern config ─────────────────────────────────────────────────────── function normalizeConfig(c) { var d = defaultConfig(); @@ -888,6 +939,9 @@ transmittalRecord: transmittalRecord, findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin, getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields, + // By-tracking grid + addToTrackingGrid: addToTrackingGrid, removeFromTrackingGrid: removeFromTrackingGrid, + trackingGridKeys: trackingGridKeys, setFileIdentity: setFileIdentity, setWorklist: setWorklist, appendWorklist: appendWorklist, clearWorklist: clearWorklist, getWorklist: getWorklist, getWorklistRow: getWorklistRow, assignFromRow: assignFromRow, unassignRowFile: unassignRowFile, diff --git a/classifier/js/resize.js b/classifier/js/resize.js index 76be460..5a63d65 100644 --- a/classifier/js/resize.js +++ b/classifier/js/resize.js @@ -8,32 +8,39 @@ let resizingColumn = null; let startX = 0; let startWidth = 0; + let activeTable = null; + let activeOnResize = null; /** - * Initialize column resizing + * Initialize column resizing on a table. Defaults to the rename-in-place + * spreadsheet when no table is passed (back-compatible). onResize(table) is + * called after each drag ends, so a caller can persist the new widths. */ - function init() { - const table = window.app.dom.spreadsheet; + function init(table, onResize) { + table = table || (window.app.dom && window.app.dom.spreadsheet); + if (!table) return; const headers = table.querySelectorAll('thead th'); - + headers.forEach(th => { // Skip if resize handle already exists if (th.querySelector('.column-resizer')) return; - + // Add resize handle const resizer = document.createElement('div'); resizer.className = 'column-resizer'; th.appendChild(resizer); - + // Mouse down on resizer resizer.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); - + resizingColumn = th; startX = e.pageX; startWidth = th.offsetWidth; - + activeTable = table; + activeOnResize = onResize || null; + document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }); @@ -61,6 +68,8 @@ resizingColumn = null; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); + if (activeOnResize && activeTable) { try { activeOnResize(activeTable); } catch (_) { /* ignore */ } } + activeTable = null; activeOnResize = null; } // Export module diff --git a/classifier/js/target-tree.js b/classifier/js/target-tree.js index 7b8a412..8671060 100644 --- a/classifier/js/target-tree.js +++ b/classifier/js/target-tree.js @@ -43,7 +43,7 @@ matchNamesBtn: document.getElementById('matchNamesBtn'), clearListBtn: document.getElementById('clearListBtn'), hideAssignedToggle: document.getElementById('hideAssignedToggle'), - addTrackingRootBtn: document.getElementById('addTrackingRootBtn'), + trackingColsBtn: document.getElementById('trackingColsBtn'), addPartyBtn: document.getElementById('addPartyBtn'), stats: document.getElementById('classifyStats'), }; @@ -78,22 +78,16 @@ var text = t ? t.getData('text') : ''; if (text) { e.preventDefault(); openPasteDialog(text); } }); - els.addTrackingRootBtn.addEventListener('click', function () { - var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n' - + 'Brace patterns expand: BMB-{PM,EL}-{0001-0002,0005}_A (IFR)', ''); - addFoldersFromPattern(null, name); - }); + if (els.trackingColsBtn) els.trackingColsBtn.addEventListener('click', openColumnChooser); els.addPartyBtn.addEventListener('click', function () { var name = prompt('Party name (also the transmittal-number prefix):', ''); if (name && name.trim()) C().addParty(name.trim()); }); - els.trackingTree.addEventListener('click', onTrackingClick); els.transmittalTree.addEventListener('click', onTransmittalClick); - els.trackingTree.addEventListener('change', onFileNameChange); els.transmittalTree.addEventListener('change', onFileNameChange); - setupDropZone(els.trackingTree, 'tracking'); + setupGridDrop(els.trackingTree); setupDropZone(els.transmittalTree, 'transmittal'); C().on(render); @@ -151,28 +145,13 @@ function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : 'tracking'; } function reRenderSource() { if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); } - // Expand a brace pattern into folder names and create them (confirming a - // multi-create first). parentId null = root folders. See expandFolderPattern. - function addFoldersFromPattern(parentId, raw) { - if (!raw || !raw.trim()) return; - var names = C().expandFolderPattern(raw); - if (!names.length) return; - if (names.length > 1) { - var shown = names.slice(0, 8).join('\n'); - if (names.length > 8) shown += '\n…and ' + (names.length - 8) + ' more'; - if (!confirm('Create ' + names.length + ' folders?\n\n' + shown)) return; - } - // Each expanded name is parsed into nested tracking levels (split on - // "-", final "_" splits the leaf rev), reusing shared ancestors. - names.forEach(function (nm) { C().addTrackingPath(parentId, C().parseFolderLevels(nm)); }); - } // ── render ─────────────────────────────────────────────────────────────── function render() { if (!initialized || !C().isEnabled()) return; var files = allFiles(); var placed = buildPlaced(files); - renderTrackingInto(els.trackingTree, C().getTrackingTree(), placed.tracking); + renderTrackingGrid(els.trackingTree); renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal); renderWorklist(placed.byTracking); renderStats(files); @@ -258,165 +237,173 @@ return rfHit(orig) || rfHit(C().deriveTarget(f).filename || ''); } - // ── By-tracking: merged-cell table ────────────────────────────────────── - // The positional hierarchy reads left-to-right as columns (one per configured - // field), ancestor cells span their descendants' rows, and the revision (the - // leaf) gets its own aligned column. Each placed file is a row. + // ── By-tracking: flat editable grid (one row per file) ────────────────── + var GRID_COLS = [ + { id: 'status', title: '', cls: 'tg-status', fixed: true }, + { id: 'orig', title: 'Original name', cls: 'tg-orig' }, + { id: 'tn', title: 'Tracking number', cls: 'tg-tn' }, + { id: 'rev', title: 'Rev (status)', cls: 'tg-rev' }, + { id: 'title', title: 'Title', cls: 'tg-title' }, + { id: 'x', title: '', cls: 'tg-x', fixed: true }, + ]; + var GRID_PREFS_KEY = 'zddc.classifier.trackingCols'; + function gridPrefs() { try { return JSON.parse(localStorage.getItem(GRID_PREFS_KEY)) || {}; } catch (_) { return {}; } } + function saveGridPrefs(p) { try { localStorage.setItem(GRID_PREFS_KEY, JSON.stringify(p)); } catch (_) { /* private mode */ } } - // A node is a revision leaf when its name ends in a "(STATUS)" we recognise — - // tracking field codes never carry a parenthesised status, so this cleanly - // separates "0001" (a SEQ field) from "A (IFR)" (a revision). - function revStatusOf(name) { - var m = /\(\s*([A-Za-z0-9-]{1,5})\s*\)\s*$/.exec(name || ''); - return (m && window.zddc.isValidStatus(m[1])) ? m[1] : null; - } - function isRevisionLeaf(node) { - return !(node.children || []).length && revStatusOf(node.name) != null; - } - // Flatten the tree into rows: { path:[fieldNodes], rev:revNode|null, file }. - function buildTrackingRows(nodes, placedMap) { - var rows = []; - function emit(path, rev, files) { - var fs = (files && files.length) ? files : [null]; - fs.forEach(function (f) { rows.push({ path: path, rev: rev, file: f }); }); - } - function walk(node, ancestors) { - var placed = placedMap[node.id] || []; - if (isRevisionLeaf(node)) { emit(ancestors, node, placed); return; } - var myPath = ancestors.concat(node); // node is a tracking field segment - if (placed.length) emit(myPath, null, placed); // files dropped on a partial number - var kids = node.children || []; - if (kids.length) kids.forEach(function (c) { walk(c, myPath); }); - else if (!placed.length) emit(myPath, null, []); // empty leaf = drop target - } - nodes.forEach(function (n) { walk(n, []); }); - return rows; - } - function rowMatches(row) { - if (!rfActive()) return true; - if (row.file && fileRowMatches(row.file)) return true; - if (row.rev && rfHit(row.rev.name)) return true; - for (var i = 0; i < row.path.length; i++) { if (rfHit(row.path[i].name)) return true; } - return false; - } - - function fieldCellContent(node) { - var inner = el('div', 'tcell__inner'); - inner.appendChild(el('span', 'tcell__name', node.name)); - inner.appendChild(nodeActions([ - { act: 'add', label: '+', title: 'Add child segment / revision' }, - { act: 'rename', label: '✎', title: 'Rename' }, - { act: 'del', label: '🗑', title: 'Delete' }, - ])); - return inner; - } - function revCellContent(node, placedMap) { - var inner = el('div', 'tcell__inner trev__inner'); - // The revision name doubles as a preview link for its placed file (the - // common case is one file per revision). No count bubble. - var files = placedMap[node.id] || []; - if (files.length) { - var link = el('a', 'tcell__name tcell__preview', node.name); - link.href = '#'; - link.dataset.previewKey = C().srcKeyForFile(files[0]); - link.title = 'Preview ' + files[0].originalFilename + (files[0].extension ? '.' + files[0].extension : ''); - inner.appendChild(link); - } else { - inner.appendChild(el('span', 'tcell__name', node.name)); - } - inner.appendChild(nodeActions([ - { act: 'rename', label: '✎', title: 'Rename revision' }, - { act: 'del', label: '🗑', title: 'Delete' }, - ])); - return inner; - } - // A placed-file cell: editable ZDDC name + validation badge; the original - // filename is on hover, not shown inline. Reuses .tfile/.tfile__name so the - // delegated preview + name-edit handlers apply. - function fileCellContent(f) { + // A file's current identity, read from the placement model so per-field edits + // keep the fields they didn't touch. + function currentIdent(f) { var d = C().deriveTarget(f); - var conflict = C().hasHashConflict(d.key); // same name, different bytes - var bad = d.errors.length || conflict; - var row = el('div', 'tfile' + (bad ? ' tfile--err' : '')); - row.dataset.key = d.key; - var orig = f.originalFilename + (f.extension ? '.' + f.extension : ''); - var name = el('input', 'tfile__name' + (bad ? ' tfile__name--err' : '')); - name.type = 'text'; - name.value = d.filename || ''; - name.placeholder = '(incomplete)'; - name.title = (conflict ? 'Same tracking+revision as another file but DIFFERENT content — fix before copying · ' : '') - + (d.errors.length ? d.errors.join('; ') + ' · ' : '') + 'original: ' + orig; - row.appendChild(name); - row.appendChild(el('span', 'tfile__badge' + (bad ? ' tfile__badge--err' : ' tfile__badge--ok'), - conflict ? '≠' : (d.errors.length ? '⚠' : '✓'))); - return row; + return { tracking: d.tracking || '', rev: (d.revision || '') + (d.status ? ' (' + d.status + ')' : ''), title: d.title || '' }; } + function gridTnWarn(tn) { + tn = (tn || '').trim(); if (!tn) return ''; + var n = tn.split('-').length, want = C().getTrackingFields().length; + return (n < want - 1 || n > want) ? ('Has ' + n + ' segments; the pattern expects ' + want + '.') : ''; + } + function previewKey(key) { + var f = fileByKey(key); + if (f && window.app.modules.preview && window.app.modules.preview.previewFile) window.app.modules.preview.previewFile(f); + } + // The By-tracking grid validates the NAME only — the transmittal (path) is a + // different tab, so its "not placed in a transmittal" error doesn't count here. + function nameErrors(d) { return (d.errors || []).filter(function (e) { return e.indexOf('transmittal') === -1; }); } - function renderTrackingInto(container, nodes, placedMap) { + function renderTrackingGrid(container) { container.textContent = ''; - if (!nodes.length) { - container.appendChild(el('div', 'target-empty', 'No tracking numbers yet — “+ Root folder” to start.')); + var c = C(); + var files = c.trackingGridKeys().map(fileByKey).filter(Boolean) + .filter(function (f) { return !rfActive() || fileRowMatches(f); }); + if (!files.length) { + container.appendChild(el('div', 'target-empty', rfActive() ? 'No matches.' + : 'No files yet — drag files here from the left, then type each one’s tracking number, revision, and title. A file that’s already ZDDC-named fills in automatically.')); return; } - var rows = buildTrackingRows(nodes, placedMap).filter(rowMatches); - if (!rows.length) { - container.appendChild(el('div', 'target-empty', rfActive() ? 'No matches in the tracking tree.' : 'No tracking numbers yet.')); - return; - } - var fields = C().getTrackingFields(); - var maxPath = rows.reduce(function (m, r) { return Math.max(m, r.path.length); }, 0); - var nCols = Math.max(fields.length, maxPath); + var prefs = gridPrefs(), hidden = prefs.hidden || {}, widths = prefs.widths || {}; + var cols = GRID_COLS.filter(function (col) { return !hidden[col.id]; }); - function cellId(row, col) { - if (col < nCols) { var n = row.path[col]; return n ? n.id : null; } - return row.rev ? row.rev.id : null; // col === nCols → revision - } - // Rowspan run starting at row i for column col (0 = covered from above). - function spanAt(col, i) { - var id = cellId(rows[i], col); - if (id == null) return 1; - if (i > 0 && cellId(rows[i - 1], col) === id) return 0; - var span = 1; - for (var j = i + 1; j < rows.length; j++) { if (cellId(rows[j], col) === id) span++; else break; } - return span; - } - - var table = el('table', 'ttable'); + var table = el('table', 'ttable ttable--grid'); var thead = el('thead'), htr = el('tr'); - for (var c = 0; c < nCols; c++) { - htr.appendChild(el('th', 'ttable__fh', fields[c] ? fields[c].name + (fields[c].optional ? ' ?' : '') : '·')); - } - htr.appendChild(el('th', 'ttable__rh', 'REVISION')); - htr.appendChild(el('th', 'ttable__fileh', 'Files')); + cols.forEach(function (col) { + var th = el('th', 'tg-th ' + col.cls, col.title); + th.dataset.col = col.id; + if (widths[col.id]) { th.style.width = th.style.minWidth = th.style.maxWidth = widths[col.id] + 'px'; } + htr.appendChild(th); + }); thead.appendChild(htr); table.appendChild(thead); var tbody = el('tbody'); - rows.forEach(function (row, i) { - var tr = el('tr'); - for (var col = 0; col < nCols; col++) { - var span = spanAt(col, i); - if (span === 0) continue; // merged from the row above - var node = row.path[col] || null; - var td = el('td', 'ttable__cell' + (node ? '' : ' ttable__cell--empty')); - if (span > 1) td.rowSpan = span; - if (node) { td.dataset.id = node.id; td.appendChild(fieldCellContent(node)); } + files.forEach(function (f) { + var key = c.srcKeyForFile(f), d = c.deriveTarget(f); + var bad = nameErrors(d).length || c.hasHashConflict(key); + var tr = el('tr', 'tg-row' + (bad ? ' tg-row--err' : '')); + tr.dataset.key = key; + cols.forEach(function (col) { + var td = el('td', 'tg-td ' + col.cls); + buildGridCell(col.id, td, f, key); tr.appendChild(td); - } - var rspan = spanAt(nCols, i); - if (rspan !== 0) { - var rtd = el('td', 'ttable__rev' + (row.rev ? '' : ' ttable__cell--empty')); - if (rspan > 1) rtd.rowSpan = rspan; - if (row.rev) { rtd.dataset.id = row.rev.id; rtd.appendChild(revCellContent(row.rev, placedMap)); } - tr.appendChild(rtd); - } - var ftd = el('td', 'ttable__file'); - if (row.file) ftd.appendChild(fileCellContent(row.file)); - else ftd.appendChild(el('span', 'ttable__drop', 'drop a file here')); - tr.appendChild(ftd); + }); tbody.appendChild(tr); }); table.appendChild(tbody); container.appendChild(table); + if (window.app.modules.resize && window.app.modules.resize.init) window.app.modules.resize.init(table, persistColWidths); + } + + function buildGridCell(colId, td, f, key) { + var c = C(), d = c.deriveTarget(f), conflict = c.hasHashConflict(key); + if (colId === 'status') { + var ne = nameErrors(d), ok = !ne.length && !conflict; + var badge = el('span', 'tfile__badge ' + (ok ? 'tfile__badge--ok' : 'tfile__badge--err'), conflict ? '≠' : (ne.length ? '⚠' : '✓')); + badge.title = (conflict ? 'Same tracking+revision as another file but DIFFERENT content. ' : '') + (ne.length ? ne.join('; ') : 'Complete'); + td.appendChild(badge); return; + } + if (colId === 'orig') { + var orig = f.originalFilename + (f.extension ? '.' + f.extension : ''); + var link = el('a', 'tg-orig__link', orig); + link.href = '#'; link.title = 'Preview ' + orig; + link.addEventListener('click', function (e) { e.preventDefault(); previewKey(key); }); + td.appendChild(link); return; + } + if (colId === 'x') { + var rm = el('button', 'tnode__act tg-x__btn', '✕'); + rm.title = 'Remove from the grid'; + rm.addEventListener('click', function () { c.removeFromTrackingGrid(key); }); + td.appendChild(rm); return; + } + // editable: tn / rev / title + var ident = currentIdent(f); + var value = colId === 'tn' ? ident.tracking : colId === 'rev' ? ident.rev : ident.title; + var ph = colId === 'tn' ? 'ACME-…-0001' : colId === 'rev' ? 'A (IFR)' : 'title'; + var warn = colId === 'tn' ? gridTnWarn(ident.tracking) : ''; + var inp = el('input', 'tg-input' + (warn ? ' is-warn' : '')); + inp.type = 'text'; inp.value = value || ''; inp.placeholder = ph; inp.spellcheck = false; + if (warn) inp.title = warn; + inp.addEventListener('change', function () { + var cur = currentIdent(f); // re-read so a prior edit isn't clobbered + if (colId === 'tn') cur.tracking = inp.value.trim(); + else if (colId === 'rev') cur.rev = inp.value.trim(); + else cur.title = inp.value; + c.setFileIdentity(key, cur); + }); + td.appendChild(inp); + } + + function persistColWidths(table) { + var p = gridPrefs(); p.widths = p.widths || {}; + Array.prototype.forEach.call(table.querySelectorAll('thead th[data-col]'), function (th) { p.widths[th.dataset.col] = Math.round(th.offsetWidth); }); + saveGridPrefs(p); + } + + // Drag files onto the grid → add as rows; auto-fill any already ZDDC-named. + function setupGridDrop(container) { + container.addEventListener('dragover', function (e) { + if (!window.app.modules.dnd.active()) return; + e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; container.classList.add('tg-drop-hover'); + }); + container.addEventListener('dragleave', function (e) { if (e.target === container) container.classList.remove('tg-drop-hover'); }); + container.addEventListener('drop', function (e) { + container.classList.remove('tg-drop-hover'); + e.preventDefault(); + var keys = window.app.modules.dnd.getDrag(); window.app.modules.dnd.clearDrag(); + if (keys.length) onGridDrop(keys); + }); + } + function onGridDrop(keys) { + var c = C(); + c.addToTrackingGrid(keys); + keys.forEach(function (k) { + var f = fileByKey(k); if (!f) return; + var p = window.zddc.parseFilename(f.originalFilename + (f.extension ? '.' + f.extension : '')); + if (p && p.valid && p.trackingNumber) c.setFileIdentity(k, { tracking: p.trackingNumber, rev: p.revision + (p.status ? ' (' + p.status + ')' : ''), title: p.title || '' }); + }); + } + + // "Columns ▾" chooser — hide/show grid columns (status + ✕ always shown). + function openColumnChooser() { + var open = document.querySelector('.col-chooser'); + if (open) { open.remove(); return; } + var hidden = (gridPrefs().hidden || {}); + var menu = el('div', 'col-chooser'); + GRID_COLS.forEach(function (col) { + if (col.fixed) return; + var lbl = el('label', 'col-chooser__item'); + var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !hidden[col.id]; + cb.addEventListener('change', function () { + var p = gridPrefs(); p.hidden = p.hidden || {}; + if (cb.checked) delete p.hidden[col.id]; else p.hidden[col.id] = true; + saveGridPrefs(p); render(); + }); + lbl.appendChild(cb); lbl.appendChild(document.createTextNode(' ' + col.title)); + menu.appendChild(lbl); + }); + var r = els.trackingColsBtn.getBoundingClientRect(); + menu.style.top = (r.bottom + 2) + 'px'; menu.style.left = r.left + 'px'; + document.body.appendChild(menu); + setTimeout(function () { + function off(e) { if (!menu.contains(e.target) && e.target !== els.trackingColsBtn) { menu.remove(); document.removeEventListener('mousedown', off); } } + document.addEventListener('mousedown', off); + }, 0); } // Transmittal tree @@ -901,40 +888,6 @@ if (parsed.title != null) C().setTitleOverride(tf.dataset.key, parsed.title); // place/setTitleOverride fire classify.notify → re-render. } - // Collapse/expand a node and its whole subtree (ctrl/cmd-click a toggle). - function setSubtreeCollapsed(nodeId, collapse) { - var node = C().getNode(nodeId); - if (!node) return; - (function walk(n) { - if ((n.children || []).length) { if (collapse) collapsed[n.id] = true; else delete collapsed[n.id]; } - (n.children || []).forEach(walk); - })(node); - } - function onTrackingClick(e) { - if (previewFromTarget(e)) return; - var btn = e.target.closest('[data-act]'); - if (!btn) return; - var act = btn.dataset.act; - var id = closestNodeId(btn); - if (act === 'toggle') { - var collapse = !collapsed[id]; - if (e.ctrlKey || e.metaKey) setSubtreeCollapsed(id, collapse); - else if (collapse) collapsed[id] = true; else delete collapsed[id]; - render(); - return; - } - if (act === 'add') { - var name = prompt('Child folder name (next tracking segment, or a leaf revision like "A (IFR)").\n' - + 'Brace patterns expand: {PM,EL,EM}-MOM-{0001-0002,0005}_A (IFR)', ''); - addFoldersFromPattern(id, name); - } else if (act === 'rename') { - var node = C().getNode(id); - var nn = prompt('Rename folder:', node ? node.name : ''); - if (nn && nn.trim()) C().renameNode(id, nn.trim()); - } else if (act === 'del') { - if (confirm('Delete this folder and everything under it? Files placed here become unassigned.')) C().deleteNode(id); - } - } function onTransmittalClick(e) { if (previewFromTarget(e)) return; var btn = e.target.closest('[data-act]'); @@ -1026,25 +979,10 @@ var keys = window.app.modules.dnd.getDrag(); window.app.modules.dnd.clearDrag(); if (!keys.length) return; - if (axis === 'tracking') placeTrackingDrop(keys, t.id); - else C().place(keys, t.id, axis); + C().place(keys, t.id, axis); }); } - // Tracking drop: if the target is already a complete leaf, assign directly; - // otherwise prompt for the remaining levels (parsed + nested under it) so a - // file can be dropped on an existing partial tracking number and completed. - function placeTrackingDrop(keys, nodeId) { - if (C().trackingNodeComplete(nodeId)) { C().place(keys, nodeId, 'tracking'); return; } - var label = C().trackingPathLabel(nodeId); - var input = prompt('Dropping under "' + label + '".\n' - + 'Add the remaining tracking levels (e.g. "0001_0 (IFU)"), or leave blank to drop here:', ''); - if (input === null) return; // cancelled - var levels = C().parseFolderLevels(input.trim()); - var target = levels.length ? C().addTrackingPath(nodeId, levels) : nodeId; - C().place(keys, target, 'tracking'); - } - // Reveal a source key's placement in the target pane (source → target). function reveal(key) { var a = C().getAssignment(key); diff --git a/classifier/template.html b/classifier/template.html index 7e0ea8a..7c10c9e 100644 --- a/classifier/template.html +++ b/classifier/template.html @@ -184,11 +184,11 @@