Add user-defined invoice set structure dialog (ADR 0016)
Playwright Tests / test (push) Canceled after 0s

Lets a back-office user correct/define which invoice line belongs to
which "set" and how deep it is nested, instead of relying only on the
implicit "everything after a Set header until the next Set" grouping
fds__prepInvoice derives. Sets may contain sets; every change applies
immediately to the cached draft session and is reflected in the editor
and PDF (ADR 0006/0008).

Backend:
- InvoiceSetStructure: pure parent-link forest (assign, change level,
  create, place/move-sibling, depth-first linearise + level recompute).
- InvoiceDraftSetStructureDeltas: new inv/dpatch targets set.assign,
  set.level, set.create, set.move.
- InvoiceSetPricing.Build is now order-preserving and nesting-aware
  (governed by the nearest converted ancestor); the set-item switch
  sums a header's transitive descendants so nested conversions never
  double-count.
- FuchsPdf indents nested set members per level, mirroring the editor.

Frontend:
- New fis.inv_sets.js: "Set-Struktur bearbeiten" dialog — per-block
  table of all lines with drag & drop reorder (before/after/into),
  ▲▼ order and ◀▶ level buttons, fold/unfold per set (view-only), and
  a create bar for new Set/Text lines. Posts one delta per action; the
  dialog is a pure view of the server state (ADR 0006).
- _Layout.cshtml / fis_main.js: cache-bust the dynamically imported
  per-module bundles (web/fis.<mdl>.<locale>.js/.css) with an asset
  version derived from the newest bundle write time, so a gulp rebuild
  is picked up without a stale cached module.

Docs: ADR 0016, live-draft-editing concept, INVOICE_SET_PRICING.md,
USER_GUIDE.md, ARCHITECTURE.md updated. Tests: InvoiceSetStructureTests
(new) + additions to InvoiceDraftServiceTests/InvoiceSetPricingTests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 08:55:05 +02:00
co-authored by Claude Sonnet 5
parent c1936a327e
commit ecc74cf3c6
31 changed files with 3003 additions and 147 deletions
@@ -81,6 +81,37 @@
}
}
.emailccfrm {
position: absolute;
font-size: 0.7rem;
top: 2.2rem;
left: 0.5rem;
width: auto;
height: auto;
cursor: pointer;
.content {
color: #000;
}
/* Unlike the other optional fields (plain "-" placeholder), this one names the action —
there is no default "Zusatzempfänger" from MFR, so an empty state must read as an
offer to add one, not as missing data. */
.content:empty::before {
content: 'Zusatzempfänger hinzufügen';
min-width: 5rem;
height: 1rem;
display: block;
position: absolute;
}
&:hover {
div.axf {
display: block;
}
}
}
.admfrm {
position: absolute;
right: 3rem;
@@ -250,6 +281,12 @@
&.sethdr td.txt {
font-weight: bold;
}
/* Nested set members (ADR 0016): indent per level, mirroring the PDF's per-level indent. */
@for $i from 1 through 6 {
&.lvl#{$i} td.txt {
padding-left: calc(0.4rem + #{$i * 1.4rem});
}
}
}
> tfoot > tr {
@@ -493,3 +530,71 @@
color: #445;
}
}
/* ── Set structure dialog (ADR 0016, see $inv.sets in fis.inv_sets.js) ─────────────────── */
.modal .setstruct {
&.busy { opacity: 0.6; pointer-events: none; }
div.shint { font-size: 0.85rem; color: #666; margin: 0 0 0.8rem 0; }
div.sblock {
margin: 0 0 1.4rem 0;
div.sbhd {
font-weight: bold;
padding: 0.4rem 0.6rem;
border-top: 1.5px solid #000;
border-bottom: 1px solid #ddd;
&.dropok { background-color: #e6f2e6; outline: 2px dashed #5a5; }
}
table.stbl {
width: 100%;
border-collapse: collapse;
th { text-align: left; font-size: 0.8rem; color: #666; padding: 0.3rem 0.4rem; border-bottom: 1px solid #ddd; }
td { padding: 0.3rem 0.4rem; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
td.grip { width: 1.6rem; cursor: grab; color: #999; }
td.pos, td.qty { width: 4rem; white-space: nowrap; }
td.typ { width: 7rem; white-space: nowrap; color: #666; font-size: 0.85rem;
span.fold { cursor: pointer; margin-right: 0.35rem; color: #555; display: inline-block; width: 1rem; }
}
td.ttl {
span.ind { display: inline-block; }
span.seti { margin-right: 0.3rem; color: #888; }
span.cnt { color: #999; font-weight: normal; font-size: 0.85rem; }
}
td.act { width: 7.5rem; white-space: nowrap; text-align: right;
div.ibtn { display: inline-block; cursor: pointer; padding: 0 0.25rem; &.disabled { opacity: 0.25; cursor: default; } }
}
tr.srow {
&.isset td.ttl { font-weight: bold; }
&.hid { display: none; }
&.folded td.ttl { font-style: italic; }
&.dragging { opacity: 0.4; }
&.dropok td { background-color: #e6f2e6; }
&.dropbefore td { border-top: 3px solid #5a5; }
&.dropafter td { border-bottom: 3px solid #5a5; }
}
}
div.sroot {
margin: 0.3rem 0 0.5rem 0;
padding: 0.4rem 0.6rem;
border: 1px dashed #bbb;
color: #888;
font-size: 0.85rem;
&.dropok { background-color: #e6f2e6; border-color: #5a5; color: #363; }
}
div.screate {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem;
padding: 0.4rem 0.6rem;
background-color: #f7f7f7;
span.lbl { font-size: 0.85rem; color: #666; }
select, input[type="text"] { padding: 0.2rem 0.3rem; }
input[type="text"] { flex: 1 1 14rem; min-width: 10rem; }
}
}
}