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
+337
View File
@@ -870,4 +870,341 @@ public class InvoiceDraftServiceTests
Assert.Equal(4, final.History.Count);
Assert.DoesNotContain(final.ValidationMessages, m => m.Severity == "error");
}
// ── User-defined set structure (ADR 0016): set.assign / set.level / set.create ───────────
// A(set) > B(set) > m1, m2 ; A > m3 ; x standalone. Delivered linearised, like fds__prepInvoice + Linearize would.
private static JObject NestedSetPayload() => JObject.Parse(@"{
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[{'Id':'1','text':'Auftrag','itm':[
{'id':'A','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'A'},
{'id':'B','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'A'},
{'id':'m1','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':'B'},
{'id':'m2','typ':'material','vt':40,'vv':7.6,'vs':10,'vsv':1.9,'vat':'19%','SetItmId':'B'},
{'id':'m3','typ':'material','vt':25,'vv':4.75,'vs':0,'vsv':0,'vat':'19%','SetItmId':'A'},
{'id':'x','typ':'material','vt':10,'vv':1.9,'vs':0,'vsv':0,'vat':'19%','SetItmId':null}],
'items':[{'id':'A','type':'set','total_net':0},{'id':'B','type':'set','total_net':0},
{'id':'m1','type':'material','total_net':60,'vat':'19%'},{'id':'m2','type':'material','total_net':40,'vat':'19%'},
{'id':'m3','type':'material','total_net':25,'vat':'19%'},{'id':'x','type':'material','total_net':10,'vat':'19%'}]}]
}");
private static string[] LineOrder(InvoiceDraftSession s, int block = 0) =>
((JArray)((JObject)s.Req[block])["itm"]!).OfType<JObject>().Select(l => (string)l["id"]!).ToArray();
private static JObject LineOf(InvoiceDraftSession s, string id, int block = 0) =>
((JArray)((JObject)s.Req[block])["itm"]!).OfType<JObject>().Single(l => (string)l["id"]! == id);
private static JObject ItemOf(InvoiceDraftSession s, string id, int block = 0) =>
((JArray)((JObject)s.Req[block])["items"]!).OfType<JObject>().Single(l => (string)l["id"]! == id);
[Fact]
public void OpenFromPayload_NestedSets_PublishesLevelsAndMirrorsSetIdOntoItems()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
Assert.Equal(0, LineOf(s, "A")["lvl"]!.Value<int>());
Assert.Equal(1, LineOf(s, "B")["lvl"]!.Value<int>());
Assert.Equal(2, LineOf(s, "m1")["lvl"]!.Value<int>());
Assert.Equal(1, LineOf(s, "m3")["lvl"]!.Value<int>());
Assert.Equal(0, LineOf(s, "x")["lvl"]!.Value<int>());
Assert.Equal("A", (string)ItemOf(s, "B")["setId"]!); // a nested set is a member of its parent
Assert.Equal("B", (string)ItemOf(s, "m1")["setId"]!);
Assert.Equal(JTokenType.Null, ItemOf(s, "A")["setId"]!.Type); // self-reference -> no parent
Assert.Equal(2, ItemOf(s, "m1")["level"]!.Value<int>());
Assert.Equal(135m, s.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_SetAssign_DropOntoSet_BecomesLastMember_BumpsVersionRecordsHistory()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.assign", Ref = "x", Value = JObject.Parse("{'parent':'A'}") });
Assert.NotNull(s2);
Assert.Equal(1, s2!.Version);
Assert.Equal(new[] { "A", "B", "m1", "m2", "m3", "x" }, LineOrder(s2));
Assert.Equal(1, LineOf(s2, "x")["lvl"]!.Value<int>());
Assert.Equal("A", (string)LineOf(s2, "x")["SetItmId"]!);
Assert.Equal("A", (string)ItemOf(s2, "x")["setId"]!);
Assert.Equal(135m, s2.Sums.TotalNet); // structure never changes the total
var h = Assert.Single(s2.History);
Assert.Equal("set.assign", h.Target); Assert.Equal("x", h.Ref); Assert.Equal("", h.OldValue); Assert.Equal("A", h.NewValue);
Assert.Equal("1.3", (string)LineOf(s2, "x")["p"]!); // positions renumbered along the new order (hierarchical, ADR 0017)
}
[Fact]
public void ApplyPatch_SetAssign_PullSetOut_ItsMembersFollowOneLevelUp()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.assign", Ref = "B", Value = JToken.FromObject("") });
Assert.Equal(new[] { "A", "m3", "B", "m1", "m2", "x" }, LineOrder(s2!));
Assert.Equal(0, LineOf(s2!, "B")["lvl"]!.Value<int>());
Assert.Equal(1, LineOf(s2!, "m2")["lvl"]!.Value<int>());
Assert.Equal("B", (string)LineOf(s2!, "m2")["SetItmId"]!);
Assert.Equal("A", s2!.History[0].OldValue);
}
[Theory]
[InlineData("A", "B")] // onto its own descendant — cycle
[InlineData("x", "m3")] // onto a non-set line
[InlineData("m1", "B")] // unchanged
[InlineData("nope", "A")] // unknown item
[InlineData("", "A")] // missing ref
public void ApplyPatch_SetAssign_Invalid_IsNoOp_NoVersionBumpNoHistory(string item, string parent)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var before = LineOrder(s);
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.assign", Ref = item, Value = JObject.Parse("{'parent':'" + parent + "'}") });
Assert.Equal(0, s2!.Version);
Assert.Empty(s2.History);
Assert.Equal(before, LineOrder(s2));
}
[Theory]
[InlineData("m3", 1, "1", "2", new[] { "A", "B", "m1", "m2", "m3", "x" })] // indent: joins preceding sibling set B
[InlineData("m1", -1, "2", "1", new[] { "A", "B", "m2", "m1", "m3", "x" })] // outdent: leaves B, placed after B's subtree
[InlineData("B", -1, "1", "0", new[] { "A", "m3", "B", "m1", "m2", "x" })] // outdent a set: members travel with it
public void ApplyPatch_SetLevel_IndentOutdent_ReordersAndRecordsLevels(string item, int delta, string oldLvl, string newLvl, string[] order)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.level", Ref = item, Value = JToken.FromObject(delta) });
Assert.Equal(1, s2!.Version);
Assert.Equal(order, LineOrder(s2));
var h = Assert.Single(s2.History);
Assert.Equal("set.level", h.Target); Assert.Equal(oldLvl, h.OldValue); Assert.Equal(newLvl, h.NewValue);
Assert.Equal(int.Parse(newLvl), LineOf(s2, item)["lvl"]!.Value<int>());
Assert.Equal(135m, s2.Sums.TotalNet);
}
[Theory]
[InlineData("x", "-1")] // already top level
[InlineData("A", "in")] // nothing precedes the first line
[InlineData("m1", "0")] // no direction
[InlineData("m1", "abc")] // unparseable
public void ApplyPatch_SetLevel_NoMove_IsNoOp(string item, string value)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var before = LineOrder(s);
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.level", Ref = item, Value = JToken.FromObject(value) });
Assert.Equal(0, s2!.Version);
Assert.Equal(before, LineOrder(s2));
}
[Fact]
public void ApplyPatch_SetCreate_Set_InsideParent_AppendsAsLastMember_UnpricedAndNumbered()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.create", Ref = "1", Value = JObject.Parse("{'typ':'set','title':'Unterset','parent':'B'}") });
Assert.Equal(1, s2!.Version);
var order = LineOrder(s2);
Assert.Equal(7, order.Length);
string id = order[4]; // A, B, m1, m2, NEW, m3, x
Assert.StartsWith("sN", id);
Assert.Equal(new[] { "A", "B", "m1", "m2", id, "m3", "x" }, order);
var co = LineOf(s2, id);
Assert.Equal("set", (string)co["typ"]!);
Assert.Equal("Unterset", (string)co["t"]!);
Assert.Equal(0m, co["vt"]!.Value<decimal>()); // unconverted — the "Auf Setpreis umstellen" switch applies later
Assert.Equal(2, co["lvl"]!.Value<int>());
Assert.Equal("1.1.3", (string)co["p"]!); // a set header is numbered, hierarchically (ADR 0017)
Assert.Equal("set", (string)ItemOf(s2, id)["type"]!);
Assert.Equal("B", (string)ItemOf(s2, id)["setId"]!);
Assert.Equal(135m, s2.Sums.TotalNet);
var h = Assert.Single(s2.History);
Assert.Equal("set.create", h.Target); Assert.Equal("1", h.Ref); Assert.Equal("set:Unterset", h.NewValue);
}
[Fact]
public void ApplyPatch_SetCreate_Text_TopLevel_IsUnnumberedFreeText()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.create", Ref = "1", Value = JObject.Parse("{'typ':'text','title':'Hinweis','parent':''}") });
string id = LineOrder(s2!).Last();
var co = LineOf(s2!, id);
Assert.Equal("Text", (string)co["typ"]!);
Assert.Equal("<p>Hinweis</p>", (string)co["t"]!);
Assert.Equal("", (string)co["p"]!); // free text carries no position number
Assert.Equal(0, co["lvl"]!.Value<int>());
Assert.Equal("text", (string)ItemOf(s2!, id)["type"]!);
Assert.Equal("<p>Hinweis</p>", (string)ItemOf(s2!, id)["desc"]!);
}
[Theory]
[InlineData("1", "{'typ':'material','title':'Neu','parent':''}")] // unsupported type
[InlineData("1", "{'typ':'set','title':' ','parent':''}")] // blank title
[InlineData("1", "{'typ':'set','title':'Neu','parent':'m3'}")] // parent is not a set
[InlineData("99", "{'typ':'set','title':'Neu','parent':''}")] // unknown block
[InlineData("1", "'not an object'")] // wrong value shape
public void ApplyPatch_SetCreate_Invalid_IsNoOp(string blockRef, string valueJson)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.create", Ref = blockRef, Value = JToken.Parse(valueJson) });
Assert.Equal(0, s2!.Version);
Assert.Equal(6, LineOrder(s2).Length);
Assert.Empty(s2.History);
}
[Fact]
public void ApplyPatch_ItemSetPrice_NestedSet_SumsTransitiveDescendants_NullsAll_TotalUnchanged()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "A" });
var a = LineOf(s2!, "A");
Assert.Equal(125m, a["vt"]!.Value<decimal>()); // 60 + 40 + 25 (B itself contributed 0 — unconverted)
Assert.Equal(23.75m, a["vv"]!.Value<decimal>());
Assert.Equal(10m, a["vs"]!.Value<decimal>());
foreach (var id in new[] { "B", "m1", "m2", "m3" })
Assert.Equal(JTokenType.Null, LineOf(s2!, id)["vt"]!.Type);
Assert.Equal(10m, LineOf(s2!, "x")["vt"]!.Value<decimal>()); // standalone untouched
Assert.Equal(135m, s2!.Sums.TotalNet);
Assert.Equal(125m, ItemOf(s2, "A")["total_net"]!.Value<decimal>());
Assert.Equal(JTokenType.Null, ItemOf(s2, "B")["total_net"]!.Type);
}
[Fact]
public void ApplyPatch_ItemSetPrice_InnerThenOuter_NoDoubleCounting()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "B" });
Assert.Equal(100m, LineOf(svc.Get(s.Token)!, "B")["vt"]!.Value<decimal>());
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "A" });
Assert.Equal(125m, LineOf(s2!, "A")["vt"]!.Value<decimal>()); // B's set price (100) + m3 (25)
Assert.Equal(JTokenType.Null, LineOf(s2!, "B")["vt"]!.Type);
Assert.Equal(135m, s2!.Sums.TotalNet);
Assert.Equal(2, s2.Version);
}
[Fact]
public void ApplyPatch_SetCreateThenAssignThenSetPrice_UserDefinedSetGetsMembersSum()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.create", Ref = "1", Value = JObject.Parse("{'typ':'set','title':'Extra','parent':''}") });
string newId = LineOrder(svc.Get(s.Token)!).Last();
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.assign", Ref = "x", Value = JObject.Parse("{'parent':'" + newId + "'}") });
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = newId });
Assert.Equal(3, s2!.Version);
Assert.Equal(10m, LineOf(s2, newId)["vt"]!.Value<decimal>());
Assert.Equal(JTokenType.Null, LineOf(s2, "x")["vt"]!.Type);
Assert.Equal(135m, s2.Sums.TotalNet);
Assert.Equal(new[] { "A", "B", "m1", "m2", "m3", newId, "x" }, LineOrder(s2));
}
[Fact]
public void BuildState_NestedConvertedSet_SetDisplayBlanksWholeSubtree_AndLinesCarryLevels()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "A" });
var state = JObject.FromObject(svc.BuildState(s2!));
var disp = (JObject)state["setDisplay"]!;
Assert.True(disp["A"]!["p"]!.Value<bool>()); Assert.True(disp["A"]!["h"]!.Value<bool>());
Assert.False(disp["B"]!["p"]!.Value<bool>()); Assert.True(disp["B"]!["h"]!.Value<bool>()); // nested header inside a converted set: blank, still emphasised
Assert.False(disp["m1"]!["p"]!.Value<bool>());
Assert.False(disp["m3"]!["p"]!.Value<bool>());
Assert.True(disp["x"]!["p"]!.Value<bool>());
Assert.Equal(2, state["req"]![0]!["itm"]![2]!["lvl"]!.Value<int>());
}
[Fact]
public void ApplyPatch_BlockReplace_KeepsUserDefinedStructure_FromPostedParentLinks()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
// The editor re-posts the block after a local edit, carrying SetItmId per row (rrw -> co).
var nb = JObject.Parse(@"{'Id':'1','text':'Auftrag','itm':[
{'id':'A','typ':'set','vt':0,'vat':'19%','SetItmId':'A'},
{'id':'x','typ':'material','vt':10,'vv':1.9,'vat':'19%','SetItmId':'A'}],
'items':[{'id':'A','type':'set'},{'id':'x','type':'material','total_net':10}]}");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "1", Value = nb });
Assert.Equal(1, LineOf(s2!, "x")["lvl"]!.Value<int>());
Assert.Equal("A", (string)ItemOf(s2!, "x")["setId"]!);
Assert.Equal(10m, s2!.Sums.TotalNet);
}
[Theory]
[InlineData("B", "1", "0", "1", new[] { "A", "m3", "B", "m1", "m2", "x" })] // down among A's members, subtree travels
[InlineData("x", "-1", "1", "0", new[] { "x", "A", "B", "m1", "m2", "m3" })] // up at top level
[InlineData("m2", "up", "1", "0", new[] { "A", "B", "m2", "m1", "m3", "x" })] // word form
public void ApplyPatch_SetMove_Sibling_ReordersAndRecordsIndices(string item, string value, string oldIdx, string newIdx, string[] order)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.move", Ref = item, Value = JToken.Parse(value.StartsWith("-") || char.IsDigit(value[0]) ? value : "'" + value + "'") });
Assert.Equal(1, s2!.Version);
Assert.Equal(order, LineOrder(s2));
var h = Assert.Single(s2.History);
Assert.Equal("set.move", h.Target); Assert.Equal(oldIdx, h.OldValue); Assert.Equal(newIdx, h.NewValue);
Assert.Equal(135m, s2.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_SetMove_Anchor_DragBetweenRows_AdoptsAnchorsParent()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.move", Ref = "x", Value = JObject.Parse("{'anchor':'m1','where':'before'}") });
Assert.Equal(new[] { "A", "B", "x", "m1", "m2", "m3" }, LineOrder(s2!));
Assert.Equal(2, LineOf(s2!, "x")["lvl"]!.Value<int>());
Assert.Equal("B", (string)LineOf(s2!, "x")["SetItmId"]!);
Assert.Equal("", s2!.History[0].OldValue);
Assert.StartsWith("before:m1", s2.History[0].NewValue);
Assert.Equal(3, LineOf(s2, "x")["p"]!.Value<int>());
}
[Theory]
[InlineData("A", "-1")] // first line, no previous sibling
[InlineData("A", "{'anchor':'m1','where':'after'}")] // anchor inside own subtree
[InlineData("x", "{'anchor':'m1','where':'sideways'}")] // bad direction
[InlineData("x", "{'anchor':'','where':'before'}")] // missing anchor
[InlineData("nope", "1")]
public void ApplyPatch_SetMove_Invalid_IsNoOp(string item, string valueJson)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var before = LineOrder(s);
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.move", Ref = item, Value = JToken.Parse(valueJson) });
Assert.Equal(0, s2!.Version);
Assert.Equal(before, LineOrder(s2));
Assert.Empty(s2.History);
}
}
+135
View File
@@ -201,4 +201,139 @@ public class InvoiceSetPricingTests
.Sum(i => decimal.Parse(i["total_net"]!.ToString()!, System.Globalization.CultureInfo.InvariantCulture));
Assert.Equal(memberSum, setLine.TotalNet);
}
// ── Nested sets (ADR 0016): order preserved, levels reported, nearest converted ancestor rules ──
private static Dictionary<string, object?> NestedHeader(string id, string parent, string title, string total) => new()
{
["type"] = "set", ["id"] = id, ["setId"] = parent, ["title"] = title, ["qty"] = "1", ["total_net"] = total, ["price_net"] = total
};
// A(converted 125) > B(unconverted) > m1, m2 ; A > m3 ; x standalone
private static List<Dictionary<string, object?>> NestedOuterConverted() => new()
{
SetHeader("A", "Komplett", "125.00"),
NestedHeader("B", "A", "Unterset", "0"),
Member("B", "m1", "60.00", "60.00"),
Member("B", "m2", "40.00", "40.00"),
Member("A", "m3", "25.00", "25.00"),
Standalone("x", "10.00", "10.00")
};
[Fact]
public void Build_Nested_OuterConverted_SetPrice_BlanksWholeSubtree_ReportsLevels()
{
var lines = InvoiceSetPricing.Build(NestedOuterConverted(), SetDisplayMode.SetPrice);
Assert.Equal(new[] { "Komplett", "Unterset", "m1", "m2", "m3", "x" }, lines.Select(l => l.Title)); // input order kept
Assert.Equal(new[] { 0, 1, 2, 2, 1, 0 }, lines.Select(l => l.Level));
Assert.True(lines[0].IsSetHeader); Assert.True(lines[0].ShowPrice); Assert.Equal(125m, lines[0].TotalNet);
Assert.True(lines[1].IsSetHeader); Assert.False(lines[1].ShowPrice); // nested header inside a converted set
Assert.False(lines[2].ShowPrice); Assert.False(lines[3].ShowPrice); Assert.False(lines[4].ShowPrice);
Assert.True(lines[5].ShowPrice); Assert.Equal(10m, lines[5].TotalNet);
}
[Fact]
public void Build_Nested_OuterConverted_SetOnly_DropsWholeSubtree_KeepsStandalone()
{
var lines = InvoiceSetPricing.Build(NestedOuterConverted(), SetDisplayMode.SetOnly);
Assert.Equal(new[] { "Komplett", "x" }, lines.Select(l => l.Title));
Assert.True(lines[0].ShowPrice); Assert.True(lines[1].ShowPrice);
}
// A(unconverted) > B(converted 100) > m1, m2 ; A > m3(25)
private static List<Dictionary<string, object?>> NestedInnerConverted() => new()
{
SetHeader("A", "Komplett"),
NestedHeader("B", "A", "Unterset", "100.00"),
Member("B", "m1", "60.00", "60.00"),
Member("B", "m2", "40.00", "40.00"),
Member("A", "m3", "25.00", "25.00")
};
[Theory]
[InlineData(SetDisplayMode.SetPrice, 5)]
[InlineData(SetDisplayMode.SetOnly, 3)]
public void Build_Nested_InnerConvertedOnly_InnerSubtreeCollapses_OuterStaysIndividuallyPriced(SetDisplayMode mode, int count)
{
var lines = InvoiceSetPricing.Build(NestedInnerConverted(), mode);
Assert.Equal(count, lines.Count);
var a = lines.Single(l => l.Title == "Komplett");
Assert.True(a.IsSetHeader); Assert.False(a.ShowPrice); // outer still unconverted -> blank
var b = lines.Single(l => l.Title == "Unterset");
Assert.True(b.IsSetHeader); Assert.True(b.ShowPrice); Assert.Equal(100m, b.TotalNet); Assert.Equal(1, b.Level);
var m3 = lines.Single(l => l.Title == "m3");
Assert.True(m3.ShowPrice); Assert.Equal(25m, m3.TotalNet); Assert.Equal(1, m3.Level);
if (mode == SetDisplayMode.SetPrice) Assert.All(lines.Where(l => l.Title is "m1" or "m2"), l => Assert.False(l.ShowPrice));
else Assert.DoesNotContain(lines, l => l.Title is "m1" or "m2");
}
[Fact]
public void Build_PreservesInputOrder_EvenWhenStandaloneSitsBetweenHeaderAndMember()
{
var items = new List<Dictionary<string, object?>>
{
SetHeader("10", "Set", "1000.00"),
Standalone("Anfahrt", "50.00", "50.00"),
Member("10", "Waschbecken", "600.00", "600.00")
};
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
Assert.Equal(new[] { "Set", "Anfahrt", "Waschbecken" }, lines.Select(l => l.Title)); // editor == PDF sequence (ADR 0008)
Assert.True(lines[1].ShowPrice);
Assert.False(lines[2].ShowPrice);
}
[Fact]
public void Build_HeaderSelfReferencingSetId_IsTopLevel_NotItsOwnMember()
{
var header = SetHeader("7", "Set", "100.00"); header["setId"] = "7"; // the shape fds__prepInvoice delivers
var lines = InvoiceSetPricing.Build(new List<Dictionary<string, object?>> { header, Member("7", "A", "100.00", "100.00") }, SetDisplayMode.SetOnly);
Assert.Single(lines);
Assert.Equal(0, lines[0].Level);
Assert.True(lines[0].ShowPrice);
}
[Fact]
public void Build_MemberPointingAtUnknownSet_IsTreatedAsStandalone()
{
var lines = InvoiceSetPricing.Build(new List<Dictionary<string, object?>> { Member("ghost", "A", "5.00", "5.00") }, SetDisplayMode.SetOnly);
var l = Assert.Single(lines);
Assert.True(l.ShowPrice); Assert.Equal(0, l.Level);
}
[Fact]
public void AssignPositionLabels_SetWithMembersPlusStandalone_IsHierarchical()
{
// Set(10) header + 2 members, then a standalone item — mirrors Sample().
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetPrice);
InvoiceSetPricing.AssignPositionLabels(new[] { lines });
Assert.Equal(new[] { "1", "1.1", "1.2", "2" }, lines.Select(l => l.PositionLabel));
}
[Fact]
public void AssignPositionLabels_TextLineGetsNoLabel_AndDoesNotShiftFollowingNumbers()
{
var items = new List<Dictionary<string, object?>>
{
Standalone("A", "1.00", "1.00"),
new() { ["type"] = "text", ["title"] = "", ["desc"] = "Hinweis", ["qty"] = "" },
Standalone("B", "2.00", "2.00")
};
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
InvoiceSetPricing.AssignPositionLabels(new[] { lines });
Assert.Equal(new[] { "1", "", "2" }, lines.Select(l => l.PositionLabel));
}
[Fact]
public void AssignPositionLabels_SpansMultipleBlocks_Continuously()
{
var block1 = InvoiceSetPricing.Build(new List<Dictionary<string, object?>> { Standalone("A", "1.00", "1.00") }, SetDisplayMode.SetPrice);
var block2 = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetPrice);
InvoiceSetPricing.AssignPositionLabels(new[] { block1, block2 });
Assert.Equal(new[] { "1" }, block1.Select(l => l.PositionLabel));
Assert.Equal(new[] { "2", "2.1", "2.2", "3" }, block2.Select(l => l.PositionLabel));
}
}
+386
View File
@@ -0,0 +1,386 @@
using System.Collections.Generic;
using System.Linq;
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exercises the pure set-structure logic (ADR 0016): parent links (<c>SetItmId</c>) on a block's
/// <c>itm</c> lines form a forest; levels, depth-first linearisation, assign / level / create
/// mutations and the mirror onto the <c>items</c> contract shape are all verified without a
/// service or database. Ids: A/B are sets, m* are members, x is standalone.
/// </summary>
public class InvoiceSetStructureTests
{
// A(set) > B(set) > m1, m2 ; A > m3 ; x standalone — delivered already linearised.
private static JObject NestedBlock() => JObject.Parse(@"{
'Id':'1','text':'Auftrag',
'itm':[
{'id':'A','typ':'set','vt':0,'vat':'19%','SetItmId':'A'},
{'id':'B','typ':'set','vt':0,'vat':'19%','SetItmId':'A'},
{'id':'m1','typ':'material','vt':60,'vat':'19%','SetItmId':'B'},
{'id':'m2','typ':'material','vt':40,'vat':'19%','SetItmId':'B'},
{'id':'m3','typ':'material','vt':25,'vat':'19%','SetItmId':'A'},
{'id':'x','typ':'material','vt':10,'vat':'19%','SetItmId':null}],
'items':[
{'id':'A','type':'set'},{'id':'B','type':'set'},
{'id':'m1','type':'material','total_net':60},{'id':'m2','type':'material','total_net':40},
{'id':'m3','type':'material','total_net':25},{'id':'x','type':'material','total_net':10}]
}");
private static string[] Order(JObject block) => InvoiceSetStructure.Lines(block).Select(InvoiceSetStructure.Id).ToArray();
private static string[] ItemOrder(JObject block) => ((JArray)block["items"]!).OfType<JObject>().Select(i => (string)i["id"]!).ToArray();
private static int Lvl(JObject block, string id) => InvoiceSetStructure.Lines(block).Single(l => InvoiceSetStructure.Id(l) == id)[InvoiceSetStructure.LevelKey]!.Value<int>();
private static JObject Item(JObject block, string id) => ((JArray)block["items"]!).OfType<JObject>().Single(i => (string)i["id"]! == id);
private static string? Parent(JObject block, string id)
{
var lines = InvoiceSetStructure.Lines(block);
return InvoiceSetStructure.ParentOf(lines.Single(l => InvoiceSetStructure.Id(l) == id), InvoiceSetStructure.ById(lines));
}
// ── Levels / parent normalisation ─────────────────────────────────────────
[Fact]
public void Levels_NestedForest_ReportsDepthPerLine()
{
var levels = InvoiceSetStructure.Levels(InvoiceSetStructure.Lines(NestedBlock()));
Assert.Equal(0, levels["A"]);
Assert.Equal(1, levels["B"]);
Assert.Equal(2, levels["m1"]);
Assert.Equal(2, levels["m2"]);
Assert.Equal(1, levels["m3"]);
Assert.Equal(0, levels["x"]);
}
[Theory]
[InlineData("A")] // self-reference (the shape fds__prepInvoice delivers for a header)
[InlineData("nope")] // unknown id
[InlineData("m3")] // points at a non-set line
[InlineData("")]
public void ParentOf_SelfUnknownNonSetOrEmpty_IsTopLevel(string parent)
{
var block = NestedBlock();
InvoiceSetStructure.Lines(block).Single(l => InvoiceSetStructure.Id(l) == "A")["SetItmId"] = parent;
Assert.Null(Parent(block, "A"));
Assert.Equal(0, InvoiceSetStructure.Levels(InvoiceSetStructure.Lines(block))["A"]);
}
[Fact]
public void Levels_CyclicChain_DegradesToTopLevel_AndLinearizeKeepsEveryLine()
{
var block = JObject.Parse(@"{'Id':'1','itm':[
{'id':'A','typ':'set','SetItmId':'B'},{'id':'B','typ':'set','SetItmId':'A'},{'id':'m','typ':'material','SetItmId':'A'}]}");
var levels = InvoiceSetStructure.Levels(InvoiceSetStructure.Lines(block));
Assert.Equal(0, levels["A"]);
Assert.Equal(0, levels["B"]);
InvoiceSetStructure.Linearize(block);
Assert.Equal(3, Order(block).Length); // nothing lost
Assert.Contains("m", Order(block));
}
[Fact]
public void Descendants_AreTransitive_ExcludeTheSetItself_AndUnrelatedLines()
{
var ids = InvoiceSetStructure.Descendants(InvoiceSetStructure.Lines(NestedBlock()), "A").Select(InvoiceSetStructure.Id).ToArray();
Assert.Equal(new[] { "B", "m3", "m1", "m2" }, ids); // breadth-first: direct members first, then B's
Assert.DoesNotContain("A", ids);
Assert.DoesNotContain("x", ids);
Assert.Equal(new[] { "m1", "m2" }, InvoiceSetStructure.Descendants(InvoiceSetStructure.Lines(NestedBlock()), "B").Select(InvoiceSetStructure.Id));
Assert.Empty(InvoiceSetStructure.Descendants(InvoiceSetStructure.Lines(NestedBlock()), "x"));
}
// ── Linearize / RecomputeLevels ───────────────────────────────────────────
[Fact]
public void Linearize_ScrambledInput_EmitsEachSetFollowedByItsMembersRecursively_AndMirrorsItems()
{
var block = JObject.Parse(@"{'Id':'1',
'itm':[{'id':'m1','typ':'material','SetItmId':'B'},{'id':'x','typ':'material'},{'id':'B','typ':'set','SetItmId':'A'},
{'id':'m3','typ':'material','SetItmId':'A'},{'id':'A','typ':'set'},{'id':'m2','typ':'material','SetItmId':'B'}],
'items':[{'id':'m2'},{'id':'osum0','type':'osum'},{'id':'A'},{'id':'x'},{'id':'B'},{'id':'m1'},{'id':'m3'}]}");
InvoiceSetStructure.Linearize(block);
Assert.Equal(new[] { "x", "A", "B", "m1", "m2", "m3" }, Order(block)); // roots keep their relative order (x came before A)
Assert.Equal(new[] { "x", "A", "B", "m1", "m2", "m3", "osum0" }, ItemOrder(block)); // items mirrored; unmatched entries kept at the end
Assert.Equal(0, Lvl(block, "A")); Assert.Equal(1, Lvl(block, "B")); Assert.Equal(2, Lvl(block, "m2")); Assert.Equal(1, Lvl(block, "m3"));
Assert.Equal("A", (string)Item(block, "B")["setId"]!);
Assert.Equal("B", (string)Item(block, "m1")["setId"]!);
Assert.Equal(JTokenType.Null, Item(block, "A")["setId"]!.Type);
Assert.Equal(2, Item(block, "m1")["level"]!.Value<int>());
Assert.Null(Item(block, "osum0")["level"]); // untouched
}
[Fact]
public void RecomputeLevels_DoesNotReorder_ButPublishesLevelsAndMirrorsSetId()
{
var block = JObject.Parse(@"{'Id':'1',
'itm':[{'id':'m1','typ':'material','SetItmId':'A'},{'id':'A','typ':'set','SetItmId':'A'}],
'items':[{'id':'m1','setId':'stale'},{'id':'A','setId':'A'}]}");
var s = new InvoiceDraftSession { Req = new JArray { block } };
InvoiceSetStructure.RecomputeLevels(s);
Assert.Equal(new[] { "m1", "A" }, Order(block)); // order untouched
Assert.Equal(1, Lvl(block, "m1"));
Assert.Equal("A", (string)Item(block, "m1")["setId"]!); // stale mirror corrected
Assert.Equal(JTokenType.Null, Item(block, "A")["setId"]!.Type); // self-reference mirrored as "no parent"
}
// ── Assign ────────────────────────────────────────────────────────────────
[Fact]
public void Assign_StandaloneOntoSet_BecomesLastMember()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.Assign(block, "x", "A", out string oldParent));
Assert.Equal("", oldParent);
Assert.Equal(new[] { "A", "B", "m1", "m2", "m3", "x" }, Order(block));
Assert.Equal(1, Lvl(block, "x"));
Assert.Equal("A", (string)Item(block, "x")["setId"]!);
}
[Fact]
public void Assign_SetOntoSet_KeepsItsOwnMembersOneLevelDeeper()
{
var block = JObject.Parse(@"{'Id':'1','itm':[
{'id':'A','typ':'set'},{'id':'m3','typ':'material','SetItmId':'A'},
{'id':'B','typ':'set'},{'id':'m1','typ':'material','SetItmId':'B'},{'id':'m2','typ':'material','SetItmId':'B'}]}");
Assert.True(InvoiceSetStructure.Assign(block, "B", "A", out _));
Assert.Equal(new[] { "A", "m3", "B", "m1", "m2" }, Order(block));
Assert.Equal(1, Lvl(block, "B"));
Assert.Equal(2, Lvl(block, "m1"));
Assert.Equal("B", Parent(block, "m1")); // still B's member
}
[Fact]
public void Assign_PullOutToTopLevel_PlacesLineAfterItsFormerSubtree_MembersFollow()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.Assign(block, "B", "", out string oldParent));
Assert.Equal("A", oldParent);
Assert.Equal(new[] { "A", "m3", "B", "m1", "m2", "x" }, Order(block));
Assert.Equal(0, Lvl(block, "B"));
Assert.Equal(1, Lvl(block, "m1"));
Assert.Equal(JTokenType.Null, Item(block, "B")["setId"]!.Type);
}
[Fact]
public void Assign_DeepMemberToTopLevel_LandsAfterTheWholeRootSubtree()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.Assign(block, "m1", "", out _));
Assert.Equal(new[] { "A", "B", "m2", "m3", "m1", "x" }, Order(block));
Assert.Equal(0, Lvl(block, "m1"));
}
[Theory]
[InlineData("x", "")] // already top level
[InlineData("m1", "B")] // unchanged parent
[InlineData("A", "A")] // onto itself
[InlineData("A", "B")] // onto its own descendant (cycle)
[InlineData("x", "m3")] // onto a non-set line
[InlineData("x", "nope")] // unknown target
[InlineData("nope", "A")] // unknown item
public void Assign_InvalidOrUnchanged_IsNoOp(string item, string parent)
{
var block = NestedBlock();
var before = Order(block);
Assert.False(InvoiceSetStructure.Assign(block, item, parent, out _));
Assert.Equal(before, Order(block));
}
// ── ChangeLevel ───────────────────────────────────────────────────────────
[Fact]
public void ChangeLevel_Indent_JoinsNearestPrecedingSiblingSetAsLastMember()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.ChangeLevel(block, "m3", +1, out int oldLevel, out int newLevel));
Assert.Equal(1, oldLevel); Assert.Equal(2, newLevel);
Assert.Equal(new[] { "A", "B", "m1", "m2", "m3", "x" }, Order(block));
Assert.Equal("B", Parent(block, "m3"));
}
[Fact]
public void ChangeLevel_Outdent_JoinsGrandparentRightAfterFormerParentSubtree()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.ChangeLevel(block, "m1", -1, out int oldLevel, out int newLevel));
Assert.Equal(2, oldLevel); Assert.Equal(1, newLevel);
Assert.Equal(new[] { "A", "B", "m2", "m1", "m3", "x" }, Order(block));
Assert.Equal("A", Parent(block, "m1"));
}
[Fact]
public void ChangeLevel_OutdentSet_TakesItsMembersAlong()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.ChangeLevel(block, "B", -1, out _, out int newLevel));
Assert.Equal(0, newLevel);
Assert.Equal(new[] { "A", "m3", "B", "m1", "m2", "x" }, Order(block));
Assert.Equal(1, Lvl(block, "m2"));
}
[Fact]
public void ChangeLevel_IndentTopLevelLineAfterASet_JoinsThatSet()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.ChangeLevel(block, "x", +1, out int oldLevel, out int newLevel));
Assert.Equal(0, oldLevel); Assert.Equal(1, newLevel);
Assert.Equal(new[] { "A", "B", "m1", "m2", "m3", "x" }, Order(block));
Assert.Equal("A", Parent(block, "x"));
}
[Theory]
[InlineData("A", +1)] // first line — nothing precedes it
[InlineData("m1", +1)] // first member of B — no preceding sibling set inside B
[InlineData("x", -1)] // already top level
[InlineData("m1", 0)] // no direction
[InlineData("nope", -1)] // unknown
public void ChangeLevel_NoMovePossible_IsNoOp(string item, int delta)
{
var block = NestedBlock();
var before = Order(block);
Assert.False(InvoiceSetStructure.ChangeLevel(block, item, delta, out _, out _));
Assert.Equal(before, Order(block));
}
// ── Create ────────────────────────────────────────────────────────────────
[Fact]
public void Create_Set_InsideParent_AppendsAsLastMember_WritesBothShapes()
{
var block = NestedBlock();
string? id = InvoiceSetStructure.Create(block, "set", " Unterset ", "B");
Assert.NotNull(id);
Assert.StartsWith("sN", id);
Assert.Equal(new[] { "A", "B", "m1", "m2", id!, "m3", "x" }, Order(block));
var co = InvoiceSetStructure.Lines(block).Single(l => InvoiceSetStructure.Id(l) == id);
Assert.Equal("set", (string)co["typ"]!);
Assert.Equal("Unterset", (string)co["t"]!);
Assert.Equal(0m, co["vt"]!.Value<decimal>());
Assert.Equal("19%", (string)co["vat"]!); // adopts the block's rate
Assert.Equal(2, Lvl(block, id!));
var io = Item(block, id!);
Assert.Equal("set", (string)io["type"]!);
Assert.Equal("Unterset", (string)io["title"]!);
Assert.Equal("B", (string)io["setId"]!);
Assert.Equal(0m, io["total_net"]!.Value<decimal>());
}
[Fact]
public void Create_Text_TopLevel_AppendsAtBlockEnd_HtmlEncodesTitle()
{
var block = NestedBlock();
string? id = InvoiceSetStructure.Create(block, "TEXT", "Hinweis <b>fett</b>", "");
Assert.NotNull(id);
Assert.Equal(id, Order(block).Last());
var co = InvoiceSetStructure.Lines(block).Last();
Assert.Equal("Text", (string)co["typ"]!);
Assert.Equal("<p>Hinweis &lt;b&gt;fett&lt;/b&gt;</p>", (string)co["t"]!);
Assert.Equal(0, Lvl(block, id!));
Assert.Equal("text", (string)Item(block, id!)["type"]!);
Assert.Equal("<p>Hinweis &lt;b&gt;fett&lt;/b&gt;</p>", (string)Item(block, id!)["desc"]!);
}
[Fact]
public void Create_WhenBlockHasNoLinesOrItems_CreatesBothArrays_WithFallbackRate()
{
var block = JObject.Parse(@"{'Id':'9'}");
string? id = InvoiceSetStructure.Create(block, "set", "Neu", "");
Assert.NotNull(id);
Assert.Single((JArray)block["itm"]!);
Assert.Single((JArray)block["items"]!);
Assert.Equal("19%", (string)((JArray)block["itm"]!)[0]["vat"]!);
}
[Theory]
[InlineData("material", "Neu", "")] // unsupported type
[InlineData("set", " ", "")] // blank title
[InlineData("set", "Neu", "m3")] // parent is not a set
[InlineData("set", "Neu", "nope")] // unknown parent
public void Create_Invalid_ReturnsNullAndLeavesBlockUntouched(string typ, string title, string parent)
{
var block = NestedBlock();
var before = Order(block);
Assert.Null(InvoiceSetStructure.Create(block, typ, title, parent));
Assert.Equal(before, Order(block));
Assert.Equal(6, ((JArray)block["items"]!).Count);
}
// ── Place / MoveSibling (reorder) ─────────────────────────────────────────
[Fact]
public void Place_BeforeAnchor_BecomesAnchorsSibling_SubtreeTravels()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.Place(block, "B", "x", after: false, out string oldParent, out string newParent));
Assert.Equal("A", oldParent); Assert.Equal("", newParent);
Assert.Equal(new[] { "A", "m3", "B", "m1", "m2", "x" }, Order(block));
Assert.Equal(0, Lvl(block, "B")); Assert.Equal(1, Lvl(block, "m1"));
}
[Fact]
public void Place_AfterAnchor_LandsAfterAnchorsWholeSubtree()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.Place(block, "x", "B", after: true, out _, out string newParent));
Assert.Equal("A", newParent); // x is now B's sibling inside A
Assert.Equal(new[] { "A", "B", "m1", "m2", "x", "m3" }, Order(block));
Assert.Equal(1, Lvl(block, "x"));
}
[Fact]
public void Place_MemberBeforeItsOwnParent_MovesUpOneLevel()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.Place(block, "m1", "B", after: false, out _, out string newParent));
Assert.Equal("A", newParent);
Assert.Equal(new[] { "A", "m1", "B", "m2", "m3", "x" }, Order(block));
Assert.Equal(1, Lvl(block, "m1"));
}
[Theory]
[InlineData("A", "m1", true)] // anchor inside own subtree
[InlineData("A", "A", false)] // itself
[InlineData("nope", "x", true)] // unknown item
[InlineData("x", "nope", false)] // unknown anchor
public void Place_Invalid_IsNoOp(string item, string anchor, bool after)
{
var block = NestedBlock();
var before = Order(block);
Assert.False(InvoiceSetStructure.Place(block, item, anchor, after, out _, out _));
Assert.Equal(before, Order(block));
}
[Fact]
public void MoveSibling_Down_SwapsWithNextSibling_SubtreeTravels()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.MoveSibling(block, "B", +1, out int oldIndex, out int newIndex));
Assert.Equal(0, oldIndex); Assert.Equal(1, newIndex);
Assert.Equal(new[] { "A", "m3", "B", "m1", "m2", "x" }, Order(block));
Assert.Equal(1, Lvl(block, "B")); Assert.Equal(2, Lvl(block, "m1")); // still inside A
}
[Fact]
public void MoveSibling_Up_TopLevel_MovesWholeSetAboveStandalone()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.MoveSibling(block, "x", -1, out int oldIndex, out int newIndex));
Assert.Equal(1, oldIndex); Assert.Equal(0, newIndex);
Assert.Equal(new[] { "x", "A", "B", "m1", "m2", "m3" }, Order(block));
}
[Theory]
[InlineData("A", -1)] // first top-level line
[InlineData("x", +1)] // last top-level line
[InlineData("m1", -1)] // first member of B
[InlineData("m2", +1)] // last member of B (m3 belongs to A, not B)
[InlineData("m1", 0)]
[InlineData("nope", 1)]
public void MoveSibling_AtEdge_IsNoOp(string item, int delta)
{
var block = NestedBlock();
var before = Order(block);
Assert.False(InvoiceSetStructure.MoveSibling(block, item, delta, out _, out _));
Assert.Equal(before, Order(block));
}
}
+2
View File
@@ -177,6 +177,8 @@ disable/failure). See [`Concepts/erechnung-output.md`](Concepts/erechnung-output
Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; DB/request-scoped services are scoped (see `Program.cs`).
The **Admin** module (`Do_Process_Admin`, `ISystemStatusService`) surfaces a live system-status/diagnostics page (host, SQL/Key Vault/blob/MFR connectivity, email config, test-email) restricted to `fds_sys` > 4 — see ADR [0011](Decisions/0011-admin-module-system-status.md) and the [concept doc](Concepts/admin-system-status.md).
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
Invoice/reminder drafts are edited **backend-authoritatively**: the truth is an in-memory `InvoiceDraftSession` mutated by single deltas (`IInvoiceDraftService`), including the user-defined **set structure** (`InvoiceSetStructure` — parent links, nesting, levels; ADR [0016](Decisions/0016-user-defined-set-structure.md)) and the set-price operations (`InvoiceSetPricing`, ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)). See the [live draft editing concept](Concepts/live-draft-editing.md) and ADRs [0006](Decisions/0006-backend-authoritative-draft-editing.md)/[0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md).
`FuchsPdf` / `FuchsVisualization` remain as static rendering libraries used *by* the services. The earlier static, controller-coupled helpers (`FuchsWidgets`, `FuchsReports`, `Banking`, `FuchsFdsEmail`) have been removed.
### 4.4 SQL-First Data Access
+22 -1
View File
@@ -1,9 +1,10 @@
---
status: Active
lastUpdated: 2026-07-10
lastUpdated: 2026-09-09
applyTo:
- "Fuchs/Services/InvoiceDraft*"
- "Fuchs/Services/IInvoiceDraft*"
- "Fuchs/code/InvoiceSetStructure.cs"
- "Fuchs/Services/ReminderDraft*"
- "Fuchs/Services/IReminderDraft*"
- "Fuchs/code/InvoiceDraftSession.cs"
@@ -18,6 +19,8 @@ applyTo:
relatedDecisions:
- "0006-backend-authoritative-draft-editing.md"
- "0007-targeted-draft-signalr-groups.md"
- "0009-block-setprice-modes-are-irreversible-mutations.md"
- "0016-user-defined-set-structure.md"
---
# Live draft editing (backend-authoritative invoice previews)
@@ -83,6 +86,19 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
`BuildInvoiceRequestList`), applies deltas (`ApplyDelta`), builds the view-state DTO,
flushes to the DB by reusing `IInvoiceService.RegisterInvoiceAsync` (no new persistence
path), renders previews from a synthesised registration, and discards by reloading.
- **Set structure** (ADR 0016). Every cached line carries a parent link (`SetItmId` → the
owning `typ == "set"` line, null/self = top level); sets may nest. `fds__prepInvoice`'s
implicit tagging is the initial structure; the user edits it in the "Set-Struktur
bearbeiten" dialog (`$inv.sets`, `fis.inv_sets.js`) which posts one delta per action —
`set.assign` { parent } (drop onto a set / pull out), `set.level` ±1 (indent under the
preceding sibling set / outdent to the grandparent), `set.create` { typ: set|text, title,
parent }, `set.move` ±1 | { anchor, where } (reorder among siblings / drag before or after
a row; the subtree travels along). `InvoiceDraftSetStructureDeltas` delegates to the pure
`InvoiceSetStructure` (assign / change level / create / place, then **linearise** the block depth-first and mirror
`setId`/`level` onto the `items` contract). `Refresh` publishes `lvl` on every line
(`RecomputeLevels`), rendered as an indent class in the editor and a per-level indent in the
PDF. `item.setprice` sums a header's **transitive** descendants. Structure deltas never
change values — only order, membership, levels and therefore position numbers.
- **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding
TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after,
the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`.
@@ -97,12 +113,15 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
## Key files
- `Fuchs/code/InvoiceDraftSession.cs` — session + `ChangeHistoryEntry` + `InvoiceDraftSums`.
- `Fuchs/code/InvoiceDraftCalculator.cs` — pure recompute + validate.
- `Fuchs/code/InvoiceSetStructure.cs` — pure set forest: parent links, levels, linearise, assign/level/create (ADR 0016).
- `Fuchs/Services/InvoiceDraftSetStructureDeltas.cs` — the `set.*` patch targets.
- `Fuchs/Services/InvoiceDraftCache.cs` / `IInvoiceDraftCache.cs` — in-memory store + TTL.
- `Fuchs/Services/InvoiceDraftEditService.cs` / `IInvoiceDraftService.cs` — orchestration + delta contract.
- `Fuchs/Services/InvoiceDraftExpiryService.cs` — idle warn/evict monitor.
- `Fuchs/Notifications/DraftPreviewHub.cs`, `DraftNotifier.cs`, `IDraftNotifier.cs` — targeted signals.
- `Fuchs/Controllers/IntranetController.InvoiceDraft.cs` — `inv/d*` endpoints.
- `Fuchs/js/intranet/fis_main.js`, `Fuchs/js/intranet/modules/fis.inv_shared.js` — client.
- `Fuchs/js/intranet/modules/fis.inv_sets.js` — the set-structure dialog (`$inv.sets`), a view of `dstate.req`.
## Reminders (Zahlungserinnerung)
@@ -151,3 +170,5 @@ amount pair.
## Related decisions
- [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.md)
- [0007 — Targeted draft SignalR groups](../Decisions/0007-targeted-draft-signalr-groups.md)
- [0009 — Block set-price modes are irreversible mutations](../Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
- [0016 — User-defined set structure](../Decisions/0016-user-defined-set-structure.md)
@@ -0,0 +1,112 @@
---
status: Accepted
date: 2026-09-09
applyTo:
- "Fuchs/code/InvoiceSetStructure.cs"
- "Fuchs/code/InvoiceSetPricing.cs"
- "Fuchs/Services/InvoiceDraft*"
- "Fuchs/Services/IInvoiceDraft*"
- "Fuchs/code/InvoiceDraftCalculator.cs"
- "Fuchs/code/FuchsPdf.cs"
- "Fuchs/js/intranet/modules/fis.inv_sets.js"
- "Fuchs/js/intranet/modules/fis.inv_shared.js"
- "Fuchs/Docs/INVOICE_SET_PRICING.md"
supersededBy: ""
---
# 0016 — Set membership is an explicit, user-editable parent link on the cached lines; sets may nest; the server linearises
## Context
Set membership on an invoice draft was purely **implicit**: `fds__prepInvoice` tags every item
that follows a zero-priced `Type = 'Set'` header (until the next such header) with that header's
id (`SetItmId`), and the set-item switch (ADR [0009](0009-block-setprice-modes-are-irreversible-mutations.md),
function 1) sums exactly those tagged lines. Users had no way to correct that grouping — a
trailing item that does not belong, a material line that should sit inside the set, a set that
should contain another set — nor to introduce a set (or a free-text line) that does not exist
in mfr. The product owner asked for a dialog that lets the user define which line belongs to
which set, create new set/text lines, change a line's level, and nest sets, with every change
applied **immediately** to the cached draft and reflected in the editor.
## Decision
1. **The implicit derivation stays as the initial structure.** `fds__prepInvoice`'s
`SetItmId` window function is unchanged; a freshly opened draft shows exactly the grouping
it delivered.
2. **Membership is an explicit parent link on every cached line.** Each line in a block's
`itm` array carries `SetItmId` = the id of the `typ == "set"` line that owns it (null, or a
self-reference as delivered for headers, = top level). The links form a forest per
service-request block; the block is the boundary — a line is never assigned to a set of
another block. `InvoiceSetStructure.ParentOf` normalises the link (self-reference, unknown
id, non-set target → top level).
3. **Sets may contain sets.** A set's own `SetItmId` names its parent set. Moving a set keeps
its members attached — they simply sit one level deeper/shallower. The depth of every line is
published as `lvl` (co shape) / `level` (items contract) and mirrored onto `items[].setId`,
so the PDF and the editor read one structure.
4. **Four new backend-authoritative deltas** (`inv/dpatch`, handled by
`InvoiceDraftSetStructureDeltas` → pure `InvoiceSetStructure`):
- `set.assign` (Ref = item, Value `{ parent }`; `""` = top level) — drop a line onto a set
(it becomes the set's **last** member) or pull it out (placed right after the subtree it
came from). Rejected as a no-op: unknown item, target not a set of the same block, target
is the item itself or one of its descendants (cycle), parent unchanged.
- `set.level` (Ref = item, Value ±1) — indent = join the nearest **preceding sibling set**
as its last member; outdent = join the grandparent right after the former parent's
subtree. No-op when no such move exists.
- `set.create` (Ref = block, Value `{ typ: "set" | "text", title, parent }`) — a new
zero-priced set header (convertible later via the item switch) or a free-text line, as the
last member of `parent` / end of the block. Both line shapes are written; the title is
HTML-encoded for the text line.
- `set.move` (Ref = item, Value ±1 or `{ anchor, where: "before" | "after" }`) — reorder:
one step up/down among the line's siblings, or become the anchor's sibling placed before
it / after its subtree (drag between rows). The line's own subtree travels with it, so a
set can be reordered folded or unfolded alike. Rejected: anchor inside the item's subtree.
5. **The server linearises after every structural change**: the block's lines are reordered
depth-first (each set immediately followed by its members, recursively; siblings keep their
relative order), levels are recomputed and the `items` contract is mirrored (order,
`setId`, `level`). `InvoiceSetPricing.Build` is therefore **order-preserving**: it no longer
regroups members next to their header at render time; a line is governed by its nearest
converted ancestor (blank in SetPrice, dropped in SetOnly). Editor and PDF print the same
sequence and the same per-level indent (ADR 0008).
6. **The set-item switch sums transitive descendants.** `item.setprice` on a set now sums and
nulls the header's whole subtree: an unconverted nested set contributes its members'
prices, an already-converted nested set contributes its own set price — so converting inner
then outer, or outer directly, both conserve the total and count nothing twice.
7. **The dialog is a view, not a model.** `$inv.sets` (fis.inv_sets.js) renders
`state.req[].itm` (pos, type, quantity, title, indent, ▲▼ order and ◀▶ level buttons, fold
toggles, per-block create bar), posts one delta per action and re-renders from the next
`dstate`. Folding (`collapsed` set ids) is browser-only view state. The dialog performs no
restructuring, ordering or numbering itself (ADR 0006).
## Consequences
- `Refresh` runs `InvoiceSetStructure.RecomputeLevels` on every patch, so a `block.replace`
posted by the editor (rows carry `SetItmId` from `rrw`) keeps the user-defined structure; the
editor never has to re-derive it. Levels are rendered as an indent class (`lvl1…`) in the
editor and a per-level left indent in the PDF.
- `invSumUpdate` now tags a nested **set** with its `setId` too (previously only non-set
members were tagged), and `BuildSetDisplay` blanks the whole subtree of a converted set —
including nested headers, which stay emphasised.
- Structural deltas never change a value: totals, VAT splits and `NetByBlock` are unaffected;
only positions are renumbered along the new order.
- Row drag in the editor (the existing `Sortable`) reorders rows without touching parent links;
a member dragged away from its set keeps its membership and indent. The structure dialog is
the place to change membership.
- Tests: `InvoiceSetStructureTests` (pure forest logic), `InvoiceDraftServiceTests` (deltas,
history, nested set price, state), `InvoiceSetPricingTests` (nesting, order preservation).
## Alternatives considered
- **Derive membership from row order (everything after a set until the next set)** as the
live model: rejected — that is exactly what the user cannot correct, and it cannot express
nesting or an item that follows a set but does not belong to it.
- **A separate structure table (tree) beside the lines**: rejected — a parent link per line
keeps the single `itm`/`items` shape the calculator, the PDF, the persistence path and the
editor already consume, so nothing else had to be re-shaped.
- **Render-time regrouping (keep `Build` moving members next to their header)**: rejected —
with explicit links the server can guarantee contiguity by linearising once, and an
order-preserving `Build` is the only way the editor and the PDF are guaranteed to show the
same sequence.
- **Client-side restructuring with a full block re-post**: rejected per ADR 0006/0008 — one
delta per action, the server owns the mutation, the browser mirrors.
+48 -9
View File
@@ -1,8 +1,10 @@
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
> Governed by ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
> (the two menu modes) and ADR [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md)
> (everything server-side, PDF == editor). ADR 0009 **redefined** the two menu modes from the
> (the two menu modes), ADR [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md)
> (everything server-side, PDF == editor) and ADR
> [0016](Decisions/0016-user-defined-set-structure.md) (set membership is an explicit,
> user-editable parent link; sets may nest — see "Set structure" below). ADR 0009 **redefined** the two menu modes from the
> reversible, non-mutating display toggles this document previously described into irreversible,
> per-service-request-block mutations — the text below reflects the redefinition.
@@ -40,6 +42,37 @@ for the full breakdown.
> or removes (mode 3), and the set-item switch (mode 1) writes exactly the members' sum onto the
> header.
## Set structure (ADR 0016): which line belongs to which set
`SetItmId` is no longer only what `fds__prepInvoice` derived from row order — it is the
**explicit parent link** of every cached line, and the user can edit it:
- **Initial structure** = the implicit derivation (every item after a zero-priced Set header
until the next one). Nothing changes for drafts the user never touches.
- **"Set-Struktur bearbeiten"** (editor menu → `$inv.sets`, `fis.inv_sets.js`) shows every
line per service-request block (Pos., Typ, Menge, Bezeichnung, indented by level). Drag a
line onto a set → `set.assign { parent }` (it becomes the set's last member); drag it onto
the block heading / the "oberste Ebene" zone → `set.assign { parent: "" }` (pulled out, placed
after the subtree it came from). Drag a line to the **top/bottom edge** of another row →
`set.move { anchor, where: before|after }` (reorder: it becomes that row's sibling; a set
travels with its members, folded or not); ▲ / ▼ post `set.move` −1 / +1 (one step among
siblings). The ◀ / ▶ buttons post `set.level` −1 / +1 (outdent to the
grandparent / indent under the nearest preceding sibling set). Sets can be folded via the
triangle (view state only). The per-block bar creates a
new **Set** (zero-priced header, convertible via function 1) or **Text** line inside any set
or at the top level → `set.create { typ, title, parent }`.
- **Sets may contain sets.** A set dropped onto another set keeps its own members (one level
deeper). Function 1 (`item.setprice`) sums the header's **transitive** descendants and nulls
them all; converting inner-then-outer or outer directly conserves the total either way.
- **Only sets are drop targets; a line never leaves its block.** Cycles (a set onto its own
descendant) and no-op moves are rejected server-side (`InvoiceSetStructure`), the browser
merely greys them out.
- **The server linearises** after each structural delta (depth-first: set, then its members,
recursively; siblings keep their order) and publishes `lvl`/`level`; `InvoiceSetPricing.Build`
is order-preserving, so editor and PDF show the same sequence and the same per-level indent.
Every change is applied immediately to the cached session and pushed back via
`draftReady`/`dstate`; the dialog re-renders from that state.
## Back-end
> **Migration status (ADR 0009).** The code below still reflects the previous
@@ -51,9 +84,14 @@ for the full breakdown.
> migration lands so it stays a faithful description of the code.
- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation:
`SetDisplayMode` + `Build(items, mode)` → ordered `InvoiceSetLine`s, each with
`ShowPrice` and `IsSetHeader`. `ModeFromInvoiceOptions(...)` reads the mode
`SetDisplayMode` + `Build(items, mode)` → `InvoiceSetLine`s **in input order**, each with
`ShowPrice`, `IsSetHeader` and `Level` (nesting depth). A line is governed by its nearest
*converted* ancestor set (blank in SetPrice, dropped in SetOnly); an unconverted header is
blank with individually priced members. `ModeFromInvoiceOptions(...)` reads the mode
from the invoice options. Fully covered by `Fuchs.Tests/InvoiceSetPricingTests.cs`.
- `Fuchs/code/InvoiceSetStructure.cs` — the pure set forest (parent links, levels, depth-first
linearisation, assign / change level / create); `Fuchs/Services/InvoiceDraftSetStructureDeltas.cs`
maps the `set.*` patch targets onto it. Covered by `InvoiceSetStructureTests` + `InvoiceDraftServiceTests`.
- `FuchsPdf.ApplyInvoice` renders through `InvoiceSetPricing.Build`: lines with
`ShowPrice == false` render **blank** price/total cells (not `0,00 €`), and the
set header line is rendered emphasised. Invoices without sets pass through
@@ -82,11 +120,12 @@ Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
C# PDF. This change closes that gap for **all** invoices, not just sets.)
3. **Set flags (function 1 only)** — `invSumUpdate` tags items as it builds `items[]`:
an item with `type === 'set'` is a header (`id` = its set id); a member item's
`setId` is taken directly from the server-computed `SetItmId` field on the row
(`rrx.SetItmId`, populated by `fds__prepInvoice`'s `[SetItmID]` window function,
anchored on the still-unconverted, zero-priced `'set'` header that owns it) —
**not** re-derived from row order in the browser. `mfr__items` itself still has no
an item with `type === 'set'` is a header (`id` = its set id); a member's
`setId` is taken directly from the `SetItmId` field on the row
(`rrx.SetItmId` — initially populated by `fds__prepInvoice`'s `[SetItmID]` window function,
anchored on the still-unconverted, zero-priced `'set'` header that owns it, and thereafter
maintained by the server per ADR 0016, including for a **nested set**, which is a member of
its parent set) — **not** re-derived from row order in the browser. `mfr__items` itself still has no
explicit member link; `fds__prepInvoice` computes `SetItmId` per request from the
item list, so only items the server actually attributes to a set are tagged, and
unrelated items following a set in the list are never swept in. The header row's
+14
View File
@@ -50,6 +50,20 @@ Serviceaufträge kommen aus dem MFR-ERP-System und werden regelmäßig automatis
1. Aus einem Serviceauftrag eine Rechnung **vorbereiten** → Vorschau prüfen.
2. **Speichern** (Entwurf) oder weiter **bearbeiten**. Entwürfe tragen ein Wasserzeichen.
### Sets (Positionen zusammenfassen)
- Ein **Set** aus mfr fasst die darauf folgenden Positionen zusammen; mit **„Auf Setpreis
umstellen“** (Waage-Symbol an der Set-Zeile) erhält das Set die Summe seiner Positionen als
Preis, die Positionen selbst werden ohne Preis gedruckt (nicht rückgängig zu machen).
- **„Set-Struktur bearbeiten“** (Menü) zeigt alle Positionen je Auftrag. Positionen per
**Drag & Drop auf ein Set** ziehen, um sie zuzuordnen; auf die Auftragszeile oder den Bereich
„oberste Ebene“ ziehen, um sie aus dem Set zu lösen. **Reihenfolge**: Zeile an den oberen
bzw. unteren Rand einer anderen Zeile ziehen (davor/danach) oder ▲ / ▼ nutzen. Mit ◀ / ▶ eine
Ebene höher/tiefer. Sets lassen sich über das Dreieck zuklappen und auch zugeklappt verschieben.
Sets können Sets enthalten; ein verschobenes Set nimmt seine Positionen mit. Über die Leiste
„Neu“ lassen sich neue **Set**- oder **Text**-Zeilen anlegen (auch innerhalb eines Sets).
- Jede Änderung wird sofort übernommen und im Editor sowie in der Vorschau abgebildet
(eingerückt je Ebene); die Rechnungssumme ändert sich dadurch nicht.
### Finalisieren & Versenden
1. **Bestätigen/Finalisieren**: Die Rechnung erhält eine Rechnungsnummer, das PDF wird erzeugt und gespeichert.
2. Ist eine E-Mail-Adresse hinterlegt, wird die Rechnung **automatisch als PDF-Anhang** per E-Mail versendet.
+18 -12
View File
@@ -95,6 +95,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
switch (d.Target)
{
case "email": return SetNewText(s, "invoiceemail", d, ref oldValue, ref newValue);
case "emailcc": return SetNewText(s, "invoiceemailcc", d, ref oldValue, ref newValue); // "Zusatzempfänger" — Cc recipient
case "address":
// Structured dialog form posts a JSON object; a plain string keeps the legacy
// free-text path working (backward compatibility).
@@ -124,6 +125,10 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
case "item.setprice": return ApplyItemSetPrice(s, d, ref oldValue, ref newValue);
case "block.setprice": return ApplyBlockSetPricing(s, removeMembers: false, ref oldValue, ref newValue);
case "block.setonly": return ApplyBlockSetPricing(s, removeMembers: true, ref oldValue, ref newValue);
case "set.assign":
case "set.level":
case "set.create":
case "set.move": return InvoiceDraftSetStructureDeltas.Apply(s, d, ref oldValue, ref newValue); // ADR 0016: user-defined set structure
default: return false;
}
}
@@ -226,9 +231,12 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
/// manually afterwards (ordinary item edit), which is why this is one-way.
/// <para>
/// The header row's own <c>SetItmId</c> now self-references its own id (rather than being
/// <c>null</c>) once <c>fds__prepInvoice</c> stopped special-casing it, so membership is
/// determined by <c>id == Ref</c> first (captured as <paramref name="header"/> below and
/// <c>continue</c>d past) — a header is never mistaken for its own member.
/// <c>null</c>) once <c>fds__prepInvoice</c> stopped special-casing it; membership is resolved
/// by <see cref="InvoiceSetStructure.Descendants"/>, which never yields the header itself.
/// Because sets may contain sets (ADR 0016), the members are the header's <b>transitive</b>
/// descendants: a nested, still-unconverted set contributes its members' prices, a nested
/// already-converted set contributes its own set price — either way every descendant line is
/// price-nulled afterwards, so the total is conserved and nothing is counted twice.
/// </para>
/// </summary>
private static bool ApplyItemSetPrice(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
@@ -236,17 +244,12 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
if (string.IsNullOrEmpty(d.Ref)) return false;
foreach (var blockTok in s.Req)
{
if (blockTok is not JObject block || block["itm"] is not JArray lines) continue;
JObject? header = null;
var members = new List<JObject>();
foreach (var lineTok in lines)
{
if (lineTok is not JObject co) continue;
if (Str(co["id"]) == d.Ref) { header = co; continue; } // the header itself is never a member, even if its SetItmId self-references
if (Str(co["SetItmId"]) == d.Ref) members.Add(co);
}
if (blockTok is not JObject block || block["itm"] is not JArray) continue;
var lines = InvoiceSetStructure.Lines(block);
var header = lines.FirstOrDefault(co => Str(co["id"]) == d.Ref);
if (header == null) continue;
if (!string.Equals(Str(header["typ"]), "set", StringComparison.OrdinalIgnoreCase)) return false;
var members = InvoiceSetStructure.Descendants(lines, d.Ref);
oldValue = InvoiceDraftCalculator.Dec(header["vt"]).ToString(CultureInfo.InvariantCulture);
decimal sumNet = 0, sumVat = 0, sumSvcNet = 0, sumSvcVat = 0;
@@ -256,6 +259,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
sumVat += InvoiceDraftCalculator.Dec(m["vv"]);
sumSvcNet += InvoiceDraftCalculator.Dec(m["vs"]);
sumSvcVat += InvoiceDraftCalculator.Dec(m["vsv"]);
if (Str(header["vat"]).Length == 0 && Str(m["vat"]).Length > 0) header["vat"] = Str(m["vat"]); // a user-created set adopts its members' rate
NullLinePrice(m); // ADR 0009: null (empty cell), not 0 — excluded from the sum, distinct from a real 0,00 €
}
header["v"] = sumNet; header["vt"] = sumNet; header["vv"] = sumVat; header["vs"] = sumSvcNet; header["vsv"] = sumSvcVat;
@@ -540,6 +544,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
{
InvoiceDraftCalculator.RecomputeLineValues(session);
InvoiceDraftCalculator.RecomputeTotals(session);
InvoiceSetStructure.RecomputeLevels(session); // ADR 0016: publish lvl + mirror setId/level onto the items contract — must run before RecomputePositions, which reads lvl (ADR 0017)
InvoiceDraftCalculator.RecomputePositions(session);
InvoiceDraftCalculator.Validate(session);
}
@@ -609,6 +614,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
["InvoiceTitle"] = title,
["SendToAddress"] = Str(session.New["invoiceaddress"]),
["SendToEmail"] = Str(session.New["invoiceemail"]),
["SendToEmailCc"] = Str(session.New["invoiceemailcc"]),
["ProvisionLocation"] = loc,
["ProvisionPeriod"] = Str(session.New["provisionperiod"]),
["PaymentTerm"] = Str(session.Admin["paymentterms"]).ne(Str(session.New["paymentterm"])),
@@ -0,0 +1,120 @@
using System.Globalization;
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
namespace Fuchs.Services;
/// <summary>
/// The <c>set.*</c> patch targets of <c>inv/dpatch</c> (ADR 0016) — the user-defined set structure
/// dialog's edits, applied to the authoritative cached session. Each handler locates the block,
/// delegates the actual tree mutation to the pure <see cref="InvoiceSetStructure"/> and reports the
/// old/new values for the change history. The browser never restructures anything itself: it posts
/// one delta, the server rewrites the block (parent links, depth-first order, levels) and the normal
/// <c>draftReady</c> refresh re-renders both the editor rows and the open structure dialog.
/// <list type="bullet">
/// <item><c>set.assign</c> — <c>Ref</c> = item id, <c>Value</c> = { parent: set id | "" }: drop a line onto a set / pull it out to the top level.</item>
/// <item><c>set.level</c> — <c>Ref</c> = item id, <c>Value</c> = +1 | -1: indent under the preceding sibling set / outdent to the grandparent.</item>
/// <item><c>set.create</c> — <c>Ref</c> = block id, <c>Value</c> = { typ: "set" | "text", title, parent: set id | "" }: add a new set header or free-text line.</item>
/// <item><c>set.move</c> — <c>Ref</c> = item id, <c>Value</c> = -1 | +1 (one step up/down among its siblings) or { anchor: item id, where: "before" | "after" } (drag between rows: become the anchor's sibling, before it or after its subtree). The item's own subtree travels with it.</item>
/// </list>
/// </summary>
internal static class InvoiceDraftSetStructureDeltas
{
public static bool Apply(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue) =>
d.Target switch
{
"set.assign" => Assign(s, d, ref oldValue, ref newValue),
"set.level" => Level(s, d, ref oldValue, ref newValue),
"set.create" => Create(s, d, ref oldValue, ref newValue),
"set.move" => Move(s, d, ref oldValue, ref newValue),
_ => false
};
private static bool Assign(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (string.IsNullOrEmpty(d.Ref)) return false;
var block = BlockOfItem(s, d.Ref);
if (block == null) return false;
string parent = d.Value is JObject vo ? InvoiceSetStructure.Str(vo["parent"]) : d.ValueString;
if (!InvoiceSetStructure.Assign(block, d.Ref, parent.Trim(), out string oldParent)) return false;
oldValue = oldParent;
newValue = parent.Trim();
return true;
}
private static bool Level(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (string.IsNullOrEmpty(d.Ref)) return false;
var block = BlockOfItem(s, d.Ref);
if (block == null) return false;
int delta = ParseDelta(d.Value);
if (!InvoiceSetStructure.ChangeLevel(block, d.Ref, delta, out int oldLevel, out int newLevel)) return false;
oldValue = oldLevel.ToString(CultureInfo.InvariantCulture);
newValue = newLevel.ToString(CultureInfo.InvariantCulture);
return true;
}
private static bool Create(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (d.Value is not JObject vo) return false;
var block = FindBlock(s, d.Ref);
if (block == null) return false;
string typ = InvoiceSetStructure.Str(vo["typ"]);
string title = InvoiceSetStructure.Str(vo["title"]);
string parent = InvoiceSetStructure.Str(vo["parent"]).Trim();
string? id = InvoiceSetStructure.Create(block, typ, title, parent);
if (id == null) return false;
oldValue = "";
newValue = typ.Trim().ToLowerInvariant() + ":" + title.Trim();
return true;
}
private static bool Move(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (string.IsNullOrEmpty(d.Ref)) return false;
var block = BlockOfItem(s, d.Ref);
if (block == null) return false;
if (d.Value is JObject vo)
{
string anchor = InvoiceSetStructure.Str(vo["anchor"]).Trim();
string where = InvoiceSetStructure.Str(vo["where"]).Trim().ToLowerInvariant();
if (anchor.Length == 0 || where is not ("before" or "after")) return false;
if (!InvoiceSetStructure.Place(block, d.Ref, anchor, after: where == "after", out string oldParent, out string newParent)) return false;
oldValue = oldParent;
newValue = where + ":" + anchor + (newParent.Length > 0 ? " (" + newParent + ")" : "");
return true;
}
int delta = ParseDelta(d.Value);
if (!InvoiceSetStructure.MoveSibling(block, d.Ref, delta, out int oldIndex, out int newIndex)) return false;
oldValue = oldIndex.ToString(CultureInfo.InvariantCulture);
newValue = newIndex.ToString(CultureInfo.InvariantCulture);
return true;
}
/// <summary>Accepts a JSON number, a numeric string, or the words "in"/"out" (indent/outdent), "up"/"down". 0 when unparseable.</summary>
private static int ParseDelta(JToken? value)
{
if (value == null || value.Type == JTokenType.Null) return 0;
if (value.Type is JTokenType.Integer or JTokenType.Float) return Math.Sign(value.Value<double>());
string s = InvoiceSetStructure.Str(value).Trim().ToLowerInvariant();
if (s is "in" or "indent" or "+" or "down") return 1;
if (s is "out" or "outdent" or "-" or "up") return -1;
return int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) ? Math.Sign(n) : 0;
}
private static JObject? FindBlock(InvoiceDraftSession s, string blockId)
{
if (string.IsNullOrEmpty(blockId)) return null;
foreach (var b in s.Req)
if (b is JObject bo && InvoiceSetStructure.Str(bo["Id"]) == blockId) return bo;
return null;
}
/// <summary>The block whose <c>itm</c> array contains the line <paramref name="itemId"/>.</summary>
private static JObject? BlockOfItem(InvoiceDraftSession s, string itemId)
{
foreach (var b in s.Req)
if (b is JObject bo && InvoiceSetStructure.Lines(bo).Any(l => InvoiceSetStructure.Id(l) == itemId)) return bo;
return null;
}
}
+12
View File
@@ -15,6 +15,18 @@
}
authinfo["requestedaccount"] = "";
authinfo["accountrequired"] = false;
// Cache-buster for the per-module bundles ($ocms.getScript loads web/fis.<mdl>.<locale>.js and
// web/fis.<mdl>.css dynamically, so asp-append-version cannot cover them): the newest write
// time of any module bundle, appended as ?v=… by fis_main.js, so a gulp rebuild is picked up
// on the next page load instead of a stale cached module.
try
{
var webDir = new System.IO.DirectoryInfo(System.IO.Path.Combine(HostEnvironment.WebRootPath, "web"));
authinfo["assetversion"] = webDir.Exists
? webDir.GetFiles("fis.*").Select(f => f.LastWriteTimeUtc.Ticks).DefaultIfEmpty(0L).Max().ToString()
: "0";
}
catch { authinfo["assetversion"] = "0"; }
string appName = ViewData["AppName"] as string ?? "Fuchs Intranet";
string fullName = ViewData["FullName"] as string ?? "";
+4 -2
View File
@@ -77,7 +77,8 @@
"js/intranet/modules/fis.req_txt_de.js",
"js/intranet/modules/fis.inv_txt_de.js",
"js/intranet/modules/fis.inv.js",
"js/intranet/modules/fis.inv_shared.js"
"js/intranet/modules/fis.inv_shared.js",
"js/intranet/modules/fis.inv_sets.js"
],
"minify": { "enabled": true }
},
@@ -99,7 +100,8 @@
"js/intranet/modules/fis.req_txt_de.js",
"js/intranet/modules/fis.inv_txt_de.js",
"js/intranet/modules/fis.req.js",
"js/intranet/modules/fis.inv_shared.js"
"js/intranet/modules/fis.inv_shared.js",
"js/intranet/modules/fis.inv_sets.js"
],
"minify": { "enabled": true }
},
+23 -13
View File
@@ -162,6 +162,9 @@ public static class FuchsPdf
/// (type <c>text</c>/<c>title</c>) show neither a price nor a position number. This is the flat
/// (non-collapsing) rendering used for every set mode except the explicit <c>setonly</c>.
/// </summary>
/// <summary>Indent per nesting level of a set member on the invoice (mirrors the editor's per-level padding).</summary>
internal const double SetLevelIndentMm = 4;
private static InvoiceSetLine MapItemToLine(Dictionary<string, object?> i)
{
string type = i.nz("type", "").ToLowerInvariant();
@@ -180,7 +183,8 @@ public static class FuchsPdf
TotalNet = total,
ShowPrice = !isText && hasPrice,
Numbered = !isText, // numbered like the editor: every line except free-text/heading, even price-blanked set members
IsSetHeader = type == "set"
IsSetHeader = type == "set",
Level = int.TryParse(i.nz("level", "0"), out int lvl) && lvl > 0 ? lvl : 0 // ADR 0016 nesting depth → indent
};
}
@@ -772,9 +776,22 @@ public static class FuchsPdf
// and drops the members entirely.
// Blocks without any set items are unaffected and always render flat.
var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions"));
int pos = 0;
foreach (var block in inv.InvoiceBlocks)
// Function-1 (SetItmId-linked) sets still collapse via Build; block-mode sets (ADR 0009)
// and plain blocks render flat — their set row is emphasised on its own and nulled
// members map to blank cells (see MapItemToLine).
var blockLines = inv.InvoiceBlocks
.Select(block => InvoiceSetPricing.HasSetMembers(block.Items)
? InvoiceSetPricing.Build(block.Items, setMode)
: block.Items.Select(MapItemToLine).ToList())
.ToList();
// Position labels are hierarchical ("1", "1.1", "1.2", "2", …, ADR 0017) and span the
// whole invoice, so they must be computed once across every block, not per block.
InvoiceSetPricing.AssignPositionLabels(blockLines);
for (int bi = 0; bi < inv.InvoiceBlocks.Count; bi++)
{
var block = inv.InvoiceBlocks[bi];
if (!string.IsNullOrWhiteSpace(block.Heading))
{
var hr = tbl.AddRow();
@@ -783,19 +800,12 @@ public static class FuchsPdf
hr.Cells[1].AddParagraph().WithStyle("TblCell_RTitle").AddFormattedText(block.Heading, TextFormat.Bold);
}
// Function-1 (SetItmId-linked) sets still collapse via Build; block-mode sets (ADR 0009)
// and plain blocks render flat — their set row is emphasised on its own and nulled
// members map to blank cells (see MapItemToLine).
var lines = InvoiceSetPricing.HasSetMembers(block.Items)
? InvoiceSetPricing.Build(block.Items, setMode)
: block.Items.Select(MapItemToLine).ToList();
foreach (var line in lines)
foreach (var line in blockLines[bi])
{
bool numbered = line.Numbered; // every line except free-text/heading (mirrors the editor's RecomputePositions), even price-blanked set members
var row = tbl.AddRow();
row.HeightRule = RowHeightRule.Auto;
row.Cells[0].AddParagraph(numbered ? (++pos).ToString() : "").Style = "TblCell_Base";
row.Cells[0].AddParagraph(line.PositionLabel).Style = "TblCell_Base";
if (line.Level > 0) row.Cells[1].Format.LeftIndent = Unit.FromMillimeter(SetLevelIndentMm * line.Level); // nested set member (ADR 0016) — indented like the editor
if (!string.IsNullOrEmpty(line.Title)) // skip the empty paragraph that added a blank line before free text
{
var titleCell = row.Cells[1].AddParagraph();
+22 -10
View File
@@ -109,27 +109,35 @@ public static class InvoiceDraftCalculator
/// <summary>
/// Renumbers the visible line positions authoritatively (the port of the client-side
/// numbering in <c>invSumUpdate</c>): priced lines are numbered sequentially across the whole
/// invoice — matching the PDF's <c>Pos.</c> column — while heading/free-text lines
/// (<c>typ</c> = "text"/"title") carry no number. The result is written onto each line's
/// <c>p</c> field so it flows back to the browser (via the view state) and into the PDF; this
/// keeps the online editor and the PDF preview showing the same position numbers, including
/// after a reorder.
/// numbering in <c>invSumUpdate</c>): priced lines are numbered hierarchically across the
/// whole invoice — a set header gets a number at its own level, its members get subsidiary
/// numbers one level deeper (e.g. "3", "3.1", "3.2") — matching the PDF's <c>Pos.</c> column
/// (ADR 0017), while heading/free-text lines (<c>typ</c> = "text"/"title") carry no number.
/// Levels come from <c>lvl</c> (written by <see cref="InvoiceSetStructure"/>, default 0). The
/// result is written onto each line's <c>p</c> field (a string, since "3.1" isn't numeric) so
/// it flows back to the browser (via the view state) and into the PDF; this keeps the online
/// editor and the PDF preview showing the same position labels, including after a reorder.
/// </summary>
public static void RecomputePositions(InvoiceDraftSession session)
{
int pos = 0;
var lines = new List<JObject>();
var input = new List<(bool Numbered, int Level)>();
foreach (var blockTok in session.Req)
{
if (blockTok is not JObject block || block["itm"] is not JArray lines) continue;
foreach (var lineTok in lines)
if (blockTok is not JObject block || block["itm"] is not JArray itms) continue;
foreach (var lineTok in itms)
{
if (lineTok is not JObject co) continue;
string typ = Str(co["typ"]).Trim().ToLowerInvariant();
bool numbered = typ is not ("text" or "title"); // only headings/free-text carry no number (mirrors invSumUpdate)
co["p"] = numbered ? (JToken)(++pos) : (JToken)"";
int level = (int?)co[InvoiceSetStructure.LevelKey] ?? 0;
lines.Add(co);
input.Add((numbered, level));
}
}
var labels = InvoiceLineNumbering.Compute(input);
for (int i = 0; i < lines.Count; i++)
lines[i]["p"] = labels[i];
}
/// <summary>
@@ -149,6 +157,10 @@ public static class InvoiceDraftCalculator
else if (!IsValidEmail(email))
Add("email", "error", "Die E-Mail-Adresse ist ungültig.");
string emailCc = Str(session.New["invoiceemailcc"]).Trim(); // "Zusatzempfänger" — optional, may stay empty
if (emailCc.Length > 0 && !IsValidEmail(emailCc))
Add("emailcc", "error", "Die E-Mail-Adresse des Zusatzempfängers ist ungültig.");
if (Str(session.New["invoiceaddress"]).Trim().Length == 0)
Add("address", "warning", "Es ist keine Rechnungsanschrift hinterlegt.");
+79 -48
View File
@@ -1,4 +1,5 @@
using static OCORE.commons;
using System.Linq;
using static OCORE.commons;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.intranet;
@@ -40,13 +41,24 @@ public sealed class InvoiceSetLine
public bool Numbered { get; init; } = true;
/// <summary>True for the set header line (rendered emphasised).</summary>
public bool IsSetHeader { get; init; }
/// <summary>Nesting depth (0 = top level; a member of a top-level set is 1, a member of a set inside it is 2 …) — rendered as an indent (ADR 0016).</summary>
public int Level { get; init; }
/// <summary>
/// The hierarchical position label ("1", "1.1", "1.2", "2", …) as printed in the PDF's
/// <c>Pos.</c> column (ADR 0017), or "" for unnumbered lines. Assigned via
/// <see cref="InvoiceSetPricing.AssignPositionLabels"/> across the whole invoice, since a
/// single <see cref="Build"/> call only sees one block.
/// </summary>
public string PositionLabel { get; set; } = "";
}
/// <summary>
/// Transforms raw invoice line items into display lines according to a
/// <see cref="SetDisplayMode"/>. A "set" item is identified by
/// <c>type == "set"</c>; its members carry <c>setId</c> equal to the set
/// header's <c>id</c>. Items that belong to no set pass through unchanged.
/// header's <c>id</c> — a nested set is itself a member of its parent set
/// (ADR 0016), and every line reports its depth as <see cref="InvoiceSetLine.Level"/>.
/// Items that belong to no set pass through unchanged.
///
/// The invoice total is taken from the registration balance, not from these
/// lines, so calling <see cref="Build"/> is a pure, non-mutating transformation
@@ -94,65 +106,81 @@ public static class InvoiceSetPricing
items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
/// <summary>
/// Produces the ordered display lines for the given items and mode.
/// Standalone items are always shown with their price. A set header whose
/// own <c>total_net</c> is still zero (not yet converted via the set-item
/// switch) is rendered blank with its members individually priced,
/// regardless of <paramref name="mode"/>.
/// Produces the display lines for the given items and mode, <b>preserving the input order</b>
/// (the draft service already linearises each block depth-first — set, then its members,
/// recursively — so editor and PDF print the very same sequence, ADR 0008/0016).
/// Standalone items are always shown with their price. A set header whose own
/// <c>total_net</c> is still zero (not yet converted via the set-item switch) is rendered
/// blank with its members individually priced, regardless of <paramref name="mode"/>.
/// Sets may nest: a line is governed by its <i>nearest converted ancestor</i> — inside one, it
/// is price-blanked (SetPrice) or dropped (SetOnly); a nested set that is itself converted
/// but sits inside an unconverted one behaves like a converted top-level set for its own subtree.
/// </summary>
public static List<InvoiceSetLine> Build(IReadOnlyList<Dictionary<string, object?>> items, SetDisplayMode mode)
{
var result = new List<InvoiceSetLine>();
if (items.Count == 0) return result;
// Group members by their set id.
var membersBySet = items
.Where(i => !IsSetHeader(i) && !string.IsNullOrEmpty(SetIdOf(i)))
.GroupBy(SetIdOf)
.ToDictionary(g => g.Key!, g => g.ToList());
var headers = new Dictionary<string, Dictionary<string, object?>>();
foreach (var i in items)
if (IsSetHeader(i) && i.nz("id", "").Length > 0) headers.TryAdd(i.nz("id", ""), i);
static bool Converted(Dictionary<string, object?> h)
{
FuchsPdf.ParseDec(h.no("total_net", 0), out decimal own);
return own != 0;
}
// Walks the parent chain (setId → header) and reports depth + whether any ancestor is converted.
(int level, bool insideConverted) Ancestry(Dictionary<string, object?> i)
{
int level = 0; bool inside = false;
var seen = new HashSet<string> { i.nz("id", "") };
string? pid = ParentIdOf(i, headers);
while (pid != null && seen.Add(pid))
{
level++;
var h = headers[pid];
if (Converted(h)) inside = true;
pid = ParentIdOf(h, headers);
}
return (level, inside);
}
foreach (var item in items)
{
var (level, insideConverted) = Ancestry(item);
if (insideConverted && mode == SetDisplayMode.SetOnly) continue; // collapsed into its set's single line
if (IsSetHeader(item))
{
string setId = HeaderIdOf(item);
var members = membersBySet.TryGetValue(setId, out var m) ? m : new List<Dictionary<string, object?>>();
FuchsPdf.ParseDec(item.no("total_net", 0), out decimal headerOwnTotal);
if (headerOwnTotal == 0)
{
// Not yet converted (the set-item switch, ApplyItemSetPrice, hasn't run): the
// header still carries its as-delivered zero price, so the chosen display mode
// does not apply yet — show the header blank and each member with its own,
// individual price, exactly as if there were no set grouping at all.
result.Add(HeaderLine(item, 0, showPrice: false));
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: true));
continue;
}
switch (mode)
{
case SetDisplayMode.SetPrice:
result.Add(HeaderLine(item, headerOwnTotal, showPrice: true));
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: false));
break;
case SetDisplayMode.SetOnly:
result.Add(HeaderLine(item, headerOwnTotal, showPrice: true));
break;
}
}
else if (!string.IsNullOrEmpty(SetIdOf(item)))
{
// Member — already emitted next to its header above; skip standalone emission.
// Unconverted (still zero-priced) header: blank price, members keep their own price.
// Converted: priced line; members blank (SetPrice) — or dropped above (SetOnly).
bool show = headerOwnTotal != 0 && !insideConverted;
result.Add(HeaderLine(item, show ? headerOwnTotal : 0, showPrice: show, level));
}
else
{
result.Add(MemberLine(item, showPrice: true)); // standalone item
result.Add(MemberLine(item, showPrice: !insideConverted, level));
}
}
return result;
}
/// <summary>
/// Computes and assigns <see cref="InvoiceSetLine.PositionLabel"/> across every block's lines
/// (in the order the blocks are printed), so numbering spans the whole invoice exactly like
/// <c>InvoiceDraftCalculator.RecomputePositions</c> does for the online editor (ADR 0017).
/// </summary>
public static void AssignPositionLabels(IEnumerable<IReadOnlyList<InvoiceSetLine>> blocks)
{
var lines = blocks.SelectMany(b => b).ToList();
var labels = InvoiceLineNumbering.Compute(lines.Select(l => (l.Numbered, l.Level)).ToList());
for (int i = 0; i < lines.Count; i++)
lines[i].PositionLabel = labels[i];
}
// ── helpers ──────────────────────────────────────────────────────────────
private static bool IsSetHeader(Dictionary<string, object?> i) =>
string.Equals(i.nz("type", ""), "set", StringComparison.OrdinalIgnoreCase);
@@ -174,13 +202,14 @@ public static class InvoiceSetPricing
return string.IsNullOrEmpty(s) ? null : s;
}
private static string HeaderIdOf(Dictionary<string, object?> i)
/// <summary>The owning set header's id: <c>setId</c> when it names another known header; null for top-level lines and self-references.</summary>
private static string? ParentIdOf(Dictionary<string, object?> i, Dictionary<string, Dictionary<string, object?>> headers)
{
string s = i.nz("setId", "");
return string.IsNullOrEmpty(s) ? i.nz("id", "") : s;
string s = SetIdOf(i) ?? "";
return s.Length > 0 && s != i.nz("id", "") && headers.ContainsKey(s) ? s : null;
}
private static InvoiceSetLine HeaderLine(Dictionary<string, object?> i, decimal setTotal, bool showPrice) => new()
private static InvoiceSetLine HeaderLine(Dictionary<string, object?> i, decimal setTotal, bool showPrice, int level) => new()
{
Id = i.nz("id", ""),
Title = i.nz("title", ""),
@@ -190,10 +219,11 @@ public static class InvoiceSetPricing
TotalNet = setTotal,
ShowPrice = showPrice,
Numbered = true, // a set header is always numbered
IsSetHeader = true
IsSetHeader = true,
Level = level
};
private static InvoiceSetLine MemberLine(Dictionary<string, object?> i, bool showPrice)
private static InvoiceSetLine MemberLine(Dictionary<string, object?> i, bool showPrice, int level)
{
FuchsPdf.ParseDec(i.no("price_net", 0), out decimal price);
FuchsPdf.ParseDec(i.no("total_net", 0), out decimal total);
@@ -207,7 +237,8 @@ public static class InvoiceSetPricing
TotalNet = total,
ShowPrice = showPrice && !IsNoPriceLine(i), // headings/free text print no price
Numbered = !IsNoPriceLine(i), // …but are still numbered unless heading/free-text
IsSetHeader = false
IsSetHeader = false,
Level = level
};
}
}
+404
View File
@@ -0,0 +1,404 @@
using Newtonsoft.Json.Linq;
namespace Fuchs.intranet;
/// <summary>
/// The explicit, user-editable <b>set structure</b> of an invoice draft (ADR 0016): which line
/// belongs to which set, and how deep it sits. Every line in a block's <c>itm</c> array carries a
/// parent link in <c>SetItmId</c> (the id of the <c>typ == "set"</c> line that owns it, or
/// null/self for a top-level line). Sets may contain sets, so the links form a forest per block;
/// the depth of each line is published as <c>lvl</c> (0 = top level) and mirrored onto the
/// <c>items</c> contract shape as <c>setId</c>/<c>level</c>.
/// <para>
/// The implicit derivation (<c>fds__prepInvoice</c> tags every item following a zero-priced Set
/// header with that header's id) is kept as the <b>initial</b> structure; this class only applies
/// the user's explicit changes on top of it — assigning a line to a set, pulling it out, changing
/// its level, or creating a new set/text line — and then re-linearises the block depth-first so
/// every set is immediately followed by its members, recursively. Pure and static, so it is
/// exhaustively unit-tested (<c>InvoiceSetStructureTests</c>); the draft service merely delegates.
/// </para>
/// </summary>
public static class InvoiceSetStructure
{
/// <summary>Parent link on the editor <c>co</c> line shape.</summary>
public const string ParentKey = "SetItmId";
/// <summary>Depth (0 = top level) on the editor <c>co</c> line shape.</summary>
public const string LevelKey = "lvl";
// ── Structural queries ───────────────────────────────────────────────────
internal static string Id(JObject co) => Str(co["id"]);
internal static bool IsSet(JObject co) =>
string.Equals(Str(co["typ"]), "set", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// The effective parent set id of a line: its <c>SetItmId</c> when that names <i>another</i>
/// set line of the same block; null for top-level lines, a self-reference (the shape
/// <c>fds__prepInvoice</c> delivers for a header), an unknown id or a non-set target.
/// </summary>
internal static string? ParentOf(JObject co, IReadOnlyDictionary<string, JObject> byId)
{
string p = Str(co[ParentKey]);
if (p.Length == 0 || p == Id(co)) return null;
return byId.TryGetValue(p, out var parent) && IsSet(parent) ? p : null;
}
/// <summary>The block's line objects in their current order (non-object entries skipped).</summary>
internal static List<JObject> Lines(JObject block) =>
block["itm"] is JArray arr ? arr.OfType<JObject>().ToList() : new List<JObject>();
internal static Dictionary<string, JObject> ById(IEnumerable<JObject> lines)
{
var d = new Dictionary<string, JObject>();
foreach (var l in lines) { string id = Id(l); if (id.Length > 0) d.TryAdd(id, l); }
return d;
}
/// <summary>Depth of every line (0 = top level). A cyclic chain is cut at its first repeat, so it degrades to a root.</summary>
internal static Dictionary<string, int> Levels(List<JObject> lines)
{
var byId = ById(lines);
var result = new Dictionary<string, int>();
foreach (var line in lines)
{
int depth = 0;
var seen = new HashSet<string> { Id(line) };
var cur = line;
while (ParentOf(cur, byId) is { } pid)
{
if (!seen.Add(pid)) { depth = 0; break; } // cyclic chain — degrade to top level (Linearize emits it as a root, too)
depth++;
cur = byId[pid];
}
result[Id(line)] = depth;
}
return result;
}
/// <summary>All lines transitively owned by the set <paramref name="setId"/> (never the set itself), in current order.</summary>
public static List<JObject> Descendants(List<JObject> lines, string setId)
{
var byId = ById(lines);
var children = new Dictionary<string, List<JObject>>();
foreach (var l in lines)
if (ParentOf(l, byId) is { } p)
(children.TryGetValue(p, out var lst) ? lst : children[p] = new List<JObject>()).Add(l);
var result = new List<JObject>();
var seen = new HashSet<string> { setId };
var queue = new Queue<string>();
queue.Enqueue(setId);
while (queue.Count > 0)
{
string cur = queue.Dequeue();
if (!children.TryGetValue(cur, out var kids)) continue;
foreach (var k in kids)
{
string kid = Id(k);
if (!seen.Add(kid)) continue;
result.Add(k);
queue.Enqueue(kid);
}
}
return result;
}
/// <summary>The id of the top-level ancestor of a line (itself when it is top level).</summary>
private static string RootOf(JObject line, IReadOnlyDictionary<string, JObject> byId)
{
var seen = new HashSet<string> { Id(line) };
var cur = line;
while (ParentOf(cur, byId) is { } pid && seen.Add(pid)) cur = byId[pid];
return Id(cur);
}
// ── Recompute / linearise ────────────────────────────────────────────────
/// <summary>
/// Publishes the depth of every line (<c>lvl</c>) and mirrors parent + depth onto the
/// <c>items</c> contract shape (<c>setId</c>/<c>level</c>) for every block — without
/// reordering anything. Runs on every refresh so the view state and the PDF read one truth.
/// </summary>
public static void RecomputeLevels(InvoiceDraftSession session)
{
foreach (var blockTok in session.Req)
if (blockTok is JObject block) ApplyLevels(block);
}
private static void ApplyLevels(JObject block)
{
var lines = Lines(block);
if (lines.Count == 0) return;
var byId = ById(lines);
var levels = Levels(lines);
foreach (var line in lines) line[LevelKey] = levels[Id(line)];
if (block["items"] is not JArray items) return;
foreach (var io in items.OfType<JObject>())
{
string id = Str(io["id"]);
if (!byId.TryGetValue(id, out var co)) continue; // e.g. combined-sum rows use differing ids — leave untouched
io["level"] = levels[id];
io["setId"] = ParentOf(co, byId) is { } p ? p : JValue.CreateNull();
}
}
/// <summary>
/// Reorders a block's lines depth-first — each set immediately followed by its members,
/// recursively, siblings keeping their current relative order — then republishes levels and
/// mirrors the order onto <c>items</c>. Lines caught in a cyclic chain are emitted as roots.
/// </summary>
public static void Linearize(JObject block)
{
var lines = Lines(block);
if (lines.Count == 0) return;
var byId = ById(lines);
var children = new Dictionary<string, List<JObject>>();
var roots = new List<JObject>();
foreach (var l in lines)
{
if (ParentOf(l, byId) is { } p)
(children.TryGetValue(p, out var lst) ? lst : children[p] = new List<JObject>()).Add(l);
else roots.Add(l);
}
var ordered = new List<JObject>();
var seen = new HashSet<string>();
void Visit(JObject l)
{
if (!seen.Add(Id(l))) return;
ordered.Add(l);
if (children.TryGetValue(Id(l), out var kids)) foreach (var k in kids) Visit(k);
}
foreach (var r in roots) Visit(r);
foreach (var l in lines) Visit(l); // anything unreachable (cycle) — keep, as a root
var itm = (JArray)block["itm"]!;
itm.Clear();
foreach (var l in ordered) itm.Add(l);
if (block["items"] is JArray items)
{
var itemsById = new Dictionary<string, JObject>();
var rest = new List<JObject>();
foreach (var io in items.OfType<JObject>())
{
string id = Str(io["id"]);
if (id.Length > 0 && byId.ContainsKey(id) && itemsById.TryAdd(id, io)) continue;
rest.Add(io);
}
items.Clear();
foreach (var l in ordered) if (itemsById.TryGetValue(Id(l), out var io)) items.Add(io);
foreach (var io in rest) items.Add(io);
}
ApplyLevels(block);
}
// ── Mutations ────────────────────────────────────────────────────────────
/// <summary>
/// Assigns line <paramref name="itemId"/> to the set <paramref name="parentId"/> ("" = pull it
/// out to the top level). The line's own descendants move with it (one level deeper/shallower).
/// A line dropped onto a set becomes its last member; a line pulled out is placed right after
/// the subtree it came from. No-op (false) when the item is unknown, the target is not a set
/// line of this block, the target is the item itself or one of its descendants (cycle), or
/// the parent is unchanged.
/// </summary>
public static bool Assign(JObject block, string itemId, string parentId, out string oldParent)
{
oldParent = "";
var lines = Lines(block);
var byId = ById(lines);
if (!byId.TryGetValue(itemId, out var item)) return false;
string? current = ParentOf(item, byId);
oldParent = current ?? "";
string? target = parentId.Length == 0 ? null : parentId;
if (target == current) return false;
if (target != null)
{
if (target == itemId || !byId.TryGetValue(target, out var parent) || !IsSet(parent)) return false;
if (Descendants(lines, itemId).Any(d => Id(d) == target)) return false; // would create a cycle
}
var itm = (JArray)block["itm"]!;
if (target != null)
{
item.Remove();
itm.Add(item); // last member of the target set
}
else
{
string root = RootOf(item, byId);
var subtree = new HashSet<string>(Descendants(lines, root).Select(Id)) { root };
var own = new HashSet<string>(Descendants(lines, itemId).Select(Id)) { itemId };
var last = lines.Where(l => subtree.Contains(Id(l)) && !own.Contains(Id(l))).LastOrDefault();
item.Remove();
if (last != null) last.AddAfterSelf(item); else itm.Add(item);
}
item[ParentKey] = ParentToken(target);
Linearize(block);
return true;
}
/// <summary>
/// Moves a line one level deeper (<paramref name="delta"/> &gt; 0: it becomes the last member of
/// the nearest preceding sibling set) or one level up (<paramref name="delta"/> &lt; 0: it joins
/// its grandparent right after its former parent's subtree). Returns false when no such move
/// exists (no preceding sibling set / already top level) or the item is unknown.
/// </summary>
public static bool ChangeLevel(JObject block, string itemId, int delta, out int oldLevel, out int newLevel)
{
oldLevel = newLevel = 0;
if (delta == 0) return false;
Linearize(block); // guarantees contiguous subtrees + fresh levels
var lines = Lines(block);
var byId = ById(lines);
if (!byId.TryGetValue(itemId, out var item)) return false;
oldLevel = newLevel = Levels(lines)[itemId];
string? parent = ParentOf(item, byId);
if (delta > 0)
{
JObject? sibling = null;
foreach (var l in lines)
{
if (Id(l) == itemId) break;
if (ParentOf(l, byId) == parent && IsSet(l)) sibling = l; // nearest preceding sibling set
}
if (sibling == null) return false;
if (!Assign(block, itemId, Id(sibling), out _)) return false;
newLevel = Levels(Lines(block))[itemId];
return true;
}
if (parent == null) return false;
string grand = ParentOf(byId[parent], byId) ?? "";
// Place the line (with its own subtree) right after its former parent's subtree.
var parentSubtree = new HashSet<string>(Descendants(lines, parent).Select(Id));
var own = new HashSet<string>(Descendants(lines, itemId).Select(Id)) { itemId };
var anchor = lines.Where(l => (parentSubtree.Contains(Id(l)) || Id(l) == parent) && !own.Contains(Id(l))).Last();
var moving = lines.Where(l => own.Contains(Id(l))).ToList();
foreach (var l in moving) l.Remove();
JToken after = anchor;
foreach (var l in moving) { after.AddAfterSelf(l); after = l; }
item[ParentKey] = ParentToken(grand.Length > 0 ? grand : null);
Linearize(block);
newLevel = Levels(Lines(block))[itemId];
return true;
}
/// <summary>
/// Places line <paramref name="itemId"/> (with its whole subtree) immediately <b>before</b> the
/// anchor line or <b>after</b> the anchor's subtree, adopting the anchor's parent — i.e. it
/// becomes the anchor's sibling. This is the reorder primitive behind drag-between-rows and
/// the ▲/▼ buttons. Rejected (false) when the item or anchor is unknown, they are the same
/// line, or the anchor sits inside the item's own subtree.
/// </summary>
public static bool Place(JObject block, string itemId, string anchorId, bool after, out string oldParent, out string newParent)
{
oldParent = newParent = "";
if (itemId == anchorId) return false;
Linearize(block); // contiguous subtrees
var lines = Lines(block);
var byId = ById(lines);
if (!byId.TryGetValue(itemId, out var item) || !byId.TryGetValue(anchorId, out var anchor)) return false;
var own = new HashSet<string>(Descendants(lines, itemId).Select(Id)) { itemId };
if (own.Contains(anchorId)) return false; // cannot place a set relative to its own member
oldParent = ParentOf(item, byId) ?? "";
newParent = ParentOf(anchor, byId) ?? "";
var moving = lines.Where(l => own.Contains(Id(l))).ToList();
foreach (var l in moving) l.Remove();
JToken slot = after
? lines.Where(l => !own.Contains(Id(l)) && (Id(l) == anchorId || Descendants(lines, anchorId).Any(d => Id(d) == Id(l)))).Last()
: anchor;
if (after) { foreach (var l in moving) { slot.AddAfterSelf(l); slot = l; } }
else { foreach (var l in moving) slot.AddBeforeSelf(l); }
item[ParentKey] = ParentToken(newParent.Length > 0 ? newParent : null);
Linearize(block);
return true;
}
/// <summary>
/// Moves a line one step up (<paramref name="delta"/> &lt; 0) or down (&gt; 0) among its
/// siblings (same parent); its subtree travels with it. False at the first/last position or
/// for an unknown line. Reports the 0-based sibling index before and after.
/// </summary>
public static bool MoveSibling(JObject block, string itemId, int delta, out int oldIndex, out int newIndex)
{
oldIndex = newIndex = -1;
if (delta == 0) return false;
Linearize(block);
var lines = Lines(block);
var byId = ById(lines);
if (!byId.TryGetValue(itemId, out var item)) return false;
string? parent = ParentOf(item, byId);
var siblings = lines.Where(l => ParentOf(l, byId) == parent).ToList();
oldIndex = siblings.FindIndex(l => Id(l) == itemId);
int target = oldIndex + Math.Sign(delta);
if (target < 0 || target >= siblings.Count) return false;
if (!Place(block, itemId, Id(siblings[target]), after: delta > 0, out _, out _)) return false;
newIndex = target;
return true;
}
/// <summary>
/// Creates a new line of <paramref name="typ"/> "set" (an unconverted, zero-priced set header
/// that can later be switched to a set price) or "text" (a free-text line) titled
/// <paramref name="title"/>, as the last member of <paramref name="parentId"/> ("" = end of the
/// block). Both line shapes (<c>itm</c> + <c>items</c>) are written. Returns the new id, or
/// null when the type is unknown, the title is blank or the parent is not a set of this block.
/// </summary>
public static string? Create(JObject block, string typ, string title, string parentId)
{
string kind = (typ ?? "").Trim().ToLowerInvariant();
title = (title ?? "").Trim();
if (kind is not ("set" or "text") || title.Length == 0) return null;
var lines = Lines(block);
var byId = ById(lines);
string? parent = parentId.Length == 0 ? null : parentId;
if (parent != null && (!byId.TryGetValue(parent, out var p) || !IsSet(p))) return null;
string id = "sN" + Guid.NewGuid().ToString("N")[..8];
string rate = lines.Select(l => Str(l["vat"])).FirstOrDefault(r => r.Length > 0) ?? "19%";
JObject co, io;
if (kind == "set")
{
co = new JObject
{
["id"] = id, ["typ"] = "set", ["p"] = "", ["q"] = "", ["t"] = title, ["tt"] = "",
["v"] = 0, ["vt"] = 0, ["vv"] = 0, ["vs"] = 0, ["vsv"] = 0, ["vat"] = rate, ["det"] = false,
[ParentKey] = ParentToken(parent)
};
io = new JObject
{
["id"] = id, ["type"] = "set", ["title"] = title, ["desc"] = "", ["qty"] = "",
["price_net"] = 0, ["total_net"] = 0, ["vat"] = rate, ["setId"] = ParentToken(parent)
};
}
else
{
string html = "<p>" + System.Net.WebUtility.HtmlEncode(title) + "</p>";
co = new JObject
{
["id"] = id, ["typ"] = "Text", ["p"] = "", ["q"] = JValue.CreateNull(), ["t"] = html, ["tt"] = JValue.CreateNull(),
["v"] = JValue.CreateNull(), ["vt"] = JValue.CreateNull(), ["vv"] = JValue.CreateNull(), // free text: no price at all (empty cell, ADR 0009 null semantics)
["vs"] = JValue.CreateNull(), ["vsv"] = JValue.CreateNull(), ["vat"] = "", ["det"] = false,
[ParentKey] = ParentToken(parent)
};
io = new JObject
{
["id"] = id, ["type"] = "text", ["title"] = "", ["desc"] = html, ["qty"] = "",
["price_net"] = "", ["total_net"] = "", ["vat"] = "", ["setId"] = ParentToken(parent)
};
}
if (block["itm"] is not JArray itm) { itm = new JArray(); block["itm"] = itm; }
itm.Add(co);
if (block["items"] is JArray items) items.Add(io); else block["items"] = new JArray { io };
Linearize(block);
return id;
}
// ── helpers ──────────────────────────────────────────────────────────────
private static JToken ParentToken(string? parent) => parent != null ? new JValue(parent) : JValue.CreateNull();
internal static string Str(JToken? t) =>
t == null || t.Type == JTokenType.Null ? "" : t.Type == JTokenType.String ? t.Value<string>() ?? "" : t.ToString();
}
+4 -1
View File
@@ -17,8 +17,11 @@
}
$ocms[mdl].auth = auth;
if (auth.manage > 0) {
/* ?v=<assetversion> (newest bundle write time, emitted by _Layout) busts the browser
cache of the dynamically imported module bundle + css after a gulp rebuild. */
let av = '?v=' + encodeURIComponent($ocms.auth.assetversion || '0');
$ocms.getScript({
module: mdl, script: ['/web/fis', mdl, $ocms.auth.locale || 'de', 'js'].join('.'), css: ['/web/fis', mdl, 'css'].join('.'), condition: typeof $ocms[mdl].init2 !== 'function'
module: mdl, script: ['/web/fis', mdl, $ocms.auth.locale || 'de', 'js'].join('.') + av, css: ['/web/fis', mdl, 'css'].join('.') + av, condition: typeof $ocms[mdl].init2 !== 'function'
}, function () { $ocms[mdl].init2(); });
}
}, error: function () {
+200
View File
@@ -0,0 +1,200 @@
/* ── 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).append($$.sc('ind').css('width', (lvl * 1.4).toString() + 'rem')).append($$.s($inv.sets.title(co)));
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 = $('<select></select>').attr({ name: 'typ' }).append([$$.opt('set', $ict.settyp.set), $$.opt('text', $ict.settyp.text)]);
let par = $('<select></select>').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 = $('<input type="text" />').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;
}
};
+36 -14
View File
@@ -86,7 +86,7 @@ $inv.iMn = (ix) => {
yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) — once set,
changing it again is not supported (the user edits the line items directly instead); it
reappears only if 'Änderungen verwerfen' reverts to a DB state that was never switched. */
$inv.emOpt = (admin) => 'iss,p13b,ctp' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
$inv.emOpt = (admin) => 'iss,p13b,ctp,setst' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
$inv.eM = (r, re, opt) => {
let m = [];
if (booln(r, false) === true || booln(re, false) === true) {
@@ -104,6 +104,9 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('setm') === true) {
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
}
if ((opt || '').split(',').includes('setst') === true) { /* user-defined set structure dialog (ADR 0016) */
m.push({ lbl: $ict.setst, fnc: () => $inv.sets.open() });
}
if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() });
@@ -172,12 +175,13 @@ $inv.d = {
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
return;
}
tbl.data('dver', state.version).data('serverSums', state.sums);
tbl.data('dver', state.version).data('serverSums', state.sums).data('dstate', state);
$inv.d.footer(tbl, state.sums || {}, state.admin || {});
$inv.d.notes(state.notes || []);
$inv.d.validation(state.validation || []);
$inv.d.applyItems(tbl, state.req || []);
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
if ($inv.sets && $inv.sets.isOpen()) { $inv.sets.render(state); } /* the set-structure dialog is a view of the same state (ADR 0016) */
/* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode:
hides it right after a mode switch, and restores it if a discard reverted the draft to
a DB state that was never switched (ADR 0006 backend-authoritative refresh). */
@@ -223,13 +227,15 @@ $inv.d = {
let changed = isNew || (dta.Type || '') !== (co.typ || '')
|| dta.net !== val(co.v) || dta.net_val !== val(co.vt) || dta.vat_val !== val(co.vv)
|| dta.svcnet_val !== val(co.vs) || dta.svcvat_val !== val(co.vsv)
|| (dta.position || '') !== (co.p != null ? co.p : '');
|| (dta.position || '') !== (co.p != null ? co.p : '')
|| (dta.lvl || 0) !== (co.lvl || 0) || (dta.SetItmId || null) !== (co.SetItmId != null ? co.SetItmId : null);
$.extend(dta, {
Id: id, Type: co.typ,
net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv),
svcnet_val: val(co.vs), svcvat_val: val(co.vsv),
vat: co.vat, position: (co.p != null ? co.p : ''),
SetItmId: (co.SetItmId != null ? co.SetItmId : null)
SetItmId: (co.SetItmId != null ? co.SetItmId : null),
lvl: (co.lvl || 0) /* nesting depth (ADR 0016) — rendered as an indent */
});
if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row)
rw.data(dta);
@@ -311,7 +317,7 @@ $inv.d = {
/* Map an inline recipient field to its delta target and send it. No token-guard here for the
same reason as syncChanged: sync() waits for an in-flight seed instead of dropping the edit. */
syncField: function (nme, val) {
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', invoiceemailcc: 'emailcc', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
let target = map[nme]; if (!target) { return; }
$inv.d.sync({ Target: target, Value: val });
},
@@ -898,6 +904,7 @@ $inv.cntInv = function (data) { //invoice continuation
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender));
fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null);
@@ -960,6 +967,7 @@ $inv.cSt = function (data) {
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender));
fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null);
@@ -1101,7 +1109,7 @@ $inv.eHtml = function (ev) {
Leistungsdatum). The backend sanitises HTML too (single source of truth, ADR 0006), but
keeping these plain here avoids the UI briefly holding the wrapped value. Multi-line fields
(invoiceaddress, loc) stay HTML-capable and are normalised to newlines server-side. */
let isPlainText = ['invoiceemail', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
let isPlainText = ['invoiceemail', 'invoiceemailcc', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
let flds = isPlainText
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
@@ -1234,7 +1242,7 @@ $inv.rrw = function () {
} else if (rw.is('.itm.osum') === true) {
co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false };
} else {
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null };
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
co.p = dta.position || (dta.SortOrder || '');
@@ -1274,6 +1282,7 @@ $inv.rrw = function () {
}
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows (fn1 header + block set row) */
.aC((co.lvl || 0) > 0 ? 'lvl' + Math.min(co.lvl, 6) : '') /* indent nested set members (ADR 0016) */
.tC('hidenote', hn).append(tda);
dta.co = co;
};
@@ -1304,8 +1313,23 @@ $inv.invSumUpdate = function () {
let tbl = $(this);
let ba = [];
let bds = tbl.children('tbody');
/* Hierarchical position numbering (ADR 0017), spanning the whole invoice: a running counter
per nesting depth, ported from InvoiceLineNumbering.Compute. A set header gets a number at
its own level ("3"); its members get subsidiary numbers one level deeper ("3.1", "3.2"). A
row's numbering never depends on the MFR SortOrder value — including for a Text/Title
header row synced with SortOrder -1, which used to leak "-1" straight into the Pos. column
here because it never went through this loop at all. */
let posCounters = [];
let nextPositionLabel = (numbered, lvl) => {
if (!numbered) { return ''; }
let l = Math.max(lvl || 0, 0);
while (posCounters.length <= l) { posCounters.push(0); }
posCounters.length = l + 1;
posCounters[l]++;
return posCounters.join('.');
};
bds.each((bi, bdy) => {
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm'), iso = 0, ipos = 0;
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm');
b.tC('empty', itm.length < 1);
itm.each((ti, tx) => {
let rrx = $(tx).data() || {};
@@ -1318,14 +1342,12 @@ $inv.invSumUpdate = function () {
by the id !== '' && !== citem.id check below). */
let citem = $inv.itemToContract(rrx);
let sid = (rrx.SetItmId || '').toString();
if (citem.type !== 'set' && sid !== '' && sid !== citem.id) { citem.setId = sid; }
/* a nested set (ADR 0016) is itself a member of its parent set, so the type is not excluded here */
if (sid !== '' && sid !== citem.id) { citem.setId = sid; }
citems.push(citem);
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
rrx.SortOrder = iso;
rrx.position = ipos;
let numbered = ['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false;
rrx.position = nextPositionLabel(numbered, (rrx.co || {}).lvl || 0);
$inv.rrw.call(tx);
}
});
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems });
});
@@ -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; }
}
}
}
@@ -45,6 +45,24 @@
setprice: 'Set mit Preis – Positionen ohne Preis',
setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
},
/* user-defined set structure dialog (ADR 0016) */
setst: 'Set-Struktur bearbeiten',
setsHint: 'Reihenfolge: Zeile per Drag & Drop an den oberen/unteren Rand einer anderen Zeile ziehen (davor/danach) oder ▲▼ nutzen. Zuordnung: Zeile auf die Mitte eines Sets ziehen (hinein) bzw. auf die Auftragszeile oder "oberste Ebene" (aus dem Set lösen); ◀▶ ändert die Ebene. Sets lassen sich über das Dreieck zu-/aufklappen und auch zugeklappt verschieben – ihre Positionen wandern mit. Änderungen werden sofort übernommen.',
setsUp: 'Nach oben (innerhalb der Ebene)',
setsDown: 'Nach unten (innerhalb der Ebene)',
setsCollapse: 'Set zuklappen',
setsExpand: 'Set aufklappen',
setsHR: ['', 'Pos.', 'Typ', 'Menge', 'Bezeichnung', 'Reihenfolge / Ebene'],
setsRoot: 'Hierher ziehen = aus dem Set lösen (oberste Ebene)',
setsOut: 'Eine Ebene höher (aus dem Set lösen)',
setsIn: 'Eine Ebene tiefer (dem vorangehenden Set zuordnen)',
setsNew: 'Neu:',
setsInto: 'in',
setsTop: 'oberste Ebene',
setsTitle: 'Bezeichnung',
setsAdd: 'Hinzufügen',
setsNoTitle: '(ohne Bezeichnung)',
settyp: { set: 'Set', text: 'Text', title: 'Text', material: 'Material', service: 'Arbeitsleistung', equipment: 'Ausrüstung', other: 'Position', osum: 'Zusammenfassung', intinvoiceallocation: 'Abschlag', intinvoiceallocationdiff: 'Abschlag (offen)' },
ctp: 'Ansprechpartner festlegen',
mfr: 'Von MFR neu abrufen',
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
+189
View File
@@ -1,3 +1,4 @@
@charset "UTF-8";
/* basics */
/* Color SCHEME */
/* #8396bd */
@@ -169,6 +170,33 @@ table.if td.num {
.edit_frm .invoice_layout .emailfrm:hover div.axf {
display: block;
}
.edit_frm .invoice_layout .emailccfrm {
position: absolute;
font-size: 0.7rem;
top: 2.2rem;
left: 0.5rem;
width: auto;
height: auto;
cursor: pointer;
}
.edit_frm .invoice_layout .emailccfrm .content {
color: #000;
}
.edit_frm .invoice_layout .emailccfrm {
/* 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. */
}
.edit_frm .invoice_layout .emailccfrm .content:empty::before {
content: "Zusatzempfänger hinzufügen";
min-width: 5rem;
height: 1rem;
display: block;
position: absolute;
}
.edit_frm .invoice_layout .emailccfrm:hover div.axf {
display: block;
}
.edit_frm .invoice_layout .admfrm {
position: absolute;
right: 3rem;
@@ -302,6 +330,27 @@ table.if td.num {
.edit_frm .invoice_layout table.invi > tbody > tr.sethdr td.txt {
font-weight: bold;
}
.edit_frm .invoice_layout table.invi > tbody > tr {
/* Nested set members (ADR 0016): indent per level, mirroring the PDF's per-level indent. */
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl1 td.txt {
padding-left: calc(0.4rem + 1.4rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl2 td.txt {
padding-left: calc(0.4rem + 2.8rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl3 td.txt {
padding-left: calc(0.4rem + 4.2rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl4 td.txt {
padding-left: calc(0.4rem + 5.6rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl5 td.txt {
padding-left: calc(0.4rem + 7rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl6 td.txt {
padding-left: calc(0.4rem + 8.4rem);
}
.edit_frm .invoice_layout table.invi th.currency, .edit_frm .invoice_layout table.invi th.num, .edit_frm .invoice_layout table.invi td.currency, .edit_frm .invoice_layout table.invi td.num {
text-align: right;
white-space: nowrap;
@@ -511,3 +560,143 @@ table.if td.num {
.adr-conformity .tx {
color: #445;
}
/* ── Set structure dialog (ADR 0016, see $inv.sets in fis.inv_sets.js) ─────────────────── */
.modal .setstruct.busy {
opacity: 0.6;
pointer-events: none;
}
.modal .setstruct div.shint {
font-size: 0.85rem;
color: #666;
margin: 0 0 0.8rem 0;
}
.modal .setstruct div.sblock {
margin: 0 0 1.4rem 0;
}
.modal .setstruct div.sblock div.sbhd {
font-weight: bold;
padding: 0.4rem 0.6rem;
border-top: 1.5px solid #000;
border-bottom: 1px solid #ddd;
}
.modal .setstruct div.sblock div.sbhd.dropok {
background-color: #e6f2e6;
outline: 2px dashed #5a5;
}
.modal .setstruct div.sblock table.stbl {
width: 100%;
border-collapse: collapse;
}
.modal .setstruct div.sblock table.stbl th {
text-align: left;
font-size: 0.8rem;
color: #666;
padding: 0.3rem 0.4rem;
border-bottom: 1px solid #ddd;
}
.modal .setstruct div.sblock table.stbl td {
padding: 0.3rem 0.4rem;
border-bottom: 1px solid #f0f0f0;
vertical-align: middle;
}
.modal .setstruct div.sblock table.stbl td.grip {
width: 1.6rem;
cursor: grab;
color: #999;
}
.modal .setstruct div.sblock table.stbl td.pos, .modal .setstruct div.sblock table.stbl td.qty {
width: 4rem;
white-space: nowrap;
}
.modal .setstruct div.sblock table.stbl td.typ {
width: 7rem;
white-space: nowrap;
color: #666;
font-size: 0.85rem;
}
.modal .setstruct div.sblock table.stbl td.typ span.fold {
cursor: pointer;
margin-right: 0.35rem;
color: #555;
display: inline-block;
width: 1rem;
}
.modal .setstruct div.sblock table.stbl td.ttl span.ind {
display: inline-block;
}
.modal .setstruct div.sblock table.stbl td.ttl span.seti {
margin-right: 0.3rem;
color: #888;
}
.modal .setstruct div.sblock table.stbl td.ttl span.cnt {
color: #999;
font-weight: normal;
font-size: 0.85rem;
}
.modal .setstruct div.sblock table.stbl td.act {
width: 7.5rem;
white-space: nowrap;
text-align: right;
}
.modal .setstruct div.sblock table.stbl td.act div.ibtn {
display: inline-block;
cursor: pointer;
padding: 0 0.25rem;
}
.modal .setstruct div.sblock table.stbl td.act div.ibtn.disabled {
opacity: 0.25;
cursor: default;
}
.modal .setstruct div.sblock table.stbl tr.srow.isset td.ttl {
font-weight: bold;
}
.modal .setstruct div.sblock table.stbl tr.srow.hid {
display: none;
}
.modal .setstruct div.sblock table.stbl tr.srow.folded td.ttl {
font-style: italic;
}
.modal .setstruct div.sblock table.stbl tr.srow.dragging {
opacity: 0.4;
}
.modal .setstruct div.sblock table.stbl tr.srow.dropok td {
background-color: #e6f2e6;
}
.modal .setstruct div.sblock table.stbl tr.srow.dropbefore td {
border-top: 3px solid #5a5;
}
.modal .setstruct div.sblock table.stbl tr.srow.dropafter td {
border-bottom: 3px solid #5a5;
}
.modal .setstruct div.sblock div.sroot {
margin: 0.3rem 0 0.5rem 0;
padding: 0.4rem 0.6rem;
border: 1px dashed #bbb;
color: #888;
font-size: 0.85rem;
}
.modal .setstruct div.sblock div.sroot.dropok {
background-color: #e6f2e6;
border-color: #5a5;
color: #363;
}
.modal .setstruct div.sblock div.screate {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.6rem;
background-color: #f7f7f7;
}
.modal .setstruct div.sblock div.screate span.lbl {
font-size: 0.85rem;
color: #666;
}
.modal .setstruct div.sblock div.screate select, .modal .setstruct div.sblock div.screate input[type=text] {
padding: 0.2rem 0.3rem;
}
.modal .setstruct div.sblock div.screate input[type=text] {
flex: 1 1 14rem;
min-width: 10rem;
}
+256 -14
View File
@@ -65,6 +65,7 @@ let $rct = {
invoiceaddress: 'Adresse'
, loc: 'Leistungsort / Lieferadresse'
, invoiceemail: 'Email'
, invoiceemailcc: 'Zusatzempfänger (CC)'
}
}, $rcol = {
@@ -154,6 +155,24 @@ let $ict = {
setprice: 'Set mit Preis – Positionen ohne Preis',
setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
},
/* user-defined set structure dialog (ADR 0016) */
setst: 'Set-Struktur bearbeiten',
setsHint: 'Reihenfolge: Zeile per Drag & Drop an den oberen/unteren Rand einer anderen Zeile ziehen (davor/danach) oder ▲▼ nutzen. Zuordnung: Zeile auf die Mitte eines Sets ziehen (hinein) bzw. auf die Auftragszeile oder "oberste Ebene" (aus dem Set lösen); ◀▶ ändert die Ebene. Sets lassen sich über das Dreieck zu-/aufklappen und auch zugeklappt verschieben – ihre Positionen wandern mit. Änderungen werden sofort übernommen.',
setsUp: 'Nach oben (innerhalb der Ebene)',
setsDown: 'Nach unten (innerhalb der Ebene)',
setsCollapse: 'Set zuklappen',
setsExpand: 'Set aufklappen',
setsHR: ['', 'Pos.', 'Typ', 'Menge', 'Bezeichnung', 'Reihenfolge / Ebene'],
setsRoot: 'Hierher ziehen = aus dem Set lösen (oberste Ebene)',
setsOut: 'Eine Ebene höher (aus dem Set lösen)',
setsIn: 'Eine Ebene tiefer (dem vorangehenden Set zuordnen)',
setsNew: 'Neu:',
setsInto: 'in',
setsTop: 'oberste Ebene',
setsTitle: 'Bezeichnung',
setsAdd: 'Hinzufügen',
setsNoTitle: '(ohne Bezeichnung)',
settyp: { set: 'Set', text: 'Text', title: 'Text', material: 'Material', service: 'Arbeitsleistung', equipment: 'Ausrüstung', other: 'Position', osum: 'Zusammenfassung', intinvoiceallocation: 'Abschlag', intinvoiceallocationdiff: 'Abschlag (offen)' },
ctp: 'Ansprechpartner festlegen',
mfr: 'Von MFR neu abrufen',
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
@@ -629,7 +648,7 @@ $inv.iMn = (ix) => {
yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) — once set,
changing it again is not supported (the user edits the line items directly instead); it
reappears only if 'Änderungen verwerfen' reverts to a DB state that was never switched. */
$inv.emOpt = (admin) => 'iss,p13b,ctp' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
$inv.emOpt = (admin) => 'iss,p13b,ctp,setst' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
$inv.eM = (r, re, opt) => {
let m = [];
if (booln(r, false) === true || booln(re, false) === true) {
@@ -647,6 +666,9 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('setm') === true) {
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
}
if ((opt || '').split(',').includes('setst') === true) { /* user-defined set structure dialog (ADR 0016) */
m.push({ lbl: $ict.setst, fnc: () => $inv.sets.open() });
}
if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() });
@@ -715,12 +737,13 @@ $inv.d = {
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
return;
}
tbl.data('dver', state.version).data('serverSums', state.sums);
tbl.data('dver', state.version).data('serverSums', state.sums).data('dstate', state);
$inv.d.footer(tbl, state.sums || {}, state.admin || {});
$inv.d.notes(state.notes || []);
$inv.d.validation(state.validation || []);
$inv.d.applyItems(tbl, state.req || []);
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
if ($inv.sets && $inv.sets.isOpen()) { $inv.sets.render(state); } /* the set-structure dialog is a view of the same state (ADR 0016) */
/* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode:
hides it right after a mode switch, and restores it if a discard reverted the draft to
a DB state that was never switched (ADR 0006 backend-authoritative refresh). */
@@ -766,13 +789,15 @@ $inv.d = {
let changed = isNew || (dta.Type || '') !== (co.typ || '')
|| dta.net !== val(co.v) || dta.net_val !== val(co.vt) || dta.vat_val !== val(co.vv)
|| dta.svcnet_val !== val(co.vs) || dta.svcvat_val !== val(co.vsv)
|| (dta.position || '') !== (co.p != null ? co.p : '');
|| (dta.position || '') !== (co.p != null ? co.p : '')
|| (dta.lvl || 0) !== (co.lvl || 0) || (dta.SetItmId || null) !== (co.SetItmId != null ? co.SetItmId : null);
$.extend(dta, {
Id: id, Type: co.typ,
net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv),
svcnet_val: val(co.vs), svcvat_val: val(co.vsv),
vat: co.vat, position: (co.p != null ? co.p : ''),
SetItmId: (co.SetItmId != null ? co.SetItmId : null)
SetItmId: (co.SetItmId != null ? co.SetItmId : null),
lvl: (co.lvl || 0) /* nesting depth (ADR 0016) — rendered as an indent */
});
if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row)
rw.data(dta);
@@ -854,7 +879,7 @@ $inv.d = {
/* Map an inline recipient field to its delta target and send it. No token-guard here for the
same reason as syncChanged: sync() waits for an in-flight seed instead of dropping the edit. */
syncField: function (nme, val) {
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', invoiceemailcc: 'emailcc', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
let target = map[nme]; if (!target) { return; }
$inv.d.sync({ Target: target, Value: val });
},
@@ -1441,6 +1466,7 @@ $inv.cntInv = function (data) { //invoice continuation
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender));
fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null);
@@ -1503,6 +1529,7 @@ $inv.cSt = function (data) {
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender));
fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null);
@@ -1644,7 +1671,7 @@ $inv.eHtml = function (ev) {
Leistungsdatum). The backend sanitises HTML too (single source of truth, ADR 0006), but
keeping these plain here avoids the UI briefly holding the wrapped value. Multi-line fields
(invoiceaddress, loc) stay HTML-capable and are normalised to newlines server-side. */
let isPlainText = ['invoiceemail', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
let isPlainText = ['invoiceemail', 'invoiceemailcc', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
let flds = isPlainText
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
@@ -1777,7 +1804,7 @@ $inv.rrw = function () {
} else if (rw.is('.itm.osum') === true) {
co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false };
} else {
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null };
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
co.p = dta.position || (dta.SortOrder || '');
@@ -1817,6 +1844,7 @@ $inv.rrw = function () {
}
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows (fn1 header + block set row) */
.aC((co.lvl || 0) > 0 ? 'lvl' + Math.min(co.lvl, 6) : '') /* indent nested set members (ADR 0016) */
.tC('hidenote', hn).append(tda);
dta.co = co;
};
@@ -1847,8 +1875,23 @@ $inv.invSumUpdate = function () {
let tbl = $(this);
let ba = [];
let bds = tbl.children('tbody');
/* Hierarchical position numbering (ADR 0017), spanning the whole invoice: a running counter
per nesting depth, ported from InvoiceLineNumbering.Compute. A set header gets a number at
its own level ("3"); its members get subsidiary numbers one level deeper ("3.1", "3.2"). A
row's numbering never depends on the MFR SortOrder value — including for a Text/Title
header row synced with SortOrder -1, which used to leak "-1" straight into the Pos. column
here because it never went through this loop at all. */
let posCounters = [];
let nextPositionLabel = (numbered, lvl) => {
if (!numbered) { return ''; }
let l = Math.max(lvl || 0, 0);
while (posCounters.length <= l) { posCounters.push(0); }
posCounters.length = l + 1;
posCounters[l]++;
return posCounters.join('.');
};
bds.each((bi, bdy) => {
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm'), iso = 0, ipos = 0;
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm');
b.tC('empty', itm.length < 1);
itm.each((ti, tx) => {
let rrx = $(tx).data() || {};
@@ -1861,14 +1904,12 @@ $inv.invSumUpdate = function () {
by the id !== '' && !== citem.id check below). */
let citem = $inv.itemToContract(rrx);
let sid = (rrx.SetItmId || '').toString();
if (citem.type !== 'set' && sid !== '' && sid !== citem.id) { citem.setId = sid; }
/* a nested set (ADR 0016) is itself a member of its parent set, so the type is not excluded here */
if (sid !== '' && sid !== citem.id) { citem.setId = sid; }
citems.push(citem);
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
rrx.SortOrder = iso;
rrx.position = ipos;
let numbered = ['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false;
rrx.position = nextPositionLabel(numbered, (rrx.co || {}).lvl || 0);
$inv.rrw.call(tx);
}
});
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems });
});
@@ -2403,3 +2444,204 @@ $inv.mfrrel = (id) => {
}
});
};
/* ── 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).append($$.sc('ind').css('width', (lvl * 1.4).toString() + 'rem')).append($$.s($inv.sets.title(co)));
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 = $('<select></select>').attr({ name: 'typ' }).append([$$.opt('set', $ict.settyp.set), $$.opt('text', $ict.settyp.text)]);
let par = $('<select></select>').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 = $('<input type="text" />').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;
}
};
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -2882,8 +2882,11 @@ $ocms.init = function (ev) {
}
$ocms[mdl].auth = auth;
if (auth.manage > 0) {
/* ?v=<assetversion> (newest bundle write time, emitted by _Layout) busts the browser
cache of the dynamically imported module bundle + css after a gulp rebuild. */
let av = '?v=' + encodeURIComponent($ocms.auth.assetversion || '0');
$ocms.getScript({
module: mdl, script: ['/web/fis', mdl, $ocms.auth.locale || 'de', 'js'].join('.'), css: ['/web/fis', mdl, 'css'].join('.'), condition: typeof $ocms[mdl].init2 !== 'function'
module: mdl, script: ['/web/fis', mdl, $ocms.auth.locale || 'de', 'js'].join('.') + av, css: ['/web/fis', mdl, 'css'].join('.') + av, condition: typeof $ocms[mdl].init2 !== 'function'
}, function () { $ocms[mdl].init2(); });
}
}, error: function () {
+1 -1
View File
File diff suppressed because one or more lines are too long
+189
View File
@@ -1,3 +1,4 @@
@charset "UTF-8";
/* basics */
/* Color SCHEME */
/* #8396bd */
@@ -306,6 +307,33 @@ table.if th.keep, table.if td.keep {
.edit_frm .invoice_layout .emailfrm:hover div.axf {
display: block;
}
.edit_frm .invoice_layout .emailccfrm {
position: absolute;
font-size: 0.7rem;
top: 2.2rem;
left: 0.5rem;
width: auto;
height: auto;
cursor: pointer;
}
.edit_frm .invoice_layout .emailccfrm .content {
color: #000;
}
.edit_frm .invoice_layout .emailccfrm {
/* 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. */
}
.edit_frm .invoice_layout .emailccfrm .content:empty::before {
content: "Zusatzempfänger hinzufügen";
min-width: 5rem;
height: 1rem;
display: block;
position: absolute;
}
.edit_frm .invoice_layout .emailccfrm:hover div.axf {
display: block;
}
.edit_frm .invoice_layout .admfrm {
position: absolute;
right: 3rem;
@@ -439,6 +467,27 @@ table.if th.keep, table.if td.keep {
.edit_frm .invoice_layout table.invi > tbody > tr.sethdr td.txt {
font-weight: bold;
}
.edit_frm .invoice_layout table.invi > tbody > tr {
/* Nested set members (ADR 0016): indent per level, mirroring the PDF's per-level indent. */
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl1 td.txt {
padding-left: calc(0.4rem + 1.4rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl2 td.txt {
padding-left: calc(0.4rem + 2.8rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl3 td.txt {
padding-left: calc(0.4rem + 4.2rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl4 td.txt {
padding-left: calc(0.4rem + 5.6rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl5 td.txt {
padding-left: calc(0.4rem + 7rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl6 td.txt {
padding-left: calc(0.4rem + 8.4rem);
}
.edit_frm .invoice_layout table.invi th.currency, .edit_frm .invoice_layout table.invi th.num, .edit_frm .invoice_layout table.invi td.currency, .edit_frm .invoice_layout table.invi td.num {
text-align: right;
white-space: nowrap;
@@ -648,3 +697,143 @@ table.if th.keep, table.if td.keep {
.adr-conformity .tx {
color: #445;
}
/* ── Set structure dialog (ADR 0016, see $inv.sets in fis.inv_sets.js) ─────────────────── */
.modal .setstruct.busy {
opacity: 0.6;
pointer-events: none;
}
.modal .setstruct div.shint {
font-size: 0.85rem;
color: #666;
margin: 0 0 0.8rem 0;
}
.modal .setstruct div.sblock {
margin: 0 0 1.4rem 0;
}
.modal .setstruct div.sblock div.sbhd {
font-weight: bold;
padding: 0.4rem 0.6rem;
border-top: 1.5px solid #000;
border-bottom: 1px solid #ddd;
}
.modal .setstruct div.sblock div.sbhd.dropok {
background-color: #e6f2e6;
outline: 2px dashed #5a5;
}
.modal .setstruct div.sblock table.stbl {
width: 100%;
border-collapse: collapse;
}
.modal .setstruct div.sblock table.stbl th {
text-align: left;
font-size: 0.8rem;
color: #666;
padding: 0.3rem 0.4rem;
border-bottom: 1px solid #ddd;
}
.modal .setstruct div.sblock table.stbl td {
padding: 0.3rem 0.4rem;
border-bottom: 1px solid #f0f0f0;
vertical-align: middle;
}
.modal .setstruct div.sblock table.stbl td.grip {
width: 1.6rem;
cursor: grab;
color: #999;
}
.modal .setstruct div.sblock table.stbl td.pos, .modal .setstruct div.sblock table.stbl td.qty {
width: 4rem;
white-space: nowrap;
}
.modal .setstruct div.sblock table.stbl td.typ {
width: 7rem;
white-space: nowrap;
color: #666;
font-size: 0.85rem;
}
.modal .setstruct div.sblock table.stbl td.typ span.fold {
cursor: pointer;
margin-right: 0.35rem;
color: #555;
display: inline-block;
width: 1rem;
}
.modal .setstruct div.sblock table.stbl td.ttl span.ind {
display: inline-block;
}
.modal .setstruct div.sblock table.stbl td.ttl span.seti {
margin-right: 0.3rem;
color: #888;
}
.modal .setstruct div.sblock table.stbl td.ttl span.cnt {
color: #999;
font-weight: normal;
font-size: 0.85rem;
}
.modal .setstruct div.sblock table.stbl td.act {
width: 7.5rem;
white-space: nowrap;
text-align: right;
}
.modal .setstruct div.sblock table.stbl td.act div.ibtn {
display: inline-block;
cursor: pointer;
padding: 0 0.25rem;
}
.modal .setstruct div.sblock table.stbl td.act div.ibtn.disabled {
opacity: 0.25;
cursor: default;
}
.modal .setstruct div.sblock table.stbl tr.srow.isset td.ttl {
font-weight: bold;
}
.modal .setstruct div.sblock table.stbl tr.srow.hid {
display: none;
}
.modal .setstruct div.sblock table.stbl tr.srow.folded td.ttl {
font-style: italic;
}
.modal .setstruct div.sblock table.stbl tr.srow.dragging {
opacity: 0.4;
}
.modal .setstruct div.sblock table.stbl tr.srow.dropok td {
background-color: #e6f2e6;
}
.modal .setstruct div.sblock table.stbl tr.srow.dropbefore td {
border-top: 3px solid #5a5;
}
.modal .setstruct div.sblock table.stbl tr.srow.dropafter td {
border-bottom: 3px solid #5a5;
}
.modal .setstruct div.sblock div.sroot {
margin: 0.3rem 0 0.5rem 0;
padding: 0.4rem 0.6rem;
border: 1px dashed #bbb;
color: #888;
font-size: 0.85rem;
}
.modal .setstruct div.sblock div.sroot.dropok {
background-color: #e6f2e6;
border-color: #5a5;
color: #363;
}
.modal .setstruct div.sblock div.screate {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.6rem;
background-color: #f7f7f7;
}
.modal .setstruct div.sblock div.screate span.lbl {
font-size: 0.85rem;
color: #666;
}
.modal .setstruct div.sblock div.screate select, .modal .setstruct div.sblock div.screate input[type=text] {
padding: 0.2rem 0.3rem;
}
.modal .setstruct div.sblock div.screate input[type=text] {
flex: 1 1 14rem;
min-width: 10rem;
}
+256 -14
View File
@@ -65,6 +65,7 @@ let $rct = {
invoiceaddress: 'Adresse'
, loc: 'Leistungsort / Lieferadresse'
, invoiceemail: 'Email'
, invoiceemailcc: 'Zusatzempfänger (CC)'
}
}, $rcol = {
@@ -154,6 +155,24 @@ let $ict = {
setprice: 'Set mit Preis – Positionen ohne Preis',
setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
},
/* user-defined set structure dialog (ADR 0016) */
setst: 'Set-Struktur bearbeiten',
setsHint: 'Reihenfolge: Zeile per Drag & Drop an den oberen/unteren Rand einer anderen Zeile ziehen (davor/danach) oder ▲▼ nutzen. Zuordnung: Zeile auf die Mitte eines Sets ziehen (hinein) bzw. auf die Auftragszeile oder "oberste Ebene" (aus dem Set lösen); ◀▶ ändert die Ebene. Sets lassen sich über das Dreieck zu-/aufklappen und auch zugeklappt verschieben – ihre Positionen wandern mit. Änderungen werden sofort übernommen.',
setsUp: 'Nach oben (innerhalb der Ebene)',
setsDown: 'Nach unten (innerhalb der Ebene)',
setsCollapse: 'Set zuklappen',
setsExpand: 'Set aufklappen',
setsHR: ['', 'Pos.', 'Typ', 'Menge', 'Bezeichnung', 'Reihenfolge / Ebene'],
setsRoot: 'Hierher ziehen = aus dem Set lösen (oberste Ebene)',
setsOut: 'Eine Ebene höher (aus dem Set lösen)',
setsIn: 'Eine Ebene tiefer (dem vorangehenden Set zuordnen)',
setsNew: 'Neu:',
setsInto: 'in',
setsTop: 'oberste Ebene',
setsTitle: 'Bezeichnung',
setsAdd: 'Hinzufügen',
setsNoTitle: '(ohne Bezeichnung)',
settyp: { set: 'Set', text: 'Text', title: 'Text', material: 'Material', service: 'Arbeitsleistung', equipment: 'Ausrüstung', other: 'Position', osum: 'Zusammenfassung', intinvoiceallocation: 'Abschlag', intinvoiceallocationdiff: 'Abschlag (offen)' },
ctp: 'Ansprechpartner festlegen',
mfr: 'Von MFR neu abrufen',
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
@@ -610,7 +629,7 @@ $inv.iMn = (ix) => {
yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) — once set,
changing it again is not supported (the user edits the line items directly instead); it
reappears only if 'Änderungen verwerfen' reverts to a DB state that was never switched. */
$inv.emOpt = (admin) => 'iss,p13b,ctp' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
$inv.emOpt = (admin) => 'iss,p13b,ctp,setst' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
$inv.eM = (r, re, opt) => {
let m = [];
if (booln(r, false) === true || booln(re, false) === true) {
@@ -628,6 +647,9 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('setm') === true) {
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
}
if ((opt || '').split(',').includes('setst') === true) { /* user-defined set structure dialog (ADR 0016) */
m.push({ lbl: $ict.setst, fnc: () => $inv.sets.open() });
}
if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() });
@@ -696,12 +718,13 @@ $inv.d = {
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
return;
}
tbl.data('dver', state.version).data('serverSums', state.sums);
tbl.data('dver', state.version).data('serverSums', state.sums).data('dstate', state);
$inv.d.footer(tbl, state.sums || {}, state.admin || {});
$inv.d.notes(state.notes || []);
$inv.d.validation(state.validation || []);
$inv.d.applyItems(tbl, state.req || []);
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
if ($inv.sets && $inv.sets.isOpen()) { $inv.sets.render(state); } /* the set-structure dialog is a view of the same state (ADR 0016) */
/* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode:
hides it right after a mode switch, and restores it if a discard reverted the draft to
a DB state that was never switched (ADR 0006 backend-authoritative refresh). */
@@ -747,13 +770,15 @@ $inv.d = {
let changed = isNew || (dta.Type || '') !== (co.typ || '')
|| dta.net !== val(co.v) || dta.net_val !== val(co.vt) || dta.vat_val !== val(co.vv)
|| dta.svcnet_val !== val(co.vs) || dta.svcvat_val !== val(co.vsv)
|| (dta.position || '') !== (co.p != null ? co.p : '');
|| (dta.position || '') !== (co.p != null ? co.p : '')
|| (dta.lvl || 0) !== (co.lvl || 0) || (dta.SetItmId || null) !== (co.SetItmId != null ? co.SetItmId : null);
$.extend(dta, {
Id: id, Type: co.typ,
net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv),
svcnet_val: val(co.vs), svcvat_val: val(co.vsv),
vat: co.vat, position: (co.p != null ? co.p : ''),
SetItmId: (co.SetItmId != null ? co.SetItmId : null)
SetItmId: (co.SetItmId != null ? co.SetItmId : null),
lvl: (co.lvl || 0) /* nesting depth (ADR 0016) — rendered as an indent */
});
if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row)
rw.data(dta);
@@ -835,7 +860,7 @@ $inv.d = {
/* Map an inline recipient field to its delta target and send it. No token-guard here for the
same reason as syncChanged: sync() waits for an in-flight seed instead of dropping the edit. */
syncField: function (nme, val) {
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', invoiceemailcc: 'emailcc', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
let target = map[nme]; if (!target) { return; }
$inv.d.sync({ Target: target, Value: val });
},
@@ -1422,6 +1447,7 @@ $inv.cntInv = function (data) { //invoice continuation
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender));
fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null);
@@ -1484,6 +1510,7 @@ $inv.cSt = function (data) {
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender));
fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null);
@@ -1625,7 +1652,7 @@ $inv.eHtml = function (ev) {
Leistungsdatum). The backend sanitises HTML too (single source of truth, ADR 0006), but
keeping these plain here avoids the UI briefly holding the wrapped value. Multi-line fields
(invoiceaddress, loc) stay HTML-capable and are normalised to newlines server-side. */
let isPlainText = ['invoiceemail', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
let isPlainText = ['invoiceemail', 'invoiceemailcc', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
let flds = isPlainText
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
@@ -1758,7 +1785,7 @@ $inv.rrw = function () {
} else if (rw.is('.itm.osum') === true) {
co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false };
} else {
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null };
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
co.p = dta.position || (dta.SortOrder || '');
@@ -1798,6 +1825,7 @@ $inv.rrw = function () {
}
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows (fn1 header + block set row) */
.aC((co.lvl || 0) > 0 ? 'lvl' + Math.min(co.lvl, 6) : '') /* indent nested set members (ADR 0016) */
.tC('hidenote', hn).append(tda);
dta.co = co;
};
@@ -1828,8 +1856,23 @@ $inv.invSumUpdate = function () {
let tbl = $(this);
let ba = [];
let bds = tbl.children('tbody');
/* Hierarchical position numbering (ADR 0017), spanning the whole invoice: a running counter
per nesting depth, ported from InvoiceLineNumbering.Compute. A set header gets a number at
its own level ("3"); its members get subsidiary numbers one level deeper ("3.1", "3.2"). A
row's numbering never depends on the MFR SortOrder value — including for a Text/Title
header row synced with SortOrder -1, which used to leak "-1" straight into the Pos. column
here because it never went through this loop at all. */
let posCounters = [];
let nextPositionLabel = (numbered, lvl) => {
if (!numbered) { return ''; }
let l = Math.max(lvl || 0, 0);
while (posCounters.length <= l) { posCounters.push(0); }
posCounters.length = l + 1;
posCounters[l]++;
return posCounters.join('.');
};
bds.each((bi, bdy) => {
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm'), iso = 0, ipos = 0;
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm');
b.tC('empty', itm.length < 1);
itm.each((ti, tx) => {
let rrx = $(tx).data() || {};
@@ -1842,14 +1885,12 @@ $inv.invSumUpdate = function () {
by the id !== '' && !== citem.id check below). */
let citem = $inv.itemToContract(rrx);
let sid = (rrx.SetItmId || '').toString();
if (citem.type !== 'set' && sid !== '' && sid !== citem.id) { citem.setId = sid; }
/* a nested set (ADR 0016) is itself a member of its parent set, so the type is not excluded here */
if (sid !== '' && sid !== citem.id) { citem.setId = sid; }
citems.push(citem);
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
rrx.SortOrder = iso;
rrx.position = ipos;
let numbered = ['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false;
rrx.position = nextPositionLabel(numbered, (rrx.co || {}).lvl || 0);
$inv.rrw.call(tx);
}
});
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems });
});
@@ -2384,3 +2425,204 @@ $inv.mfrrel = (id) => {
}
});
};
/* ── 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).append($$.sc('ind').css('width', (lvl * 1.4).toString() + 'rem')).append($$.s($inv.sets.title(co)));
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 = $('<select></select>').attr({ name: 'typ' }).append([$$.opt('set', $ict.settyp.set), $$.opt('text', $ict.settyp.text)]);
let par = $('<select></select>').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 = $('<input type="text" />').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;
}
};
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long