ZDDC/classifier/js/target-tree.js
ZDDC 8f839fc0c9 feat(classifier): Hide Assigned filter, left-aligned node controls, brace-expand add
Classify & Copy polish — in either target tab the goal is to assign or exclude
every left-pane file until nothing remains:

- Hide Assigned checkbox (classify mode, in the folder-tree pane header):
  collapses the source tree to only what's left on the ACTIVE axis — hides
  files already assigned in the current tab (or excluded) and any folder whose
  scanned subtree is thereby empty. Re-renders on tab switch; target-tree
  exposes activeAxis().
- Node add/edit/delete controls moved to the LEFT of the level name and made
  always-visible (was right-aligned + hover-only), so building/pruning the
  tracking and transmittal trees is one click.
- Brace expansion in the add-folder box: "BMB-187023-{PM,EL,EM}-MOM-
  {0001-0002,0005}_A (IFR)" creates all 9 folders — {a,b} alternation +
  {N-M} zero-padded numeric ranges, cartesian product across groups; a
  multi-create is confirmed first. New classify.expandFolderPattern().

Tests: expandFolderPattern unit cases + a Hide-Assigned DOM test
(classify.spec.js → 29 passed; classifier.spec.js → 4 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:31:14 -05:00

423 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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": <party>/{received,issued}/<transmittal folder>.
*
* 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);
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;
}
names.forEach(function (nm) { C().addTrackingNode(parentId, 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.title = d.errors.length ? d.errors.join('; ') : 'Click to find this file in the source tree';
row.dataset.key = d.key; // for cross-tree reveal
row.appendChild(el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : '')));
row.appendChild(el('span', 'tfile__arrow', '→'));
row.appendChild(el('span', 'tfile__name', d.filename || '(incomplete)'));
box.appendChild(row);
});
return box;
}
// Tracking tree (recursive)
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) { container.appendChild(trackingNode(n, placedMap)); });
}
function trackingNode(n, placedMap) {
var isLeaf = (n.children || []).length === 0;
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 ? '·' : (collapsed[n.id] ? '▸' : '▾'));
if (!isLeaf) toggle.dataset.act = 'toggle';
row.appendChild(toggle);
row.appendChild(nodeActions([
{ act: 'add', label: '', title: 'Add child folder' },
{ act: 'rename', label: '✎', title: 'Rename' },
{ act: 'del', label: '🗑', title: 'Delete' },
]));
row.appendChild(el('span', 'tnode__name', n.name));
var placed = placedMap[n.id] || [];
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
wrap.appendChild(row);
if (placed.length) wrap.appendChild(fileList(placed));
if (!isLeaf && !collapsed[n.id]) {
var kids = el('div', 'tnode__children');
(n.children || []).forEach(function (c) { kids.appendChild(trackingNode(c, placedMap)); });
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) { container.appendChild(partyNode(p, placedMap)); });
}
function partyNode(party, placedMap) {
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(nodeActions([
{ act: 'rename-party', label: '✎', title: 'Rename party' },
{ act: 'del-party', label: '🗑', title: 'Delete party' },
]));
row.appendChild(el('span', 'tnode__name', party.name));
wrap.appendChild(row);
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) {
sw.appendChild(binNode(bin, placedMap));
});
wrap.appendChild(sw);
});
return wrap;
}
function binNode(bin, placedMap) {
var wrap = el('div', 'tnode tnode--bin');
wrap.dataset.id = bin.id;
var row = el('div', 'tnode__row');
row.appendChild(nodeActions([{ act: 'del', label: '🗑', title: 'Delete transmittal' }]));
row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)'));
var placed = placedMap[bin.id] || [];
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
wrap.appendChild(row);
if (placed.length) wrap.appendChild(fileList(placed));
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 revealInSource(e) {
var tf = e.target.closest('.tfile');
if (tf && tf.dataset.key && window.app.modules.tree.revealFile) {
window.app.modules.tree.revealFile(tf.dataset.key);
return true;
}
return false;
}
function onTrackingClick(e) {
if (revealInSource(e)) return;
var btn = e.target.closest('[data-act]');
if (!btn) return;
var act = btn.dataset.act;
var id = closestNodeId(btn);
if (act === 'toggle') { collapsed[id] = !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 (revealInSource(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) C().place(keys, t.id, axis);
});
}
// 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,
reveal: reveal,
};
})();