Header gets a Rename / Classify & Copy switch. In Classify & Copy mode the spreadsheet pane is replaced by a tabbed target pane (By tracking number / By transmittal), while the source tree stays on the left. - target-tree.js: renders both trees from classify state; tracking-folder create/rename/delete (leaf folders styled as the revision); party CRUD + per-slot inline transmittal-bin form (date + TRN/SUB + seq + optional status/title); shows the derived filename + a validation badge for each placed file; live header stats (done / in progress / unassigned / excluded). - app.js setMode(): swaps panes, toggles classify mode, re-renders both trees. - 3 UI smoke tests added to classify.spec.js (12 total green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
301 lines
13 KiB
JavaScript
301 lines
13 KiB
JavaScript
/**
|
||
* 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;
|
||
|
||
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"):', '');
|
||
if (name && name.trim()) C().addTrackingNode(null, name.trim());
|
||
});
|
||
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);
|
||
|
||
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; }
|
||
function allFiles() {
|
||
var s = window.app.modules.store;
|
||
return s && s.getAllFiles ? s.getAllFiles() : [];
|
||
}
|
||
|
||
function showTab(which) {
|
||
var t = which === 'transmittal';
|
||
els.trackingTab.classList.toggle('active', !t);
|
||
els.transmittalTab.classList.toggle('active', t);
|
||
els.trackingPanel.hidden = t;
|
||
els.transmittalPanel.hidden = !t;
|
||
}
|
||
|
||
// ── render ───────────────────────────────────────────────────────────────
|
||
function render() {
|
||
if (!initialized || !C().isEnabled()) return;
|
||
var files = allFiles();
|
||
renderTrackingInto(els.trackingTree, C().getTrackingTree(), files);
|
||
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), files);
|
||
renderStats(files);
|
||
}
|
||
|
||
function renderStats(files) {
|
||
if (!els.stats) return;
|
||
var s = C().stats(files);
|
||
els.stats.textContent = s.done + ' done · ' + s.partial + ' in progress · '
|
||
+ s.none + ' unassigned · ' + s.excluded + ' excluded';
|
||
}
|
||
|
||
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('; ') : '';
|
||
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, files) {
|
||
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, files)); });
|
||
}
|
||
function trackingNode(n, files) {
|
||
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(el('span', 'tnode__name', n.name));
|
||
|
||
var placed = C().filesInNode(n.id, 'tracking', files);
|
||
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 (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, files)); });
|
||
wrap.appendChild(kids);
|
||
}
|
||
return wrap;
|
||
}
|
||
|
||
// Transmittal tree
|
||
function renderTransmittalInto(container, parties, files) {
|
||
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, files)); });
|
||
}
|
||
function partyNode(party, files) {
|
||
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);
|
||
|
||
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, files));
|
||
});
|
||
wrap.appendChild(sw);
|
||
});
|
||
return wrap;
|
||
}
|
||
function binNode(bin, files) {
|
||
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)'));
|
||
var placed = C().filesInNode(bin.id, 'transmittal', files);
|
||
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 (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 onTrackingClick(e) {
|
||
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)"):', '');
|
||
if (name && name.trim()) C().addTrackingNode(id, name.trim());
|
||
} 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) {
|
||
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);
|
||
}
|
||
}
|
||
|
||
window.app.modules.targetTree = {
|
||
init: init,
|
||
render: render,
|
||
showTab: showTab,
|
||
};
|
||
})();
|