Compare commits
3 commits
8e10e5e5e6
...
3d553ce9d4
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d553ce9d4 | |||
| f66b9c5d55 | |||
| cfdf0f6db9 |
14 changed files with 1182 additions and 288 deletions
|
|
@ -581,10 +581,26 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
|||
/* The "By existing" catalog is now a normal in-flow tab panel. */
|
||||
#mdlTree { flex: 1; min-height: 0; }
|
||||
#mdlTree .seltable { height: 100%; }
|
||||
.mdl-rev__input {
|
||||
width: 8rem; padding: 0.15rem 0.35rem; border: 1px solid var(--border);
|
||||
/* Editable "From a list" cells — fill the column (the table is width:auto, so
|
||||
the column sizes to its header/content, and the input never widens it). */
|
||||
.mdl-rev__input, .fromlist-tn__input, .fromlist-title__input {
|
||||
width: 100%; min-width: 4rem; box-sizing: border-box;
|
||||
padding: 0.15rem 0.35rem; border: 1px solid var(--border);
|
||||
border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 0.8rem;
|
||||
}
|
||||
.fromlist-tn__input { font-family: var(--mono, monospace); }
|
||||
.mdl-rev__input.is-warn, .fromlist-tn__input.is-warn { border-color: var(--warning, #b8860b); }
|
||||
.fromlist-src { white-space: nowrap; }
|
||||
.src-badge {
|
||||
display: inline-block; margin-right: 0.25rem; padding: 0 0.3rem; border-radius: 0.7rem;
|
||||
font-size: 0.64rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em;
|
||||
border: 1px solid var(--border); color: var(--text-muted);
|
||||
}
|
||||
.src-badge--mdl { color: var(--primary); border-color: var(--primary); }
|
||||
.src-badge--arch { color: var(--text-secondary, var(--text-muted)); }
|
||||
.src-badge--pasted { color: var(--text-muted); }
|
||||
.src-badge--new { color: #fff; background: var(--warning, #b8860b); border-color: var(--warning, #b8860b); }
|
||||
.target-toggle { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--text-muted); cursor: pointer; }
|
||||
.seltable__extra { white-space: normal; }
|
||||
.mdlfile__name { font-size: 0.78rem; }
|
||||
#mdlPanel .tfile { gap: 0.3rem; align-items: center; padding: 0.05rem 0; cursor: grab; }
|
||||
|
|
@ -592,6 +608,26 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
|||
#mdlPanel .tfile__remove { opacity: 0.6; }
|
||||
#mdlPanel .tfile:hover .tfile__remove { opacity: 1; }
|
||||
|
||||
/* Paste + Match dialogs (inside the .copy-choice modal shell) */
|
||||
.scratch-modal__body { margin: 0 0 1rem; }
|
||||
.scratch-paste__ta {
|
||||
width: 100%; box-sizing: border-box; resize: vertical; font-family: var(--mono, monospace);
|
||||
font-size: 0.8rem; padding: 0.4rem 0.5rem; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--bg); color: var(--text);
|
||||
}
|
||||
.scratch-paste__preview, .scratch-match__list { max-height: 38vh; overflow: auto; margin-top: 0.6rem; }
|
||||
.scratch-preview__table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
|
||||
.scratch-preview__table th, .scratch-preview__table td { text-align: left; padding: 0.15rem 0.4rem; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.scratch-preview__table th { color: var(--text-muted); font-size: 0.66rem; text-transform: uppercase; }
|
||||
.scratch-preview__skip { color: var(--danger); font-size: 0.76rem; padding: 0.1rem 0; }
|
||||
.scratch-preview__more { color: var(--text-muted); font-size: 0.76rem; padding: 0.2rem 0; }
|
||||
.scratch-match__fuzzy { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--text-muted); }
|
||||
.scratch-match__row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8rem; padding: 0.15rem 0; cursor: pointer; }
|
||||
.scratch-match__file { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.scratch-match__arrow { color: var(--text-muted); }
|
||||
.scratch-match__tn { font-family: var(--mono, monospace); }
|
||||
.scratch-match__conf { color: var(--text-muted); font-size: 0.72rem; width: 3rem; text-align: right; }
|
||||
|
||||
/* ── MDL-from-archive overlay ───────────────────────────────────────────── */
|
||||
.mdl-overlay { position: fixed; inset: 0; z-index: 1100; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||
.mdl-overlay__box { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 10px 40px rgba(0,0,0,0.3); width: 100%; max-width: 1000px; height: 80vh; display: flex; flex-direction: column; }
|
||||
|
|
@ -613,12 +649,21 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
|||
}
|
||||
.seltable__count { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
|
||||
.seltable__scroll { flex: 1; min-height: 0; overflow: auto; }
|
||||
.seltable__table { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; }
|
||||
/* width:auto + nowrap cells → each column shrinks to fit its header/longest cell. */
|
||||
.seltable__table { border-collapse: separate; border-spacing: 0; width: auto; font-size: 0.82rem; }
|
||||
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
|
||||
.seltable__table thead th {
|
||||
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
|
||||
color: var(--text-muted); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
|
||||
}
|
||||
/* Per-column filter inputs: fill the column (min-width:0-ish) so they never
|
||||
force a column wider than its header/cells. */
|
||||
.seltable__table thead tr.seltable__filters th { padding: 0.08rem 0.3rem; }
|
||||
.seltable__colfilter {
|
||||
width: 100%; min-width: 2rem; box-sizing: border-box;
|
||||
padding: 0.1rem 0.3rem; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--bg); color: var(--text); font-size: 0.72rem; font-weight: 400; letter-spacing: 0; text-transform: none;
|
||||
}
|
||||
.seltable__row { cursor: pointer; user-select: none; }
|
||||
.seltable__row:hover { background: var(--bg-hover); }
|
||||
.seltable__row.is-selected { background: var(--primary-light, rgba(37,99,235,0.12)); }
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@
|
|||
function assignmentFor(key) {
|
||||
var a = state.assignments[key];
|
||||
if (!a) {
|
||||
a = { trackingNodeId: null, transmittalNodeId: null, mdlNodeId: null, excluded: false, titleOverride: null, titleFromDeliverable: true };
|
||||
a = { trackingNodeId: null, transmittalNodeId: null, excluded: false, titleOverride: null };
|
||||
state.assignments[key] = a;
|
||||
}
|
||||
return a;
|
||||
|
|
@ -134,22 +134,19 @@
|
|||
function getAssignment(key) { return state.assignments[key] || null; }
|
||||
function cleanAssignment(key) {
|
||||
var a = state.assignments[key];
|
||||
if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.mdlNodeId && !a.excluded && !a.titleOverride) {
|
||||
if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.excluded && !a.titleOverride) {
|
||||
delete state.assignments[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Place keys onto a node along one axis ('tracking' | 'transmittal').
|
||||
// nodeId null clears that axis.
|
||||
// nodeId null clears that axis. (The "From a list" tab also produces
|
||||
// 'tracking' placements — see assignFromRow.)
|
||||
function place(keys, nodeId, axis) {
|
||||
var field = axis === 'transmittal' ? 'transmittalNodeId' : axis === 'mdl' ? 'mdlNodeId' : 'trackingNodeId';
|
||||
var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId';
|
||||
keys.forEach(function (k) {
|
||||
var a = assignmentFor(k);
|
||||
a[field] = nodeId || null;
|
||||
// Tracking and MDL are alternative NAME sources — placing on one
|
||||
// clears the other so the file has a single name origin.
|
||||
if (axis === 'mdl' && nodeId) a.trackingNodeId = null;
|
||||
else if (axis === 'tracking' && nodeId) a.mdlNodeId = null;
|
||||
a.excluded = false; // placing un-excludes
|
||||
cleanAssignment(k);
|
||||
});
|
||||
|
|
@ -160,7 +157,7 @@
|
|||
keys.forEach(function (k) {
|
||||
var a = assignmentFor(k);
|
||||
a.excluded = !!excluded;
|
||||
if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; a.mdlNodeId = null; }
|
||||
if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; }
|
||||
cleanAssignment(k);
|
||||
});
|
||||
clearHashConflicts();
|
||||
|
|
@ -200,9 +197,8 @@
|
|||
});
|
||||
});
|
||||
});
|
||||
(state.mdlList || []).forEach(function (row) {
|
||||
nodeIndex[row.id] = { node: row, kind: 'mdl', parent: null };
|
||||
});
|
||||
// Scratch-list rows are NOT placement targets — drops materialize real
|
||||
// tracking-tree nodes (assignFromRow), so the list isn't indexed here.
|
||||
}
|
||||
function getNode(id) { return nodeIndex[id] ? nodeIndex[id].node : null; }
|
||||
function infoFor(id) { return nodeIndex[id] || null; }
|
||||
|
|
@ -335,26 +331,10 @@
|
|||
};
|
||||
if (out.excluded) return out;
|
||||
|
||||
// Axis 1 — NAME. An MDL deliverable (alternative to the tracking tree)
|
||||
// supplies the tracking number + title; its revision comes from the
|
||||
// classifier-local revision cell. Otherwise the tracking tree.
|
||||
if (a.mdlNodeId) {
|
||||
var mi = infoFor(a.mdlNodeId);
|
||||
if (mi && mi.kind === 'mdl') {
|
||||
var row = mi.node;
|
||||
out.tracking = row.trackingNumber || '';
|
||||
var ml = parseLeafLabel(row.revisionCell || '');
|
||||
out.revision = ml.revision; out.status = ml.status;
|
||||
out.trackingLeaf = true;
|
||||
if (!a.titleOverride && a.titleFromDeliverable !== false && row.title) out.title = row.title;
|
||||
if (!out.tracking) out.errors.push('deliverable has no tracking number');
|
||||
if (!out.revision) out.errors.push('set a revision for this deliverable (e.g. "A (IFR)")');
|
||||
else if (out.status && !zddc.isValidStatus(out.status)) out.errors.push('unknown status "' + out.status + '"');
|
||||
else if (!out.status) out.errors.push('revision needs a "(STATUS)" — e.g. "A (IFR)"');
|
||||
} else {
|
||||
out.errors.push('deliverable no longer loaded');
|
||||
}
|
||||
} else if (a.trackingNodeId) {
|
||||
// Axis 1 — NAME, always the tracking tree. The "From a list" tab drops
|
||||
// also produce tracking-tree placements (assignFromRow), so there is a
|
||||
// single name origin.
|
||||
if (a.trackingNodeId) {
|
||||
var ti = infoFor(a.trackingNodeId);
|
||||
if (ti && ti.kind === 'tracking') {
|
||||
var chain = trackingChain(ti); // [root … node]
|
||||
|
|
@ -454,10 +434,33 @@
|
|||
state.transmittalTree = obj.transmittalTree || [];
|
||||
state.outputName = obj.outputName || null;
|
||||
state.config = normalizeConfig(obj.config);
|
||||
state.mdlList = Array.isArray(obj.mdlList) ? obj.mdlList : [];
|
||||
state.mdlList = (Array.isArray(obj.mdlList) ? obj.mdlList : []).map(normalizeRow);
|
||||
rebuildIndex();
|
||||
migrateLegacyMdl(obj.mdlList); // BEFORE anything can prune; materializes old mdl placements
|
||||
notify();
|
||||
}
|
||||
// Pre-"From a list" workspaces stored a separate `mdlNodeId` axis pointing at
|
||||
// a row id. Materialize each into a real tracking placement so the
|
||||
// classification survives the model change, then drop the dead fields.
|
||||
function migrateLegacyMdl(rawRows) {
|
||||
var byOldId = Object.create(null);
|
||||
(rawRows || []).forEach(function (r) { if (r && r.id) byOldId[r.id] = r; });
|
||||
Object.keys(state.assignments).forEach(function (k) {
|
||||
var a = state.assignments[k];
|
||||
if (!a) return;
|
||||
if (a.mdlNodeId) {
|
||||
var r = byOldId[a.mdlNodeId];
|
||||
if (!a.trackingNodeId && r && r.trackingNumber) {
|
||||
var leaf = addTrackingPath(null, parseFolderLevels(r.trackingNumber + '_' + (r.revisionCell || PENDING_REV)));
|
||||
a.trackingNodeId = leaf;
|
||||
if (!a.titleOverride && r.title && a.titleFromDeliverable !== false) a.titleOverride = r.title;
|
||||
}
|
||||
delete a.mdlNodeId;
|
||||
}
|
||||
if ('titleFromDeliverable' in a) delete a.titleFromDeliverable;
|
||||
cleanAssignment(k);
|
||||
});
|
||||
}
|
||||
// Reset clears the CLASSIFICATION but keeps the pattern config — it's a
|
||||
// per-project setting, not part of the data being cleared.
|
||||
function reset() {
|
||||
|
|
@ -484,41 +487,192 @@
|
|||
function getTrackingFields() { return state.config.trackingFields; }
|
||||
function setConfig(c) { state.config = normalizeConfig(c); notify(); }
|
||||
|
||||
// ── MDL deliverables (the "By MDL" drop-target axis) ─────────────────────
|
||||
function setMdlList(rows) {
|
||||
state.mdlList = (rows || []).map(function (r) {
|
||||
return {
|
||||
id: r.id || uid(), party: r.party || '',
|
||||
trackingNumber: r.trackingNumber || '', title: r.title || '',
|
||||
inMdl: !!r.inMdl,
|
||||
archiveRevisions: Array.isArray(r.archiveRevisions) ? r.archiveRevisions : [],
|
||||
revisionCell: r.revisionCell || '',
|
||||
};
|
||||
// ── "From a list" scratch worklist ───────────────────────────────────────
|
||||
// A temporary list of known/typed tracking numbers (from the archive/MDL, a
|
||||
// paste, or a name-match). Dropping a file on a row MATERIALIZES a real
|
||||
// tracking-tree placement (assignFromRow) — the list is pure input, so it can
|
||||
// be cleared without losing any classification. `placed` is a transient
|
||||
// row→keys hint (not the source of truth, not serialized) used to re-stamp a
|
||||
// row's files when its tracking number / revision is edited.
|
||||
function rowSource(r) {
|
||||
if (r && r.source) return { mdl: !!r.source.mdl, archive: !!r.source.archive, pasted: !!r.source.pasted };
|
||||
return { mdl: !!(r && r.inMdl), archive: !!(r && Array.isArray(r.archiveRevisions) && r.archiveRevisions.length), pasted: false };
|
||||
}
|
||||
function normalizeRow(r) {
|
||||
r = r || {};
|
||||
return {
|
||||
id: r.id || uid(), party: r.party || '',
|
||||
trackingNumber: (r.trackingNumber || '').trim(), title: r.title || '',
|
||||
revisionCell: r.revisionCell || '',
|
||||
source: rowSource(r),
|
||||
archiveRevisions: Array.isArray(r.archiveRevisions) ? r.archiveRevisions : [],
|
||||
placed: Object.create(null),
|
||||
};
|
||||
}
|
||||
function setMdlList(rows) { state.mdlList = (rows || []).map(normalizeRow); notify(); }
|
||||
function appendMdlRows(rows) {
|
||||
var byTn = Object.create(null);
|
||||
state.mdlList.forEach(function (r) { if (r.trackingNumber) byTn[r.trackingNumber] = r; });
|
||||
(rows || []).forEach(function (raw) {
|
||||
var r = normalizeRow(raw), ex = r.trackingNumber ? byTn[r.trackingNumber] : null;
|
||||
if (ex) {
|
||||
if (!ex.revisionCell && r.revisionCell) ex.revisionCell = r.revisionCell;
|
||||
if (!ex.title && r.title) ex.title = r.title;
|
||||
ex.source.mdl = ex.source.mdl || r.source.mdl;
|
||||
ex.source.archive = ex.source.archive || r.source.archive;
|
||||
ex.source.pasted = ex.source.pasted || r.source.pasted;
|
||||
if (r.archiveRevisions.length && !ex.archiveRevisions.length) ex.archiveRevisions = r.archiveRevisions;
|
||||
} else {
|
||||
state.mdlList.push(r);
|
||||
if (r.trackingNumber) byTn[r.trackingNumber] = r;
|
||||
}
|
||||
});
|
||||
// Drop placements pointing at deliverables no longer loaded.
|
||||
var valid = Object.create(null);
|
||||
state.mdlList.forEach(function (r) { valid[r.id] = true; });
|
||||
Object.keys(state.assignments).forEach(function (k) {
|
||||
var a = state.assignments[k];
|
||||
if (a.mdlNodeId && !valid[a.mdlNodeId]) { a.mdlNodeId = null; cleanAssignment(k); }
|
||||
});
|
||||
rebuildIndex();
|
||||
notify();
|
||||
}
|
||||
function clearMdlList() { state.mdlList = []; notify(); } // rows only — assignments survive
|
||||
function getMdlList() { return state.mdlList; }
|
||||
function getMdlRow(id) { var i = infoFor(id); return (i && i.kind === 'mdl') ? i.node : null; }
|
||||
function getMdlRow(id) { return state.mdlList.filter(function (r) { return r.id === id; })[0] || null; }
|
||||
|
||||
// Build (creating folders as needed) the tracking-tree leaf a row points at:
|
||||
// "<tracking>_<rev (status)>". A tracking number's last segment is an
|
||||
// ancestor and the revision is ALWAYS the leaf, so a blank revision gets a
|
||||
// PENDING_REV placeholder leaf (the file shows incomplete until set; the
|
||||
// placeholder is pruned when the real revision lands).
|
||||
var PENDING_REV = 'pending';
|
||||
function leafForRow(row) {
|
||||
var tn = (row.trackingNumber || '').trim();
|
||||
if (!tn) return null;
|
||||
var rev = (row.revisionCell || '').trim() || PENDING_REV;
|
||||
return addTrackingPath(null, parseFolderLevels(tn + '_' + rev));
|
||||
}
|
||||
function assignFromRow(keys, row) {
|
||||
var leaf = leafForRow(row);
|
||||
if (!leaf || !keys || !keys.length) return;
|
||||
place(keys, leaf, 'tracking');
|
||||
keys.forEach(function (k) {
|
||||
row.placed[k] = true;
|
||||
if (row.title && row.title.trim()) {
|
||||
var a = state.assignments[k];
|
||||
if (a && !a.titleOverride) setTitleOverride(k, row.title);
|
||||
}
|
||||
});
|
||||
notify();
|
||||
}
|
||||
function nodeHasFiles(nodeId) {
|
||||
for (var k in state.assignments) { if (state.assignments[k].trackingNodeId === nodeId) return true; }
|
||||
return false;
|
||||
}
|
||||
// Delete a now-empty tracking leaf and any ancestors it leaves empty (so a
|
||||
// re-stamp doesn't litter the By-tracking tree with stale folders).
|
||||
function pruneEmptyTrackingChain(nodeId) {
|
||||
var info = infoFor(nodeId);
|
||||
while (info && info.kind === 'tracking') {
|
||||
if ((info.node.children || []).length || nodeHasFiles(info.node.id)) break;
|
||||
var parent = info.parent; // parent NODE (or null)
|
||||
deleteNode(info.node.id); // rebuilds the index
|
||||
info = parent ? infoFor(parent.id) : null;
|
||||
}
|
||||
}
|
||||
// Re-point a row's already-dropped files at the row's current leaf (after its
|
||||
// tracking number or revision was edited). Skips keys the user has since
|
||||
// un-placed elsewhere; prunes the leaves it empties.
|
||||
function restampRow(row) {
|
||||
var keys = Object.keys(row.placed || {});
|
||||
if (!keys.length) return;
|
||||
var leaf = leafForRow(row);
|
||||
if (!leaf) return;
|
||||
var old = Object.create(null);
|
||||
keys.forEach(function (k) {
|
||||
var a = state.assignments[k];
|
||||
if (a && a.trackingNodeId) { if (a.trackingNodeId !== leaf) old[a.trackingNodeId] = true; a.trackingNodeId = leaf; }
|
||||
else delete row.placed[k]; // user un-placed it elsewhere — don't resurrect
|
||||
});
|
||||
clearHashConflicts();
|
||||
Object.keys(old).forEach(pruneEmptyTrackingChain);
|
||||
notify();
|
||||
}
|
||||
function unassignRowFile(row, key) {
|
||||
var a = state.assignments[key], old = a ? a.trackingNodeId : null;
|
||||
if (row && row.placed) delete row.placed[key];
|
||||
place([key], null, 'tracking');
|
||||
if (old) pruneEmptyTrackingChain(old);
|
||||
}
|
||||
function setRowTracking(rowId, tn) {
|
||||
var r = getMdlRow(rowId); if (!r) return;
|
||||
r.trackingNumber = (tn == null ? '' : String(tn)).trim();
|
||||
restampRow(r); notify();
|
||||
}
|
||||
function setRowTitle(rowId, title) {
|
||||
var r = getMdlRow(rowId); if (!r) return;
|
||||
r.title = (title == null ? '' : String(title));
|
||||
Object.keys(r.placed || {}).forEach(function (k) { if (state.assignments[k]) setTitleOverride(k, r.title); });
|
||||
notify();
|
||||
}
|
||||
function setRevisionCell(rowId, value) { setRevisionCells([rowId], value); }
|
||||
function setRevisionCells(rowIds, value) {
|
||||
var set = Object.create(null); (rowIds || []).forEach(function (i) { set[i] = true; });
|
||||
var changed = false;
|
||||
state.mdlList.forEach(function (r) { if (set[r.id]) { r.revisionCell = (value == null ? '' : String(value)); changed = true; } });
|
||||
state.mdlList.forEach(function (r) {
|
||||
if (set[r.id]) { r.revisionCell = (value == null ? '' : String(value)); restampRow(r); changed = true; }
|
||||
});
|
||||
if (changed) notify();
|
||||
}
|
||||
function setTitleFromDeliverable(key, fromDeliverable) {
|
||||
var a = assignmentFor(key);
|
||||
a.titleFromDeliverable = !!fromDeliverable;
|
||||
cleanAssignment(key);
|
||||
notify();
|
||||
|
||||
// ── paste parsing + name matching (pure helpers, unit-tested) ─────────────
|
||||
// Parse Excel/TSV text into scratch rows. Columns: Tracking ⇥ Rev(Status) ⇥
|
||||
// Title; a 4th bare-status column merges into the revision; a lone cell that
|
||||
// parses as a full ZDDC filename is split; a header row is skipped.
|
||||
function parsePastedRows(text) {
|
||||
function unq(s) {
|
||||
s = (s == null ? '' : String(s)).trim();
|
||||
if (s.length >= 2 && s.charAt(0) === '"' && s.charAt(s.length - 1) === '"') s = s.slice(1, -1).replace(/""/g, '"');
|
||||
return s.trim();
|
||||
}
|
||||
var lines = String(text == null ? '' : text).replace(/\r\n?/g, '\n').split('\n');
|
||||
var rows = [], skipped = [], sawData = false;
|
||||
lines.forEach(function (raw, i) {
|
||||
if (!raw.trim()) return;
|
||||
var cells = raw.split('\t').map(unq);
|
||||
var c0 = cells[0] || '';
|
||||
if (!sawData && cells.length > 1 && /^(tracking|number|no\.?|doc(ument)?|drawing|item)\b/i.test(c0) && c0.indexOf('-') === -1) {
|
||||
return; // header row
|
||||
}
|
||||
var tracking = '', rev = '', title = '';
|
||||
if (cells.length === 1) {
|
||||
var p = window.zddc.parseFilename(c0);
|
||||
if (p && p.valid && p.trackingNumber) { tracking = p.trackingNumber; rev = p.revision + (p.status ? ' (' + p.status + ')' : ''); title = p.title || ''; }
|
||||
else tracking = c0;
|
||||
} else {
|
||||
tracking = c0;
|
||||
if (cells.length >= 4 && cells[2] && window.zddc.isValidStatus(cells[2])) { rev = (cells[1] + ' (' + cells[2] + ')').trim(); title = cells[3] || ''; }
|
||||
else { rev = cells[1] || ''; title = cells[2] || ''; }
|
||||
}
|
||||
if (!tracking) { skipped.push({ line: i + 1, reason: 'no tracking number', text: raw }); return; }
|
||||
sawData = true;
|
||||
rows.push({ trackingNumber: tracking, revisionCell: rev.trim(), title: title, source: { pasted: true } });
|
||||
});
|
||||
return { rows: rows, skipped: skipped };
|
||||
}
|
||||
function normTok(s) { return String(s == null ? '' : s).toUpperCase().replace(/[^A-Z0-9]/g, ''); }
|
||||
// Propose row matches for source files by finding a row whose tracking number
|
||||
// appears in the filename. opts.fuzzy also matches on the digit-run.
|
||||
function proposeMatches(files, rows, opts) {
|
||||
opts = opts || {};
|
||||
var out = [];
|
||||
(files || []).forEach(function (f) {
|
||||
var full = zddc.joinExtension(f.originalFilename, f.extension);
|
||||
var nameNorm = normTok(full), nameDigits = nameNorm.replace(/[^0-9]/g, ''), best = null;
|
||||
(rows || []).forEach(function (r) {
|
||||
var tn = r.trackingNumber || ''; if (!tn) return;
|
||||
var tnNorm = normTok(tn), conf = 0;
|
||||
if (full.indexOf(tn) !== -1) conf = 1;
|
||||
else if (tnNorm && nameNorm.indexOf(tnNorm) !== -1) conf = 0.8;
|
||||
else if (opts.fuzzy) { var d = tnNorm.replace(/[^0-9]/g, ''); if (d && nameDigits.indexOf(d) !== -1) conf = 0.5; }
|
||||
if (conf && (!best || conf > best.confidence)) best = { row: r, confidence: conf };
|
||||
});
|
||||
if (best) out.push({ file: f, row: best.row, confidence: best.confidence });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── add-folder pattern expansion ─────────────────────────────────────────
|
||||
|
|
@ -695,9 +849,12 @@
|
|||
transmittalRecord: transmittalRecord,
|
||||
findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin,
|
||||
getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields,
|
||||
setMdlList: setMdlList, getMdlList: getMdlList, getMdlRow: getMdlRow,
|
||||
setMdlList: setMdlList, appendMdlRows: appendMdlRows, clearMdlList: clearMdlList,
|
||||
getMdlList: getMdlList, getMdlRow: getMdlRow,
|
||||
assignFromRow: assignFromRow, unassignRowFile: unassignRowFile,
|
||||
setRowTracking: setRowTracking, setRowTitle: setRowTitle,
|
||||
setRevisionCell: setRevisionCell, setRevisionCells: setRevisionCells,
|
||||
setTitleFromDeliverable: setTitleFromDeliverable,
|
||||
parsePastedRows: parsePastedRows, proposeMatches: proposeMatches,
|
||||
getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
|
||||
getTransmittalTree: function () { return state.transmittalTree; },
|
||||
// derive + reverse
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@
|
|||
var openForm = null; // { partyId, slot } when a bin form is open
|
||||
var initialized = false;
|
||||
var currentTab = 'tracking'; // 'tracking' | 'existing' | 'transmittal' — active tab
|
||||
var mdlTable = null; // the seltable controller for the catalog
|
||||
var mdlPlaced = {}; // latest placed.mdl map (read by the placed-file cell)
|
||||
var mdlTable = null; // the seltable controller for the "From a list" tab
|
||||
var mdlPlaced = {}; // trackingNumber -> placed files (read by the Files cell)
|
||||
var hideAssigned = false; // "Hide assigned" toggle in the From-a-list toolbar
|
||||
var listScanned = false; // a Load has run this session (drives the "new" badge)
|
||||
|
||||
function init() {
|
||||
if (initialized) return;
|
||||
|
|
@ -37,6 +39,10 @@
|
|||
transmittalTree: document.getElementById('transmittalTree'),
|
||||
mdlTree: document.getElementById('mdlTree'),
|
||||
loadMdlBtn: document.getElementById('loadMdlBtn'),
|
||||
pasteRowsBtn: document.getElementById('pasteRowsBtn'),
|
||||
matchNamesBtn: document.getElementById('matchNamesBtn'),
|
||||
clearListBtn: document.getElementById('clearListBtn'),
|
||||
hideAssignedToggle: document.getElementById('hideAssignedToggle'),
|
||||
addTrackingRootBtn: document.getElementById('addTrackingRootBtn'),
|
||||
addPartyBtn: document.getElementById('addPartyBtn'),
|
||||
stats: document.getElementById('classifyStats'),
|
||||
|
|
@ -46,6 +52,25 @@
|
|||
if (els.existingTab) els.existingTab.addEventListener('click', function () { showTab('existing'); });
|
||||
els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); });
|
||||
if (els.loadMdlBtn) els.loadMdlBtn.addEventListener('click', loadMdl);
|
||||
if (els.pasteRowsBtn) els.pasteRowsBtn.addEventListener('click', function () { openPasteDialog(''); });
|
||||
if (els.matchNamesBtn) els.matchNamesBtn.addEventListener('click', openMatchDialog);
|
||||
if (els.clearListBtn) els.clearListBtn.addEventListener('click', function () {
|
||||
if (!C().getMdlList().length) return;
|
||||
C().clearMdlList();
|
||||
window.zddc.toast('List cleared — every assignment is kept (see By tracking number).', 'info');
|
||||
});
|
||||
if (els.hideAssignedToggle) els.hideAssignedToggle.addEventListener('change', function () {
|
||||
hideAssigned = !!els.hideAssignedToggle.checked;
|
||||
if (mdlTable) mdlTable.renderBody();
|
||||
});
|
||||
// Ctrl-V anywhere on the From-a-list panel opens the paste dialog prefilled.
|
||||
if (els.mdlPanel) els.mdlPanel.addEventListener('paste', function (e) {
|
||||
if (currentTab !== 'existing') return;
|
||||
if (e.target && e.target.closest('input, textarea')) return; // let real inputs paste
|
||||
var t = (e.clipboardData || window.clipboardData);
|
||||
var text = t ? t.getData('text') : '';
|
||||
if (text) { e.preventDefault(); openPasteDialog(text); }
|
||||
});
|
||||
els.addTrackingRootBtn.addEventListener('click', function () {
|
||||
var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n'
|
||||
+ 'Brace patterns expand: BMB-{PM,EL}-{0001-0002,0005}_A (IFR)', '');
|
||||
|
|
@ -86,15 +111,20 @@
|
|||
}
|
||||
// One pass: group files by the node they're placed in, per axis.
|
||||
function buildPlaced(files) {
|
||||
var c = C(), byT = {}, byX = {}, byM = {};
|
||||
var c = C(), byT = {}, byX = {}, byTn = {};
|
||||
files.forEach(function (f) {
|
||||
var a = c.getAssignment(c.srcKeyForFile(f));
|
||||
if (!a) return;
|
||||
if (a.trackingNodeId) (byT[a.trackingNodeId] = byT[a.trackingNodeId] || []).push(f);
|
||||
if (a.trackingNodeId) {
|
||||
(byT[a.trackingNodeId] = byT[a.trackingNodeId] || []).push(f);
|
||||
// Also index by tracking NUMBER so a "From a list" row can show
|
||||
// the files placed under it (a row is a tracking number, not a node).
|
||||
var tn = c.deriveTarget(f).tracking;
|
||||
if (tn) (byTn[tn] = byTn[tn] || []).push(f);
|
||||
}
|
||||
if (a.transmittalNodeId) (byX[a.transmittalNodeId] = byX[a.transmittalNodeId] || []).push(f);
|
||||
if (a.mdlNodeId) (byM[a.mdlNodeId] = byM[a.mdlNodeId] || []).push(f);
|
||||
});
|
||||
return { tracking: byT, transmittal: byX, mdl: byM };
|
||||
return { tracking: byT, transmittal: byX, byTracking: byTn };
|
||||
}
|
||||
|
||||
function showTab(which) {
|
||||
|
|
@ -110,8 +140,8 @@
|
|||
// with the active tab — re-render the left tree.
|
||||
reRenderSource();
|
||||
}
|
||||
// The active axis is the catalog ('mdl') on the "By existing" tab, else the tab's.
|
||||
function activeAxis() { return currentTab === 'existing' ? 'mdl' : (currentTab === 'transmittal' ? 'transmittal' : 'tracking'); }
|
||||
// "From a list" drops materialize tracking placements, so its axis is 'tracking'.
|
||||
function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : 'tracking'; }
|
||||
function reRenderSource() { if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); }
|
||||
|
||||
// Expand a brace pattern into folder names and create them (confirming a
|
||||
|
|
@ -137,7 +167,7 @@
|
|||
var placed = buildPlaced(files);
|
||||
renderTrackingInto(els.trackingTree, C().getTrackingTree(), placed.tracking);
|
||||
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal);
|
||||
renderMdlInto(placed.mdl);
|
||||
renderMdlInto(placed.byTracking);
|
||||
renderStats(files);
|
||||
}
|
||||
|
||||
|
|
@ -471,47 +501,43 @@
|
|||
return form;
|
||||
}
|
||||
|
||||
// ── By MDL (deliverables as drop targets via the shared seltable) ───────
|
||||
function renderMdlInto(placedMdl) {
|
||||
mdlPlaced = placedMdl || {};
|
||||
// ── "From a list" (scratch worklist via the shared seltable) ────────────
|
||||
function renderMdlInto(placedByTracking) {
|
||||
mdlPlaced = placedByTracking || {};
|
||||
if (!C().getMdlList().length) {
|
||||
mdlTable = null;
|
||||
els.mdlTree.textContent = '';
|
||||
els.mdlTree.appendChild(el('div', 'target-empty', 'Nothing loaded yet — “Load…”, tick the directories to scan, and their existing files + MDL deliverables appear here (one row per tracking number, latest revision).'));
|
||||
els.mdlTree.appendChild(el('div', 'target-empty', 'Empty — “Load…” numbers from the archive/MDL, “Paste rows…” from Excel, or “⚡ Match names”. Then drag files onto a row to name them. The list is a scratch pad — clearing it keeps every assignment (see By tracking number).'));
|
||||
return;
|
||||
}
|
||||
ensureMdlTable();
|
||||
mdlTable.renderBody();
|
||||
}
|
||||
function rowPlaced(r) { var f = mdlPlaced[r.trackingNumber]; return f && f.length ? f : null; }
|
||||
function ensureMdlTable() {
|
||||
if (mdlTable) return mdlTable;
|
||||
var c = C();
|
||||
// One column per configured tracking-number field (split positionally),
|
||||
// then Title, MDL (✓), Archive revisions (informational), and the editable
|
||||
// classifier-local Revision. Each column has its own autofilter.
|
||||
var cols = c.getTrackingFields().map(function (f, i) {
|
||||
return { key: 'f' + i, title: f.name, get: function (r) { return (r.trackingNumber || '').split('-')[i] || ''; } };
|
||||
});
|
||||
cols.push({ key: 'title', title: 'Title', get: function (r) { return r.title || ''; } });
|
||||
cols.push({ key: 'mdl', title: 'MDL', cls: 'catalog-mdl', get: function (r) { return r.inMdl ? '✓' : ''; } });
|
||||
cols.push({ key: 'latest', title: 'Latest rev', get: function (r) { return latestRevOf(r.archiveRevisions); } });
|
||||
cols.push({
|
||||
key: 'rev', title: 'Revision', cls: 'mdl-rev', get: function (r) { return r.revisionCell; },
|
||||
render: function (r, td) {
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'text'; inp.className = 'mdl-rev__input'; inp.value = r.revisionCell || '';
|
||||
inp.placeholder = 'A (IFR)'; inp.setAttribute('data-no-select', '');
|
||||
inp.addEventListener('change', function () { c.setRevisionCell(r.id, inp.value.trim()); });
|
||||
td.appendChild(inp);
|
||||
},
|
||||
});
|
||||
var cols = [
|
||||
{ key: 'tn', title: 'Tracking number', cls: 'fromlist-tn', get: function (r) { return r.trackingNumber || ''; },
|
||||
render: function (r, td) { editCell(td, 'fromlist-tn__input', r.trackingNumber, 'ACME-…-0001', function (v) { c.setRowTracking(r.id, v); }, tnWarn(r)); } },
|
||||
{ key: 'title', title: 'Title', cls: 'fromlist-title', get: function (r) { return r.title || ''; },
|
||||
render: function (r, td) { editCell(td, 'fromlist-title__input', r.title, 'title', function (v) { c.setRowTitle(r.id, v); }); } },
|
||||
{ key: 'src', title: 'Source', cls: 'fromlist-src', get: function (r) { var s = r.source || {}; return [s.mdl ? 'mdl' : '', s.archive ? 'arch' : '', s.pasted ? 'pasted' : ''].filter(Boolean).join(' '); },
|
||||
render: function (r, td) { renderSource(r, td); } },
|
||||
{ key: 'latest', title: 'Latest rev', get: function (r) { return latestRevOf(r.archiveRevisions); } },
|
||||
{ key: 'rev', title: 'Revision', cls: 'mdl-rev', get: function (r) { return r.revisionCell; },
|
||||
render: function (r, td) { editCell(td, 'mdl-rev__input', r.revisionCell, 'A (IFR)', function (v) { c.setRevisionCell(r.id, v); }); } },
|
||||
];
|
||||
mdlTable = window.app.modules.seltable.create({
|
||||
container: els.mdlTree,
|
||||
extraTitle: 'Files',
|
||||
rows: function () { return c.getMdlList(); },
|
||||
rows: function () {
|
||||
var list = c.getMdlList();
|
||||
return hideAssigned ? list.filter(function (r) { return !rowPlaced(r); }) : list;
|
||||
},
|
||||
rowId: function (r) { return r.id; },
|
||||
columns: cols,
|
||||
onRowDrop: function (rowId, keys) { c.place(keys, rowId, 'mdl'); },
|
||||
onRowDrop: function (rowId, keys) { var row = c.getMdlRow(rowId); if (row) c.assignFromRow(keys, row); },
|
||||
onActivate: function (ids) {
|
||||
if (!ids.length) return;
|
||||
var v = prompt('Set the revision on ' + ids.length + ' selected row(s) (e.g. "A (IFR)"):', '');
|
||||
|
|
@ -522,8 +548,37 @@
|
|||
mdlTable.render();
|
||||
return mdlTable;
|
||||
}
|
||||
// An editable seltable cell: an <input> that commits on change. `warn` is an
|
||||
// optional tooltip that flags (without blocking) a questionable value.
|
||||
function editCell(td, cls, value, placeholder, onCommit, warn) {
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'text'; inp.className = cls + (warn ? ' is-warn' : ''); inp.value = value || '';
|
||||
inp.placeholder = placeholder || ''; inp.spellcheck = false; inp.setAttribute('data-no-select', '');
|
||||
if (warn) inp.title = warn;
|
||||
inp.addEventListener('change', function () { onCommit(inp.value.trim()); });
|
||||
td.appendChild(inp);
|
||||
}
|
||||
function tnWarn(r) {
|
||||
var tn = (r.trackingNumber || '').trim(); if (!tn) return '';
|
||||
var n = tn.split('-').length, want = C().getTrackingFields().length;
|
||||
return n < want - 1 || n > want ? ('Has ' + n + ' segments; the pattern expects ' + want + '.') : '';
|
||||
}
|
||||
function renderSource(row, td) {
|
||||
var s = row.source || {};
|
||||
if (s.mdl) td.appendChild(el('span', 'src-badge src-badge--mdl', 'MDL'));
|
||||
if (s.archive) td.appendChild(el('span', 'src-badge src-badge--arch', 'arch'));
|
||||
if (s.pasted && !s.mdl && !s.archive) {
|
||||
// A pasted number matching nothing known: a likely typo / a brand-new number.
|
||||
var isNew = listScanned;
|
||||
var b = el('span', 'src-badge src-badge--new', isNew ? 'new' : 'unverified');
|
||||
b.title = isNew ? 'This tracking number isn’t in the scanned archive/MDL — you’re inventing it.' : 'Not checked against the archive/MDL — Load a directory to verify.';
|
||||
td.appendChild(b);
|
||||
} else if (s.pasted) {
|
||||
td.appendChild(el('span', 'src-badge src-badge--pasted', 'pasted'));
|
||||
}
|
||||
}
|
||||
function renderMdlPlaced(row, td) {
|
||||
var c = C(), files = mdlPlaced[row.id] || [];
|
||||
var c = C(), files = rowPlaced(row) || [];
|
||||
files.forEach(function (f) {
|
||||
var d = c.deriveTarget(f);
|
||||
var a = c.getAssignment(d.key) || {};
|
||||
|
|
@ -533,13 +588,14 @@
|
|||
var nm = el('span', 'mdlfile__name', d.filename || '(set a revision)');
|
||||
nm.title = 'from ' + f.originalFilename + (f.extension ? '.' + f.extension : '');
|
||||
line.appendChild(nm);
|
||||
var tgl = el('button', 'tnode__act', a.titleFromDeliverable === false ? 'Title: file' : 'Title: MDL');
|
||||
tgl.title = 'Use the deliverable’s title or the file’s own';
|
||||
tgl.addEventListener('click', function () { c.setTitleFromDeliverable(d.key, a.titleFromDeliverable === false); });
|
||||
var usingRow = a.titleOverride != null && row.title && a.titleOverride === row.title.trim();
|
||||
var tgl = el('button', 'tnode__act', usingRow ? 'Title: row' : 'Title: file');
|
||||
tgl.title = 'Use the row’s title or the file’s own';
|
||||
tgl.addEventListener('click', function () { c.setTitleOverride(d.key, usingRow ? '' : row.title); });
|
||||
line.appendChild(tgl);
|
||||
var rm = el('button', 'tnode__act tfile__remove', '✕');
|
||||
rm.title = 'Remove from this deliverable';
|
||||
rm.addEventListener('click', function () { c.place([d.key], null, 'mdl'); });
|
||||
rm.title = 'Remove this file from the row';
|
||||
rm.addEventListener('click', function () { c.unassignRowFile(row, d.key); });
|
||||
line.appendChild(rm);
|
||||
td.appendChild(line);
|
||||
});
|
||||
|
|
@ -642,13 +698,123 @@
|
|||
}
|
||||
}
|
||||
function finishLoad(rows) {
|
||||
C().setMdlList(rows);
|
||||
listScanned = true;
|
||||
C().appendMdlRows(rows); // APPEND — the list accumulates across batches
|
||||
showTab('existing');
|
||||
window.zddc.toast(rows.length
|
||||
? ('Catalog: ' + rows.length + ' tracking number' + (rows.length === 1 ? '' : 's') + ' from the selected directories. Drag files on, set revisions.')
|
||||
? ('Added ' + rows.length + ' tracking number' + (rows.length === 1 ? '' : 's') + ' from the selected directories. Drag files on, set revisions.')
|
||||
: 'No files or deliverables in the selected directories.', rows.length ? 'success' : 'warning');
|
||||
}
|
||||
|
||||
// ── paste + match dialogs (reuse the .copy-choice modal shell) ──────────
|
||||
function scratchModal(titleText, hintText) {
|
||||
var done = false;
|
||||
function close() { if (done) return; done = true; document.removeEventListener('keydown', onKey); back.remove(); }
|
||||
function onKey(e) { if (e.key === 'Escape') close(); }
|
||||
var back = el('div', 'copy-choice__backdrop');
|
||||
var box = el('div', 'copy-choice copy-choice--wide');
|
||||
box.appendChild(el('h3', null, titleText));
|
||||
if (hintText) box.appendChild(el('p', null, hintText));
|
||||
var body = el('div', 'scratch-modal__body'); box.appendChild(body);
|
||||
var foot = el('div', 'copy-choice__btns'); box.appendChild(foot);
|
||||
back.appendChild(box);
|
||||
back.addEventListener('click', function (e) { if (e.target === back) close(); });
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.body.appendChild(back);
|
||||
return { body: body, foot: foot, close: close };
|
||||
}
|
||||
function openPasteDialog(prefill) {
|
||||
var c = C();
|
||||
var m = scratchModal('Paste rows from Excel', 'Columns: Tracking · Rev (Status) · Title — tab-separated, as Excel copies. A header row is skipped; a pasted full filename is split.');
|
||||
var ta = document.createElement('textarea');
|
||||
ta.className = 'scratch-paste__ta'; ta.rows = 6; ta.spellcheck = false;
|
||||
ta.placeholder = 'ACME-AR-DWG-0001\tA (IFR)\tFloor plan';
|
||||
ta.value = prefill || '';
|
||||
m.body.appendChild(ta);
|
||||
var preview = el('div', 'scratch-paste__preview'); m.body.appendChild(preview);
|
||||
var add = el('button', 'btn btn-primary', 'Add rows'); add.disabled = true;
|
||||
var cancel = el('button', 'btn btn-secondary', 'Cancel'); cancel.addEventListener('click', m.close);
|
||||
m.foot.appendChild(add); m.foot.appendChild(cancel);
|
||||
var parsed = { rows: [], skipped: [] };
|
||||
function refresh() {
|
||||
parsed = c.parsePastedRows(ta.value);
|
||||
preview.textContent = '';
|
||||
if (parsed.rows.length) {
|
||||
var tbl = el('table', 'scratch-preview__table');
|
||||
var head = el('tr'); ['Tracking number', 'Revision', 'Title'].forEach(function (h) { head.appendChild(el('th', null, h)); }); tbl.appendChild(head);
|
||||
parsed.rows.slice(0, 50).forEach(function (r) {
|
||||
var tr = el('tr');
|
||||
tr.appendChild(el('td', null, r.trackingNumber));
|
||||
tr.appendChild(el('td', null, r.revisionCell || ''));
|
||||
tr.appendChild(el('td', null, r.title || ''));
|
||||
tbl.appendChild(tr);
|
||||
});
|
||||
preview.appendChild(tbl);
|
||||
if (parsed.rows.length > 50) preview.appendChild(el('div', 'scratch-preview__more', '…and ' + (parsed.rows.length - 50) + ' more'));
|
||||
}
|
||||
parsed.skipped.forEach(function (s) { preview.appendChild(el('div', 'scratch-preview__skip', 'Line ' + s.line + ' skipped — ' + s.reason)); });
|
||||
add.disabled = !parsed.rows.length;
|
||||
add.textContent = parsed.rows.length ? ('Add ' + parsed.rows.length + ' row' + (parsed.rows.length === 1 ? '' : 's')) : 'Add rows';
|
||||
}
|
||||
add.addEventListener('click', function () {
|
||||
var n = parsed.rows.length;
|
||||
c.appendMdlRows(parsed.rows);
|
||||
m.close(); showTab('existing');
|
||||
window.zddc.toast('Added ' + n + ' pasted row' + (n === 1 ? '' : 's') + '.', 'success');
|
||||
});
|
||||
ta.addEventListener('input', refresh);
|
||||
refresh(); ta.focus();
|
||||
}
|
||||
function openMatchDialog() {
|
||||
var c = C();
|
||||
var rows = c.getMdlList();
|
||||
if (!rows.length) { window.zddc.toast('Load or paste some tracking numbers first.', 'warning'); return; }
|
||||
var files = allFiles().filter(function (f) {
|
||||
var a = c.getAssignment(c.srcKeyForFile(f));
|
||||
return !(a && (a.trackingNodeId || a.excluded));
|
||||
});
|
||||
if (!files.length) { window.zddc.toast('No unassigned files to match.', 'info'); return; }
|
||||
var m = scratchModal('Match names', 'Files whose name contains a known tracking number. Review, then assign the checked matches.');
|
||||
var opts = { fuzzy: false };
|
||||
var fuzzyLbl = el('label', 'scratch-match__fuzzy');
|
||||
var fuzzy = document.createElement('input'); fuzzy.type = 'checkbox';
|
||||
fuzzyLbl.appendChild(fuzzy); fuzzyLbl.appendChild(document.createTextNode(' Looser matching (digits only)'));
|
||||
m.body.appendChild(fuzzyLbl);
|
||||
var list = el('div', 'scratch-match__list'); m.body.appendChild(list);
|
||||
var accept = el('button', 'btn btn-primary', 'Assign');
|
||||
var cancel = el('button', 'btn btn-secondary', 'Cancel'); cancel.addEventListener('click', m.close);
|
||||
m.foot.appendChild(accept); m.foot.appendChild(cancel);
|
||||
var proposals = [];
|
||||
function refresh() {
|
||||
proposals = c.proposeMatches(files, rows, opts);
|
||||
list.textContent = '';
|
||||
if (!proposals.length) { list.appendChild(el('div', 'scratch-preview__skip', 'No matches found.')); accept.disabled = true; accept.textContent = 'Assign'; return; }
|
||||
proposals.forEach(function (p, i) {
|
||||
var rowEl = el('label', 'scratch-match__row');
|
||||
var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = true; cb.dataset.i = i;
|
||||
rowEl.appendChild(cb);
|
||||
rowEl.appendChild(el('span', 'scratch-match__file', zddc.joinExtension(p.file.originalFilename, p.file.extension)));
|
||||
rowEl.appendChild(el('span', 'scratch-match__arrow', '→'));
|
||||
rowEl.appendChild(el('span', 'scratch-match__tn', p.row.trackingNumber));
|
||||
rowEl.appendChild(el('span', 'scratch-match__conf', Math.round(p.confidence * 100) + '%'));
|
||||
list.appendChild(rowEl);
|
||||
});
|
||||
accept.disabled = false; accept.textContent = 'Assign ' + proposals.length;
|
||||
}
|
||||
accept.addEventListener('click', function () {
|
||||
var n = 0;
|
||||
Array.prototype.forEach.call(list.querySelectorAll('input[type=checkbox]'), function (cb) {
|
||||
if (!cb.checked) return;
|
||||
var p = proposals[Number(cb.dataset.i)];
|
||||
if (p) { c.assignFromRow([c.srcKeyForFile(p.file)], p.row); n++; }
|
||||
});
|
||||
m.close(); showTab('existing');
|
||||
window.zddc.toast('Assigned ' + n + ' file' + (n === 1 ? '' : 's') + ' by name match.', n ? 'success' : 'info');
|
||||
});
|
||||
fuzzy.addEventListener('change', function () { opts.fuzzy = fuzzy.checked; refresh(); });
|
||||
refresh();
|
||||
}
|
||||
|
||||
// ── events ─────────────────────────────────────────────────────────────
|
||||
function closestNodeId(target) {
|
||||
var n = target.closest('[data-id]');
|
||||
|
|
@ -850,10 +1016,7 @@
|
|||
function reveal(key) {
|
||||
var a = C().getAssignment(key);
|
||||
if (!a) return;
|
||||
if (a.mdlNodeId) {
|
||||
showTab('existing');
|
||||
if (mdlTable) { mdlTable.renderBody(); }
|
||||
} else if (a.trackingNodeId) {
|
||||
if (a.trackingNodeId) {
|
||||
showTab('tracking'); collapsed = {}; render();
|
||||
flashNode(els.trackingTree, a.trackingNodeId);
|
||||
} else if (a.transmittalNodeId) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,17 @@
|
|||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ── Sorting ────────────────────────────────────────────────────────────
|
||||
// Render the tree in a stable, human order: case-insensitive, natural
|
||||
// (so "Rev 2" sorts before "Rev 10"). Non-mutating — sort copies at render.
|
||||
function cmpName(a, b) { return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); }
|
||||
function sortedFolders(list) { return (list || []).slice().sort(function (a, b) { return cmpName(a.name, b.name); }); }
|
||||
function sortedFiles(list) {
|
||||
return (list || []).slice().sort(function (a, b) {
|
||||
return cmpName(window.zddc.joinExtension(a.originalFilename, a.extension), window.zddc.joinExtension(b.originalFilename, b.extension));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Classify & Copy helpers ────────────────────────────────────────────
|
||||
function classifyOn() {
|
||||
var c = window.app.modules.classify;
|
||||
|
|
@ -58,8 +69,8 @@
|
|||
var tt = window.app.modules.targetTree;
|
||||
return (tt && tt.activeAxis) ? tt.activeAxis() : 'tracking';
|
||||
}
|
||||
function axisField(ax) { return ax === 'transmittal' ? 'transmittalNodeId' : ax === 'mdl' ? 'mdlNodeId' : 'trackingNodeId'; }
|
||||
// Bucket a file relative to the active axis (tracking | transmittal | mdl):
|
||||
function axisField(ax) { return ax === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId'; }
|
||||
// Bucket a file relative to the active axis (tracking | transmittal):
|
||||
// 'excluded' | 'assigned' (on this axis) | 'partial' (assigned on a DIFFERENT
|
||||
// axis only — the to-do for this tab) | 'unassigned' (no axis).
|
||||
function fileCategory(file) {
|
||||
|
|
@ -68,7 +79,7 @@
|
|||
if (a && a.excluded) return 'excluded';
|
||||
var ax = activeAxis();
|
||||
if (a && a[axisField(ax)]) return 'assigned';
|
||||
var others = ['tracking', 'transmittal', 'mdl'].filter(function (x) { return x !== ax; });
|
||||
var others = ['tracking', 'transmittal'].filter(function (x) { return x !== ax; });
|
||||
var any = a && others.some(function (x) { return a[axisField(x)]; });
|
||||
return any ? 'partial' : 'unassigned';
|
||||
}
|
||||
|
|
@ -175,7 +186,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
window.app.folderTree.forEach(folder => {
|
||||
sortedFolders(window.app.folderTree).forEach(folder => {
|
||||
if (!folderShown(folder)) return;
|
||||
const element = createFolderElement(folder);
|
||||
container.appendChild(element);
|
||||
|
|
@ -360,7 +371,7 @@
|
|||
if ((folder.expanded || autoOpen(folder)) && folder.children && folder.children.length > 0) {
|
||||
const childrenDiv = document.createElement('div');
|
||||
childrenDiv.className = 'folder-children';
|
||||
folder.children.forEach(child => {
|
||||
sortedFolders(folder.children).forEach(child => {
|
||||
if (!folderShown(child)) return;
|
||||
const childElement = createFolderElement(child, level + 1);
|
||||
childrenDiv.appendChild(childElement);
|
||||
|
|
@ -373,7 +384,7 @@
|
|||
if (classifyOn() && (folder.expanded || autoOpen(folder)) && folder.files && folder.files.length > 0) {
|
||||
const filesDiv = document.createElement('div');
|
||||
filesDiv.className = 'folder-children folder-files';
|
||||
folder.files.forEach(function (file) {
|
||||
sortedFiles(folder.files).forEach(function (file) {
|
||||
if (!fileShown(file)) return;
|
||||
filesDiv.appendChild(createFileElement(file, level + 1));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -159,11 +159,11 @@
|
|||
<!-- Target Trees (Classify & Copy mode) — default view -->
|
||||
<main class="target-pane" id="targetPane">
|
||||
<div class="pane-header pane-header--target">
|
||||
<p class="target-goal">Each file needs a <strong>tracking number</strong> (revision + status + title) and a <strong>transmittal folder</strong>. Name it — build one under <em>By tracking number</em> or reuse one under <em>By existing</em> — then route it under <em>By transmittal</em>.</p>
|
||||
<p class="target-goal">Each file needs a <strong>tracking number</strong> (revision + status + title) and a <strong>transmittal folder</strong>. Name it — build one under <em>By tracking number</em>, or drag onto a row under <em>From a list</em> (loaded from the archive/MDL or pasted from Excel) — then route it under <em>By transmittal</em>.</p>
|
||||
<div class="target-tabs" role="tablist">
|
||||
<div class="target-tabs__group">
|
||||
<button class="target-tab active" id="trackingTab" role="tab">By tracking number</button>
|
||||
<button class="target-tab" id="existingTab" role="tab" title="Reuse a tracking number already in the project MDL or archive — drag files onto a row to assign it.">By existing</button>
|
||||
<button class="target-tab" id="existingTab" role="tab" title="Drag files onto a list of tracking numbers — loaded from the archive/MDL, pasted from Excel, or auto-matched by name.">From a list</button>
|
||||
</div>
|
||||
<span class="target-tabs__divider" aria-hidden="true"></span>
|
||||
<div class="target-tabs__group">
|
||||
|
|
@ -200,14 +200,19 @@
|
|||
placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
|
||||
<div id="transmittalTree" class="target-tree"></div>
|
||||
</section>
|
||||
<!-- "By existing": the catalog — files + MDL deliverables from the
|
||||
directories you tick, deduped to one row per tracking number at
|
||||
the latest revision. Read-only on the MDL/archive; the Revision
|
||||
column is classifier-local. The left filetree stays the drag source. -->
|
||||
<!-- "From a list": a scratch worklist of tracking numbers — Load them
|
||||
from the archive/MDL, Paste rows from Excel, or ⚡ Match by name.
|
||||
Dragging a file onto a row MATERIALIZES a real "By tracking number"
|
||||
placement, so Clear keeps every assignment. The left filetree is
|
||||
the drag source. -->
|
||||
<section id="mdlPanel" class="target-panel" hidden>
|
||||
<div class="target-panel__toolbar">
|
||||
<button id="loadMdlBtn" class="btn btn-sm btn-secondary">⊞ Load…</button>
|
||||
<span class="target-hint">“Load…”, tick the directories to scan, then drag files onto a row to assign its tracking number; set the Revision column (ctrl-shift select + ctrl-Enter to set many).</span>
|
||||
<button id="loadMdlBtn" class="btn btn-sm btn-secondary" title="Add tracking numbers from the project archive/MDL (pick directories to scan).">⊞ Load…</button>
|
||||
<button id="pasteRowsBtn" class="btn btn-sm btn-secondary" title="Paste rows from Excel: Tracking · Rev (Status) · Title.">⎘ Paste rows…</button>
|
||||
<button id="matchNamesBtn" class="btn btn-sm btn-secondary" title="Auto-suggest assignments by matching unassigned filenames against the list.">⚡ Match names</button>
|
||||
<button id="clearListBtn" class="btn btn-sm btn-secondary" title="Empty the list. Every assignment is kept — see By tracking number.">Clear list</button>
|
||||
<label class="target-toggle" title="Hide rows that already have files assigned."><input type="checkbox" id="hideAssignedToggle"> Hide assigned</label>
|
||||
<span class="target-hint">Drag files onto a row to name them; edit the Tracking number / Revision inline (ctrl-shift + ctrl-Enter sets many). Clearing the list keeps every assignment.</span>
|
||||
</div>
|
||||
<div id="mdlTree" class="target-tree"></div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
.seltable__bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
|
||||
.seltable__count { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
|
||||
.seltable__scroll { flex: 1; min-height: 0; overflow: auto; }
|
||||
.seltable__table { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; }
|
||||
/* width:auto + nowrap cells → each column shrinks to fit its header/longest cell. */
|
||||
.seltable__table { border-collapse: separate; border-spacing: 0; width: auto; font-size: 0.82rem; }
|
||||
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
|
||||
.seltable__table thead th {
|
||||
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
|
||||
|
|
@ -13,7 +14,7 @@
|
|||
}
|
||||
.seltable__table thead tr.seltable__filters th { top: 1.55rem; padding: 0.15rem 0.35rem; }
|
||||
.seltable__colfilter {
|
||||
width: 100%; min-width: 5rem; padding: 0.15rem 0.35rem;
|
||||
width: 100%; min-width: 2rem; box-sizing: border-box; padding: 0.15rem 0.35rem;
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--bg); color: var(--text); font-size: 0.74rem; font-weight: 400; text-transform: none; letter-spacing: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -247,6 +247,33 @@ test('source file rows render with a state dot in classify mode', async ({ page
|
|||
await expect(page.locator('#folderTree .file-item .cl-dot--none')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Folder Tree renders folders and files in natural, case-insensitive order', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
const order = await page.evaluate(() => {
|
||||
window.app.folderTree = [{
|
||||
name: 'Root', path: 'Root', expanded: true, scanState: 'done',
|
||||
children: [
|
||||
{ name: 'Beta', path: 'Root/Beta', scanState: 'done', children: [], files: [] },
|
||||
{ name: 'alpha', path: 'Root/alpha', scanState: 'done', children: [], files: [] },
|
||||
{ name: 'Rev 10', path: 'Root/Rev 10', scanState: 'done', children: [], files: [] },
|
||||
{ name: 'Rev 2', path: 'Root/Rev 2', scanState: 'done', children: [], files: [] },
|
||||
],
|
||||
files: [
|
||||
{ originalFilename: 'zeta', extension: 'pdf', folderPath: 'Root' },
|
||||
{ originalFilename: 'Apple', extension: 'pdf', folderPath: 'Root' },
|
||||
{ originalFilename: 'banana', extension: 'pdf', folderPath: 'Root' },
|
||||
],
|
||||
}];
|
||||
window.app.modules.tree.render();
|
||||
return {
|
||||
folders: Array.from(document.querySelectorAll('#folderTree .folder-children .folder-name')).map(e => e.textContent.trim()),
|
||||
files: Array.from(document.querySelectorAll('#folderTree .folder-files .file-name')).map(e => e.textContent.trim()),
|
||||
};
|
||||
});
|
||||
expect(order.folders).toEqual(['alpha', 'Beta', 'Rev 2', 'Rev 10']); // case-insensitive + natural (2 < 10)
|
||||
expect(order.files).toEqual(['Apple.pdf', 'banana.pdf', 'zeta.pdf']); // case-insensitive
|
||||
});
|
||||
|
||||
test('classify: single-click a source file triggers preview', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
const previewed = await page.evaluate(() => {
|
||||
|
|
@ -1216,7 +1243,7 @@ test('seltable: autofilter + ctrl-shift selection builds complex sets', async ({
|
|||
expect(r.ctrlShiftRange).toBe('c,d'); // ctrl-shift range runs over the FILTERED order
|
||||
});
|
||||
|
||||
test('classify: an MDL placement names a file; revision from the cell, transmittal for the path', async ({ page }) => {
|
||||
test('From a list: a drop materializes a real tracking placement; row revision + transmittal complete it', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
|
|
@ -1224,32 +1251,36 @@ test('classify: an MDL placement names a file; revision from the cell, transmitt
|
|||
const f = { originalFilename: 'messy scan 47', extension: 'pdf', folderPath: 'R' };
|
||||
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
|
||||
const key = c.srcKeyForFile(f);
|
||||
c.setMdlList([{ id: 'm1', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Switchgear Spec' }]);
|
||||
c.place([key], 'm1', 'mdl');
|
||||
const beforeRev = c.deriveTarget(f); // no revision yet
|
||||
c.setRevisionCell('m1', 'A (IFR)');
|
||||
const named = c.deriveTarget(f); // named, but no transmittal → not complete
|
||||
c.setMdlList([{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Switchgear Spec' }]);
|
||||
c.assignFromRow([key], c.getMdlRow('m1')); // blank revision → partial
|
||||
const placedTracking = !!(c.getAssignment(key) || {}).trackingNodeId; // a REAL tracking placement
|
||||
const beforeRev = c.deriveTarget(f);
|
||||
c.setRevisionCell('m1', 'A (IFR)'); // re-stamps onto the leaf
|
||||
const named = c.deriveTarget(f);
|
||||
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||
c.place([key], bin, 'transmittal');
|
||||
const full = c.deriveTarget(f);
|
||||
c.setTitleFromDeliverable(key, false); // use the file's own title instead
|
||||
c.setTitleOverride(key, ''); // use the file's own title instead
|
||||
const fileTitle = c.deriveTarget(f);
|
||||
return {
|
||||
beforeRevErr: beforeRev.errors.length > 0,
|
||||
placedTracking, beforeRevErr: beforeRev.errors.length > 0,
|
||||
beforeTracking: beforeRev.tracking,
|
||||
named: named.filename, namedComplete: named.complete,
|
||||
fullName: full.filename, fullComplete: full.complete,
|
||||
fileTitleName: fileTitle.filename,
|
||||
};
|
||||
});
|
||||
expect(r.beforeRevErr).toBe(true); // a deliverable with no revision can't name a file
|
||||
expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Switchgear Spec.pdf'); // tracking+title from MDL, rev from cell
|
||||
expect(r.namedComplete).toBe(false); // still needs a transmittal for the output path
|
||||
expect(r.placedTracking).toBe(true); // not a separate axis — a tracking placement
|
||||
expect(r.beforeTracking).toBe('ACM-PRJ-EL-SPC-0001'); // full tracking number preserved while rev pending
|
||||
expect(r.beforeRevErr).toBe(true); // no revision yet → incomplete
|
||||
expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Switchgear Spec.pdf');
|
||||
expect(r.namedComplete).toBe(false); // still needs a transmittal
|
||||
expect(r.fullComplete).toBe(true);
|
||||
expect(r.fullName).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Switchgear Spec.pdf');
|
||||
expect(r.fileTitleName).toContain('messy scan 47'); // title toggle → the file's own title
|
||||
});
|
||||
|
||||
test('By existing: shows latest rev, drop on a row names the file, bulk revision applies', async ({ page }) => {
|
||||
test('From a list: clearing the list keeps classifications; the row drives the seltable', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||
|
|
@ -1257,25 +1288,124 @@ test('By existing: shows latest rev, drop on a row names the file, bulk revision
|
|||
const f = { originalFilename: 'scan', extension: 'pdf', folderPath: 'R' };
|
||||
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
|
||||
const key = c.srcKeyForFile(f);
|
||||
// Catalog rows = files ∪ MDL deliverables, deduped per tracking number.
|
||||
c.setMdlList([
|
||||
{ id: 'm1', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Spec', inMdl: true, archiveRevisions: ['A (IFR)', 'B (IFC)'] },
|
||||
{ id: 'm2', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2', inMdl: false, archiveRevisions: ['0 (IFC)'] },
|
||||
{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Spec', inMdl: true, archiveRevisions: ['A (IFR)', 'B (IFC)'], revisionCell: 'C (IFC)' },
|
||||
{ id: 'm2', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2', archiveRevisions: ['0 (IFC)'] },
|
||||
]);
|
||||
tt.showTab('existing'); // shows the catalog panel + builds the seltable into #mdlTree
|
||||
tt.showTab('existing');
|
||||
const row = document.querySelector('#mdlTree .seltable__row[data-id="m1"]');
|
||||
// Latest rev only: B (IFC) > A (IFR), so the cell shows B (IFC), not A (IFR).
|
||||
const latestShown = !!row && row.textContent.includes('B (IFC)') && !row.textContent.includes('A (IFR)');
|
||||
const latestShown = !!row && row.textContent.includes('B (IFC)'); // latest archive rev shown
|
||||
window.app.modules.dnd.setDrag([key]);
|
||||
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop the file on m1
|
||||
const placed = (c.getAssignment(key) || {}).mdlNodeId;
|
||||
c.setRevisionCells(['m1', 'm2'], 'A (IFR)'); // ctrl-Enter bulk path
|
||||
return { hasRow: !!row, latestShown, placed, named: c.deriveTarget(f).filename };
|
||||
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop on m1 (rev C set)
|
||||
const named = c.deriveTarget(f).filename;
|
||||
c.clearMdlList(); // list emptied — assignment must survive
|
||||
return {
|
||||
hasRow: !!row, latestShown,
|
||||
placedAfterDrop: !!(c.getAssignment(key) || {}).trackingNodeId,
|
||||
named,
|
||||
listLen: c.getMdlList().length,
|
||||
stillPlaced: !!(c.getAssignment(key) || {}).trackingNodeId,
|
||||
stillNamed: c.deriveTarget(f).filename,
|
||||
};
|
||||
});
|
||||
expect(r.hasRow).toBe(true);
|
||||
expect(r.latestShown).toBe(true); // only the latest archive revision shown
|
||||
expect(r.placed).toBe('m1'); // drop = tracking number only
|
||||
expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf'); // revision from the bulk-set column feeds the name
|
||||
expect(r.latestShown).toBe(true);
|
||||
expect(r.placedAfterDrop).toBe(true);
|
||||
expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_C (IFC) - Spec.pdf'); // tracking + row revision + title
|
||||
expect(r.listLen).toBe(0); // list cleared
|
||||
expect(r.stillPlaced).toBe(true); // classification survives the clear
|
||||
expect(r.stillNamed).toBe('ACM-PRJ-EL-SPC-0001_C (IFC) - Spec.pdf');
|
||||
});
|
||||
|
||||
test('From a list: editing the tracking number (bump sequence) re-stamps placed files', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
c.reset();
|
||||
const f = { originalFilename: 'plan', extension: 'pdf', folderPath: 'R' };
|
||||
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
|
||||
const key = c.srcKeyForFile(f);
|
||||
c.setMdlList([{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-DWG-0007', title: 'Plan', revisionCell: 'A (IFR)' }]);
|
||||
c.assignFromRow([key], c.getMdlRow('m1'));
|
||||
const before = c.deriveTarget(f).filename;
|
||||
c.setRowTracking('m1', 'ACM-PRJ-EL-DWG-0008'); // it's the next drawing
|
||||
const after = c.deriveTarget(f).filename;
|
||||
// the old leaf chain should be pruned (no stray 0007 folder)
|
||||
const roots = c.getTrackingTree();
|
||||
const hasStale0007 = JSON.stringify(roots).indexOf('0007') !== -1;
|
||||
return { before, after, hasStale0007 };
|
||||
});
|
||||
expect(r.before).toBe('ACM-PRJ-EL-DWG-0007_A (IFR) - Plan.pdf');
|
||||
expect(r.after).toBe('ACM-PRJ-EL-DWG-0008_A (IFR) - Plan.pdf'); // file moved with the bump
|
||||
expect(r.hasStale0007).toBe(false); // old leaf pruned
|
||||
});
|
||||
|
||||
test('From a list: load() migrates a legacy mdlNodeId placement into a tracking placement', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
c.reset();
|
||||
const f = { originalFilename: 'old', extension: 'pdf', folderPath: 'R' };
|
||||
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
|
||||
const key = c.srcKeyForFile(f);
|
||||
// A pre-"From a list" serialized workspace: the file points at an mdl row.
|
||||
c.load({
|
||||
assignments: { [key]: { mdlNodeId: 'old1', titleFromDeliverable: true, transmittalNodeId: null, excluded: false, titleOverride: null } },
|
||||
mdlList: [{ id: 'old1', trackingNumber: 'ACM-PRJ-EL-SPC-0009', title: 'Legacy', revisionCell: 'B (IFC)' }],
|
||||
});
|
||||
const a = c.getAssignment(key) || {};
|
||||
return {
|
||||
noMdlNodeId: !('mdlNodeId' in a),
|
||||
hasTracking: !!a.trackingNodeId,
|
||||
named: c.deriveTarget(f).filename,
|
||||
};
|
||||
});
|
||||
expect(r.noMdlNodeId).toBe(true); // dead field dropped
|
||||
expect(r.hasTracking).toBe(true); // materialized into the tracking tree
|
||||
expect(r.named).toBe('ACM-PRJ-EL-SPC-0009_B (IFC) - Legacy.pdf'); // classification preserved
|
||||
});
|
||||
|
||||
test('parsePastedRows handles 3-col, 4-col(status), a filename, a header, and bad rows', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
const text = [
|
||||
'Tracking Number\tRev\tTitle', // header → skipped
|
||||
'ACM-PRJ-EL-SPC-0001\tA (IFR)\tFloor plan', // 3-col
|
||||
'ACM-PRJ-EL-SPC-0002\tB\tIFC\tSection', // 4-col (status split)
|
||||
'ACM-PRJ-EL-SPC-0003_C (IFA) - Detail.pdf', // single full filename
|
||||
'\tjust a rev\t', // no tracking → skipped
|
||||
].join('\n');
|
||||
return c.parsePastedRows(text);
|
||||
});
|
||||
expect(r.rows.map(x => x.trackingNumber)).toEqual(['ACM-PRJ-EL-SPC-0001', 'ACM-PRJ-EL-SPC-0002', 'ACM-PRJ-EL-SPC-0003']);
|
||||
expect(r.rows[0].revisionCell).toBe('A (IFR)');
|
||||
expect(r.rows[1].revisionCell).toBe('B (IFC)'); // status column merged
|
||||
expect(r.rows[2].revisionCell).toBe('C (IFA)'); // split from the filename
|
||||
expect(r.rows[2].title).toBe('Detail');
|
||||
expect(r.skipped.length).toBe(1); // the no-tracking row
|
||||
});
|
||||
|
||||
test('proposeMatches finds a row whose tracking number is in the filename', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
const files = [
|
||||
{ originalFilename: 'ACM-PRJ-EL-SPC-0001 rev A', extension: 'pdf' }, // exact substring
|
||||
{ originalFilename: 'random scan', extension: 'pdf' }, // no match
|
||||
{ originalFilename: 'doc ACMPRJELSPC0002 final', extension: 'pdf' }, // normalized
|
||||
];
|
||||
const rows = [
|
||||
{ trackingNumber: 'ACM-PRJ-EL-SPC-0001' },
|
||||
{ trackingNumber: 'ACM-PRJ-EL-SPC-0002' },
|
||||
];
|
||||
const m = c.proposeMatches(files, rows, {});
|
||||
return m.map(p => ({ file: p.file.originalFilename, tn: p.row.trackingNumber, conf: p.confidence }));
|
||||
});
|
||||
expect(r.length).toBe(2); // the no-match file is dropped
|
||||
expect(r[0]).toEqual({ file: 'ACM-PRJ-EL-SPC-0001 rev A', tn: 'ACM-PRJ-EL-SPC-0001', conf: 1 });
|
||||
expect(r[1].tn).toBe('ACM-PRJ-EL-SPC-0002'); // matched via normalization
|
||||
expect(r[1].conf).toBeCloseTo(0.8);
|
||||
});
|
||||
|
||||
test('By existing: walkDirInto unions files + mdl deliverables, deduped to the latest revision', async ({ page }) => {
|
||||
|
|
|
|||
|
|
@ -2717,7 +2717,7 @@ td[data-field="trackingNumber"] {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Archive</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5</span></span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||
|
|
|
|||
|
|
@ -2824,7 +2824,7 @@ li.CodeMirror-hint-active {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Browse</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5</span></span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||
|
|
|
|||
|
|
@ -1751,10 +1751,26 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
|||
/* The "By existing" catalog is now a normal in-flow tab panel. */
|
||||
#mdlTree { flex: 1; min-height: 0; }
|
||||
#mdlTree .seltable { height: 100%; }
|
||||
.mdl-rev__input {
|
||||
width: 8rem; padding: 0.15rem 0.35rem; border: 1px solid var(--border);
|
||||
/* Editable "From a list" cells — fill the column (the table is width:auto, so
|
||||
the column sizes to its header/content, and the input never widens it). */
|
||||
.mdl-rev__input, .fromlist-tn__input, .fromlist-title__input {
|
||||
width: 100%; min-width: 4rem; box-sizing: border-box;
|
||||
padding: 0.15rem 0.35rem; border: 1px solid var(--border);
|
||||
border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 0.8rem;
|
||||
}
|
||||
.fromlist-tn__input { font-family: var(--mono, monospace); }
|
||||
.mdl-rev__input.is-warn, .fromlist-tn__input.is-warn { border-color: var(--warning, #b8860b); }
|
||||
.fromlist-src { white-space: nowrap; }
|
||||
.src-badge {
|
||||
display: inline-block; margin-right: 0.25rem; padding: 0 0.3rem; border-radius: 0.7rem;
|
||||
font-size: 0.64rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em;
|
||||
border: 1px solid var(--border); color: var(--text-muted);
|
||||
}
|
||||
.src-badge--mdl { color: var(--primary); border-color: var(--primary); }
|
||||
.src-badge--arch { color: var(--text-secondary, var(--text-muted)); }
|
||||
.src-badge--pasted { color: var(--text-muted); }
|
||||
.src-badge--new { color: #fff; background: var(--warning, #b8860b); border-color: var(--warning, #b8860b); }
|
||||
.target-toggle { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--text-muted); cursor: pointer; }
|
||||
.seltable__extra { white-space: normal; }
|
||||
.mdlfile__name { font-size: 0.78rem; }
|
||||
#mdlPanel .tfile { gap: 0.3rem; align-items: center; padding: 0.05rem 0; cursor: grab; }
|
||||
|
|
@ -1762,6 +1778,26 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
|||
#mdlPanel .tfile__remove { opacity: 0.6; }
|
||||
#mdlPanel .tfile:hover .tfile__remove { opacity: 1; }
|
||||
|
||||
/* Paste + Match dialogs (inside the .copy-choice modal shell) */
|
||||
.scratch-modal__body { margin: 0 0 1rem; }
|
||||
.scratch-paste__ta {
|
||||
width: 100%; box-sizing: border-box; resize: vertical; font-family: var(--mono, monospace);
|
||||
font-size: 0.8rem; padding: 0.4rem 0.5rem; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--bg); color: var(--text);
|
||||
}
|
||||
.scratch-paste__preview, .scratch-match__list { max-height: 38vh; overflow: auto; margin-top: 0.6rem; }
|
||||
.scratch-preview__table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
|
||||
.scratch-preview__table th, .scratch-preview__table td { text-align: left; padding: 0.15rem 0.4rem; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.scratch-preview__table th { color: var(--text-muted); font-size: 0.66rem; text-transform: uppercase; }
|
||||
.scratch-preview__skip { color: var(--danger); font-size: 0.76rem; padding: 0.1rem 0; }
|
||||
.scratch-preview__more { color: var(--text-muted); font-size: 0.76rem; padding: 0.2rem 0; }
|
||||
.scratch-match__fuzzy { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--text-muted); }
|
||||
.scratch-match__row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8rem; padding: 0.15rem 0; cursor: pointer; }
|
||||
.scratch-match__file { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.scratch-match__arrow { color: var(--text-muted); }
|
||||
.scratch-match__tn { font-family: var(--mono, monospace); }
|
||||
.scratch-match__conf { color: var(--text-muted); font-size: 0.72rem; width: 3rem; text-align: right; }
|
||||
|
||||
/* ── MDL-from-archive overlay ───────────────────────────────────────────── */
|
||||
.mdl-overlay { position: fixed; inset: 0; z-index: 1100; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||
.mdl-overlay__box { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 10px 40px rgba(0,0,0,0.3); width: 100%; max-width: 1000px; height: 80vh; display: flex; flex-direction: column; }
|
||||
|
|
@ -1783,12 +1819,21 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
|||
}
|
||||
.seltable__count { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
|
||||
.seltable__scroll { flex: 1; min-height: 0; overflow: auto; }
|
||||
.seltable__table { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; }
|
||||
/* width:auto + nowrap cells → each column shrinks to fit its header/longest cell. */
|
||||
.seltable__table { border-collapse: separate; border-spacing: 0; width: auto; font-size: 0.82rem; }
|
||||
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
|
||||
.seltable__table thead th {
|
||||
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
|
||||
color: var(--text-muted); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
|
||||
}
|
||||
/* Per-column filter inputs: fill the column (min-width:0-ish) so they never
|
||||
force a column wider than its header/cells. */
|
||||
.seltable__table thead tr.seltable__filters th { padding: 0.08rem 0.3rem; }
|
||||
.seltable__colfilter {
|
||||
width: 100%; min-width: 2rem; box-sizing: border-box;
|
||||
padding: 0.1rem 0.3rem; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--bg); color: var(--text); font-size: 0.72rem; font-weight: 400; letter-spacing: 0; text-transform: none;
|
||||
}
|
||||
.seltable__row { cursor: pointer; user-select: none; }
|
||||
.seltable__row:hover { background: var(--bg-hover); }
|
||||
.seltable__row.is-selected { background: var(--primary-light, rgba(37,99,235,0.12)); }
|
||||
|
|
@ -2362,7 +2407,7 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Classifier</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5</span></span>
|
||||
</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>
|
||||
|
|
@ -2495,11 +2540,11 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
|||
<!-- Target Trees (Classify & Copy mode) — default view -->
|
||||
<main class="target-pane" id="targetPane">
|
||||
<div class="pane-header pane-header--target">
|
||||
<p class="target-goal">Each file needs a <strong>tracking number</strong> (revision + status + title) and a <strong>transmittal folder</strong>. Name it — build one under <em>By tracking number</em> or reuse one under <em>By existing</em> — then route it under <em>By transmittal</em>.</p>
|
||||
<p class="target-goal">Each file needs a <strong>tracking number</strong> (revision + status + title) and a <strong>transmittal folder</strong>. Name it — build one under <em>By tracking number</em>, or drag onto a row under <em>From a list</em> (loaded from the archive/MDL or pasted from Excel) — then route it under <em>By transmittal</em>.</p>
|
||||
<div class="target-tabs" role="tablist">
|
||||
<div class="target-tabs__group">
|
||||
<button class="target-tab active" id="trackingTab" role="tab">By tracking number</button>
|
||||
<button class="target-tab" id="existingTab" role="tab" title="Reuse a tracking number already in the project MDL or archive — drag files onto a row to assign it.">By existing</button>
|
||||
<button class="target-tab" id="existingTab" role="tab" title="Drag files onto a list of tracking numbers — loaded from the archive/MDL, pasted from Excel, or auto-matched by name.">From a list</button>
|
||||
</div>
|
||||
<span class="target-tabs__divider" aria-hidden="true"></span>
|
||||
<div class="target-tabs__group">
|
||||
|
|
@ -2536,14 +2581,19 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
|||
placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
|
||||
<div id="transmittalTree" class="target-tree"></div>
|
||||
</section>
|
||||
<!-- "By existing": the catalog — files + MDL deliverables from the
|
||||
directories you tick, deduped to one row per tracking number at
|
||||
the latest revision. Read-only on the MDL/archive; the Revision
|
||||
column is classifier-local. The left filetree stays the drag source. -->
|
||||
<!-- "From a list": a scratch worklist of tracking numbers — Load them
|
||||
from the archive/MDL, Paste rows from Excel, or ⚡ Match by name.
|
||||
Dragging a file onto a row MATERIALIZES a real "By tracking number"
|
||||
placement, so Clear keeps every assignment. The left filetree is
|
||||
the drag source. -->
|
||||
<section id="mdlPanel" class="target-panel" hidden>
|
||||
<div class="target-panel__toolbar">
|
||||
<button id="loadMdlBtn" class="btn btn-sm btn-secondary">⊞ Load…</button>
|
||||
<span class="target-hint">“Load…”, tick the directories to scan, then drag files onto a row to assign its tracking number; set the Revision column (ctrl-shift select + ctrl-Enter to set many).</span>
|
||||
<button id="loadMdlBtn" class="btn btn-sm btn-secondary" title="Add tracking numbers from the project archive/MDL (pick directories to scan).">⊞ Load…</button>
|
||||
<button id="pasteRowsBtn" class="btn btn-sm btn-secondary" title="Paste rows from Excel: Tracking · Rev (Status) · Title.">⎘ Paste rows…</button>
|
||||
<button id="matchNamesBtn" class="btn btn-sm btn-secondary" title="Auto-suggest assignments by matching unassigned filenames against the list.">⚡ Match names</button>
|
||||
<button id="clearListBtn" class="btn btn-sm btn-secondary" title="Empty the list. Every assignment is kept — see By tracking number.">Clear list</button>
|
||||
<label class="target-toggle" title="Hide rows that already have files assigned."><input type="checkbox" id="hideAssignedToggle"> Hide assigned</label>
|
||||
<span class="target-hint">Drag files onto a row to name them; edit the Tracking number / Revision inline (ctrl-shift + ctrl-Enter sets many). Clearing the list keeps every assignment.</span>
|
||||
</div>
|
||||
<div id="mdlTree" class="target-tree"></div>
|
||||
</section>
|
||||
|
|
@ -7475,7 +7525,7 @@ X.B(E,Y);return E}return J}())
|
|||
function assignmentFor(key) {
|
||||
var a = state.assignments[key];
|
||||
if (!a) {
|
||||
a = { trackingNodeId: null, transmittalNodeId: null, mdlNodeId: null, excluded: false, titleOverride: null, titleFromDeliverable: true };
|
||||
a = { trackingNodeId: null, transmittalNodeId: null, excluded: false, titleOverride: null };
|
||||
state.assignments[key] = a;
|
||||
}
|
||||
return a;
|
||||
|
|
@ -7484,22 +7534,19 @@ X.B(E,Y);return E}return J}())
|
|||
function getAssignment(key) { return state.assignments[key] || null; }
|
||||
function cleanAssignment(key) {
|
||||
var a = state.assignments[key];
|
||||
if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.mdlNodeId && !a.excluded && !a.titleOverride) {
|
||||
if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.excluded && !a.titleOverride) {
|
||||
delete state.assignments[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Place keys onto a node along one axis ('tracking' | 'transmittal').
|
||||
// nodeId null clears that axis.
|
||||
// nodeId null clears that axis. (The "From a list" tab also produces
|
||||
// 'tracking' placements — see assignFromRow.)
|
||||
function place(keys, nodeId, axis) {
|
||||
var field = axis === 'transmittal' ? 'transmittalNodeId' : axis === 'mdl' ? 'mdlNodeId' : 'trackingNodeId';
|
||||
var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId';
|
||||
keys.forEach(function (k) {
|
||||
var a = assignmentFor(k);
|
||||
a[field] = nodeId || null;
|
||||
// Tracking and MDL are alternative NAME sources — placing on one
|
||||
// clears the other so the file has a single name origin.
|
||||
if (axis === 'mdl' && nodeId) a.trackingNodeId = null;
|
||||
else if (axis === 'tracking' && nodeId) a.mdlNodeId = null;
|
||||
a.excluded = false; // placing un-excludes
|
||||
cleanAssignment(k);
|
||||
});
|
||||
|
|
@ -7510,7 +7557,7 @@ X.B(E,Y);return E}return J}())
|
|||
keys.forEach(function (k) {
|
||||
var a = assignmentFor(k);
|
||||
a.excluded = !!excluded;
|
||||
if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; a.mdlNodeId = null; }
|
||||
if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; }
|
||||
cleanAssignment(k);
|
||||
});
|
||||
clearHashConflicts();
|
||||
|
|
@ -7550,9 +7597,8 @@ X.B(E,Y);return E}return J}())
|
|||
});
|
||||
});
|
||||
});
|
||||
(state.mdlList || []).forEach(function (row) {
|
||||
nodeIndex[row.id] = { node: row, kind: 'mdl', parent: null };
|
||||
});
|
||||
// Scratch-list rows are NOT placement targets — drops materialize real
|
||||
// tracking-tree nodes (assignFromRow), so the list isn't indexed here.
|
||||
}
|
||||
function getNode(id) { return nodeIndex[id] ? nodeIndex[id].node : null; }
|
||||
function infoFor(id) { return nodeIndex[id] || null; }
|
||||
|
|
@ -7685,26 +7731,10 @@ X.B(E,Y);return E}return J}())
|
|||
};
|
||||
if (out.excluded) return out;
|
||||
|
||||
// Axis 1 — NAME. An MDL deliverable (alternative to the tracking tree)
|
||||
// supplies the tracking number + title; its revision comes from the
|
||||
// classifier-local revision cell. Otherwise the tracking tree.
|
||||
if (a.mdlNodeId) {
|
||||
var mi = infoFor(a.mdlNodeId);
|
||||
if (mi && mi.kind === 'mdl') {
|
||||
var row = mi.node;
|
||||
out.tracking = row.trackingNumber || '';
|
||||
var ml = parseLeafLabel(row.revisionCell || '');
|
||||
out.revision = ml.revision; out.status = ml.status;
|
||||
out.trackingLeaf = true;
|
||||
if (!a.titleOverride && a.titleFromDeliverable !== false && row.title) out.title = row.title;
|
||||
if (!out.tracking) out.errors.push('deliverable has no tracking number');
|
||||
if (!out.revision) out.errors.push('set a revision for this deliverable (e.g. "A (IFR)")');
|
||||
else if (out.status && !zddc.isValidStatus(out.status)) out.errors.push('unknown status "' + out.status + '"');
|
||||
else if (!out.status) out.errors.push('revision needs a "(STATUS)" — e.g. "A (IFR)"');
|
||||
} else {
|
||||
out.errors.push('deliverable no longer loaded');
|
||||
}
|
||||
} else if (a.trackingNodeId) {
|
||||
// Axis 1 — NAME, always the tracking tree. The "From a list" tab drops
|
||||
// also produce tracking-tree placements (assignFromRow), so there is a
|
||||
// single name origin.
|
||||
if (a.trackingNodeId) {
|
||||
var ti = infoFor(a.trackingNodeId);
|
||||
if (ti && ti.kind === 'tracking') {
|
||||
var chain = trackingChain(ti); // [root … node]
|
||||
|
|
@ -7804,10 +7834,33 @@ X.B(E,Y);return E}return J}())
|
|||
state.transmittalTree = obj.transmittalTree || [];
|
||||
state.outputName = obj.outputName || null;
|
||||
state.config = normalizeConfig(obj.config);
|
||||
state.mdlList = Array.isArray(obj.mdlList) ? obj.mdlList : [];
|
||||
state.mdlList = (Array.isArray(obj.mdlList) ? obj.mdlList : []).map(normalizeRow);
|
||||
rebuildIndex();
|
||||
migrateLegacyMdl(obj.mdlList); // BEFORE anything can prune; materializes old mdl placements
|
||||
notify();
|
||||
}
|
||||
// Pre-"From a list" workspaces stored a separate `mdlNodeId` axis pointing at
|
||||
// a row id. Materialize each into a real tracking placement so the
|
||||
// classification survives the model change, then drop the dead fields.
|
||||
function migrateLegacyMdl(rawRows) {
|
||||
var byOldId = Object.create(null);
|
||||
(rawRows || []).forEach(function (r) { if (r && r.id) byOldId[r.id] = r; });
|
||||
Object.keys(state.assignments).forEach(function (k) {
|
||||
var a = state.assignments[k];
|
||||
if (!a) return;
|
||||
if (a.mdlNodeId) {
|
||||
var r = byOldId[a.mdlNodeId];
|
||||
if (!a.trackingNodeId && r && r.trackingNumber) {
|
||||
var leaf = addTrackingPath(null, parseFolderLevels(r.trackingNumber + '_' + (r.revisionCell || PENDING_REV)));
|
||||
a.trackingNodeId = leaf;
|
||||
if (!a.titleOverride && r.title && a.titleFromDeliverable !== false) a.titleOverride = r.title;
|
||||
}
|
||||
delete a.mdlNodeId;
|
||||
}
|
||||
if ('titleFromDeliverable' in a) delete a.titleFromDeliverable;
|
||||
cleanAssignment(k);
|
||||
});
|
||||
}
|
||||
// Reset clears the CLASSIFICATION but keeps the pattern config — it's a
|
||||
// per-project setting, not part of the data being cleared.
|
||||
function reset() {
|
||||
|
|
@ -7834,41 +7887,192 @@ X.B(E,Y);return E}return J}())
|
|||
function getTrackingFields() { return state.config.trackingFields; }
|
||||
function setConfig(c) { state.config = normalizeConfig(c); notify(); }
|
||||
|
||||
// ── MDL deliverables (the "By MDL" drop-target axis) ─────────────────────
|
||||
function setMdlList(rows) {
|
||||
state.mdlList = (rows || []).map(function (r) {
|
||||
return {
|
||||
id: r.id || uid(), party: r.party || '',
|
||||
trackingNumber: r.trackingNumber || '', title: r.title || '',
|
||||
inMdl: !!r.inMdl,
|
||||
archiveRevisions: Array.isArray(r.archiveRevisions) ? r.archiveRevisions : [],
|
||||
revisionCell: r.revisionCell || '',
|
||||
};
|
||||
// ── "From a list" scratch worklist ───────────────────────────────────────
|
||||
// A temporary list of known/typed tracking numbers (from the archive/MDL, a
|
||||
// paste, or a name-match). Dropping a file on a row MATERIALIZES a real
|
||||
// tracking-tree placement (assignFromRow) — the list is pure input, so it can
|
||||
// be cleared without losing any classification. `placed` is a transient
|
||||
// row→keys hint (not the source of truth, not serialized) used to re-stamp a
|
||||
// row's files when its tracking number / revision is edited.
|
||||
function rowSource(r) {
|
||||
if (r && r.source) return { mdl: !!r.source.mdl, archive: !!r.source.archive, pasted: !!r.source.pasted };
|
||||
return { mdl: !!(r && r.inMdl), archive: !!(r && Array.isArray(r.archiveRevisions) && r.archiveRevisions.length), pasted: false };
|
||||
}
|
||||
function normalizeRow(r) {
|
||||
r = r || {};
|
||||
return {
|
||||
id: r.id || uid(), party: r.party || '',
|
||||
trackingNumber: (r.trackingNumber || '').trim(), title: r.title || '',
|
||||
revisionCell: r.revisionCell || '',
|
||||
source: rowSource(r),
|
||||
archiveRevisions: Array.isArray(r.archiveRevisions) ? r.archiveRevisions : [],
|
||||
placed: Object.create(null),
|
||||
};
|
||||
}
|
||||
function setMdlList(rows) { state.mdlList = (rows || []).map(normalizeRow); notify(); }
|
||||
function appendMdlRows(rows) {
|
||||
var byTn = Object.create(null);
|
||||
state.mdlList.forEach(function (r) { if (r.trackingNumber) byTn[r.trackingNumber] = r; });
|
||||
(rows || []).forEach(function (raw) {
|
||||
var r = normalizeRow(raw), ex = r.trackingNumber ? byTn[r.trackingNumber] : null;
|
||||
if (ex) {
|
||||
if (!ex.revisionCell && r.revisionCell) ex.revisionCell = r.revisionCell;
|
||||
if (!ex.title && r.title) ex.title = r.title;
|
||||
ex.source.mdl = ex.source.mdl || r.source.mdl;
|
||||
ex.source.archive = ex.source.archive || r.source.archive;
|
||||
ex.source.pasted = ex.source.pasted || r.source.pasted;
|
||||
if (r.archiveRevisions.length && !ex.archiveRevisions.length) ex.archiveRevisions = r.archiveRevisions;
|
||||
} else {
|
||||
state.mdlList.push(r);
|
||||
if (r.trackingNumber) byTn[r.trackingNumber] = r;
|
||||
}
|
||||
});
|
||||
// Drop placements pointing at deliverables no longer loaded.
|
||||
var valid = Object.create(null);
|
||||
state.mdlList.forEach(function (r) { valid[r.id] = true; });
|
||||
Object.keys(state.assignments).forEach(function (k) {
|
||||
var a = state.assignments[k];
|
||||
if (a.mdlNodeId && !valid[a.mdlNodeId]) { a.mdlNodeId = null; cleanAssignment(k); }
|
||||
});
|
||||
rebuildIndex();
|
||||
notify();
|
||||
}
|
||||
function clearMdlList() { state.mdlList = []; notify(); } // rows only — assignments survive
|
||||
function getMdlList() { return state.mdlList; }
|
||||
function getMdlRow(id) { var i = infoFor(id); return (i && i.kind === 'mdl') ? i.node : null; }
|
||||
function getMdlRow(id) { return state.mdlList.filter(function (r) { return r.id === id; })[0] || null; }
|
||||
|
||||
// Build (creating folders as needed) the tracking-tree leaf a row points at:
|
||||
// "<tracking>_<rev (status)>". A tracking number's last segment is an
|
||||
// ancestor and the revision is ALWAYS the leaf, so a blank revision gets a
|
||||
// PENDING_REV placeholder leaf (the file shows incomplete until set; the
|
||||
// placeholder is pruned when the real revision lands).
|
||||
var PENDING_REV = 'pending';
|
||||
function leafForRow(row) {
|
||||
var tn = (row.trackingNumber || '').trim();
|
||||
if (!tn) return null;
|
||||
var rev = (row.revisionCell || '').trim() || PENDING_REV;
|
||||
return addTrackingPath(null, parseFolderLevels(tn + '_' + rev));
|
||||
}
|
||||
function assignFromRow(keys, row) {
|
||||
var leaf = leafForRow(row);
|
||||
if (!leaf || !keys || !keys.length) return;
|
||||
place(keys, leaf, 'tracking');
|
||||
keys.forEach(function (k) {
|
||||
row.placed[k] = true;
|
||||
if (row.title && row.title.trim()) {
|
||||
var a = state.assignments[k];
|
||||
if (a && !a.titleOverride) setTitleOverride(k, row.title);
|
||||
}
|
||||
});
|
||||
notify();
|
||||
}
|
||||
function nodeHasFiles(nodeId) {
|
||||
for (var k in state.assignments) { if (state.assignments[k].trackingNodeId === nodeId) return true; }
|
||||
return false;
|
||||
}
|
||||
// Delete a now-empty tracking leaf and any ancestors it leaves empty (so a
|
||||
// re-stamp doesn't litter the By-tracking tree with stale folders).
|
||||
function pruneEmptyTrackingChain(nodeId) {
|
||||
var info = infoFor(nodeId);
|
||||
while (info && info.kind === 'tracking') {
|
||||
if ((info.node.children || []).length || nodeHasFiles(info.node.id)) break;
|
||||
var parent = info.parent; // parent NODE (or null)
|
||||
deleteNode(info.node.id); // rebuilds the index
|
||||
info = parent ? infoFor(parent.id) : null;
|
||||
}
|
||||
}
|
||||
// Re-point a row's already-dropped files at the row's current leaf (after its
|
||||
// tracking number or revision was edited). Skips keys the user has since
|
||||
// un-placed elsewhere; prunes the leaves it empties.
|
||||
function restampRow(row) {
|
||||
var keys = Object.keys(row.placed || {});
|
||||
if (!keys.length) return;
|
||||
var leaf = leafForRow(row);
|
||||
if (!leaf) return;
|
||||
var old = Object.create(null);
|
||||
keys.forEach(function (k) {
|
||||
var a = state.assignments[k];
|
||||
if (a && a.trackingNodeId) { if (a.trackingNodeId !== leaf) old[a.trackingNodeId] = true; a.trackingNodeId = leaf; }
|
||||
else delete row.placed[k]; // user un-placed it elsewhere — don't resurrect
|
||||
});
|
||||
clearHashConflicts();
|
||||
Object.keys(old).forEach(pruneEmptyTrackingChain);
|
||||
notify();
|
||||
}
|
||||
function unassignRowFile(row, key) {
|
||||
var a = state.assignments[key], old = a ? a.trackingNodeId : null;
|
||||
if (row && row.placed) delete row.placed[key];
|
||||
place([key], null, 'tracking');
|
||||
if (old) pruneEmptyTrackingChain(old);
|
||||
}
|
||||
function setRowTracking(rowId, tn) {
|
||||
var r = getMdlRow(rowId); if (!r) return;
|
||||
r.trackingNumber = (tn == null ? '' : String(tn)).trim();
|
||||
restampRow(r); notify();
|
||||
}
|
||||
function setRowTitle(rowId, title) {
|
||||
var r = getMdlRow(rowId); if (!r) return;
|
||||
r.title = (title == null ? '' : String(title));
|
||||
Object.keys(r.placed || {}).forEach(function (k) { if (state.assignments[k]) setTitleOverride(k, r.title); });
|
||||
notify();
|
||||
}
|
||||
function setRevisionCell(rowId, value) { setRevisionCells([rowId], value); }
|
||||
function setRevisionCells(rowIds, value) {
|
||||
var set = Object.create(null); (rowIds || []).forEach(function (i) { set[i] = true; });
|
||||
var changed = false;
|
||||
state.mdlList.forEach(function (r) { if (set[r.id]) { r.revisionCell = (value == null ? '' : String(value)); changed = true; } });
|
||||
state.mdlList.forEach(function (r) {
|
||||
if (set[r.id]) { r.revisionCell = (value == null ? '' : String(value)); restampRow(r); changed = true; }
|
||||
});
|
||||
if (changed) notify();
|
||||
}
|
||||
function setTitleFromDeliverable(key, fromDeliverable) {
|
||||
var a = assignmentFor(key);
|
||||
a.titleFromDeliverable = !!fromDeliverable;
|
||||
cleanAssignment(key);
|
||||
notify();
|
||||
|
||||
// ── paste parsing + name matching (pure helpers, unit-tested) ─────────────
|
||||
// Parse Excel/TSV text into scratch rows. Columns: Tracking ⇥ Rev(Status) ⇥
|
||||
// Title; a 4th bare-status column merges into the revision; a lone cell that
|
||||
// parses as a full ZDDC filename is split; a header row is skipped.
|
||||
function parsePastedRows(text) {
|
||||
function unq(s) {
|
||||
s = (s == null ? '' : String(s)).trim();
|
||||
if (s.length >= 2 && s.charAt(0) === '"' && s.charAt(s.length - 1) === '"') s = s.slice(1, -1).replace(/""/g, '"');
|
||||
return s.trim();
|
||||
}
|
||||
var lines = String(text == null ? '' : text).replace(/\r\n?/g, '\n').split('\n');
|
||||
var rows = [], skipped = [], sawData = false;
|
||||
lines.forEach(function (raw, i) {
|
||||
if (!raw.trim()) return;
|
||||
var cells = raw.split('\t').map(unq);
|
||||
var c0 = cells[0] || '';
|
||||
if (!sawData && cells.length > 1 && /^(tracking|number|no\.?|doc(ument)?|drawing|item)\b/i.test(c0) && c0.indexOf('-') === -1) {
|
||||
return; // header row
|
||||
}
|
||||
var tracking = '', rev = '', title = '';
|
||||
if (cells.length === 1) {
|
||||
var p = window.zddc.parseFilename(c0);
|
||||
if (p && p.valid && p.trackingNumber) { tracking = p.trackingNumber; rev = p.revision + (p.status ? ' (' + p.status + ')' : ''); title = p.title || ''; }
|
||||
else tracking = c0;
|
||||
} else {
|
||||
tracking = c0;
|
||||
if (cells.length >= 4 && cells[2] && window.zddc.isValidStatus(cells[2])) { rev = (cells[1] + ' (' + cells[2] + ')').trim(); title = cells[3] || ''; }
|
||||
else { rev = cells[1] || ''; title = cells[2] || ''; }
|
||||
}
|
||||
if (!tracking) { skipped.push({ line: i + 1, reason: 'no tracking number', text: raw }); return; }
|
||||
sawData = true;
|
||||
rows.push({ trackingNumber: tracking, revisionCell: rev.trim(), title: title, source: { pasted: true } });
|
||||
});
|
||||
return { rows: rows, skipped: skipped };
|
||||
}
|
||||
function normTok(s) { return String(s == null ? '' : s).toUpperCase().replace(/[^A-Z0-9]/g, ''); }
|
||||
// Propose row matches for source files by finding a row whose tracking number
|
||||
// appears in the filename. opts.fuzzy also matches on the digit-run.
|
||||
function proposeMatches(files, rows, opts) {
|
||||
opts = opts || {};
|
||||
var out = [];
|
||||
(files || []).forEach(function (f) {
|
||||
var full = zddc.joinExtension(f.originalFilename, f.extension);
|
||||
var nameNorm = normTok(full), nameDigits = nameNorm.replace(/[^0-9]/g, ''), best = null;
|
||||
(rows || []).forEach(function (r) {
|
||||
var tn = r.trackingNumber || ''; if (!tn) return;
|
||||
var tnNorm = normTok(tn), conf = 0;
|
||||
if (full.indexOf(tn) !== -1) conf = 1;
|
||||
else if (tnNorm && nameNorm.indexOf(tnNorm) !== -1) conf = 0.8;
|
||||
else if (opts.fuzzy) { var d = tnNorm.replace(/[^0-9]/g, ''); if (d && nameDigits.indexOf(d) !== -1) conf = 0.5; }
|
||||
if (conf && (!best || conf > best.confidence)) best = { row: r, confidence: conf };
|
||||
});
|
||||
if (best) out.push({ file: f, row: best.row, confidence: best.confidence });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── add-folder pattern expansion ─────────────────────────────────────────
|
||||
|
|
@ -8045,9 +8249,12 @@ X.B(E,Y);return E}return J}())
|
|||
transmittalRecord: transmittalRecord,
|
||||
findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin,
|
||||
getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields,
|
||||
setMdlList: setMdlList, getMdlList: getMdlList, getMdlRow: getMdlRow,
|
||||
setMdlList: setMdlList, appendMdlRows: appendMdlRows, clearMdlList: clearMdlList,
|
||||
getMdlList: getMdlList, getMdlRow: getMdlRow,
|
||||
assignFromRow: assignFromRow, unassignRowFile: unassignRowFile,
|
||||
setRowTracking: setRowTracking, setRowTitle: setRowTitle,
|
||||
setRevisionCell: setRevisionCell, setRevisionCells: setRevisionCells,
|
||||
setTitleFromDeliverable: setTitleFromDeliverable,
|
||||
parsePastedRows: parsePastedRows, proposeMatches: proposeMatches,
|
||||
getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
|
||||
getTransmittalTree: function () { return state.transmittalTree; },
|
||||
// derive + reverse
|
||||
|
|
@ -9717,6 +9924,17 @@ X.B(E,Y);return E}return J}())
|
|||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ── Sorting ────────────────────────────────────────────────────────────
|
||||
// Render the tree in a stable, human order: case-insensitive, natural
|
||||
// (so "Rev 2" sorts before "Rev 10"). Non-mutating — sort copies at render.
|
||||
function cmpName(a, b) { return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); }
|
||||
function sortedFolders(list) { return (list || []).slice().sort(function (a, b) { return cmpName(a.name, b.name); }); }
|
||||
function sortedFiles(list) {
|
||||
return (list || []).slice().sort(function (a, b) {
|
||||
return cmpName(window.zddc.joinExtension(a.originalFilename, a.extension), window.zddc.joinExtension(b.originalFilename, b.extension));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Classify & Copy helpers ────────────────────────────────────────────
|
||||
function classifyOn() {
|
||||
var c = window.app.modules.classify;
|
||||
|
|
@ -9770,8 +9988,8 @@ X.B(E,Y);return E}return J}())
|
|||
var tt = window.app.modules.targetTree;
|
||||
return (tt && tt.activeAxis) ? tt.activeAxis() : 'tracking';
|
||||
}
|
||||
function axisField(ax) { return ax === 'transmittal' ? 'transmittalNodeId' : ax === 'mdl' ? 'mdlNodeId' : 'trackingNodeId'; }
|
||||
// Bucket a file relative to the active axis (tracking | transmittal | mdl):
|
||||
function axisField(ax) { return ax === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId'; }
|
||||
// Bucket a file relative to the active axis (tracking | transmittal):
|
||||
// 'excluded' | 'assigned' (on this axis) | 'partial' (assigned on a DIFFERENT
|
||||
// axis only — the to-do for this tab) | 'unassigned' (no axis).
|
||||
function fileCategory(file) {
|
||||
|
|
@ -9780,7 +9998,7 @@ X.B(E,Y);return E}return J}())
|
|||
if (a && a.excluded) return 'excluded';
|
||||
var ax = activeAxis();
|
||||
if (a && a[axisField(ax)]) return 'assigned';
|
||||
var others = ['tracking', 'transmittal', 'mdl'].filter(function (x) { return x !== ax; });
|
||||
var others = ['tracking', 'transmittal'].filter(function (x) { return x !== ax; });
|
||||
var any = a && others.some(function (x) { return a[axisField(x)]; });
|
||||
return any ? 'partial' : 'unassigned';
|
||||
}
|
||||
|
|
@ -9887,7 +10105,7 @@ X.B(E,Y);return E}return J}())
|
|||
return;
|
||||
}
|
||||
|
||||
window.app.folderTree.forEach(folder => {
|
||||
sortedFolders(window.app.folderTree).forEach(folder => {
|
||||
if (!folderShown(folder)) return;
|
||||
const element = createFolderElement(folder);
|
||||
container.appendChild(element);
|
||||
|
|
@ -10072,7 +10290,7 @@ X.B(E,Y);return E}return J}())
|
|||
if ((folder.expanded || autoOpen(folder)) && folder.children && folder.children.length > 0) {
|
||||
const childrenDiv = document.createElement('div');
|
||||
childrenDiv.className = 'folder-children';
|
||||
folder.children.forEach(child => {
|
||||
sortedFolders(folder.children).forEach(child => {
|
||||
if (!folderShown(child)) return;
|
||||
const childElement = createFolderElement(child, level + 1);
|
||||
childrenDiv.appendChild(childElement);
|
||||
|
|
@ -10085,7 +10303,7 @@ X.B(E,Y);return E}return J}())
|
|||
if (classifyOn() && (folder.expanded || autoOpen(folder)) && folder.files && folder.files.length > 0) {
|
||||
const filesDiv = document.createElement('div');
|
||||
filesDiv.className = 'folder-children folder-files';
|
||||
folder.files.forEach(function (file) {
|
||||
sortedFiles(folder.files).forEach(function (file) {
|
||||
if (!fileShown(file)) return;
|
||||
filesDiv.appendChild(createFileElement(file, level + 1));
|
||||
});
|
||||
|
|
@ -10792,8 +11010,10 @@ X.B(E,Y);return E}return J}())
|
|||
var openForm = null; // { partyId, slot } when a bin form is open
|
||||
var initialized = false;
|
||||
var currentTab = 'tracking'; // 'tracking' | 'existing' | 'transmittal' — active tab
|
||||
var mdlTable = null; // the seltable controller for the catalog
|
||||
var mdlPlaced = {}; // latest placed.mdl map (read by the placed-file cell)
|
||||
var mdlTable = null; // the seltable controller for the "From a list" tab
|
||||
var mdlPlaced = {}; // trackingNumber -> placed files (read by the Files cell)
|
||||
var hideAssigned = false; // "Hide assigned" toggle in the From-a-list toolbar
|
||||
var listScanned = false; // a Load has run this session (drives the "new" badge)
|
||||
|
||||
function init() {
|
||||
if (initialized) return;
|
||||
|
|
@ -10809,6 +11029,10 @@ X.B(E,Y);return E}return J}())
|
|||
transmittalTree: document.getElementById('transmittalTree'),
|
||||
mdlTree: document.getElementById('mdlTree'),
|
||||
loadMdlBtn: document.getElementById('loadMdlBtn'),
|
||||
pasteRowsBtn: document.getElementById('pasteRowsBtn'),
|
||||
matchNamesBtn: document.getElementById('matchNamesBtn'),
|
||||
clearListBtn: document.getElementById('clearListBtn'),
|
||||
hideAssignedToggle: document.getElementById('hideAssignedToggle'),
|
||||
addTrackingRootBtn: document.getElementById('addTrackingRootBtn'),
|
||||
addPartyBtn: document.getElementById('addPartyBtn'),
|
||||
stats: document.getElementById('classifyStats'),
|
||||
|
|
@ -10818,6 +11042,25 @@ X.B(E,Y);return E}return J}())
|
|||
if (els.existingTab) els.existingTab.addEventListener('click', function () { showTab('existing'); });
|
||||
els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); });
|
||||
if (els.loadMdlBtn) els.loadMdlBtn.addEventListener('click', loadMdl);
|
||||
if (els.pasteRowsBtn) els.pasteRowsBtn.addEventListener('click', function () { openPasteDialog(''); });
|
||||
if (els.matchNamesBtn) els.matchNamesBtn.addEventListener('click', openMatchDialog);
|
||||
if (els.clearListBtn) els.clearListBtn.addEventListener('click', function () {
|
||||
if (!C().getMdlList().length) return;
|
||||
C().clearMdlList();
|
||||
window.zddc.toast('List cleared — every assignment is kept (see By tracking number).', 'info');
|
||||
});
|
||||
if (els.hideAssignedToggle) els.hideAssignedToggle.addEventListener('change', function () {
|
||||
hideAssigned = !!els.hideAssignedToggle.checked;
|
||||
if (mdlTable) mdlTable.renderBody();
|
||||
});
|
||||
// Ctrl-V anywhere on the From-a-list panel opens the paste dialog prefilled.
|
||||
if (els.mdlPanel) els.mdlPanel.addEventListener('paste', function (e) {
|
||||
if (currentTab !== 'existing') return;
|
||||
if (e.target && e.target.closest('input, textarea')) return; // let real inputs paste
|
||||
var t = (e.clipboardData || window.clipboardData);
|
||||
var text = t ? t.getData('text') : '';
|
||||
if (text) { e.preventDefault(); openPasteDialog(text); }
|
||||
});
|
||||
els.addTrackingRootBtn.addEventListener('click', function () {
|
||||
var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n'
|
||||
+ 'Brace patterns expand: BMB-{PM,EL}-{0001-0002,0005}_A (IFR)', '');
|
||||
|
|
@ -10858,15 +11101,20 @@ X.B(E,Y);return E}return J}())
|
|||
}
|
||||
// One pass: group files by the node they're placed in, per axis.
|
||||
function buildPlaced(files) {
|
||||
var c = C(), byT = {}, byX = {}, byM = {};
|
||||
var c = C(), byT = {}, byX = {}, byTn = {};
|
||||
files.forEach(function (f) {
|
||||
var a = c.getAssignment(c.srcKeyForFile(f));
|
||||
if (!a) return;
|
||||
if (a.trackingNodeId) (byT[a.trackingNodeId] = byT[a.trackingNodeId] || []).push(f);
|
||||
if (a.trackingNodeId) {
|
||||
(byT[a.trackingNodeId] = byT[a.trackingNodeId] || []).push(f);
|
||||
// Also index by tracking NUMBER so a "From a list" row can show
|
||||
// the files placed under it (a row is a tracking number, not a node).
|
||||
var tn = c.deriveTarget(f).tracking;
|
||||
if (tn) (byTn[tn] = byTn[tn] || []).push(f);
|
||||
}
|
||||
if (a.transmittalNodeId) (byX[a.transmittalNodeId] = byX[a.transmittalNodeId] || []).push(f);
|
||||
if (a.mdlNodeId) (byM[a.mdlNodeId] = byM[a.mdlNodeId] || []).push(f);
|
||||
});
|
||||
return { tracking: byT, transmittal: byX, mdl: byM };
|
||||
return { tracking: byT, transmittal: byX, byTracking: byTn };
|
||||
}
|
||||
|
||||
function showTab(which) {
|
||||
|
|
@ -10882,8 +11130,8 @@ X.B(E,Y);return E}return J}())
|
|||
// with the active tab — re-render the left tree.
|
||||
reRenderSource();
|
||||
}
|
||||
// The active axis is the catalog ('mdl') on the "By existing" tab, else the tab's.
|
||||
function activeAxis() { return currentTab === 'existing' ? 'mdl' : (currentTab === 'transmittal' ? 'transmittal' : 'tracking'); }
|
||||
// "From a list" drops materialize tracking placements, so its axis is 'tracking'.
|
||||
function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : 'tracking'; }
|
||||
function reRenderSource() { if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); }
|
||||
|
||||
// Expand a brace pattern into folder names and create them (confirming a
|
||||
|
|
@ -10909,7 +11157,7 @@ X.B(E,Y);return E}return J}())
|
|||
var placed = buildPlaced(files);
|
||||
renderTrackingInto(els.trackingTree, C().getTrackingTree(), placed.tracking);
|
||||
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal);
|
||||
renderMdlInto(placed.mdl);
|
||||
renderMdlInto(placed.byTracking);
|
||||
renderStats(files);
|
||||
}
|
||||
|
||||
|
|
@ -11243,47 +11491,43 @@ X.B(E,Y);return E}return J}())
|
|||
return form;
|
||||
}
|
||||
|
||||
// ── By MDL (deliverables as drop targets via the shared seltable) ───────
|
||||
function renderMdlInto(placedMdl) {
|
||||
mdlPlaced = placedMdl || {};
|
||||
// ── "From a list" (scratch worklist via the shared seltable) ────────────
|
||||
function renderMdlInto(placedByTracking) {
|
||||
mdlPlaced = placedByTracking || {};
|
||||
if (!C().getMdlList().length) {
|
||||
mdlTable = null;
|
||||
els.mdlTree.textContent = '';
|
||||
els.mdlTree.appendChild(el('div', 'target-empty', 'Nothing loaded yet — “Load…”, tick the directories to scan, and their existing files + MDL deliverables appear here (one row per tracking number, latest revision).'));
|
||||
els.mdlTree.appendChild(el('div', 'target-empty', 'Empty — “Load…” numbers from the archive/MDL, “Paste rows…” from Excel, or “⚡ Match names”. Then drag files onto a row to name them. The list is a scratch pad — clearing it keeps every assignment (see By tracking number).'));
|
||||
return;
|
||||
}
|
||||
ensureMdlTable();
|
||||
mdlTable.renderBody();
|
||||
}
|
||||
function rowPlaced(r) { var f = mdlPlaced[r.trackingNumber]; return f && f.length ? f : null; }
|
||||
function ensureMdlTable() {
|
||||
if (mdlTable) return mdlTable;
|
||||
var c = C();
|
||||
// One column per configured tracking-number field (split positionally),
|
||||
// then Title, MDL (✓), Archive revisions (informational), and the editable
|
||||
// classifier-local Revision. Each column has its own autofilter.
|
||||
var cols = c.getTrackingFields().map(function (f, i) {
|
||||
return { key: 'f' + i, title: f.name, get: function (r) { return (r.trackingNumber || '').split('-')[i] || ''; } };
|
||||
});
|
||||
cols.push({ key: 'title', title: 'Title', get: function (r) { return r.title || ''; } });
|
||||
cols.push({ key: 'mdl', title: 'MDL', cls: 'catalog-mdl', get: function (r) { return r.inMdl ? '✓' : ''; } });
|
||||
cols.push({ key: 'latest', title: 'Latest rev', get: function (r) { return latestRevOf(r.archiveRevisions); } });
|
||||
cols.push({
|
||||
key: 'rev', title: 'Revision', cls: 'mdl-rev', get: function (r) { return r.revisionCell; },
|
||||
render: function (r, td) {
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'text'; inp.className = 'mdl-rev__input'; inp.value = r.revisionCell || '';
|
||||
inp.placeholder = 'A (IFR)'; inp.setAttribute('data-no-select', '');
|
||||
inp.addEventListener('change', function () { c.setRevisionCell(r.id, inp.value.trim()); });
|
||||
td.appendChild(inp);
|
||||
},
|
||||
});
|
||||
var cols = [
|
||||
{ key: 'tn', title: 'Tracking number', cls: 'fromlist-tn', get: function (r) { return r.trackingNumber || ''; },
|
||||
render: function (r, td) { editCell(td, 'fromlist-tn__input', r.trackingNumber, 'ACME-…-0001', function (v) { c.setRowTracking(r.id, v); }, tnWarn(r)); } },
|
||||
{ key: 'title', title: 'Title', cls: 'fromlist-title', get: function (r) { return r.title || ''; },
|
||||
render: function (r, td) { editCell(td, 'fromlist-title__input', r.title, 'title', function (v) { c.setRowTitle(r.id, v); }); } },
|
||||
{ key: 'src', title: 'Source', cls: 'fromlist-src', get: function (r) { var s = r.source || {}; return [s.mdl ? 'mdl' : '', s.archive ? 'arch' : '', s.pasted ? 'pasted' : ''].filter(Boolean).join(' '); },
|
||||
render: function (r, td) { renderSource(r, td); } },
|
||||
{ key: 'latest', title: 'Latest rev', get: function (r) { return latestRevOf(r.archiveRevisions); } },
|
||||
{ key: 'rev', title: 'Revision', cls: 'mdl-rev', get: function (r) { return r.revisionCell; },
|
||||
render: function (r, td) { editCell(td, 'mdl-rev__input', r.revisionCell, 'A (IFR)', function (v) { c.setRevisionCell(r.id, v); }); } },
|
||||
];
|
||||
mdlTable = window.app.modules.seltable.create({
|
||||
container: els.mdlTree,
|
||||
extraTitle: 'Files',
|
||||
rows: function () { return c.getMdlList(); },
|
||||
rows: function () {
|
||||
var list = c.getMdlList();
|
||||
return hideAssigned ? list.filter(function (r) { return !rowPlaced(r); }) : list;
|
||||
},
|
||||
rowId: function (r) { return r.id; },
|
||||
columns: cols,
|
||||
onRowDrop: function (rowId, keys) { c.place(keys, rowId, 'mdl'); },
|
||||
onRowDrop: function (rowId, keys) { var row = c.getMdlRow(rowId); if (row) c.assignFromRow(keys, row); },
|
||||
onActivate: function (ids) {
|
||||
if (!ids.length) return;
|
||||
var v = prompt('Set the revision on ' + ids.length + ' selected row(s) (e.g. "A (IFR)"):', '');
|
||||
|
|
@ -11294,8 +11538,37 @@ X.B(E,Y);return E}return J}())
|
|||
mdlTable.render();
|
||||
return mdlTable;
|
||||
}
|
||||
// An editable seltable cell: an <input> that commits on change. `warn` is an
|
||||
// optional tooltip that flags (without blocking) a questionable value.
|
||||
function editCell(td, cls, value, placeholder, onCommit, warn) {
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'text'; inp.className = cls + (warn ? ' is-warn' : ''); inp.value = value || '';
|
||||
inp.placeholder = placeholder || ''; inp.spellcheck = false; inp.setAttribute('data-no-select', '');
|
||||
if (warn) inp.title = warn;
|
||||
inp.addEventListener('change', function () { onCommit(inp.value.trim()); });
|
||||
td.appendChild(inp);
|
||||
}
|
||||
function tnWarn(r) {
|
||||
var tn = (r.trackingNumber || '').trim(); if (!tn) return '';
|
||||
var n = tn.split('-').length, want = C().getTrackingFields().length;
|
||||
return n < want - 1 || n > want ? ('Has ' + n + ' segments; the pattern expects ' + want + '.') : '';
|
||||
}
|
||||
function renderSource(row, td) {
|
||||
var s = row.source || {};
|
||||
if (s.mdl) td.appendChild(el('span', 'src-badge src-badge--mdl', 'MDL'));
|
||||
if (s.archive) td.appendChild(el('span', 'src-badge src-badge--arch', 'arch'));
|
||||
if (s.pasted && !s.mdl && !s.archive) {
|
||||
// A pasted number matching nothing known: a likely typo / a brand-new number.
|
||||
var isNew = listScanned;
|
||||
var b = el('span', 'src-badge src-badge--new', isNew ? 'new' : 'unverified');
|
||||
b.title = isNew ? 'This tracking number isn’t in the scanned archive/MDL — you’re inventing it.' : 'Not checked against the archive/MDL — Load a directory to verify.';
|
||||
td.appendChild(b);
|
||||
} else if (s.pasted) {
|
||||
td.appendChild(el('span', 'src-badge src-badge--pasted', 'pasted'));
|
||||
}
|
||||
}
|
||||
function renderMdlPlaced(row, td) {
|
||||
var c = C(), files = mdlPlaced[row.id] || [];
|
||||
var c = C(), files = rowPlaced(row) || [];
|
||||
files.forEach(function (f) {
|
||||
var d = c.deriveTarget(f);
|
||||
var a = c.getAssignment(d.key) || {};
|
||||
|
|
@ -11305,13 +11578,14 @@ X.B(E,Y);return E}return J}())
|
|||
var nm = el('span', 'mdlfile__name', d.filename || '(set a revision)');
|
||||
nm.title = 'from ' + f.originalFilename + (f.extension ? '.' + f.extension : '');
|
||||
line.appendChild(nm);
|
||||
var tgl = el('button', 'tnode__act', a.titleFromDeliverable === false ? 'Title: file' : 'Title: MDL');
|
||||
tgl.title = 'Use the deliverable’s title or the file’s own';
|
||||
tgl.addEventListener('click', function () { c.setTitleFromDeliverable(d.key, a.titleFromDeliverable === false); });
|
||||
var usingRow = a.titleOverride != null && row.title && a.titleOverride === row.title.trim();
|
||||
var tgl = el('button', 'tnode__act', usingRow ? 'Title: row' : 'Title: file');
|
||||
tgl.title = 'Use the row’s title or the file’s own';
|
||||
tgl.addEventListener('click', function () { c.setTitleOverride(d.key, usingRow ? '' : row.title); });
|
||||
line.appendChild(tgl);
|
||||
var rm = el('button', 'tnode__act tfile__remove', '✕');
|
||||
rm.title = 'Remove from this deliverable';
|
||||
rm.addEventListener('click', function () { c.place([d.key], null, 'mdl'); });
|
||||
rm.title = 'Remove this file from the row';
|
||||
rm.addEventListener('click', function () { c.unassignRowFile(row, d.key); });
|
||||
line.appendChild(rm);
|
||||
td.appendChild(line);
|
||||
});
|
||||
|
|
@ -11414,13 +11688,123 @@ X.B(E,Y);return E}return J}())
|
|||
}
|
||||
}
|
||||
function finishLoad(rows) {
|
||||
C().setMdlList(rows);
|
||||
listScanned = true;
|
||||
C().appendMdlRows(rows); // APPEND — the list accumulates across batches
|
||||
showTab('existing');
|
||||
window.zddc.toast(rows.length
|
||||
? ('Catalog: ' + rows.length + ' tracking number' + (rows.length === 1 ? '' : 's') + ' from the selected directories. Drag files on, set revisions.')
|
||||
? ('Added ' + rows.length + ' tracking number' + (rows.length === 1 ? '' : 's') + ' from the selected directories. Drag files on, set revisions.')
|
||||
: 'No files or deliverables in the selected directories.', rows.length ? 'success' : 'warning');
|
||||
}
|
||||
|
||||
// ── paste + match dialogs (reuse the .copy-choice modal shell) ──────────
|
||||
function scratchModal(titleText, hintText) {
|
||||
var done = false;
|
||||
function close() { if (done) return; done = true; document.removeEventListener('keydown', onKey); back.remove(); }
|
||||
function onKey(e) { if (e.key === 'Escape') close(); }
|
||||
var back = el('div', 'copy-choice__backdrop');
|
||||
var box = el('div', 'copy-choice copy-choice--wide');
|
||||
box.appendChild(el('h3', null, titleText));
|
||||
if (hintText) box.appendChild(el('p', null, hintText));
|
||||
var body = el('div', 'scratch-modal__body'); box.appendChild(body);
|
||||
var foot = el('div', 'copy-choice__btns'); box.appendChild(foot);
|
||||
back.appendChild(box);
|
||||
back.addEventListener('click', function (e) { if (e.target === back) close(); });
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.body.appendChild(back);
|
||||
return { body: body, foot: foot, close: close };
|
||||
}
|
||||
function openPasteDialog(prefill) {
|
||||
var c = C();
|
||||
var m = scratchModal('Paste rows from Excel', 'Columns: Tracking · Rev (Status) · Title — tab-separated, as Excel copies. A header row is skipped; a pasted full filename is split.');
|
||||
var ta = document.createElement('textarea');
|
||||
ta.className = 'scratch-paste__ta'; ta.rows = 6; ta.spellcheck = false;
|
||||
ta.placeholder = 'ACME-AR-DWG-0001\tA (IFR)\tFloor plan';
|
||||
ta.value = prefill || '';
|
||||
m.body.appendChild(ta);
|
||||
var preview = el('div', 'scratch-paste__preview'); m.body.appendChild(preview);
|
||||
var add = el('button', 'btn btn-primary', 'Add rows'); add.disabled = true;
|
||||
var cancel = el('button', 'btn btn-secondary', 'Cancel'); cancel.addEventListener('click', m.close);
|
||||
m.foot.appendChild(add); m.foot.appendChild(cancel);
|
||||
var parsed = { rows: [], skipped: [] };
|
||||
function refresh() {
|
||||
parsed = c.parsePastedRows(ta.value);
|
||||
preview.textContent = '';
|
||||
if (parsed.rows.length) {
|
||||
var tbl = el('table', 'scratch-preview__table');
|
||||
var head = el('tr'); ['Tracking number', 'Revision', 'Title'].forEach(function (h) { head.appendChild(el('th', null, h)); }); tbl.appendChild(head);
|
||||
parsed.rows.slice(0, 50).forEach(function (r) {
|
||||
var tr = el('tr');
|
||||
tr.appendChild(el('td', null, r.trackingNumber));
|
||||
tr.appendChild(el('td', null, r.revisionCell || ''));
|
||||
tr.appendChild(el('td', null, r.title || ''));
|
||||
tbl.appendChild(tr);
|
||||
});
|
||||
preview.appendChild(tbl);
|
||||
if (parsed.rows.length > 50) preview.appendChild(el('div', 'scratch-preview__more', '…and ' + (parsed.rows.length - 50) + ' more'));
|
||||
}
|
||||
parsed.skipped.forEach(function (s) { preview.appendChild(el('div', 'scratch-preview__skip', 'Line ' + s.line + ' skipped — ' + s.reason)); });
|
||||
add.disabled = !parsed.rows.length;
|
||||
add.textContent = parsed.rows.length ? ('Add ' + parsed.rows.length + ' row' + (parsed.rows.length === 1 ? '' : 's')) : 'Add rows';
|
||||
}
|
||||
add.addEventListener('click', function () {
|
||||
var n = parsed.rows.length;
|
||||
c.appendMdlRows(parsed.rows);
|
||||
m.close(); showTab('existing');
|
||||
window.zddc.toast('Added ' + n + ' pasted row' + (n === 1 ? '' : 's') + '.', 'success');
|
||||
});
|
||||
ta.addEventListener('input', refresh);
|
||||
refresh(); ta.focus();
|
||||
}
|
||||
function openMatchDialog() {
|
||||
var c = C();
|
||||
var rows = c.getMdlList();
|
||||
if (!rows.length) { window.zddc.toast('Load or paste some tracking numbers first.', 'warning'); return; }
|
||||
var files = allFiles().filter(function (f) {
|
||||
var a = c.getAssignment(c.srcKeyForFile(f));
|
||||
return !(a && (a.trackingNodeId || a.excluded));
|
||||
});
|
||||
if (!files.length) { window.zddc.toast('No unassigned files to match.', 'info'); return; }
|
||||
var m = scratchModal('Match names', 'Files whose name contains a known tracking number. Review, then assign the checked matches.');
|
||||
var opts = { fuzzy: false };
|
||||
var fuzzyLbl = el('label', 'scratch-match__fuzzy');
|
||||
var fuzzy = document.createElement('input'); fuzzy.type = 'checkbox';
|
||||
fuzzyLbl.appendChild(fuzzy); fuzzyLbl.appendChild(document.createTextNode(' Looser matching (digits only)'));
|
||||
m.body.appendChild(fuzzyLbl);
|
||||
var list = el('div', 'scratch-match__list'); m.body.appendChild(list);
|
||||
var accept = el('button', 'btn btn-primary', 'Assign');
|
||||
var cancel = el('button', 'btn btn-secondary', 'Cancel'); cancel.addEventListener('click', m.close);
|
||||
m.foot.appendChild(accept); m.foot.appendChild(cancel);
|
||||
var proposals = [];
|
||||
function refresh() {
|
||||
proposals = c.proposeMatches(files, rows, opts);
|
||||
list.textContent = '';
|
||||
if (!proposals.length) { list.appendChild(el('div', 'scratch-preview__skip', 'No matches found.')); accept.disabled = true; accept.textContent = 'Assign'; return; }
|
||||
proposals.forEach(function (p, i) {
|
||||
var rowEl = el('label', 'scratch-match__row');
|
||||
var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = true; cb.dataset.i = i;
|
||||
rowEl.appendChild(cb);
|
||||
rowEl.appendChild(el('span', 'scratch-match__file', zddc.joinExtension(p.file.originalFilename, p.file.extension)));
|
||||
rowEl.appendChild(el('span', 'scratch-match__arrow', '→'));
|
||||
rowEl.appendChild(el('span', 'scratch-match__tn', p.row.trackingNumber));
|
||||
rowEl.appendChild(el('span', 'scratch-match__conf', Math.round(p.confidence * 100) + '%'));
|
||||
list.appendChild(rowEl);
|
||||
});
|
||||
accept.disabled = false; accept.textContent = 'Assign ' + proposals.length;
|
||||
}
|
||||
accept.addEventListener('click', function () {
|
||||
var n = 0;
|
||||
Array.prototype.forEach.call(list.querySelectorAll('input[type=checkbox]'), function (cb) {
|
||||
if (!cb.checked) return;
|
||||
var p = proposals[Number(cb.dataset.i)];
|
||||
if (p) { c.assignFromRow([c.srcKeyForFile(p.file)], p.row); n++; }
|
||||
});
|
||||
m.close(); showTab('existing');
|
||||
window.zddc.toast('Assigned ' + n + ' file' + (n === 1 ? '' : 's') + ' by name match.', n ? 'success' : 'info');
|
||||
});
|
||||
fuzzy.addEventListener('change', function () { opts.fuzzy = fuzzy.checked; refresh(); });
|
||||
refresh();
|
||||
}
|
||||
|
||||
// ── events ─────────────────────────────────────────────────────────────
|
||||
function closestNodeId(target) {
|
||||
var n = target.closest('[data-id]');
|
||||
|
|
@ -11622,10 +12006,7 @@ X.B(E,Y);return E}return J}())
|
|||
function reveal(key) {
|
||||
var a = C().getAssignment(key);
|
||||
if (!a) return;
|
||||
if (a.mdlNodeId) {
|
||||
showTab('existing');
|
||||
if (mdlTable) { mdlTable.renderBody(); }
|
||||
} else if (a.trackingNodeId) {
|
||||
if (a.trackingNodeId) {
|
||||
showTab('tracking'); collapsed = {}; render();
|
||||
flashNode(els.trackingTree, a.trackingNodeId);
|
||||
} else if (a.transmittalNodeId) {
|
||||
|
|
|
|||
|
|
@ -1778,7 +1778,7 @@ body {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
|
|||
|
|
@ -2770,7 +2770,7 @@ dialog.modal--narrow {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Transmittal</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5</span></span>
|
||||
</div>
|
||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||
<!-- Publish split-button (Transmittal-specific primary action;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||
archive=v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3
|
||||
transmittal=v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3
|
||||
classifier=v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3
|
||||
landing=v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3
|
||||
form=v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3
|
||||
tables=v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3
|
||||
browse=v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3
|
||||
archive=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5
|
||||
transmittal=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5
|
||||
classifier=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5
|
||||
landing=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5
|
||||
form=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5
|
||||
tables=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5
|
||||
browse=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5
|
||||
|
|
|
|||
|
|
@ -1252,7 +1252,8 @@ body.is-elevated::after {
|
|||
.seltable__bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
|
||||
.seltable__count { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
|
||||
.seltable__scroll { flex: 1; min-height: 0; overflow: auto; }
|
||||
.seltable__table { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; }
|
||||
/* width:auto + nowrap cells → each column shrinks to fit its header/longest cell. */
|
||||
.seltable__table { border-collapse: separate; border-spacing: 0; width: auto; font-size: 0.82rem; }
|
||||
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
|
||||
.seltable__table thead th {
|
||||
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
|
||||
|
|
@ -1260,7 +1261,7 @@ body.is-elevated::after {
|
|||
}
|
||||
.seltable__table thead tr.seltable__filters th { top: 1.55rem; padding: 0.15rem 0.35rem; }
|
||||
.seltable__colfilter {
|
||||
width: 100%; min-width: 5rem; padding: 0.15rem 0.35rem;
|
||||
width: 100%; min-width: 2rem; box-sizing: border-box; padding: 0.15rem 0.35rem;
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--bg); color: var(--text); font-size: 0.74rem; font-weight: 400; text-transform: none; letter-spacing: 0;
|
||||
}
|
||||
|
|
@ -1769,7 +1770,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
|
|||
Loading…
Reference in a new issue