feat(classifier): mode toggle + dual-pane target trees (phase 2)
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>
This commit is contained in:
parent
a8f116734d
commit
a8403d1f73
6 changed files with 542 additions and 4 deletions
|
|
@ -56,6 +56,7 @@ concat_files \
|
||||||
"js/validator.js" \
|
"js/validator.js" \
|
||||||
"js/scanner.js" \
|
"js/scanner.js" \
|
||||||
"js/tree.js" \
|
"js/tree.js" \
|
||||||
|
"js/target-tree.js" \
|
||||||
"js/spreadsheet.js" \
|
"js/spreadsheet.js" \
|
||||||
"js/selection.js" \
|
"js/selection.js" \
|
||||||
"js/preview.js" \
|
"js/preview.js" \
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,134 @@
|
||||||
margin-left: 1.5rem;
|
margin-left: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Workflow mode switch (header) ─────────────────────────────────────── */
|
||||||
|
.mode-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.mode-btn {
|
||||||
|
border: none;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mode-btn + .mode-btn { border-left: 1px solid var(--border); }
|
||||||
|
.mode-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--bg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Target pane (Classify & Copy) ─────────────────────────────────────── */
|
||||||
|
.target-pane {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.target-pane[hidden], .spreadsheet-pane[hidden] { display: none; }
|
||||||
|
|
||||||
|
.target-tabs { display: flex; gap: 0.25rem; }
|
||||||
|
.target-tab {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom: none;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-radius: var(--radius) var(--radius) 0 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.target-tab.active {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-body { flex: 1; overflow: hidden; }
|
||||||
|
.target-panel { height: 100%; display: flex; flex-direction: column; }
|
||||||
|
.target-panel[hidden] { display: none; }
|
||||||
|
.target-panel__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.target-hint { font-size: 0.75rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.target-tree { flex: 1; overflow: auto; padding: 0.5rem 0.75rem; }
|
||||||
|
.target-empty { color: var(--text-muted); font-size: 0.85rem; padding: 1rem 0.25rem; }
|
||||||
|
|
||||||
|
/* tree nodes */
|
||||||
|
.tnode { margin: 0.1rem 0; }
|
||||||
|
.tnode__children { margin-left: 1.25rem; border-left: 1px dashed var(--border); padding-left: 0.5rem; }
|
||||||
|
.tnode__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.2rem 0.3rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.tnode__row:hover { background: var(--bg-hover); }
|
||||||
|
.tnode__toggle {
|
||||||
|
border: none; background: none; cursor: pointer;
|
||||||
|
color: var(--text-muted); width: 1.1em; font-size: 0.8rem; padding: 0;
|
||||||
|
}
|
||||||
|
.tnode__icon { font-size: 0.85rem; }
|
||||||
|
.tnode__name { flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.tnode--leaf > .tnode__row > .tnode__name { color: var(--primary); font-weight: 600; }
|
||||||
|
.tnode--party > .tnode__row > .tnode__name { font-weight: 700; }
|
||||||
|
.tnode--bin > .tnode__row > .tnode__name { color: var(--primary); }
|
||||||
|
.tnode__badge {
|
||||||
|
background: var(--primary); color: var(--bg);
|
||||||
|
border-radius: 999px; padding: 0 0.4rem; font-size: 0.7rem; font-weight: 600;
|
||||||
|
}
|
||||||
|
.tnode__actions { margin-left: auto; display: inline-flex; gap: 0.1rem; opacity: 0; transition: opacity 0.12s; }
|
||||||
|
.tnode__row:hover .tnode__actions, .tslot__row:hover .tnode__actions { opacity: 1; }
|
||||||
|
.tnode__act {
|
||||||
|
border: 1px solid var(--border); background: var(--bg);
|
||||||
|
border-radius: var(--radius); cursor: pointer;
|
||||||
|
font-size: 0.72rem; padding: 0.05rem 0.35rem; color: var(--text);
|
||||||
|
}
|
||||||
|
.tnode__act:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
|
/* placed files under a node */
|
||||||
|
.tnode__files { margin: 0.1rem 0 0.2rem 1.6rem; }
|
||||||
|
.tfile { display: flex; align-items: baseline; gap: 0.4rem; font-size: 0.75rem; padding: 0.05rem 0; }
|
||||||
|
.tfile__orig { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 14rem; }
|
||||||
|
.tfile__arrow { color: var(--text-muted); }
|
||||||
|
.tfile__name { color: var(--text); }
|
||||||
|
.tfile--err .tfile__name { color: var(--danger); }
|
||||||
|
.tfile--err::before { content: "⚠"; color: var(--danger); }
|
||||||
|
|
||||||
|
/* transmittal slots + bin form */
|
||||||
|
.tslot { margin: 0.15rem 0 0.15rem 1.1rem; }
|
||||||
|
.tslot__row { display: flex; align-items: center; gap: 0.5rem; padding: 0.15rem 0.3rem; }
|
||||||
|
.tslot__name { font-size: 0.8rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.03em; }
|
||||||
|
.tnode--bin { margin-left: 1.1rem; }
|
||||||
|
.binform {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center;
|
||||||
|
margin: 0.2rem 0 0.3rem 1.1rem; padding: 0.4rem; background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.binform input, .binform select {
|
||||||
|
font-size: 0.78rem; padding: 0.2rem 0.3rem;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
background: var(--bg); color: var(--text);
|
||||||
|
}
|
||||||
|
.binform__seq { width: 7rem; }
|
||||||
|
.binform__title { width: 11rem; }
|
||||||
|
|
||||||
|
/* drop-target affordance (used in phase 3) */
|
||||||
|
.tnode__row.drop-hover, .tslot.drop-hover { outline: 2px dashed var(--primary); outline-offset: -2px; background: var(--primary-light); }
|
||||||
|
|
||||||
/* Spreadsheet Pane */
|
/* Spreadsheet Pane */
|
||||||
.spreadsheet-pane {
|
.spreadsheet-pane {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
|
||||||
|
|
@ -158,10 +158,36 @@
|
||||||
errorFiles: document.getElementById('errorFiles'),
|
errorFiles: document.getElementById('errorFiles'),
|
||||||
|
|
||||||
// Preview
|
// Preview
|
||||||
togglePreviewBtn: document.getElementById('togglePreviewBtn')
|
togglePreviewBtn: document.getElementById('togglePreviewBtn'),
|
||||||
|
|
||||||
|
// Mode switch + Classify & Copy panes
|
||||||
|
modeRenameBtn: document.getElementById('modeRenameBtn'),
|
||||||
|
modeClassifyBtn: document.getElementById('modeClassifyBtn'),
|
||||||
|
spreadsheetPane: document.getElementById('spreadsheetPane'),
|
||||||
|
targetPane: document.getElementById('targetPane')
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch between "Rename" (in-place grid) and "Classify & Copy" (map files
|
||||||
|
* onto target trees, copy renamed copies out). The source tree (left) stays
|
||||||
|
* in both modes; only the right pane swaps.
|
||||||
|
*/
|
||||||
|
function setMode(mode) {
|
||||||
|
const classify = mode === 'classify';
|
||||||
|
app.dom.modeRenameBtn.classList.toggle('active', !classify);
|
||||||
|
app.dom.modeClassifyBtn.classList.toggle('active', classify);
|
||||||
|
if (app.dom.spreadsheetPane) app.dom.spreadsheetPane.hidden = classify;
|
||||||
|
if (app.dom.targetPane) app.dom.targetPane.hidden = !classify;
|
||||||
|
app.modules.classify.setEnabled(classify);
|
||||||
|
if (classify && app.modules.targetTree) {
|
||||||
|
app.modules.targetTree.init();
|
||||||
|
app.modules.targetTree.render();
|
||||||
|
}
|
||||||
|
// Re-render the source tree so its per-file markers appear/disappear.
|
||||||
|
if (app.modules.tree) app.modules.tree.render();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up event listeners
|
* Set up event listeners
|
||||||
*/
|
*/
|
||||||
|
|
@ -189,6 +215,10 @@
|
||||||
// Collapse tree button
|
// Collapse tree button
|
||||||
app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree);
|
app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree);
|
||||||
|
|
||||||
|
// Workflow mode switch
|
||||||
|
if (app.dom.modeRenameBtn) app.dom.modeRenameBtn.addEventListener('click', function () { setMode('rename'); });
|
||||||
|
if (app.dom.modeClassifyBtn) app.dom.modeClassifyBtn.addEventListener('click', function () { setMode('classify'); });
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
|
@ -325,6 +355,7 @@
|
||||||
app.modules.filter.init();
|
app.modules.filter.init();
|
||||||
app.modules.sort.init();
|
app.modules.sort.init();
|
||||||
app.modules.tree.setupKeyboardShortcuts();
|
app.modules.tree.setupKeyboardShortcuts();
|
||||||
|
if (app.modules.targetTree) app.modules.targetTree.init();
|
||||||
|
|
||||||
// Now scan directory (this will trigger store updates and renders)
|
// Now scan directory (this will trigger store updates and renders)
|
||||||
await app.modules.scanner.scanDirectory(dirHandle);
|
await app.modules.scanner.scanDirectory(dirHandle);
|
||||||
|
|
|
||||||
301
classifier/js/target-tree.js
Normal file
301
classifier/js/target-tree.js
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -30,6 +30,10 @@
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||||
|
<div class="mode-switch" id="modeSwitch" role="group" aria-label="Workflow mode">
|
||||||
|
<button id="modeRenameBtn" class="mode-btn active" title="Rename files in place (edits the source)">Rename</button>
|
||||||
|
<button id="modeClassifyBtn" class="mode-btn" title="Map files onto target trees and copy renamed copies to an output directory — source is never modified">Classify & Copy</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||||
|
|
@ -65,7 +69,7 @@
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Spreadsheet Table -->
|
<!-- Spreadsheet Table -->
|
||||||
<main class="spreadsheet-pane">
|
<main class="spreadsheet-pane" id="spreadsheetPane">
|
||||||
<div class="pane-header">
|
<div class="pane-header">
|
||||||
<div class="pane-header-left">
|
<div class="pane-header-left">
|
||||||
<h3>Files</h3>
|
<h3>Files</h3>
|
||||||
|
|
@ -126,6 +130,37 @@
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Target Trees (Classify & Copy mode) -->
|
||||||
|
<main class="target-pane" id="targetPane" hidden>
|
||||||
|
<div class="pane-header">
|
||||||
|
<div class="target-tabs" role="tablist">
|
||||||
|
<button class="target-tab active" id="trackingTab" role="tab">By tracking number</button>
|
||||||
|
<button class="target-tab" id="transmittalTab" role="tab">By transmittal</button>
|
||||||
|
</div>
|
||||||
|
<div class="pane-header-right">
|
||||||
|
<span id="classifyStats" class="file-stats"></span>
|
||||||
|
<span class="header-divider">|</span>
|
||||||
|
<button id="copyOutputBtn" class="btn btn-primary btn-sm" disabled title="Copy mapped files to an output directory (source untouched)">Copy…</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="target-body">
|
||||||
|
<section id="trackingPanel" class="target-panel">
|
||||||
|
<div class="target-panel__toolbar">
|
||||||
|
<button id="addTrackingRootBtn" class="btn btn-sm btn-secondary">+ Root folder</button>
|
||||||
|
<span class="target-hint">Folders join with “-” into the tracking number; the leaf folder is the revision — name it like “A (IFR)”.</span>
|
||||||
|
</div>
|
||||||
|
<div id="trackingTree" class="target-tree"></div>
|
||||||
|
</section>
|
||||||
|
<section id="transmittalPanel" class="target-panel" hidden>
|
||||||
|
<div class="target-panel__toolbar">
|
||||||
|
<button id="addPartyBtn" class="btn btn-sm btn-secondary">+ Party</button>
|
||||||
|
<span class="target-hint"><party>/{received,issued}/<transmittal>. Drag files (or a whole folder) into a transmittal.</span>
|
||||||
|
</div>
|
||||||
|
<div id="transmittalTree" class="target-tree"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Page footer — scan status lives here -->
|
<!-- Page footer — scan status lives here -->
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,13 @@ test.beforeEach(async ({ page }) => {
|
||||||
await page.goto(PAGE, { waitUntil: 'load' });
|
await page.goto(PAGE, { waitUntil: 'load' });
|
||||||
const ok = await page.evaluate(() => !!(window.app && window.app.modules && window.app.modules.classify));
|
const ok = await page.evaluate(() => !!(window.app && window.app.modules && window.app.modules.classify));
|
||||||
expect(ok).toBe(true);
|
expect(ok).toBe(true);
|
||||||
await page.evaluate(() => window.app.modules.classify.reset());
|
await page.evaluate(() => {
|
||||||
|
window.app.modules.classify.reset();
|
||||||
|
// No directory is opened in these tests; dismiss the welcome overlay so
|
||||||
|
// it doesn't intercept clicks on the in-pane controls.
|
||||||
|
const w = document.getElementById('welcomeScreen');
|
||||||
|
if (w) w.classList.add('hidden');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build a tracking chain of folders and place one file in the deepest;
|
// Build a tracking chain of folders and place one file in the deepest;
|
||||||
|
|
@ -135,6 +141,42 @@ test('exclude clears placements and reports excluded state', async ({ page }) =>
|
||||||
expect(r.d.excluded).toBe(true);
|
expect(r.d.excluded).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Phase 2: mode toggle + target-tree rendering (UI) ──────────────────────
|
||||||
|
|
||||||
|
test('mode switch swaps the spreadsheet pane for the target pane', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
expect(await page.locator('#targetPane').isHidden()).toBe(false);
|
||||||
|
expect(await page.locator('#spreadsheetPane').isHidden()).toBe(true);
|
||||||
|
await page.click('#modeRenameBtn');
|
||||||
|
expect(await page.locator('#targetPane').isHidden()).toBe(true);
|
||||||
|
expect(await page.locator('#spreadsheetPane').isHidden()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('target tree renders structure and tabs switch', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
const acme = c.addTrackingNode(null, 'ACME-PROJ');
|
||||||
|
c.addTrackingNode(acme, 'A (IFR)');
|
||||||
|
const party = c.addParty('ClientCorp');
|
||||||
|
c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||||
|
});
|
||||||
|
// Tracking panel visible by default with the nodes rendered.
|
||||||
|
await expect(page.locator('#trackingTree .tnode__name', { hasText: 'ACME-PROJ' })).toBeVisible();
|
||||||
|
await expect(page.locator('#trackingTree .tnode--leaf .tnode__name', { hasText: 'A (IFR)' })).toBeVisible();
|
||||||
|
// Switch to transmittal tab.
|
||||||
|
await page.click('#transmittalTab');
|
||||||
|
expect(await page.locator('#transmittalPanel').isHidden()).toBe(false);
|
||||||
|
await expect(page.locator('#transmittalTree .tnode--bin .tnode__name', { hasText: 'ClientCorp-TRN-0007' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"+ Root folder" button (prompt) adds a tracking node', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
page.once('dialog', (d) => d.accept('ACME-PROJ'));
|
||||||
|
await page.click('#addTrackingRootBtn');
|
||||||
|
await expect(page.locator('#trackingTree .tnode__name', { hasText: 'ACME-PROJ' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('deleting a tracking node clears the files placed in it', async ({ page }) => {
|
test('deleting a tracking node clears the files placed in it', async ({ page }) => {
|
||||||
const after = await page.evaluate((file) => {
|
const after = await page.evaluate((file) => {
|
||||||
const c = window.app.modules.classify;
|
const c = window.app.modules.classify;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue