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:
ZDDC 2026-06-09 12:19:35 -05:00
parent a8f116734d
commit a8403d1f73
6 changed files with 542 additions and 4 deletions

View file

@ -56,6 +56,7 @@ concat_files \
"js/validator.js" \
"js/scanner.js" \
"js/tree.js" \
"js/target-tree.js" \
"js/spreadsheet.js" \
"js/selection.js" \
"js/preview.js" \

View file

@ -265,6 +265,134 @@
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 {
flex: 1;

View file

@ -158,10 +158,36 @@
errorFiles: document.getElementById('errorFiles'),
// 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
*/
@ -189,6 +215,10 @@
// Collapse tree button
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
document.addEventListener('keydown', handleKeyDown);
@ -325,6 +355,7 @@
app.modules.filter.init();
app.modules.sort.init();
app.modules.tree.setupKeyboardShortcuts();
if (app.modules.targetTree) app.modules.targetTree.init();
// Now scan directory (this will trigger store updates and renders)
await app.modules.scanner.scanDirectory(dirHandle);

View 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,
};
})();

View file

@ -30,6 +30,10 @@
</div>
<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>
<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 &amp; Copy</button>
</div>
</div>
<div class="header-right">
<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>
<!-- Spreadsheet Table -->
<main class="spreadsheet-pane">
<main class="spreadsheet-pane" id="spreadsheetPane">
<div class="pane-header">
<div class="pane-header-left">
<h3>Files</h3>
@ -126,6 +130,37 @@
</div>
</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">&lt;party&gt;/{received,issued}/&lt;transmittal&gt;. Drag files (or a whole folder) into a transmittal.</span>
</div>
<div id="transmittalTree" class="target-tree"></div>
</section>
</div>
</main>
</div>
<!-- Page footer — scan status lives here -->

View file

@ -20,7 +20,13 @@ test.beforeEach(async ({ page }) => {
await page.goto(PAGE, { waitUntil: 'load' });
const ok = await page.evaluate(() => !!(window.app && window.app.modules && window.app.modules.classify));
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;
@ -135,6 +141,42 @@ test('exclude clears placements and reports excluded state', async ({ page }) =>
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 }) => {
const after = await page.evaluate((file) => {
const c = window.app.modules.classify;