Add user-defined invoice set structure dialog (ADR 0016)
Playwright Tests / test (push) Canceled after 0s
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:
@@ -870,4 +870,341 @@ public class InvoiceDraftServiceTests
|
|||||||
Assert.Equal(4, final.History.Count);
|
Assert.Equal(4, final.History.Count);
|
||||||
Assert.DoesNotContain(final.ValidationMessages, m => m.Severity == "error");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,4 +201,139 @@ public class InvoiceSetPricingTests
|
|||||||
.Sum(i => decimal.Parse(i["total_net"]!.ToString()!, System.Globalization.CultureInfo.InvariantCulture));
|
.Sum(i => decimal.Parse(i["total_net"]!.ToString()!, System.Globalization.CultureInfo.InvariantCulture));
|
||||||
Assert.Equal(memberSum, setLine.TotalNet);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <b>fett</b></p>", (string)co["t"]!);
|
||||||
|
Assert.Equal(0, Lvl(block, id!));
|
||||||
|
Assert.Equal("text", (string)Item(block, id!)["type"]!);
|
||||||
|
Assert.Equal("<p>Hinweis <b>fett</b></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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`).
|
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).
|
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()`).
|
`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.
|
`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
|
### 4.4 SQL-First Data Access
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
status: Active
|
status: Active
|
||||||
lastUpdated: 2026-07-10
|
lastUpdated: 2026-09-09
|
||||||
applyTo:
|
applyTo:
|
||||||
- "Fuchs/Services/InvoiceDraft*"
|
- "Fuchs/Services/InvoiceDraft*"
|
||||||
- "Fuchs/Services/IInvoiceDraft*"
|
- "Fuchs/Services/IInvoiceDraft*"
|
||||||
|
- "Fuchs/code/InvoiceSetStructure.cs"
|
||||||
- "Fuchs/Services/ReminderDraft*"
|
- "Fuchs/Services/ReminderDraft*"
|
||||||
- "Fuchs/Services/IReminderDraft*"
|
- "Fuchs/Services/IReminderDraft*"
|
||||||
- "Fuchs/code/InvoiceDraftSession.cs"
|
- "Fuchs/code/InvoiceDraftSession.cs"
|
||||||
@@ -18,6 +19,8 @@ applyTo:
|
|||||||
relatedDecisions:
|
relatedDecisions:
|
||||||
- "0006-backend-authoritative-draft-editing.md"
|
- "0006-backend-authoritative-draft-editing.md"
|
||||||
- "0007-targeted-draft-signalr-groups.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)
|
# 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,
|
`BuildInvoiceRequestList`), applies deltas (`ApplyDelta`), builds the view-state DTO,
|
||||||
flushes to the DB by reusing `IInvoiceService.RegisterInvoiceAsync` (no new persistence
|
flushes to the DB by reusing `IInvoiceService.RegisterInvoiceAsync` (no new persistence
|
||||||
path), renders previews from a synthesised registration, and discards by reloading.
|
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
|
- **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding
|
||||||
TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after,
|
TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after,
|
||||||
the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`.
|
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
|
## Key files
|
||||||
- `Fuchs/code/InvoiceDraftSession.cs` — session + `ChangeHistoryEntry` + `InvoiceDraftSums`.
|
- `Fuchs/code/InvoiceDraftSession.cs` — session + `ChangeHistoryEntry` + `InvoiceDraftSums`.
|
||||||
- `Fuchs/code/InvoiceDraftCalculator.cs` — pure recompute + validate.
|
- `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/InvoiceDraftCache.cs` / `IInvoiceDraftCache.cs` — in-memory store + TTL.
|
||||||
- `Fuchs/Services/InvoiceDraftEditService.cs` / `IInvoiceDraftService.cs` — orchestration + delta contract.
|
- `Fuchs/Services/InvoiceDraftEditService.cs` / `IInvoiceDraftService.cs` — orchestration + delta contract.
|
||||||
- `Fuchs/Services/InvoiceDraftExpiryService.cs` — idle warn/evict monitor.
|
- `Fuchs/Services/InvoiceDraftExpiryService.cs` — idle warn/evict monitor.
|
||||||
- `Fuchs/Notifications/DraftPreviewHub.cs`, `DraftNotifier.cs`, `IDraftNotifier.cs` — targeted signals.
|
- `Fuchs/Notifications/DraftPreviewHub.cs`, `DraftNotifier.cs`, `IDraftNotifier.cs` — targeted signals.
|
||||||
- `Fuchs/Controllers/IntranetController.InvoiceDraft.cs` — `inv/d*` endpoints.
|
- `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/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)
|
## Reminders (Zahlungserinnerung)
|
||||||
|
|
||||||
@@ -151,3 +170,5 @@ amount pair.
|
|||||||
## Related decisions
|
## Related decisions
|
||||||
- [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.md)
|
- [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.md)
|
||||||
- [0007 — Targeted draft SignalR groups](../Decisions/0007-targeted-draft-signalr-groups.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.
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
|
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
|
||||||
|
|
||||||
> Governed by ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
|
> 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)
|
> (the two menu modes), 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
|
> (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,
|
> reversible, non-mutating display toggles this document previously described into irreversible,
|
||||||
> per-service-request-block mutations — the text below reflects the redefinition.
|
> 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
|
> or removes (mode 3), and the set-item switch (mode 1) writes exactly the members' sum onto the
|
||||||
> header.
|
> 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
|
## Back-end
|
||||||
|
|
||||||
> **Migration status (ADR 0009).** The code below still reflects the previous
|
> **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.
|
> migration lands so it stays a faithful description of the code.
|
||||||
|
|
||||||
- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation:
|
- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation:
|
||||||
`SetDisplayMode` + `Build(items, mode)` → ordered `InvoiceSetLine`s, each with
|
`SetDisplayMode` + `Build(items, mode)` → `InvoiceSetLine`s **in input order**, each with
|
||||||
`ShowPrice` and `IsSetHeader`. `ModeFromInvoiceOptions(...)` reads the mode
|
`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`.
|
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
|
- `FuchsPdf.ApplyInvoice` renders through `InvoiceSetPricing.Build`: lines with
|
||||||
`ShowPrice == false` render **blank** price/total cells (not `0,00 €`), and the
|
`ShowPrice == false` render **blank** price/total cells (not `0,00 €`), and the
|
||||||
set header line is rendered emphasised. Invoices without sets pass through
|
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.)
|
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[]`:
|
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
|
an item with `type === 'set'` is a header (`id` = its set id); a member's
|
||||||
`setId` is taken directly from the server-computed `SetItmId` field on the row
|
`setId` is taken directly from the `SetItmId` field on the row
|
||||||
(`rrx.SetItmId`, populated by `fds__prepInvoice`'s `[SetItmID]` window function,
|
(`rrx.SetItmId` — initially populated by `fds__prepInvoice`'s `[SetItmID]` window function,
|
||||||
anchored on the still-unconverted, zero-priced `'set'` header that owns it) —
|
anchored on the still-unconverted, zero-priced `'set'` header that owns it, and thereafter
|
||||||
**not** re-derived from row order in the browser. `mfr__items` itself still has no
|
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
|
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
|
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
|
unrelated items following a set in the list are never swept in. The header row's
|
||||||
|
|||||||
@@ -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.
|
1. Aus einem Serviceauftrag eine Rechnung **vorbereiten** → Vorschau prüfen.
|
||||||
2. **Speichern** (Entwurf) oder weiter **bearbeiten**. Entwürfe tragen ein Wasserzeichen.
|
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
|
### Finalisieren & Versenden
|
||||||
1. **Bestätigen/Finalisieren**: Die Rechnung erhält eine Rechnungsnummer, das PDF wird erzeugt und gespeichert.
|
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.
|
2. Ist eine E-Mail-Adresse hinterlegt, wird die Rechnung **automatisch als PDF-Anhang** per E-Mail versendet.
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
switch (d.Target)
|
switch (d.Target)
|
||||||
{
|
{
|
||||||
case "email": return SetNewText(s, "invoiceemail", d, ref oldValue, ref newValue);
|
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":
|
case "address":
|
||||||
// Structured dialog form posts a JSON object; a plain string keeps the legacy
|
// Structured dialog form posts a JSON object; a plain string keeps the legacy
|
||||||
// free-text path working (backward compatibility).
|
// 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 "item.setprice": return ApplyItemSetPrice(s, d, ref oldValue, ref newValue);
|
||||||
case "block.setprice": return ApplyBlockSetPricing(s, removeMembers: false, 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 "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;
|
default: return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,9 +231,12 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
/// manually afterwards (ordinary item edit), which is why this is one-way.
|
/// manually afterwards (ordinary item edit), which is why this is one-way.
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The header row's own <c>SetItmId</c> now self-references its own id (rather than being
|
/// 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
|
/// <c>null</c>) once <c>fds__prepInvoice</c> stopped special-casing it; membership is resolved
|
||||||
/// determined by <c>id == Ref</c> first (captured as <paramref name="header"/> below and
|
/// by <see cref="InvoiceSetStructure.Descendants"/>, which never yields the header itself.
|
||||||
/// <c>continue</c>d past) — a header is never mistaken for its own member.
|
/// 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>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool ApplyItemSetPrice(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
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;
|
if (string.IsNullOrEmpty(d.Ref)) return false;
|
||||||
foreach (var blockTok in s.Req)
|
foreach (var blockTok in s.Req)
|
||||||
{
|
{
|
||||||
if (blockTok is not JObject block || block["itm"] is not JArray lines) continue;
|
if (blockTok is not JObject block || block["itm"] is not JArray) continue;
|
||||||
JObject? header = null;
|
var lines = InvoiceSetStructure.Lines(block);
|
||||||
var members = new List<JObject>();
|
var header = lines.FirstOrDefault(co => Str(co["id"]) == d.Ref);
|
||||||
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 (header == null) continue;
|
if (header == null) continue;
|
||||||
if (!string.Equals(Str(header["typ"]), "set", StringComparison.OrdinalIgnoreCase)) return false;
|
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);
|
oldValue = InvoiceDraftCalculator.Dec(header["vt"]).ToString(CultureInfo.InvariantCulture);
|
||||||
decimal sumNet = 0, sumVat = 0, sumSvcNet = 0, sumSvcVat = 0;
|
decimal sumNet = 0, sumVat = 0, sumSvcNet = 0, sumSvcVat = 0;
|
||||||
@@ -256,6 +259,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
sumVat += InvoiceDraftCalculator.Dec(m["vv"]);
|
sumVat += InvoiceDraftCalculator.Dec(m["vv"]);
|
||||||
sumSvcNet += InvoiceDraftCalculator.Dec(m["vs"]);
|
sumSvcNet += InvoiceDraftCalculator.Dec(m["vs"]);
|
||||||
sumSvcVat += InvoiceDraftCalculator.Dec(m["vsv"]);
|
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 €
|
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;
|
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.RecomputeLineValues(session);
|
||||||
InvoiceDraftCalculator.RecomputeTotals(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.RecomputePositions(session);
|
||||||
InvoiceDraftCalculator.Validate(session);
|
InvoiceDraftCalculator.Validate(session);
|
||||||
}
|
}
|
||||||
@@ -609,6 +614,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
["InvoiceTitle"] = title,
|
["InvoiceTitle"] = title,
|
||||||
["SendToAddress"] = Str(session.New["invoiceaddress"]),
|
["SendToAddress"] = Str(session.New["invoiceaddress"]),
|
||||||
["SendToEmail"] = Str(session.New["invoiceemail"]),
|
["SendToEmail"] = Str(session.New["invoiceemail"]),
|
||||||
|
["SendToEmailCc"] = Str(session.New["invoiceemailcc"]),
|
||||||
["ProvisionLocation"] = loc,
|
["ProvisionLocation"] = loc,
|
||||||
["ProvisionPeriod"] = Str(session.New["provisionperiod"]),
|
["ProvisionPeriod"] = Str(session.New["provisionperiod"]),
|
||||||
["PaymentTerm"] = Str(session.Admin["paymentterms"]).ne(Str(session.New["paymentterm"])),
|
["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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,18 @@
|
|||||||
}
|
}
|
||||||
authinfo["requestedaccount"] = "";
|
authinfo["requestedaccount"] = "";
|
||||||
authinfo["accountrequired"] = false;
|
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 appName = ViewData["AppName"] as string ?? "Fuchs Intranet";
|
||||||
string fullName = ViewData["FullName"] as string ?? "";
|
string fullName = ViewData["FullName"] as string ?? "";
|
||||||
|
|||||||
@@ -77,7 +77,8 @@
|
|||||||
"js/intranet/modules/fis.req_txt_de.js",
|
"js/intranet/modules/fis.req_txt_de.js",
|
||||||
"js/intranet/modules/fis.inv_txt_de.js",
|
"js/intranet/modules/fis.inv_txt_de.js",
|
||||||
"js/intranet/modules/fis.inv.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 }
|
"minify": { "enabled": true }
|
||||||
},
|
},
|
||||||
@@ -99,7 +100,8 @@
|
|||||||
"js/intranet/modules/fis.req_txt_de.js",
|
"js/intranet/modules/fis.req_txt_de.js",
|
||||||
"js/intranet/modules/fis.inv_txt_de.js",
|
"js/intranet/modules/fis.inv_txt_de.js",
|
||||||
"js/intranet/modules/fis.req.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 }
|
"minify": { "enabled": true }
|
||||||
},
|
},
|
||||||
|
|||||||
+23
-13
@@ -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
|
/// (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>.
|
/// (non-collapsing) rendering used for every set mode except the explicit <c>setonly</c>.
|
||||||
/// </summary>
|
/// </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)
|
private static InvoiceSetLine MapItemToLine(Dictionary<string, object?> i)
|
||||||
{
|
{
|
||||||
string type = i.nz("type", "").ToLowerInvariant();
|
string type = i.nz("type", "").ToLowerInvariant();
|
||||||
@@ -180,7 +183,8 @@ public static class FuchsPdf
|
|||||||
TotalNet = total,
|
TotalNet = total,
|
||||||
ShowPrice = !isText && hasPrice,
|
ShowPrice = !isText && hasPrice,
|
||||||
Numbered = !isText, // numbered like the editor: every line except free-text/heading, even price-blanked set members
|
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.
|
// and drops the members entirely.
|
||||||
// Blocks without any set items are unaffected and always render flat.
|
// Blocks without any set items are unaffected and always render flat.
|
||||||
var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions"));
|
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))
|
if (!string.IsNullOrWhiteSpace(block.Heading))
|
||||||
{
|
{
|
||||||
var hr = tbl.AddRow();
|
var hr = tbl.AddRow();
|
||||||
@@ -783,19 +800,12 @@ public static class FuchsPdf
|
|||||||
hr.Cells[1].AddParagraph().WithStyle("TblCell_RTitle").AddFormattedText(block.Heading, TextFormat.Bold);
|
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)
|
foreach (var line in blockLines[bi])
|
||||||
// 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)
|
|
||||||
{
|
{
|
||||||
bool numbered = line.Numbered; // every line except free-text/heading (mirrors the editor's RecomputePositions), even price-blanked set members
|
|
||||||
var row = tbl.AddRow();
|
var row = tbl.AddRow();
|
||||||
row.HeightRule = RowHeightRule.Auto;
|
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
|
if (!string.IsNullOrEmpty(line.Title)) // skip the empty paragraph that added a blank line before free text
|
||||||
{
|
{
|
||||||
var titleCell = row.Cells[1].AddParagraph();
|
var titleCell = row.Cells[1].AddParagraph();
|
||||||
|
|||||||
@@ -109,27 +109,35 @@ public static class InvoiceDraftCalculator
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Renumbers the visible line positions authoritatively (the port of the client-side
|
/// 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
|
/// numbering in <c>invSumUpdate</c>): priced lines are numbered hierarchically across the
|
||||||
/// invoice — matching the PDF's <c>Pos.</c> column — while heading/free-text lines
|
/// whole invoice — a set header gets a number at its own level, its members get subsidiary
|
||||||
/// (<c>typ</c> = "text"/"title") carry no number. The result is written onto each line's
|
/// numbers one level deeper (e.g. "3", "3.1", "3.2") — matching the PDF's <c>Pos.</c> column
|
||||||
/// <c>p</c> field so it flows back to the browser (via the view state) and into the PDF; this
|
/// (ADR 0017), while heading/free-text lines (<c>typ</c> = "text"/"title") carry no number.
|
||||||
/// keeps the online editor and the PDF preview showing the same position numbers, including
|
/// Levels come from <c>lvl</c> (written by <see cref="InvoiceSetStructure"/>, default 0). The
|
||||||
/// after a reorder.
|
/// 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>
|
/// </summary>
|
||||||
public static void RecomputePositions(InvoiceDraftSession session)
|
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)
|
foreach (var blockTok in session.Req)
|
||||||
{
|
{
|
||||||
if (blockTok is not JObject block || block["itm"] is not JArray lines) continue;
|
if (blockTok is not JObject block || block["itm"] is not JArray itms) continue;
|
||||||
foreach (var lineTok in lines)
|
foreach (var lineTok in itms)
|
||||||
{
|
{
|
||||||
if (lineTok is not JObject co) continue;
|
if (lineTok is not JObject co) continue;
|
||||||
string typ = Str(co["typ"]).Trim().ToLowerInvariant();
|
string typ = Str(co["typ"]).Trim().ToLowerInvariant();
|
||||||
bool numbered = typ is not ("text" or "title"); // only headings/free-text carry no number (mirrors invSumUpdate)
|
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>
|
/// <summary>
|
||||||
@@ -149,6 +157,10 @@ public static class InvoiceDraftCalculator
|
|||||||
else if (!IsValidEmail(email))
|
else if (!IsValidEmail(email))
|
||||||
Add("email", "error", "Die E-Mail-Adresse ist ungültig.");
|
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)
|
if (Str(session.New["invoiceaddress"]).Trim().Length == 0)
|
||||||
Add("address", "warning", "Es ist keine Rechnungsanschrift hinterlegt.");
|
Add("address", "warning", "Es ist keine Rechnungsanschrift hinterlegt.");
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using static OCORE.commons;
|
using System.Linq;
|
||||||
|
using static OCORE.commons;
|
||||||
using static OCORE.OCORE_dictionaries;
|
using static OCORE.OCORE_dictionaries;
|
||||||
|
|
||||||
namespace Fuchs.intranet;
|
namespace Fuchs.intranet;
|
||||||
@@ -40,13 +41,24 @@ public sealed class InvoiceSetLine
|
|||||||
public bool Numbered { get; init; } = true;
|
public bool Numbered { get; init; } = true;
|
||||||
/// <summary>True for the set header line (rendered emphasised).</summary>
|
/// <summary>True for the set header line (rendered emphasised).</summary>
|
||||||
public bool IsSetHeader { get; init; }
|
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>
|
/// <summary>
|
||||||
/// Transforms raw invoice line items into display lines according to a
|
/// Transforms raw invoice line items into display lines according to a
|
||||||
/// <see cref="SetDisplayMode"/>. A "set" item is identified by
|
/// <see cref="SetDisplayMode"/>. A "set" item is identified by
|
||||||
/// <c>type == "set"</c>; its members carry <c>setId</c> equal to the set
|
/// <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
|
/// The invoice total is taken from the registration balance, not from these
|
||||||
/// lines, so calling <see cref="Build"/> is a pure, non-mutating transformation
|
/// 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)));
|
items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Produces the ordered display lines for the given items and mode.
|
/// Produces the display lines for the given items and mode, <b>preserving the input order</b>
|
||||||
/// Standalone items are always shown with their price. A set header whose
|
/// (the draft service already linearises each block depth-first — set, then its members,
|
||||||
/// own <c>total_net</c> is still zero (not yet converted via the set-item
|
/// recursively — so editor and PDF print the very same sequence, ADR 0008/0016).
|
||||||
/// switch) is rendered blank with its members individually priced,
|
/// Standalone items are always shown with their price. A set header whose own
|
||||||
/// regardless of <paramref name="mode"/>.
|
/// <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>
|
/// </summary>
|
||||||
public static List<InvoiceSetLine> Build(IReadOnlyList<Dictionary<string, object?>> items, SetDisplayMode mode)
|
public static List<InvoiceSetLine> Build(IReadOnlyList<Dictionary<string, object?>> items, SetDisplayMode mode)
|
||||||
{
|
{
|
||||||
var result = new List<InvoiceSetLine>();
|
var result = new List<InvoiceSetLine>();
|
||||||
if (items.Count == 0) return result;
|
if (items.Count == 0) return result;
|
||||||
|
|
||||||
// Group members by their set id.
|
var headers = new Dictionary<string, Dictionary<string, object?>>();
|
||||||
var membersBySet = items
|
foreach (var i in items)
|
||||||
.Where(i => !IsSetHeader(i) && !string.IsNullOrEmpty(SetIdOf(i)))
|
if (IsSetHeader(i) && i.nz("id", "").Length > 0) headers.TryAdd(i.nz("id", ""), i);
|
||||||
.GroupBy(SetIdOf)
|
|
||||||
.ToDictionary(g => g.Key!, g => g.ToList());
|
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)
|
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))
|
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);
|
FuchsPdf.ParseDec(item.no("total_net", 0), out decimal headerOwnTotal);
|
||||||
|
// Unconverted (still zero-priced) header: blank price, members keep their own price.
|
||||||
if (headerOwnTotal == 0)
|
// Converted: priced line; members blank (SetPrice) — or dropped above (SetOnly).
|
||||||
{
|
bool show = headerOwnTotal != 0 && !insideConverted;
|
||||||
// Not yet converted (the set-item switch, ApplyItemSetPrice, hasn't run): the
|
result.Add(HeaderLine(item, show ? headerOwnTotal : 0, showPrice: show, level));
|
||||||
// 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.
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
result.Add(MemberLine(item, showPrice: true)); // standalone item
|
result.Add(MemberLine(item, showPrice: !insideConverted, level));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
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 ──────────────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
private static bool IsSetHeader(Dictionary<string, object?> i) =>
|
private static bool IsSetHeader(Dictionary<string, object?> i) =>
|
||||||
string.Equals(i.nz("type", ""), "set", StringComparison.OrdinalIgnoreCase);
|
string.Equals(i.nz("type", ""), "set", StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -174,13 +202,14 @@ public static class InvoiceSetPricing
|
|||||||
return string.IsNullOrEmpty(s) ? null : s;
|
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", "");
|
string s = SetIdOf(i) ?? "";
|
||||||
return string.IsNullOrEmpty(s) ? i.nz("id", "") : s;
|
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", ""),
|
Id = i.nz("id", ""),
|
||||||
Title = i.nz("title", ""),
|
Title = i.nz("title", ""),
|
||||||
@@ -190,10 +219,11 @@ public static class InvoiceSetPricing
|
|||||||
TotalNet = setTotal,
|
TotalNet = setTotal,
|
||||||
ShowPrice = showPrice,
|
ShowPrice = showPrice,
|
||||||
Numbered = true, // a set header is always numbered
|
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("price_net", 0), out decimal price);
|
||||||
FuchsPdf.ParseDec(i.no("total_net", 0), out decimal total);
|
FuchsPdf.ParseDec(i.no("total_net", 0), out decimal total);
|
||||||
@@ -207,7 +237,8 @@ public static class InvoiceSetPricing
|
|||||||
TotalNet = total,
|
TotalNet = total,
|
||||||
ShowPrice = showPrice && !IsNoPriceLine(i), // headings/free text print no price
|
ShowPrice = showPrice && !IsNoPriceLine(i), // headings/free text print no price
|
||||||
Numbered = !IsNoPriceLine(i), // …but are still numbered unless heading/free-text
|
Numbered = !IsNoPriceLine(i), // …but are still numbered unless heading/free-text
|
||||||
IsSetHeader = false
|
IsSetHeader = false,
|
||||||
|
Level = level
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"/> > 0: it becomes the last member of
|
||||||
|
/// the nearest preceding sibling set) or one level up (<paramref name="delta"/> < 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"/> < 0) or down (> 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();
|
||||||
|
}
|
||||||
@@ -17,8 +17,11 @@
|
|||||||
}
|
}
|
||||||
$ocms[mdl].auth = auth;
|
$ocms[mdl].auth = auth;
|
||||||
if (auth.manage > 0) {
|
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({
|
$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(); });
|
}, function () { $ocms[mdl].init2(); });
|
||||||
}
|
}
|
||||||
}, error: function () {
|
}, error: function () {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -86,7 +86,7 @@ $inv.iMn = (ix) => {
|
|||||||
yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) — once set,
|
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
|
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. */
|
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) => {
|
$inv.eM = (r, re, opt) => {
|
||||||
let m = [];
|
let m = [];
|
||||||
if (booln(r, false) === true || booln(re, false) === true) {
|
if (booln(r, false) === true || booln(re, false) === true) {
|
||||||
@@ -104,6 +104,9 @@ $inv.eM = (r, re, opt) => {
|
|||||||
if ((opt || '').split(',').includes('setm') === true) {
|
if ((opt || '').split(',').includes('setm') === true) {
|
||||||
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
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) */
|
if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */
|
||||||
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
|
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
|
||||||
m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() });
|
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) {
|
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
|
||||||
return;
|
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.footer(tbl, state.sums || {}, state.admin || {});
|
||||||
$inv.d.notes(state.notes || []);
|
$inv.d.notes(state.notes || []);
|
||||||
$inv.d.validation(state.validation || []);
|
$inv.d.validation(state.validation || []);
|
||||||
$inv.d.applyItems(tbl, state.req || []);
|
$inv.d.applyItems(tbl, state.req || []);
|
||||||
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
|
$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:
|
/* 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
|
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). */
|
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 || '')
|
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.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.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, {
|
$.extend(dta, {
|
||||||
Id: id, Type: co.typ,
|
Id: id, Type: co.typ,
|
||||||
net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv),
|
net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv),
|
||||||
svcnet_val: val(co.vs), svcvat_val: val(co.vsv),
|
svcnet_val: val(co.vs), svcvat_val: val(co.vsv),
|
||||||
vat: co.vat, position: (co.p != null ? co.p : ''),
|
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)
|
if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row)
|
||||||
rw.data(dta);
|
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
|
/* 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. */
|
same reason as syncChanged: sync() waits for an in-flight seed instead of dropping the edit. */
|
||||||
syncField: function (nme, val) {
|
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; }
|
let target = map[nme]; if (!target) { return; }
|
||||||
$inv.d.sync({ Target: target, Value: val });
|
$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('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
|
||||||
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
|
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
|
||||||
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, 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));
|
$$.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);
|
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('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
|
||||||
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
|
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
|
||||||
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, 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));
|
$$.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);
|
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
|
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
|
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. */
|
(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
|
let flds = isPlainText
|
||||||
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
|
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
|
||||||
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
|
: [{ 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) {
|
} 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 };
|
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 {
|
} 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 */
|
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
|
||||||
|
|
||||||
co.p = dta.position || (dta.SortOrder || '');
|
co.p = dta.position || (dta.SortOrder || '');
|
||||||
@@ -1274,6 +1282,7 @@ $inv.rrw = function () {
|
|||||||
}
|
}
|
||||||
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
|
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) */
|
.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);
|
.tC('hidenote', hn).append(tda);
|
||||||
dta.co = co;
|
dta.co = co;
|
||||||
};
|
};
|
||||||
@@ -1304,8 +1313,23 @@ $inv.invSumUpdate = function () {
|
|||||||
let tbl = $(this);
|
let tbl = $(this);
|
||||||
let ba = [];
|
let ba = [];
|
||||||
let bds = tbl.children('tbody');
|
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) => {
|
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);
|
b.tC('empty', itm.length < 1);
|
||||||
itm.each((ti, tx) => {
|
itm.each((ti, tx) => {
|
||||||
let rrx = $(tx).data() || {};
|
let rrx = $(tx).data() || {};
|
||||||
@@ -1318,14 +1342,12 @@ $inv.invSumUpdate = function () {
|
|||||||
by the id !== '' && !== citem.id check below). */
|
by the id !== '' && !== citem.id check below). */
|
||||||
let citem = $inv.itemToContract(rrx);
|
let citem = $inv.itemToContract(rrx);
|
||||||
let sid = (rrx.SetItmId || '').toString();
|
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);
|
citems.push(citem);
|
||||||
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
|
let numbered = ['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false;
|
||||||
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
|
rrx.position = nextPositionLabel(numbered, (rrx.co || {}).lvl || 0);
|
||||||
rrx.SortOrder = iso;
|
|
||||||
rrx.position = ipos;
|
|
||||||
$inv.rrw.call(tx);
|
$inv.rrw.call(tx);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems });
|
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 {
|
.admfrm {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 3rem;
|
right: 3rem;
|
||||||
@@ -250,6 +281,12 @@
|
|||||||
&.sethdr td.txt {
|
&.sethdr td.txt {
|
||||||
font-weight: bold;
|
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 {
|
> tfoot > tr {
|
||||||
@@ -493,3 +530,71 @@
|
|||||||
color: #445;
|
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',
|
setprice: 'Set mit Preis – Positionen ohne Preis',
|
||||||
setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
|
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',
|
ctp: 'Ansprechpartner festlegen',
|
||||||
mfr: 'Von MFR neu abrufen',
|
mfr: 'Von MFR neu abrufen',
|
||||||
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
|
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@charset "UTF-8";
|
||||||
/* basics */
|
/* basics */
|
||||||
/* Color SCHEME */
|
/* Color SCHEME */
|
||||||
/* #8396bd */
|
/* #8396bd */
|
||||||
@@ -169,6 +170,33 @@ table.if td.num {
|
|||||||
.edit_frm .invoice_layout .emailfrm:hover div.axf {
|
.edit_frm .invoice_layout .emailfrm:hover div.axf {
|
||||||
display: block;
|
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 {
|
.edit_frm .invoice_layout .admfrm {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 3rem;
|
right: 3rem;
|
||||||
@@ -302,6 +330,27 @@ table.if td.num {
|
|||||||
.edit_frm .invoice_layout table.invi > tbody > tr.sethdr td.txt {
|
.edit_frm .invoice_layout table.invi > tbody > tr.sethdr td.txt {
|
||||||
font-weight: bold;
|
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 {
|
.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;
|
text-align: right;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -511,3 +560,143 @@ table.if td.num {
|
|||||||
.adr-conformity .tx {
|
.adr-conformity .tx {
|
||||||
color: #445;
|
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
@@ -65,6 +65,7 @@ let $rct = {
|
|||||||
invoiceaddress: 'Adresse'
|
invoiceaddress: 'Adresse'
|
||||||
, loc: 'Leistungsort / Lieferadresse'
|
, loc: 'Leistungsort / Lieferadresse'
|
||||||
, invoiceemail: 'Email'
|
, invoiceemail: 'Email'
|
||||||
|
, invoiceemailcc: 'Zusatzempfänger (CC)'
|
||||||
|
|
||||||
}
|
}
|
||||||
}, $rcol = {
|
}, $rcol = {
|
||||||
@@ -154,6 +155,24 @@ let $ict = {
|
|||||||
setprice: 'Set mit Preis – Positionen ohne Preis',
|
setprice: 'Set mit Preis – Positionen ohne Preis',
|
||||||
setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
|
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',
|
ctp: 'Ansprechpartner festlegen',
|
||||||
mfr: 'Von MFR neu abrufen',
|
mfr: 'Von MFR neu abrufen',
|
||||||
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
|
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,
|
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
|
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. */
|
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) => {
|
$inv.eM = (r, re, opt) => {
|
||||||
let m = [];
|
let m = [];
|
||||||
if (booln(r, false) === true || booln(re, false) === true) {
|
if (booln(r, false) === true || booln(re, false) === true) {
|
||||||
@@ -647,6 +666,9 @@ $inv.eM = (r, re, opt) => {
|
|||||||
if ((opt || '').split(',').includes('setm') === true) {
|
if ((opt || '').split(',').includes('setm') === true) {
|
||||||
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
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) */
|
if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */
|
||||||
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
|
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
|
||||||
m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() });
|
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) {
|
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
|
||||||
return;
|
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.footer(tbl, state.sums || {}, state.admin || {});
|
||||||
$inv.d.notes(state.notes || []);
|
$inv.d.notes(state.notes || []);
|
||||||
$inv.d.validation(state.validation || []);
|
$inv.d.validation(state.validation || []);
|
||||||
$inv.d.applyItems(tbl, state.req || []);
|
$inv.d.applyItems(tbl, state.req || []);
|
||||||
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
|
$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:
|
/* 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
|
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). */
|
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 || '')
|
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.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.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, {
|
$.extend(dta, {
|
||||||
Id: id, Type: co.typ,
|
Id: id, Type: co.typ,
|
||||||
net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv),
|
net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv),
|
||||||
svcnet_val: val(co.vs), svcvat_val: val(co.vsv),
|
svcnet_val: val(co.vs), svcvat_val: val(co.vsv),
|
||||||
vat: co.vat, position: (co.p != null ? co.p : ''),
|
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)
|
if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row)
|
||||||
rw.data(dta);
|
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
|
/* 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. */
|
same reason as syncChanged: sync() waits for an in-flight seed instead of dropping the edit. */
|
||||||
syncField: function (nme, val) {
|
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; }
|
let target = map[nme]; if (!target) { return; }
|
||||||
$inv.d.sync({ Target: target, Value: val });
|
$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('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
|
||||||
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
|
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
|
||||||
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, 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));
|
$$.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);
|
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('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
|
||||||
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
|
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
|
||||||
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, 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));
|
$$.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);
|
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
|
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
|
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. */
|
(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
|
let flds = isPlainText
|
||||||
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
|
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
|
||||||
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
|
: [{ 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) {
|
} 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 };
|
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 {
|
} 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 */
|
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
|
||||||
|
|
||||||
co.p = dta.position || (dta.SortOrder || '');
|
co.p = dta.position || (dta.SortOrder || '');
|
||||||
@@ -1817,6 +1844,7 @@ $inv.rrw = function () {
|
|||||||
}
|
}
|
||||||
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
|
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) */
|
.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);
|
.tC('hidenote', hn).append(tda);
|
||||||
dta.co = co;
|
dta.co = co;
|
||||||
};
|
};
|
||||||
@@ -1847,8 +1875,23 @@ $inv.invSumUpdate = function () {
|
|||||||
let tbl = $(this);
|
let tbl = $(this);
|
||||||
let ba = [];
|
let ba = [];
|
||||||
let bds = tbl.children('tbody');
|
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) => {
|
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);
|
b.tC('empty', itm.length < 1);
|
||||||
itm.each((ti, tx) => {
|
itm.each((ti, tx) => {
|
||||||
let rrx = $(tx).data() || {};
|
let rrx = $(tx).data() || {};
|
||||||
@@ -1861,14 +1904,12 @@ $inv.invSumUpdate = function () {
|
|||||||
by the id !== '' && !== citem.id check below). */
|
by the id !== '' && !== citem.id check below). */
|
||||||
let citem = $inv.itemToContract(rrx);
|
let citem = $inv.itemToContract(rrx);
|
||||||
let sid = (rrx.SetItmId || '').toString();
|
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);
|
citems.push(citem);
|
||||||
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
|
let numbered = ['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false;
|
||||||
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
|
rrx.position = nextPositionLabel(numbered, (rrx.co || {}).lvl || 0);
|
||||||
rrx.SortOrder = iso;
|
|
||||||
rrx.position = ipos;
|
|
||||||
$inv.rrw.call(tx);
|
$inv.rrw.call(tx);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems });
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -2882,8 +2882,11 @@ $ocms.init = function (ev) {
|
|||||||
}
|
}
|
||||||
$ocms[mdl].auth = auth;
|
$ocms[mdl].auth = auth;
|
||||||
if (auth.manage > 0) {
|
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({
|
$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(); });
|
}, function () { $ocms[mdl].init2(); });
|
||||||
}
|
}
|
||||||
}, error: function () {
|
}, error: function () {
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,3 +1,4 @@
|
|||||||
|
@charset "UTF-8";
|
||||||
/* basics */
|
/* basics */
|
||||||
/* Color SCHEME */
|
/* Color SCHEME */
|
||||||
/* #8396bd */
|
/* #8396bd */
|
||||||
@@ -306,6 +307,33 @@ table.if th.keep, table.if td.keep {
|
|||||||
.edit_frm .invoice_layout .emailfrm:hover div.axf {
|
.edit_frm .invoice_layout .emailfrm:hover div.axf {
|
||||||
display: block;
|
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 {
|
.edit_frm .invoice_layout .admfrm {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 3rem;
|
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 {
|
.edit_frm .invoice_layout table.invi > tbody > tr.sethdr td.txt {
|
||||||
font-weight: bold;
|
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 {
|
.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;
|
text-align: right;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -648,3 +697,143 @@ table.if th.keep, table.if td.keep {
|
|||||||
.adr-conformity .tx {
|
.adr-conformity .tx {
|
||||||
color: #445;
|
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
@@ -65,6 +65,7 @@ let $rct = {
|
|||||||
invoiceaddress: 'Adresse'
|
invoiceaddress: 'Adresse'
|
||||||
, loc: 'Leistungsort / Lieferadresse'
|
, loc: 'Leistungsort / Lieferadresse'
|
||||||
, invoiceemail: 'Email'
|
, invoiceemail: 'Email'
|
||||||
|
, invoiceemailcc: 'Zusatzempfänger (CC)'
|
||||||
|
|
||||||
}
|
}
|
||||||
}, $rcol = {
|
}, $rcol = {
|
||||||
@@ -154,6 +155,24 @@ let $ict = {
|
|||||||
setprice: 'Set mit Preis – Positionen ohne Preis',
|
setprice: 'Set mit Preis – Positionen ohne Preis',
|
||||||
setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
|
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',
|
ctp: 'Ansprechpartner festlegen',
|
||||||
mfr: 'Von MFR neu abrufen',
|
mfr: 'Von MFR neu abrufen',
|
||||||
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
|
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,
|
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
|
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. */
|
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) => {
|
$inv.eM = (r, re, opt) => {
|
||||||
let m = [];
|
let m = [];
|
||||||
if (booln(r, false) === true || booln(re, false) === true) {
|
if (booln(r, false) === true || booln(re, false) === true) {
|
||||||
@@ -628,6 +647,9 @@ $inv.eM = (r, re, opt) => {
|
|||||||
if ((opt || '').split(',').includes('setm') === true) {
|
if ((opt || '').split(',').includes('setm') === true) {
|
||||||
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
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) */
|
if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */
|
||||||
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
|
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
|
||||||
m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() });
|
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) {
|
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
|
||||||
return;
|
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.footer(tbl, state.sums || {}, state.admin || {});
|
||||||
$inv.d.notes(state.notes || []);
|
$inv.d.notes(state.notes || []);
|
||||||
$inv.d.validation(state.validation || []);
|
$inv.d.validation(state.validation || []);
|
||||||
$inv.d.applyItems(tbl, state.req || []);
|
$inv.d.applyItems(tbl, state.req || []);
|
||||||
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
|
$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:
|
/* 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
|
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). */
|
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 || '')
|
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.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.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, {
|
$.extend(dta, {
|
||||||
Id: id, Type: co.typ,
|
Id: id, Type: co.typ,
|
||||||
net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv),
|
net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv),
|
||||||
svcnet_val: val(co.vs), svcvat_val: val(co.vsv),
|
svcnet_val: val(co.vs), svcvat_val: val(co.vsv),
|
||||||
vat: co.vat, position: (co.p != null ? co.p : ''),
|
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)
|
if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row)
|
||||||
rw.data(dta);
|
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
|
/* 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. */
|
same reason as syncChanged: sync() waits for an in-flight seed instead of dropping the edit. */
|
||||||
syncField: function (nme, val) {
|
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; }
|
let target = map[nme]; if (!target) { return; }
|
||||||
$inv.d.sync({ Target: target, Value: val });
|
$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('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
|
||||||
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
|
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
|
||||||
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, 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));
|
$$.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);
|
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('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
|
||||||
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
|
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
|
||||||
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, 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));
|
$$.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);
|
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
|
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
|
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. */
|
(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
|
let flds = isPlainText
|
||||||
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
|
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
|
||||||
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
|
: [{ 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) {
|
} 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 };
|
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 {
|
} 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 */
|
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
|
||||||
|
|
||||||
co.p = dta.position || (dta.SortOrder || '');
|
co.p = dta.position || (dta.SortOrder || '');
|
||||||
@@ -1798,6 +1825,7 @@ $inv.rrw = function () {
|
|||||||
}
|
}
|
||||||
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
|
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) */
|
.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);
|
.tC('hidenote', hn).append(tda);
|
||||||
dta.co = co;
|
dta.co = co;
|
||||||
};
|
};
|
||||||
@@ -1828,8 +1856,23 @@ $inv.invSumUpdate = function () {
|
|||||||
let tbl = $(this);
|
let tbl = $(this);
|
||||||
let ba = [];
|
let ba = [];
|
||||||
let bds = tbl.children('tbody');
|
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) => {
|
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);
|
b.tC('empty', itm.length < 1);
|
||||||
itm.each((ti, tx) => {
|
itm.each((ti, tx) => {
|
||||||
let rrx = $(tx).data() || {};
|
let rrx = $(tx).data() || {};
|
||||||
@@ -1842,14 +1885,12 @@ $inv.invSumUpdate = function () {
|
|||||||
by the id !== '' && !== citem.id check below). */
|
by the id !== '' && !== citem.id check below). */
|
||||||
let citem = $inv.itemToContract(rrx);
|
let citem = $inv.itemToContract(rrx);
|
||||||
let sid = (rrx.SetItmId || '').toString();
|
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);
|
citems.push(citem);
|
||||||
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
|
let numbered = ['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false;
|
||||||
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
|
rrx.position = nextPositionLabel(numbered, (rrx.co || {}).lvl || 0);
|
||||||
rrx.SortOrder = iso;
|
|
||||||
rrx.position = ipos;
|
|
||||||
$inv.rrw.call(tx);
|
$inv.rrw.call(tx);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems });
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user