/** * 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}/. * * 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. */ (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' | 'worklist' | 'transmittal' — active tab var worklistGrid = null; // the seltable controller for the "From a list" tab var worklistPlaced = {}; // trackingNumber -> placed files (read by the Files cell) var hideAssigned = false; // "Hide assigned" toggle in the From-a-list toolbar var listScanned = false; // a Load has run this session (drives the "new" badge) function init() { if (initialized) return; initialized = true; els = { trackingTab: document.getElementById('trackingTab'), worklistTab: document.getElementById('worklistTab'), transmittalTab: document.getElementById('transmittalTab'), trackingPanel: document.getElementById('trackingPanel'), transmittalPanel: document.getElementById('transmittalPanel'), worklistPanel: document.getElementById('worklistPanel'), trackingTree: document.getElementById('trackingTree'), transmittalTree: document.getElementById('transmittalTree'), worklistTable: document.getElementById('worklistTable'), loadWorklistBtn: document.getElementById('loadWorklistBtn'), pasteRowsBtn: document.getElementById('pasteRowsBtn'), matchNamesBtn: document.getElementById('matchNamesBtn'), clearListBtn: document.getElementById('clearListBtn'), hideAssignedToggle: document.getElementById('hideAssignedToggle'), addTrackingRootBtn: document.getElementById('addTrackingRootBtn'), addPartyBtn: document.getElementById('addPartyBtn'), stats: document.getElementById('classifyStats'), }; els.trackingTab.addEventListener('click', function () { showTab('tracking'); }); if (els.worklistTab) els.worklistTab.addEventListener('click', function () { showTab('worklist'); }); els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); }); if (els.loadWorklistBtn) els.loadWorklistBtn.addEventListener('click', loadWorklist); if (els.pasteRowsBtn) els.pasteRowsBtn.addEventListener('click', function () { openPasteDialog(''); }); if (els.matchNamesBtn) els.matchNamesBtn.addEventListener('click', openMatchDialog); if (els.clearListBtn) els.clearListBtn.addEventListener('click', function () { var list = C().getWorklist(); if (!list.length) return; // Warn before stranding files that still need a revision: they stay // assigned (on a "pending" leaf under By tracking number), but the // row you'd use to finish them here is about to disappear. var pending = 0; list.forEach(function (r) { if (!(r.revisionCell || '').trim()) pending += Object.keys(r.placed || {}).length; }); if (pending && !confirm(pending + ' file' + (pending === 1 ? '' : 's') + ' still need a revision. They stay assigned (a “pending” folder under By tracking number), but the list row to finish them here goes away. Clear anyway?')) return; C().clearWorklist(); window.zddc.toast('List cleared — every assignment is kept (see By tracking number).', 'info'); }); if (els.hideAssignedToggle) els.hideAssignedToggle.addEventListener('change', function () { hideAssigned = !!els.hideAssignedToggle.checked; if (worklistGrid) worklistGrid.renderBody(); }); // Ctrl-V anywhere on the From-a-list panel opens the paste dialog prefilled. if (els.worklistPanel) els.worklistPanel.addEventListener('paste', function (e) { if (currentTab !== 'worklist') return; if (e.target && e.target.closest('input, textarea')) return; // let real inputs paste var t = (e.clipboardData || window.clipboardData); 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); }); 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'); setupDropZone(els.transmittalTree, 'transmittal'); C().on(render); if (window.app.modules.store && window.app.modules.store.on) { window.app.modules.store.on('files', render); } render(); } function C() { return window.app.modules.classify; } // Every scanned source file (classify mode reads the left tree, not the // selection-scoped grid). Lazy folders contribute their files once scanned. function allFiles() { var out = []; (function walk(nodes) { (nodes || []).forEach(function (n) { (n.files || []).forEach(function (f) { out.push(f); }); walk(n.children); }); })(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' || which === 'worklist') ? which : 'tracking'; els.trackingTab.classList.toggle('active', currentTab === 'tracking'); if (els.worklistTab) els.worklistTab.classList.toggle('active', currentTab === 'worklist'); els.transmittalTab.classList.toggle('active', currentTab === 'transmittal'); els.trackingPanel.hidden = currentTab !== 'tracking'; if (els.worklistPanel) els.worklistPanel.hidden = currentTab !== 'worklist'; els.transmittalPanel.hidden = currentTab !== 'transmittal'; render(); // The source-tree Show filters are per-axis, so the visible set changes // with the active tab — re-render the left tree. reRenderSource(); } // "From a list" drops materialize tracking placements, so its axis is 'tracking'. 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); renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal); renderWorklist(placed.byTracking); renderStats(files); } function renderStats(files) { var s = C().stats(files); if (els.stats) { els.stats.textContent = s.done + ' done · ' + s.partial + ' in progress · ' + s.none + ' unassigned · ' + s.excluded + ' excluded'; } var copyBtn = document.getElementById('copyOutputBtn'); if (copyBtn) { copyBtn.disabled = s.done === 0; copyBtn.textContent = s.done ? ('Copy ' + s.done + '…') : 'Copy…'; } } function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; 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) ──────────── 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: 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. // 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) { 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; } 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; } 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 function renderTransmittalInto(container, parties, placedMap) { container.textContent = ''; if (!parties.length) { container.appendChild(el('div', 'target-empty', 'No parties yet — “+ Party” to start.')); 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; } 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; } // ── "From a list" (scratch worklist via the shared seltable) ──────────── function renderWorklist(placedByTracking) { worklistPlaced = placedByTracking || {}; if (!C().getWorklist().length) { worklistGrid = null; els.worklistTable.textContent = ''; els.worklistTable.appendChild(el('div', 'target-empty', 'Empty — “Load…” numbers from the archive/MDL, “Paste rows…” from Excel, or “⚡ Match names”. Then drag files onto a row to name them. The list is a scratch pad — clearing it keeps every assignment (see By tracking number).')); return; } ensureWorklistGrid(); worklistGrid.renderBody(); } function rowPlaced(r) { var f = worklistPlaced[r.trackingNumber]; return f && f.length ? f : null; } function ensureWorklistGrid() { if (worklistGrid) return worklistGrid; var c = C(); var cols = [ { key: 'tn', title: 'Tracking number', cls: 'worklist-tn', get: function (r) { return r.trackingNumber || ''; }, render: function (r, td) { editCell(td, 'worklist-tn__input', r.trackingNumber, 'ACME-…-0001', function (v) { c.setRowTracking(r.id, v); }, tnWarn(r)); } }, { key: 'title', title: 'Title', cls: 'worklist-title', get: function (r) { return r.title || ''; }, render: function (r, td) { editCell(td, 'worklist-title__input', r.title, 'title', function (v) { c.setRowTitle(r.id, v); }); } }, { key: 'cur', title: 'Current name', cls: 'worklist-cur', get: function (r) { return r.currentName || ''; } }, { key: 'src', title: 'Source', cls: 'worklist-src', get: function (r) { var s = r.source || {}; return [s.mdl ? 'mdl' : '', s.archive ? 'arch' : '', s.pasted ? 'pasted' : ''].filter(Boolean).join(' '); }, render: function (r, td) { renderSource(r, td); } }, { key: 'latest', title: 'Latest rev', get: function (r) { return latestRevOf(r.archiveRevisions); } }, { key: 'rev', title: 'Revision', cls: 'worklist-rev', get: function (r) { return r.revisionCell; }, render: function (r, td) { editCell(td, 'worklist-rev__input', r.revisionCell, 'A (IFR)', function (v) { c.setRevisionCell(r.id, v); }); } }, ]; worklistGrid = window.app.modules.seltable.create({ container: els.worklistTable, extraTitle: 'Files', rows: function () { var list = c.getWorklist(); return hideAssigned ? list.filter(function (r) { return !rowPlaced(r); }) : list; }, rowId: function (r) { return r.id; }, columns: cols, onRowDrop: function (rowId, keys) { var row = c.getWorklistRow(rowId); if (row) c.assignFromRow(keys, row); }, onActivate: function (ids) { if (!ids.length) return; var v = prompt('Set the revision on ' + ids.length + ' selected row(s) (e.g. "A (IFR)"):', ''); if (v != null) c.setRevisionCells(ids, v.trim()); }, rowExtra: function (r, td) { renderWorklistFiles(r, td); }, }); worklistGrid.render(); return worklistGrid; } // An editable seltable cell: an that commits on change. `warn` is an // optional tooltip that flags (without blocking) a questionable value. function editCell(td, cls, value, placeholder, onCommit, warn) { var inp = document.createElement('input'); inp.type = 'text'; inp.className = cls + (warn ? ' is-warn' : ''); inp.value = value || ''; inp.placeholder = placeholder || ''; inp.spellcheck = false; inp.setAttribute('data-no-select', ''); if (warn) inp.title = warn; inp.addEventListener('change', function () { onCommit(inp.value.trim()); }); td.appendChild(inp); } function tnWarn(r) { var tn = (r.trackingNumber || '').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 renderSource(row, td) { var s = row.source || {}; if (s.mdl) td.appendChild(el('span', 'src-badge src-badge--mdl', 'MDL')); if (s.archive) td.appendChild(el('span', 'src-badge src-badge--arch', 'arch')); if (s.pasted && !s.mdl && !s.archive) { // A pasted number matching nothing known: a likely typo / a brand-new number. var isNew = listScanned; var b = el('span', 'src-badge src-badge--new', isNew ? 'new' : 'unverified'); b.title = isNew ? 'This tracking number isn’t in the scanned archive/MDL — you’re inventing it.' : 'Not checked against the archive/MDL — Load a directory to verify.'; td.appendChild(b); } else if (s.pasted) { td.appendChild(el('span', 'src-badge src-badge--pasted', 'pasted')); } } function renderWorklistFiles(row, td) { var c = C(), files = rowPlaced(row) || []; files.forEach(function (f) { var d = c.deriveTarget(f); var a = c.getAssignment(d.key) || {}; var line = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : '')); line.dataset.key = d.key; line.draggable = true; line.addEventListener('dragstart', function (e) { window.app.modules.dnd.setDrag([d.key], e); }); var nm = el('span', 'mdlfile__name', d.filename || '(set a revision)'); nm.title = 'from ' + f.originalFilename + (f.extension ? '.' + f.extension : ''); line.appendChild(nm); var usingRow = a.titleOverride != null && row.title && a.titleOverride === row.title.trim(); var tgl = el('button', 'tnode__act', usingRow ? 'Title: row' : 'Title: file'); tgl.title = 'Use the row’s title or the file’s own'; tgl.addEventListener('click', function () { c.setTitleOverride(d.key, usingRow ? '' : row.title); }); line.appendChild(tgl); var rm = el('button', 'tnode__act tfile__remove', '✕'); rm.title = 'Remove this file from the row'; rm.addEventListener('click', function () { c.unassignRowFile(row, d.key); }); line.appendChild(rm); td.appendChild(line); }); } // "From a list" loader: "Load…" opens a multi-select directory tree (scoped // to the served context); every ticked directory is walked recursively into // the union of existing files + MDL deliverables, deduped by tracking number // to one row at the latest revision. Writes/alters nothing — the revision // cell is classifier-local and starts blank. function isRowYaml(nm) { return /\.yaml$/i.test(nm) && nm !== 'table.yaml' && nm !== 'form.yaml'; } // The newest combined " ()" string in a set, by revision token. function latestRevOf(revs) { var best = null, bestTok = null; (revs || []).forEach(function (r) { var tok = String(r).replace(/\s*\([^)]*\)\s*$/, '').trim(); // "A (IFR)" → "A" if (best == null || window.zddc.compareRevisions(tok, bestTok) > 0) { best = r; bestTok = tok; } }); return best || ''; } // Where is the classifier served? Decides the directory-tree roots. // 'local' → offline (file://), pick a folder. // 'all' → standalone /_apps/classifier.html, root at every accessible project. // {one:p} → under /…, root at just that project. function detectScope(pathname, hasSource, protocol) { if (!hasSource || protocol === 'file:') return 'local'; if (/^\/_apps\//.test(pathname || '')) return 'all'; var seg = (String(pathname || '').split('/').filter(Boolean)[0]) || ''; return seg ? { one: seg } : 'all'; } async function buildRoots() { var src = window.zddc && window.zddc.source; var scope = detectScope(location.pathname, !!src, location.protocol); if (scope === 'local') { if (!window.showDirectoryPicker) { window.zddc.toast('Loading a local folder needs the File System Access API (Chromium).', 'error'); return null; } try { var dir = await window.showDirectoryPicker({ mode: 'read' }); return [{ label: dir.name || 'Selected folder', handle: dir }]; } catch (e) { if (e.name !== 'AbortError') window.zddc.toast('Could not open the folder — ' + (e.message || e), 'error'); return null; } } function archiveOf(rel) { if (rel.charAt(rel.length - 1) !== '/') rel += '/'; return new src.HttpDirectoryHandle(new URL(rel + 'archive/', location.origin).href, 'archive'); } if (scope === 'all') { var projects = await window.app.modules.copy.fetchAccessProjects(); if (projects == null) { window.zddc.toast('Could not load your projects from the server.', 'error'); return null; } if (!projects.length) { window.zddc.toast('No projects you can access on this server.', 'warning'); return null; } return projects.map(function (p) { return { label: (p.title ? p.name + ' — ' + p.title : p.name), handle: archiveOf(p.url || ('/' + p.name + '/')) }; }); } return [{ label: scope.one, handle: archiveOf('/' + scope.one + '/') }]; } async function loadWorklist() { var roots = await buildRoots(); if (!roots) return; var picked = await window.app.modules.dirPicker.pick(roots); if (!picked || !picked.length) return; var byTn = Object.create(null); function ensure(tn) { return byTn[tn] || (byTn[tn] = { tracking: tn, title: '', inMdl: false, party: '', revs: Object.create(null) }); } window.zddc.toast('Scanning selected directories…', 'info', { durationMs: 4000 }); try { for (var i = 0; i < picked.length; i++) await walkDirInto(picked[i], ensure); } catch (e) { window.zddc.toast('Reading the directories failed — ' + (e.message || e), 'error'); return; } var rows = Object.keys(byTn).map(function (tn) { var x = byTn[tn]; return { id: tn, party: x.party, trackingNumber: tn, title: x.title, inMdl: x.inMdl, archiveRevisions: Object.keys(x.revs).sort(), revisionCell: '' }; }); finishLoad(rows); } // Walk a ticked directory recursively. A dir named "mdl" (or the ticked dir // itself being an mdl folder) yields *.yaml deliverables → inMdl + title; // every other ZDDC-named file is an archive revision of its tracking number. async function walkDirInto(dirH, ensure) { var party = (dirH.name && String(dirH.name).replace(/\/$/, '')) || ''; if (party === 'mdl') return readMdlYamls(dirH, ensure); for await (var entry of dirH.values()) { var nm = String(entry.name).replace(/\/$/, ''); if (entry.kind === 'directory') { if (nm.charAt(0) === '.' || nm.charAt(0) === '_' || nm === 'rsk') continue; var child = entry.getDirectoryHandle ? entry : await dirH.getDirectoryHandle(nm); if (nm === 'mdl') await readMdlYamls(child, ensure); else await walkDirInto(child, ensure); } else { var p = window.zddc.parseFilename(nm); if (p && p.valid && p.trackingNumber) { var row = ensure(p.trackingNumber); if (!row.title) row.title = p.title || ''; if (!row.party) row.party = party; row.revs[(p.revision + (p.status ? ' (' + p.status + ')' : '')).trim()] = true; } } } } async function readMdlYamls(mdlH, ensure) { for await (var ye of mdlH.values()) { var ynm = String(ye.name).replace(/\/$/, ''); if (ye.kind !== 'file' || !isRowYaml(ynm)) continue; var obj = null; try { obj = window.jsyaml.load(await (await ye.getFile()).text()); } catch (_) { /* skip */ } var row = ensure(ynm.replace(/\.yaml$/i, '')); row.inMdl = true; if (!row.title && obj && obj.title) row.title = obj.title; } } function finishLoad(rows) { listScanned = true; C().appendWorklist(rows); // APPEND — the list accumulates across batches showTab('worklist'); window.zddc.toast(rows.length ? ('Added ' + rows.length + ' tracking number' + (rows.length === 1 ? '' : 's') + ' from the selected directories. Drag files on, set revisions.') : 'No files or deliverables in the selected directories.', rows.length ? 'success' : 'warning'); } // ── paste + match dialogs (reuse the .copy-choice modal shell) ────────── function scratchModal(titleText, hintText) { var done = false; function close() { if (done) return; done = true; document.removeEventListener('keydown', onKey); back.remove(); } function onKey(e) { if (e.key === 'Escape') close(); } var back = el('div', 'copy-choice__backdrop'); var box = el('div', 'copy-choice copy-choice--wide'); box.appendChild(el('h3', null, titleText)); if (hintText) box.appendChild(el('p', null, hintText)); var body = el('div', 'scratch-modal__body'); box.appendChild(body); var foot = el('div', 'copy-choice__btns'); box.appendChild(foot); back.appendChild(box); // Close on a genuine backdrop click only — not when a drag that began in // the paste textarea (selecting text) ends out on the backdrop. var pressedBackdrop = false; back.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === back); }); back.addEventListener('click', function (e) { if (e.target === back && pressedBackdrop) close(); }); document.addEventListener('keydown', onKey); document.body.appendChild(back); return { body: body, foot: foot, close: close }; } function unassignedFiles() { var c = C(); return allFiles().filter(function (f) { var a = c.getAssignment(c.srcKeyForFile(f)); return !(a && (a.trackingNodeId || a.excluded)); }); } // Assign every exact, unambiguous (1:1) current-name match without prompting; // returns the count. Lower-confidence / ambiguous matches are left for the // user to review via "Match names". function autoAssignByName() { var c = C(), n = 0; c.proposeMatches(unassignedFiles(), c.getWorklist(), {}).forEach(function (p) { if (p.auto) { c.assignFromRow([c.srcKeyForFile(p.file)], p.row); n++; } }); return n; } function openPasteDialog(prefill) { var c = C(); var m = scratchModal('Paste rows from Excel', 'Fixed columns, tab-separated as Excel copies: Tracking number · Rev (Status) · Title · Current name. A header row is skipped. The current name is matched against your files — exact matches are assigned automatically.'); var ta = document.createElement('textarea'); ta.className = 'scratch-paste__ta'; ta.rows = 6; ta.spellcheck = false; ta.placeholder = 'ACME-AR-DWG-0001\tA (IFR)\tFloor plan\tIMG_4471.pdf'; ta.value = prefill || ''; m.body.appendChild(ta); var preview = el('div', 'scratch-paste__preview'); m.body.appendChild(preview); var add = el('button', 'btn btn-primary', 'Add rows'); add.disabled = true; var cancel = el('button', 'btn btn-secondary', 'Cancel'); cancel.addEventListener('click', m.close); m.foot.appendChild(add); m.foot.appendChild(cancel); var parsed = { rows: [], skipped: [] }; function refresh() { parsed = c.parsePastedRows(ta.value); preview.textContent = ''; if (parsed.rows.length) { var tbl = el('table', 'scratch-preview__table'); var head = el('tr'); ['Tracking number', 'Revision', 'Title', 'Current name'].forEach(function (h) { head.appendChild(el('th', null, h)); }); tbl.appendChild(head); parsed.rows.slice(0, 50).forEach(function (r) { var tr = el('tr'); tr.appendChild(el('td', null, r.trackingNumber)); tr.appendChild(el('td', null, r.revisionCell || '')); tr.appendChild(el('td', null, r.title || '')); tr.appendChild(el('td', null, r.currentName || '')); tbl.appendChild(tr); }); preview.appendChild(tbl); if (parsed.rows.length > 50) preview.appendChild(el('div', 'scratch-preview__more', '…and ' + (parsed.rows.length - 50) + ' more')); } parsed.skipped.forEach(function (s) { preview.appendChild(el('div', 'scratch-preview__skip', 'Line ' + s.line + ' skipped — ' + s.reason)); }); add.disabled = !parsed.rows.length; add.textContent = parsed.rows.length ? ('Add ' + parsed.rows.length + ' row' + (parsed.rows.length === 1 ? '' : 's')) : 'Add rows'; } add.addEventListener('click', function () { var n = parsed.rows.length; c.appendWorklist(parsed.rows); m.close(); showTab('worklist'); var assigned = autoAssignByName(); var msg = 'Added ' + n + ' pasted row' + (n === 1 ? '' : 's') + '.'; if (assigned) msg += ' Auto-assigned ' + assigned + ' file' + (assigned === 1 ? '' : 's') + ' by current name.'; window.zddc.toast(msg + (assigned ? ' Review the rest with ⚡ Match names.' : ''), 'success'); }); ta.addEventListener('input', refresh); refresh(); ta.focus(); } function openMatchDialog() { var c = C(); var rows = c.getWorklist(); if (!rows.length) { window.zddc.toast('Load or paste some tracking numbers first.', 'warning'); return; } var files = unassignedFiles(); if (!files.length) { window.zddc.toast('No unassigned files to match.', 'info'); return; } var m = scratchModal('Match names', 'Each unassigned file matched to a row by its “Current name” (or the tracking number in its filename). Exact matches are pre-checked; review the rest, then Assign.'); var opts = { fuzzy: false }; var fuzzyLbl = el('label', 'scratch-match__fuzzy'); var fuzzy = document.createElement('input'); fuzzy.type = 'checkbox'; fuzzyLbl.appendChild(fuzzy); fuzzyLbl.appendChild(document.createTextNode(' Looser matching (digits only)')); m.body.appendChild(fuzzyLbl); var list = el('div', 'scratch-match__list'); m.body.appendChild(list); var accept = el('button', 'btn btn-primary', 'Assign'); var cancel = el('button', 'btn btn-secondary', 'Cancel'); cancel.addEventListener('click', m.close); m.foot.appendChild(accept); m.foot.appendChild(cancel); var proposals = []; function refresh() { proposals = c.proposeMatches(files, rows, opts); list.textContent = ''; if (!proposals.length) { list.appendChild(el('div', 'scratch-preview__skip', 'No matches found.')); accept.disabled = true; accept.textContent = 'Assign'; return; } proposals.forEach(function (p, i) { var rowEl = el('label', 'scratch-match__row' + (p.auto ? '' : ' scratch-match__row--review')); var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!p.auto; // pre-check only exact 1:1 matches; opt in to the rest cb.dataset.i = i; rowEl.appendChild(cb); rowEl.appendChild(el('span', 'scratch-match__file', window.zddc.joinExtension(p.file.originalFilename, p.file.extension))); rowEl.appendChild(el('span', 'scratch-match__arrow', '→')); rowEl.appendChild(el('span', 'scratch-match__tn', p.row.trackingNumber)); var tag = el('span', 'scratch-match__conf', Math.round(p.confidence * 100) + '% · ' + (p.via === 'name' ? 'name' : 'tracking#')); rowEl.appendChild(tag); list.appendChild(rowEl); }); accept.disabled = false; accept.textContent = 'Assign checked'; } accept.addEventListener('click', function () { var n = 0; Array.prototype.forEach.call(list.querySelectorAll('input[type=checkbox]'), function (cb) { if (!cb.checked) return; var p = proposals[Number(cb.dataset.i)]; if (p) { c.assignFromRow([c.srcKeyForFile(p.file)], p.row); n++; } }); m.close(); showTab('worklist'); window.zddc.toast('Assigned ' + n + ' file' + (n === 1 ? '' : 's') + ' by name match.', n ? 'success' : 'info'); }); fuzzy.addEventListener('change', function () { opts.fuzzy = fuzzy.checked; refresh(); }); refresh(); } // ── 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. } // 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]'); 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; } 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); }); container.addEventListener('drop', function (e) { var t = dropTarget(e.target, axis); clearHover(container); if (!t) return; e.preventDefault(); 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); }); } // 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); if (!a) return; if (a.trackingNodeId) { showTab('tracking'); collapsed = {}; render(); flashNode(els.trackingTree, a.trackingNodeId); } else if (a.transmittalNodeId) { showTab('transmittal'); render(); flashNode(els.transmittalTree, a.transmittalNodeId); } } function flashNode(container, id) { var node = container.querySelector('[data-id="' + id + '"]'); if (!node) return; node.scrollIntoView({ block: 'center' }); var row = node.querySelector('.tnode__row') || node; row.classList.add('reveal-flash'); setTimeout(function () { row.classList.remove('reveal-flash'); }, 1500); } window.app.modules.targetTree = { init: init, render: render, showTab: showTab, activeAxis: activeAxis, setNameFilter: setNameFilter, reveal: reveal, // test seams (pure) _detectScope: detectScope, _latestRevOf: latestRevOf, _walkDirInto: walkDirInto, }; })();