feat(classifier): rename transmittals; remove/move files already in one

By-transmittal pane gains the editing it was missing:
- Rename a transmittal after creation (✎ on the bin → prompt). The name IS the
  filing folder (deriveTarget reads bin.name), so renaming changes where copies
  land, not just the label.
- Each placed file row is now draggable — drop it on another transmittal to MOVE
  it — and carries an ✕ to remove it from the transmittal (clears that axis).
  previewFromTarget now lets action buttons through so ✕ doesn't trigger preview.

Test: rename feeds the derived folder; the row is draggable + has remove; ✕
clears the transmittal; re-place moves it to another bin (55 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-11 09:12:38 -05:00
parent d951c3a5e7
commit bc762a7d74
3 changed files with 75 additions and 3 deletions

View file

@ -444,7 +444,11 @@
/* placed files under a node */ /* placed files under a node */
.tnode__files { margin: 0.1rem 0 0.2rem 1.6rem; } .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 { display: flex; align-items: baseline; gap: 0.4rem; font-size: 0.75rem; padding: 0.05rem 0; cursor: grab; }
.tfile[draggable="true"]:active { cursor: grabbing; }
.tfile__remove { opacity: 0; flex: 0 0 auto; align-self: center; line-height: 1; }
.tfile:hover .tfile__remove { opacity: 1; }
.tfile__remove:hover { color: var(--danger); border-color: var(--danger); }
.tfile__orig { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 14rem; } .tfile__orig { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 14rem; }
.tfile__arrow { color: var(--text-muted); } .tfile__arrow { color: var(--text-muted); }
.tfile__name { color: var(--text); } .tfile__name { color: var(--text); }

View file

@ -158,14 +158,18 @@
return wrap; return wrap;
} }
// Placed files inside a transmittal bin. Each row is draggable (drag onto
// another bin to MOVE it) and carries an ✕ to remove it from the transmittal.
function fileList(files) { function fileList(files) {
var box = el('div', 'tnode__files'); var box = el('div', 'tnode__files');
files.forEach(function (f) { files.forEach(function (f) {
var d = C().deriveTarget(f); var d = C().deriveTarget(f);
var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : '')); var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : ''));
row.dataset.key = d.key; row.dataset.key = d.key;
row.draggable = true;
row.addEventListener('dragstart', function (e) { window.app.modules.dnd.setDrag([d.key], e); });
var orig = el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : '')); var orig = el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : ''));
orig.title = 'Click to preview'; orig.title = 'Drag to another transmittal to move · click to preview';
row.appendChild(orig); row.appendChild(orig);
row.appendChild(el('span', 'tfile__arrow', '→')); row.appendChild(el('span', 'tfile__arrow', '→'));
// Editable derived filename — edit it to re-file the item. // Editable derived filename — edit it to re-file the item.
@ -175,6 +179,10 @@
name.placeholder = '(incomplete)'; name.placeholder = '(incomplete)';
name.title = d.errors.length ? d.errors.join('; ') : 'Edit this ZDDC filename to re-file the item'; name.title = d.errors.length ? d.errors.join('; ') : 'Edit this ZDDC filename to re-file the item';
row.appendChild(name); row.appendChild(name);
var rm = el('button', 'tnode__act tfile__remove', '✕');
rm.dataset.act = 'untransmit';
rm.title = 'Remove from this transmittal';
row.appendChild(rm);
box.appendChild(row); box.appendChild(row);
}); });
return box; return box;
@ -421,7 +429,10 @@
var row = el('div', 'tnode__row'); var row = el('div', 'tnode__row');
row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)')); row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)'));
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length))); if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
row.appendChild(nodeActions([{ act: 'del', label: '🗑', title: 'Delete transmittal' }])); row.appendChild(nodeActions([
{ act: 'rename-bin', label: '✎', title: 'Rename transmittal' },
{ act: 'del', label: '🗑', title: 'Delete transmittal' },
]));
wrap.appendChild(row); wrap.appendChild(row);
if (shownFiles.length) wrap.appendChild(fileList(shownFiles)); if (shownFiles.length) wrap.appendChild(fileList(shownFiles));
return wrap; return wrap;
@ -468,6 +479,7 @@
} }
return true; return true;
} }
if (e.target.closest('[data-act]')) return false; // action button — not a preview
if (e.target.closest('.tfile__name')) return false; if (e.target.closest('.tfile__name')) return false;
var tf = e.target.closest('.tfile'); var tf = e.target.closest('.tfile');
if (!tf || !tf.dataset.key) return false; if (!tf || !tf.dataset.key) return false;
@ -544,6 +556,18 @@
render(); render();
return; return;
} }
if (act === 'untransmit') {
var tf = btn.closest('.tfile');
if (tf && tf.dataset.key) C().place([tf.dataset.key], null, 'transmittal');
return;
}
if (act === 'rename-bin') {
var bid = closestNodeId(btn);
var bn = C().getNode(bid);
var nn = prompt('Rename transmittal (this becomes its folder name):', bn ? bn.name : '');
if (nn && nn.trim()) C().renameNode(bid, nn.trim());
return;
}
if (act === 'bincancel') { openForm = null; render(); return; } if (act === 'bincancel') { openForm = null; render(); return; }
if (act === 'binadd') { if (act === 'binadd') {
var form = btn.closest('.binform'); var form = btn.closest('.binform');

View file

@ -1133,3 +1133,47 @@ test('copy: verifies copied bytes; a bad write fails verification and is removed
expect(r.removed).toBe(1); // bad copy removed… expect(r.removed).toBe(1); // bad copy removed…
expect(r.left).toBe(0); // …so a re-run re-copies it expect(r.left).toBe(0); // …so a re-run re-copies it
}); });
test('transmittal: rename a bin (feeds the folder), remove and move a placed file', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(async () => {
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
c.reset();
const f = { originalFilename: 'doc', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
const key = c.srcKeyForFile(f);
const party = c.addParty('CC');
const bin1 = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
const bin2 = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0008' });
c.place([key], bin1, 'transmittal');
tt.showTab('transmittal'); tt.render();
// Rename the bin → it becomes the copy folder name.
c.renameNode(bin1, 'My Custom Transmittal');
const renamed = c.getNode(bin1).name === 'My Custom Transmittal';
const folder = c.deriveTarget(f).transmittalFolder;
// The placed-file row is draggable (move) and carries a remove button.
tt.render();
const row = document.querySelector('#transmittalTree .tfile[data-key]');
const draggable = !!(row && row.draggable);
const hasRemove = !!(row && row.querySelector('.tfile__remove[data-act="untransmit"]'));
// Remove from the transmittal (click ✕).
row.querySelector('.tfile__remove').click();
const a1 = c.getAssignment(key);
const removed = !(a1 && a1.transmittalNodeId);
// Move = re-place onto another bin (what dropping on bin2 does).
c.place([key], bin2, 'transmittal');
const movedTo = (c.getAssignment(key) || {}).transmittalNodeId === bin2;
return { renamed, folder, draggable, hasRemove, removed, movedTo };
});
expect(r.renamed).toBe(true);
expect(r.folder).toBe('My Custom Transmittal'); // rename drives the filing folder
expect(r.draggable).toBe(true);
expect(r.hasRemove).toBe(true);
expect(r.removed).toBe(true);
expect(r.movedTo).toBe(true);
});