diff --git a/classifier/css/layout.css b/classifier/css/layout.css index bb0d45a..0cbb2be 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -444,7 +444,11 @@ /* 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 { 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__arrow { color: var(--text-muted); } .tfile__name { color: var(--text); } diff --git a/classifier/js/target-tree.js b/classifier/js/target-tree.js index 25c4864..c833818 100644 --- a/classifier/js/target-tree.js +++ b/classifier/js/target-tree.js @@ -158,14 +158,18 @@ 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) { 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.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 : '')); - orig.title = 'Click to preview'; + orig.title = 'Drag to another transmittal to move · click to preview'; row.appendChild(orig); row.appendChild(el('span', 'tfile__arrow', '→')); // Editable derived filename — edit it to re-file the item. @@ -175,6 +179,10 @@ name.placeholder = '(incomplete)'; name.title = d.errors.length ? d.errors.join('; ') : 'Edit this ZDDC filename to re-file the item'; 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); }); return box; @@ -421,7 +429,10 @@ var row = el('div', 'tnode__row'); row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)')); 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); if (shownFiles.length) wrap.appendChild(fileList(shownFiles)); return wrap; @@ -468,6 +479,7 @@ } return true; } + if (e.target.closest('[data-act]')) return false; // action button — not a preview if (e.target.closest('.tfile__name')) return false; var tf = e.target.closest('.tfile'); if (!tf || !tf.dataset.key) return false; @@ -544,6 +556,18 @@ render(); 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 === 'binadd') { var form = btn.closest('.binform'); diff --git a/tests/classify.spec.js b/tests/classify.spec.js index 90016c8..4fa35fe 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -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.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); +});