/* ── User-defined set structure dialog (ADR 0016) ──────────────────────────── Lets the user define which line belongs to which set (and how deep) instead of relying only on the implicit "everything after a Set header until the next Set" derivation that fds__prepInvoice delivers as the initial structure. The dialog is a pure view of the authoritative server state (state.req[].itm — the co shape with SetItmId + lvl): every action posts ONE delta (set.assign / set.level / set.create) via $inv.d.sync, the server rewrites the cached block (parent links, depth-first order, levels) and the normal draftReady/dstate refresh re-renders both the editor rows and this dialog (applyState -> $inv.sets.render). The browser never restructures anything itself (ADR 0006/0008). */ $inv.sets = { o: null, /* open dialog handle ($ocms.dlg result) or null */ c: null, /* dialog content container */ drag: null, /* { id, bid } of the row currently being dragged */ /* Reorder = set.move (▲▼ or drag before/after a row), membership = set.assign (drag into a set / root zone), depth = set.level (◀▶). Folding is purely a view state kept in $inv.sets.collapsed. */ isOpen: () => !!$inv.sets.o, open: function () { let tbl = $inv.d.tbl(); if (tbl.length < 1 || $inv.sets.isOpen()) { return; } if ($inv.d.token() === '' && !tbl.data('dstate')) { return; } /* no draft session (e.g. reminder editor) — nothing to structure */ let c = $$.dc('setstruct'); $inv.sets.c = c; $inv.sets.o = $ocms.dlg(c, { title: $ict.setst, size: [-1, 960], exclusive: false, close: () => { $inv.sets.o = null; $inv.sets.c = null; $inv.sets.drag = null; } }); let state = tbl.data('dstate'); if (state) { $inv.sets.render(state); } else { $inv.d.refresh(); } }, /* plain-text title of a co line (co.t is HTML for text lines / titles) */ title: (co) => { let t = $$.d().html(co.t || '').text().replace(/\s+/g, ' ').trim(); return t !== '' ? t : ($ict.setsNoTitle || ''); }, typLbl: (co) => { let k = (co.typ || '').toString().toLowerCase(); return ($ict.settyp || {})[k] || co.typ || ''; }, isSet: (co) => (co.typ || '').toString().toLowerCase() === 'set', /* Normalised parent id of a line, mirroring InvoiceSetStructure.ParentOf: SetItmId when it names ANOTHER set line of the same block; '' for top level / self-reference / unknown. */ parentOf: (co, byId) => { let p = (co.SetItmId == null ? '' : co.SetItmId).toString(), id = (co.id || '').toString(); if (p === '' || p === id) { return ''; } let t = byId[p]; return (t && $inv.sets.isSet(t)) ? p : ''; }, isDescendant: (candidateId, ancestorId, byId) => { let seen = {}, cur = byId[candidateId]; while (cur) { let p = $inv.sets.parentOf(cur, byId); if (p === '' || seen[p]) { return false; } if (p === ancestorId) { return true; } seen[p] = true; cur = byId[p]; } return false; }, /* Whether a line may be dropped onto the given target set (same block, not itself, no cycle). */ canDrop: (drag, targetId, bid, byId) => !!drag && drag.bid === bid && drag.id !== targetId && !$inv.sets.isDescendant(targetId, drag.id, byId), sync: (delta) => { if ($inv.sets.c) { $inv.sets.c.aC('busy'); } return $inv.d.sync(delta); }, render: function (state) { let c = $inv.sets.c; if (!c) { return; } let bdy = c.closest('.modal-body'), scroll = bdy.length ? bdy.scrollTop() : 0; c.empty().rC('busy'); $$.dc('shint', c).text($ict.setsHint || ''); (state.req || []).forEach((b) => { if (!b) { return; } let bid = (b.Id || '').toString(); if (bid === '') { return; } let itms = (b.itm || []).filter((co) => co && (co.id || '') !== ''), byId = {}; itms.forEach((co) => { byId[(co.id || '').toString()] = co; }); let blk = $$.dc('sblock', c).data({ bid: bid }); $inv.sets.rootZone($$.dc('sbhd', blk).append($$.sc('nme', b.text || b.nme || bid)), bid, byId); let ts = $$.tblset({ class: 'stbl' }, blk); let thr = $$.tr(ts.hd); ($ict.setsHR || []).forEach((h) => $$.th(thr, h)); itms.forEach((co) => $inv.sets.row(ts.bdy, co, bid, itms, byId)); $inv.sets.rootZone($$.dc('sroot', blk).text($ict.setsRoot || ''), bid, byId); $inv.sets.createBar(blk, bid, itms, byId); }); if (bdy.length) { bdy.scrollTop(scroll); } }, /* Ancestor-collapsed test: a row is hidden while any set above it is folded. */ collapsed: {}, /* set id -> true (survives re-renders; default: everything expanded) */ hiddenByCollapse: function (co, byId) { let seen = {}, cur = co; while (cur) { let p = $inv.sets.parentOf(cur, byId); if (p === '' || seen[p]) { return false; } if ($inv.sets.collapsed[p]) { return true; } seen[p] = true; cur = byId[p]; } return false; }, /* Drop zone of a pointer position within a row: top edge = before, bottom edge = after, middle = into (sets only; for other rows the middle counts as "after"). */ zone: function (ev, tr, isSet) { let oe = ev.originalEvent || ev, r = tr[0].getBoundingClientRect(), y = (oe.clientY || 0) - r.top, h = r.height || 1; if (y < h * 0.3) { return 'before'; } if (y > h * 0.7 || !isSet) { return 'after'; } return 'into'; }, clearDrop: () => { $('.setstruct .dropok, .setstruct .dropbefore, .setstruct .dropafter').rC('dropok dropbefore dropafter'); }, /* One line of the structure table: drag handle, fold toggle, pos, type, qty, indented title, order buttons (▲▼ among siblings) and level buttons (◀▶). */ row: function (tbody, co, bid, itms, byId) { let id = (co.id || '').toString(), lvl = parseInt(co.lvl || 0, 10) || 0, isSet = $inv.sets.isSet(co); let parent = $inv.sets.parentOf(co, byId), folded = isSet && !!$inv.sets.collapsed[id]; let tr = $$.tr(tbody, { draggable: 'true' }).aC('srow lvl' + lvl).tC('isset', isSet).tC('folded', folded).data({ id: id, bid: bid }); if ($inv.sets.hiddenByCollapse(co, byId)) { tr.aC('hid'); } $$.tdc('grip', tr).append(gi('move')).attr('title', $rct.mR); $$.tdc('pos', tr).text(co.p != null ? co.p : ''); let typ = $$.tdc('typ', tr); if (isSet) { /* fold toggle sits in the type column, in front of "Set" */ typ.append($$.sc('fold').attr('title', folded ? $ict.setsExpand : $ict.setsCollapse).append(gi(folded ? 'triangle-right' : 'triangle-bottom')).click((ev) => { ev.stopPropagation(); if (folded) { delete $inv.sets.collapsed[id]; } else { $inv.sets.collapsed[id] = true; } let state = $inv.d.tbl().data('dstate'); if (state) { $inv.sets.render(state); } })); } typ.append($$.s($inv.sets.typLbl(co))); $$.tdc('qty', tr).text(co.q || ''); let ttl = $$.tdc('ttl', tr); if (parent !== '') { ttl.append($$.sc('seti', $inv.sets.title(byId[parent]))); } ttl.append($$.sc('txt', $inv.sets.title(co)).click(function (ev) { ev.stopPropagation(); $(this).tC('exp'); })); if (isSet) { let n = itms.filter((x) => $inv.sets.parentOf(x, byId) === id).length; ttl.append($$.sc('cnt', ' (' + n + ')')); } /* siblings (same parent, current order) drive the ▲▼ buttons; a preceding sibling set drives ▶ */ let sibs = itms.filter((x) => $inv.sets.parentOf(x, byId) === parent), si = sibs.findIndex((x) => (x.id || '').toString() === id); let hasSibSet = sibs.slice(0, si).some((x) => $inv.sets.isSet(x)); let act = $$.tdc('act', tr); let btn = (cls, glyph, title, enabled, delta) => $$.dc('ibtn ' + cls, act, { title: title }).append(gi(glyph)).tC('disabled', !enabled) .click(() => { if (enabled) { $inv.sets.sync(delta); } }); btn('up', 'arrow-up', $ict.setsUp, si > 0, { Target: 'set.move', Ref: id, Value: -1 }); btn('down', 'arrow-down', $ict.setsDown, si >= 0 && si < sibs.length - 1, { Target: 'set.move', Ref: id, Value: 1 }); btn('out', 'chevron-left', $ict.setsOut, lvl > 0, { Target: 'set.level', Ref: id, Value: -1 }); btn('in', 'chevron-right', $ict.setsIn, hasSibSet, { Target: 'set.level', Ref: id, Value: 1 }); tr.on('dragstart', (ev) => { $inv.sets.drag = { id: id, bid: bid }; tr.aC('dragging'); try { ev.originalEvent.dataTransfer.setData('text/plain', id); ev.originalEvent.dataTransfer.effectAllowed = 'move'; } catch (e) { /* ignore */ } }).on('dragend', () => { tr.rC('dragging'); $inv.sets.drag = null; $inv.sets.clearDrop(); }); /* Every row is a target: before / after (reorder, become the anchor's sibling — works on a folded set too, its subtree travels along) and, for sets, into (become its last member). */ let ok = () => $inv.sets.canDrop($inv.sets.drag, id, bid, byId); tr.on('dragover', (ev) => { if (!ok()) { return; } ev.preventDefault(); let z = $inv.sets.zone(ev, tr, isSet); tr.rC('dropok dropbefore dropafter').aC(z === 'into' ? 'dropok' : (z === 'before' ? 'dropbefore' : 'dropafter')); }).on('dragleave', () => { tr.rC('dropok dropbefore dropafter'); }).on('drop', (ev) => { ev.preventDefault(); $inv.sets.clearDrop(); let d = $inv.sets.drag; $inv.sets.drag = null; if (!$inv.sets.canDrop(d, id, bid, byId)) { return; } let z = $inv.sets.zone(ev, tr, isSet); if (z === 'into') { $inv.sets.sync({ Target: 'set.assign', Ref: d.id, Value: { parent: id } }); } else { $inv.sets.sync({ Target: 'set.move', Ref: d.id, Value: { anchor: id, where: z } }); } }); return tr; }, /* Drop target for "pull out of its set" (top level of the block). */ rootZone: function (el, bid, byId) { let ok = () => { let d = $inv.sets.drag; return !!d && d.bid === bid && !!byId[d.id] && $inv.sets.parentOf(byId[d.id], byId) !== ''; }; el.on('dragover', (ev) => { if (ok()) { ev.preventDefault(); el.aC('dropok'); } }) .on('dragleave', () => { el.rC('dropok'); }) .on('drop', (ev) => { ev.preventDefault(); el.rC('dropok'); let d = $inv.sets.drag; $inv.sets.drag = null; if (d && d.bid === bid && byId[d.id] && $inv.sets.parentOf(byId[d.id], byId) !== '') { $inv.sets.sync({ Target: 'set.assign', Ref: d.id, Value: { parent: '' } }); } }); return el; }, /* "New line" bar per block: type (Set/Text), target set (top level or any set), title. */ createBar: function (blk, bid, itms, byId) { let bar = $$.dc('screate', blk); let typ = $('').attr({ name: 'typ' }).append([$$.opt('set', $ict.settyp.set), $$.opt('text', $ict.settyp.text)]); let par = $('').attr({ name: 'parent' }).append($$.opt('', $ict.setsTop)); itms.forEach((co) => { if (!$inv.sets.isSet(co)) { return; } let lvl = parseInt(co.lvl || 0, 10) || 0; par.append($$.opt((co.id || '').toString(), ' '.repeat(lvl * 3) + $inv.sets.title(co))); }); let ttl = $('').attr({ name: 'title', placeholder: $ict.setsTitle, maxlength: 250 }); let add = () => { let t = (ttl.val() || '').toString().trim(); if (t === '') { ttl.focus(); return; } $inv.sets.sync({ Target: 'set.create', Ref: bid, Value: { typ: typ.val(), title: t, parent: par.val() || '' } }); ttl.val(''); }; ttl.on('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); add(); } }); bar.append([$$.sc('lbl', $ict.setsNew), typ, $$.sc('lbl', $ict.setsInto), par, ttl, $$.bbtn($ict.setsAdd, 'sadd').click(add)]); return bar; } };