/** * 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' | 'transmittal' — the active axis function init() { if (initialized) return; initialized = true; els = { trackingTab: document.getElementById('trackingTab'), transmittalTab: document.getElementById('transmittalTab'), trackingPanel: document.getElementById('trackingPanel'), transmittalPanel: document.getElementById('transmittalPanel'), trackingTree: document.getElementById('trackingTree'), transmittalTree: document.getElementById('transmittalTree'), addTrackingRootBtn: document.getElementById('addTrackingRootBtn'), addPartyBtn: document.getElementById('addPartyBtn'), stats: document.getElementById('classifyStats'), }; els.trackingTab.addEventListener('click', function () { showTab('tracking'); }); els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); }); 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 = {}; 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); if (a.transmittalNodeId) (byX[a.transmittalNodeId] = byX[a.transmittalNodeId] || []).push(f); }); return { tracking: byT, transmittal: byX }; } function showTab(which) { var t = which === 'transmittal'; currentTab = t ? 'transmittal' : 'tracking'; els.trackingTab.classList.toggle('active', !t); els.transmittalTab.classList.toggle('active', t); els.trackingPanel.hidden = t; els.transmittalPanel.hidden = !t; // The "Hide Assigned" filter on the source tree is per-axis, so the // visible set changes with the active tab — re-render the left tree. if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); } function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : 'tracking'; } // 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); 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; } 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; var orig = el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : '')); orig.title = '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); 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 || ''); } // 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; 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' }, { 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 wrap; } // 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: '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; } // ── events ───────────────────────────────────────────────────────────── function closestNodeId(target) { var n = target.closest('.tnode'); 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) { 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 === '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) { 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 }; } 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('.tnode[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, }; })();