Refactor code structure and remove redundant sections for improved readability and maintainability
This commit is contained in:
@@ -369,6 +369,21 @@ public partial class IntranetController
|
||||
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the PDF inline (browser shows it) while advertising the real download filename —
|
||||
/// both a quoted ASCII form and RFC 5987 <c>filename*</c> for spaces/non-ASCII. Works around
|
||||
/// the OCORE FileContentResult helper, whose classic-MVC <c>ExecuteResult(ControllerContext)</c>
|
||||
/// never runs under ASP.NET Core, so the filename was dropped and downloads used the "idoc"
|
||||
/// endpoint segment.
|
||||
/// </summary>
|
||||
private void SetInlinePdfFilename(string filename)
|
||||
{
|
||||
string safe = (filename ?? "").Replace("\"", "").Replace("\r", " ").Replace("\n", " ").Trim();
|
||||
if (safe.Length == 0) return;
|
||||
Response.Headers["Content-Disposition"] =
|
||||
$"inline; filename=\"{safe}\"; filename*=UTF-8''{Uri.EscapeDataString(safe)}";
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleRequestIdoc: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); }
|
||||
@@ -381,9 +396,13 @@ public partial class IntranetController
|
||||
byte[]? ct = Form("create", "0") != "1"
|
||||
? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec)
|
||||
: _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
return ct != null
|
||||
? await FileContentResultAsync(ct, "application/pdf", filename, inline: true)
|
||||
: await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id);
|
||||
if (ct == null)
|
||||
return await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id);
|
||||
// Serve inline for the in-browser viewer, but carry the real DocumentName so the browser's
|
||||
// "download" uses "Rechnung R2026-0121.pdf" instead of the "idoc" endpoint segment. (The
|
||||
// OCORE FileContentResult helper drops the filename under ASP.NET Core, so set it here.)
|
||||
SetInlinePdfFilename(filename);
|
||||
return File(ct, "application/pdf");
|
||||
}
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
|
||||
@@ -51,9 +51,22 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
|
||||
`Sums`, `ValidationMessages`, `History`, `Version`, `Token`, `InvId`, `LastAccessUtc`.
|
||||
- **Calculation** (`InvoiceDraftCalculator`, static/pure) ports the former client math:
|
||||
`RecomputeItem` (quantity × price × VAT, the `quantChange` port), `RecomputeTotals`
|
||||
(the `invSumUpdate`/`csms` aggregation + §13b reverse-charge), and `Validate`
|
||||
(email/address/items/VAT-rate/negative-total checks). Being pure, it is exhaustively
|
||||
unit-tested.
|
||||
(the `invSumUpdate`/`csms` aggregation + §13b reverse-charge), `RecomputePositions`
|
||||
(numbers every line except heading/free-text lines continuously across the whole invoice —
|
||||
mirroring the editor's `invSumUpdate`, so the editor and the PDF show identical `Pos.` numbers,
|
||||
including after a reorder),
|
||||
and `Validate` (email/address/items/VAT-rate/negative-total checks). Being pure, it is
|
||||
exhaustively unit-tested.
|
||||
- **Sanitisation & reorder.** Scalar text deltas (`title`/`email`/`address`/`provisionperiod`/
|
||||
`provisionlocation`) and the section heading (`block.replace`) are stripped of the editor's
|
||||
TinyMCE HTML (`<p>…</p>`, `<br>`) to plain text in `ApplyDelta` (`HtmlToPlain`) — the backend
|
||||
is the single source of truth, so no HTML reaches the DB, the PDF or a reloaded draft. Section
|
||||
drags post a `block.order` delta (`["id",…]`) that reorders `Req`; positions are then
|
||||
renumbered and pushed back via the view state (`applyState`/`applyPositions`). The change
|
||||
history records the **changed field** (e.g. the new heading text), never the whole block JSON.
|
||||
The PDF (`FuchsPdf`) renders a heading row per block (`FdsInvoiceData.InvoiceBlocks`) and shows
|
||||
every position's price (set members are priced like standalone lines; only `setonly` collapses
|
||||
them), so the PDF preview mirrors the online editor.
|
||||
- **Orchestration** (`InvoiceDraftEditService`, scoped) opens sessions (from a fresh
|
||||
payload or by reloading a DB draft via `fds__getInvoice`, reshaped like
|
||||
`BuildInvoiceRequestList`), applies deltas (`ApplyDelta`), builds the view-state DTO,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Fuchs.intranet;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
@@ -59,8 +60,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return null;
|
||||
|
||||
string oldValue = "";
|
||||
bool mutated = ApplyDelta(session, delta, ref oldValue);
|
||||
string oldValue = "", newValue = "";
|
||||
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
||||
if (!mutated)
|
||||
{
|
||||
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
|
||||
@@ -75,71 +76,89 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
Target = delta.Target,
|
||||
Ref = delta.Ref,
|
||||
OldValue = oldValue,
|
||||
NewValue = delta.ValueString,
|
||||
NewValue = newValue,
|
||||
Version = session.Version
|
||||
});
|
||||
_cache.Set(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <summary>Applies one delta to the payload; returns whether anything changed and captures the prior value.</summary>
|
||||
private static bool ApplyDelta(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue)
|
||||
/// <summary>
|
||||
/// Applies one delta to the payload; returns whether anything changed and captures the prior
|
||||
/// and new value for the change history. Scalar text fields and the section heading are
|
||||
/// sanitised from the editor's HTML (TinyMCE wraps inline edits in <c><p>…</p></c>)
|
||||
/// to plain text here — the backend is the single source of truth (ADR 0006), so no HTML ever
|
||||
/// reaches the DB, the PDF or a reloaded draft, regardless of which UI path produced it.
|
||||
/// </summary>
|
||||
private static bool ApplyDelta(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
switch (d.Target)
|
||||
{
|
||||
case "email": return SetNew(s, "invoiceemail", d, ref oldValue);
|
||||
case "address": return SetNew(s, "invoiceaddress", d, ref oldValue);
|
||||
case "title": return SetNew(s, "invoicetitle", d, ref oldValue);
|
||||
case "provisionperiod": return SetNew(s, "provisionperiod", d, ref oldValue);
|
||||
case "email": return SetNewText(s, "invoiceemail", d, ref oldValue, ref newValue);
|
||||
case "address": return SetNewText(s, "invoiceaddress", d, ref oldValue, ref newValue);
|
||||
case "title": return SetNewText(s, "invoicetitle", d, ref oldValue, ref newValue);
|
||||
case "provisionperiod": return SetNewText(s, "provisionperiod", d, ref oldValue, ref newValue);
|
||||
case "provisionlocation":
|
||||
oldValue = Str(s.New["provisionlocation"]);
|
||||
s.New["provisionlocation"] = d.ValueString;
|
||||
s.New["loc"] = d.ValueString; // editor mirrors both
|
||||
newValue = HtmlToPlain(d.ValueString);
|
||||
s.New["provisionlocation"] = newValue;
|
||||
s.New["loc"] = newValue; // editor mirrors both
|
||||
return true;
|
||||
case "contact": return SetContact(s, d, ref oldValue);
|
||||
case "setmode": return SetAdmin(s, "setmode", d, ref oldValue);
|
||||
case "contact": return SetContact(s, d, ref oldValue, ref newValue);
|
||||
case "setmode": return SetAdmin(s, "setmode", d, ref oldValue, ref newValue);
|
||||
case "p13b":
|
||||
oldValue = Str(s.Admin["p13b"]);
|
||||
s.Admin["p13b"] = d.Value != null && d.Value.Type != JTokenType.Null
|
||||
bool p13b = d.Value != null && d.Value.Type != JTokenType.Null
|
||||
? AsBool(d.Value) : !AsBool(s.Admin["p13b"]); // toggle when no explicit value
|
||||
s.Admin["p13b"] = p13b;
|
||||
newValue = p13b ? "§13b" : "";
|
||||
return true;
|
||||
case "block.replace": return ReplaceBlock(s, d, ref oldValue);
|
||||
case "block.remove": return RemoveBlock(s, d, ref oldValue);
|
||||
case "block.replace": return ReplaceBlock(s, d, ref oldValue, ref newValue);
|
||||
case "block.remove": return RemoveBlock(s, d, ref oldValue, ref newValue);
|
||||
case "block.order": return ReorderBlocks(s, d, ref oldValue, ref newValue);
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SetNew(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue)
|
||||
private static bool SetNewText(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
oldValue = Str(s.New[key]);
|
||||
s.New[key] = d.ValueString;
|
||||
newValue = HtmlToPlain(d.ValueString);
|
||||
s.New[key] = newValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool SetAdmin(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue)
|
||||
private static bool SetAdmin(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
oldValue = Str(s.Admin[key]);
|
||||
s.Admin[key] = d.ValueString;
|
||||
newValue = d.ValueString;
|
||||
s.Admin[key] = newValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool SetContact(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue)
|
||||
private static bool SetContact(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
oldValue = Str(s.New["CustomValues"]);
|
||||
JObject cvo = TryParseObject(oldValue);
|
||||
JObject prev = TryParseObject(Str(s.New["CustomValues"]));
|
||||
oldValue = ContactLabel(Str(prev["contactName"]), Str(prev["contactEmail"]));
|
||||
JObject cvo = (JObject)prev.DeepClone();
|
||||
if (d.Value is JObject vo)
|
||||
{
|
||||
cvo["contactName"] = vo["name"] ?? vo["contactName"] ?? "";
|
||||
cvo["contactEmail"] = vo["email"] ?? vo["contactEmail"] ?? "";
|
||||
}
|
||||
s.New["CustomValues"] = cvo.ToString(Newtonsoft.Json.Formatting.None);
|
||||
newValue = ContactLabel(Str(cvo["contactName"]), Str(cvo["contactEmail"]));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ContactLabel(string name, string email) =>
|
||||
string.IsNullOrEmpty(name) ? email : string.IsNullOrEmpty(email) ? name : $"{name} <{email}>";
|
||||
|
||||
/// <summary>Replaces (or inserts) a whole block — the editor re-emits an edited block's line arrays as one delta.</summary>
|
||||
private static bool ReplaceBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue)
|
||||
private static bool ReplaceBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
if (d.Value is not JObject nb) return false;
|
||||
SanitizeBlockText(nb);
|
||||
string bid = !string.IsNullOrEmpty(d.Ref) ? d.Ref : Str(nb["Id"]);
|
||||
var existing = FindBlock(s, bid);
|
||||
if (existing != null)
|
||||
@@ -152,18 +171,58 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
oldValue = "";
|
||||
s.Req.Add(nb);
|
||||
}
|
||||
newValue = Str(nb["text"]); // the section heading — never the whole block JSON
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool RemoveBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue)
|
||||
private static bool RemoveBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
var block = FindBlock(s, d.Ref);
|
||||
if (block == null) return false;
|
||||
oldValue = Str(block["text"]);
|
||||
newValue = "";
|
||||
block.Remove();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reorders the service-request blocks to the id sequence the editor posts after a section
|
||||
/// drag (<c>Value</c> = ["id",…]). Named ids move into the given order; any not named are kept
|
||||
/// in their current relative order at the end. Totals are unaffected; item position numbers
|
||||
/// are renumbered by <see cref="Refresh"/> and pushed back to the browser via the view state.
|
||||
/// </summary>
|
||||
private static bool ReorderBlocks(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
if (d.Value is not JArray order) return false;
|
||||
var current = s.Req.OfType<JObject>().ToList();
|
||||
oldValue = string.Join(",", current.Select(b => Str(b["Id"])));
|
||||
|
||||
var byId = current.ToDictionary(b => Str(b["Id"]), b => b);
|
||||
var ordered = new List<JObject>();
|
||||
var seen = new HashSet<string>();
|
||||
foreach (var idTok in order)
|
||||
{
|
||||
string id = Str(idTok);
|
||||
if (byId.TryGetValue(id, out var blk) && seen.Add(id)) ordered.Add(blk);
|
||||
}
|
||||
foreach (var b in current) // append blocks the order list didn't mention, in place
|
||||
if (seen.Add(Str(b["Id"]))) ordered.Add(b);
|
||||
|
||||
newValue = string.Join(",", ordered.Select(b => Str(b["Id"])));
|
||||
if (oldValue == newValue) return false; // no-op reorder
|
||||
|
||||
s.Req.Clear();
|
||||
foreach (var b in ordered) s.Req.Add(b);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Strips the editor's HTML from a block's heading (<c>text</c>/<c>nme</c>) before it is cached.</summary>
|
||||
private static void SanitizeBlockText(JObject block)
|
||||
{
|
||||
if (block["text"] != null) block["text"] = HtmlToPlain(Str(block["text"]));
|
||||
if (block["nme"] != null) block["nme"] = HtmlToPlain(Str(block["nme"]));
|
||||
}
|
||||
|
||||
// ── View state / history ────────────────────────────────────────────────
|
||||
public object BuildState(InvoiceDraftSession session)
|
||||
{
|
||||
@@ -230,6 +289,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
private static void Refresh(InvoiceDraftSession session)
|
||||
{
|
||||
InvoiceDraftCalculator.RecomputeTotals(session);
|
||||
InvoiceDraftCalculator.RecomputePositions(session);
|
||||
InvoiceDraftCalculator.Validate(session);
|
||||
}
|
||||
|
||||
@@ -349,4 +409,31 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
}
|
||||
return new JObject();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the editor's HTML (TinyMCE-wrapped inline edits, e.g. <c><p>18.06.2026</p></c>)
|
||||
/// to plain text: line-break-producing tags become newlines, remaining tags are stripped and
|
||||
/// entities decoded. Multi-line fields (address, Leistungsort) keep their line breaks — the PDF
|
||||
/// splits those on <c>\n</c>/<c><br></c> — while single-line fields collapse to one line.
|
||||
/// Blank lines are removed so a stray <c><p></p></c> never becomes an empty row.
|
||||
/// </summary>
|
||||
internal static string HtmlToPlain(string? raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw)) return "";
|
||||
if (raw.IndexOf('<') < 0 && raw.IndexOf('&') < 0) return raw.Trim();
|
||||
|
||||
// Turn line-break / block-close tags into newlines before stripping the rest.
|
||||
string s = Regex.Replace(raw, @"<\s*br\s*/?\s*>", "\n", RegexOptions.IgnoreCase);
|
||||
s = Regex.Replace(s, @"</\s*(p|div|li|tr|h[1-6])\s*>", "\n", RegexOptions.IgnoreCase);
|
||||
|
||||
var doc = new HtmlAgilityPack.HtmlDocument();
|
||||
doc.LoadHtml(s);
|
||||
string text = System.Net.WebUtility.HtmlDecode(doc.DocumentNode.InnerText);
|
||||
|
||||
var lines = text.Replace("\r\n", "\n").Replace('\r', '\n')
|
||||
.Split('\n')
|
||||
.Select(l => l.Trim())
|
||||
.Where(l => l.Length > 0);
|
||||
return string.Join("\n", lines).Trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,10 +63,64 @@ public class InvoiceService : IInvoiceService
|
||||
|
||||
inv.InvoiceRegistration = new GenericObjectDictionary(dset.Table("inv").FirstRow.toObjectDictionary());
|
||||
inv.IsDraft = inv.InvoiceRegistration.getItem("IsFinal", false) is not true;
|
||||
_logger.LogDebug("LoadInvoiceAsync loaded id={Id} draft={Draft}", inv.Id, inv.IsDraft);
|
||||
// Reconstruct the service-request blocks + line items so the PDF renders the positions.
|
||||
// Without this the reloaded/finalized invoice showed an empty item table (only the header
|
||||
// + totals came from InvoiceRegistration), i.e. it did not match the cached preview.
|
||||
inv.Req = BuildPdfRequestBlocks(dset);
|
||||
_logger.LogDebug("LoadInvoiceAsync loaded id={Id} draft={Draft} blocks={Blocks}", inv.Id, inv.IsDraft, inv.Req?.Count ?? 0);
|
||||
return inv;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the invoice's service-request groups and line items from the persisted
|
||||
/// <c>req</c>/<c>itm</c> tables (<c>fds__getInvoice</c>) into the exact block shape the PDF
|
||||
/// consumes (<see cref="FdsInvoiceData.InvoiceBlocks"/> → item contract
|
||||
/// <c>type/title/desc/qty/price_net/total_net</c>). Item order follows the persisted
|
||||
/// <c>SortOrder</c>, so a reordered draft renders in its saved order — making the finalized
|
||||
/// PDF and the re-downloaded (<c>idoc</c>) document identical to the cached preview.
|
||||
/// </summary>
|
||||
private static List<Dictionary<string, object>> BuildPdfRequestBlocks(SQLDataSet dset)
|
||||
{
|
||||
var blocks = new List<Dictionary<string, object>>();
|
||||
if (!dset.Contains("req")) return blocks;
|
||||
|
||||
var reqTable = dset.Tables("req");
|
||||
string reqSort = reqTable.Columns.Contains("order") ? "order" : "";
|
||||
foreach (DataRow rq in reqTable.Select("", reqSort))
|
||||
{
|
||||
var rdic = rq.toObjectDictionary();
|
||||
var items = new List<Dictionary<string, object?>>();
|
||||
if (dset.Contains("itm"))
|
||||
{
|
||||
var itmTable = dset.Tables("itm");
|
||||
string itmSort = itmTable.Columns.Contains("order") ? "order" : "";
|
||||
foreach (DataRow it in itmTable.Select($"[InvRqId] = '{rdic.nz("Id")}'", itmSort))
|
||||
{
|
||||
var d = it.toObjectDictionary();
|
||||
// The persisted "Text" holds the item's rendered HTML (the editor's co.t); it is
|
||||
// the full title+description, so it maps to the contract's desc (title stays empty).
|
||||
items.Add(new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = d.nz("mfr__item"),
|
||||
["type"] = d.nz("Type"),
|
||||
["title"] = "",
|
||||
["desc"] = d.nz("Text"),
|
||||
["qty"] = d.nz("Quantity"),
|
||||
["price_net"] = d.no("value", 0),
|
||||
["total_net"] = d.no("value_total", 0)
|
||||
});
|
||||
}
|
||||
}
|
||||
blocks.Add(new Dictionary<string, object>
|
||||
{
|
||||
["Id"] = rdic.nz("mfr__servicerequest"),
|
||||
["text"] = System.Net.WebUtility.HtmlDecode(rdic.nz("title")),
|
||||
["items"] = items
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
|
||||
string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
@@ -212,7 +266,7 @@ public class InvoiceService : IInvoiceService
|
||||
var reg = invoice.InvoiceRegistration;
|
||||
var tb = new FuchsPdf.FdsTextBlocks
|
||||
{
|
||||
AdminRef = reg?.getString("Id") ?? "",
|
||||
AdminRef = (reg?.getString("InvoiceId") ?? "").ne(reg?.getString("Id") ?? ""),
|
||||
Address = reg?.getString("SendToAddress") is { Length: > 0 } sa
|
||||
? sa.Replace("<br>", "\n").Replace("<br/>", "\n").Split('\n').Select(t => t.Trim()).ToArray()
|
||||
: Array.Empty<string>(),
|
||||
|
||||
@@ -19,7 +19,7 @@ public class FdsInvoiceData
|
||||
public GenericObjectDictionary? Admin { get; private set; }
|
||||
public GenericObjectDictionary? NewValues { get; private set; }
|
||||
public GenericObjectDictionary? Sms { get; private set; }
|
||||
public List<Dictionary<string, object>>? Req { get; private set; }
|
||||
public List<Dictionary<string, object>>? Req { get; internal set; }
|
||||
|
||||
public GenericObjectDictionary? InvoiceRegistration { get; internal set; }
|
||||
public bool IsDraft { get; internal set; } = true;
|
||||
@@ -46,9 +46,25 @@ public class FdsInvoiceData
|
||||
get
|
||||
{
|
||||
var result = new List<Dictionary<string, object?>>();
|
||||
foreach (var block in InvoiceBlocks) result.AddRange(block.Items);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The service-request groups as they should render on the invoice: each block carries its
|
||||
/// heading (the section title the editor shows) and its line items. The PDF renders a heading
|
||||
/// row per block followed by that block's items, so the online editor and the PDF stay in sync.
|
||||
/// </summary>
|
||||
public List<InvoiceBlock> InvoiceBlocks
|
||||
{
|
||||
get
|
||||
{
|
||||
var result = new List<InvoiceBlock>();
|
||||
if (Req == null) return result;
|
||||
foreach (var req in Req)
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>();
|
||||
if (req.TryGetValue("items", out var itmsObj))
|
||||
{
|
||||
IEnumerable<Dictionary<string, object?>>? itms =
|
||||
@@ -56,8 +72,12 @@ public class FdsInvoiceData
|
||||
?? (itmsObj is JArray ja
|
||||
? ja.ToObject<List<Dictionary<string, object?>>>()
|
||||
: null);
|
||||
if (itms != null) result.AddRange(itms);
|
||||
if (itms != null) items.AddRange(itms);
|
||||
}
|
||||
string heading = "";
|
||||
if (req.TryGetValue("text", out var th) && th != null) heading = th.ToString() ?? "";
|
||||
if (heading.Length == 0 && req.TryGetValue("nme", out var nh) && nh != null) heading = nh.ToString() ?? "";
|
||||
result.Add(new InvoiceBlock { Heading = heading, Items = items });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -196,3 +216,12 @@ public class FdsInvoiceData
|
||||
return double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A service-request group as it renders on the invoice: a heading plus its line items.</summary>
|
||||
public sealed class InvoiceBlock
|
||||
{
|
||||
/// <summary>The section heading (editor's <c>text</c>/<c>nme</c>); empty when the group has none.</summary>
|
||||
public string Heading { get; init; } = "";
|
||||
/// <summary>The group's line items (the editor's <c>items</c> contract).</summary>
|
||||
public List<Dictionary<string, object?>> Items { get; init; } = new();
|
||||
}
|
||||
|
||||
+65
-22
@@ -137,6 +137,30 @@ public static class FuchsPdf
|
||||
public static string TranslatePaymentTerm(string pt) =>
|
||||
pt.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen");
|
||||
|
||||
/// <summary>
|
||||
/// Maps one editor item-contract entry to a display line, mirroring the online editor: the
|
||||
/// item's own price/total is shown, a set header is emphasised, and free-text/heading lines
|
||||
/// (type <c>text</c>/<c>title</c>) show neither a price nor a position number. This is the flat
|
||||
/// (non-collapsing) rendering used for every set mode except the explicit <c>setonly</c>.
|
||||
/// </summary>
|
||||
private static InvoiceSetLine MapItemToLine(Dictionary<string, object?> i)
|
||||
{
|
||||
string type = i.nz("type", "").ToLowerInvariant();
|
||||
bool isText = type is "text" or "title";
|
||||
ParseDec(i.no("price_net", 0), out decimal price);
|
||||
ParseDec(i.no("total_net", 0), out decimal total);
|
||||
return new InvoiceSetLine
|
||||
{
|
||||
Title = i.nz("title", ""),
|
||||
Desc = i.nz("desc", ""),
|
||||
Qty = i.nz("qty", ""),
|
||||
PriceNet = price,
|
||||
TotalNet = total,
|
||||
ShowPrice = !isText,
|
||||
IsSetHeader = type == "set"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a numeric value coming from JSON deserialization (long/double), SQL
|
||||
/// (decimal), or an already-invariant numeric string. Numeric CLR types are
|
||||
@@ -593,30 +617,49 @@ public static class FuchsPdf
|
||||
hRow.Cells[i].Format.Alignment = i >= 2 ? ParagraphAlignment.Right : ParagraphAlignment.Left;
|
||||
}
|
||||
|
||||
// Data rows — resolved through the set-display mode (see InvoiceSetPricing).
|
||||
// For invoices without sets this passes items through unchanged; for sets it
|
||||
// emits set header + members per the chosen mode, blanking price cells where
|
||||
// a line should show no price. Totals come from the registration balance, so
|
||||
// the mode is purely presentational.
|
||||
// Data rows — grouped by service-request block (see FdsInvoiceData.InvoiceBlocks).
|
||||
// Per the product decision the PDF must mirror the online editor exactly: each section
|
||||
// prints its heading, every position shows its own price, and positions are numbered the
|
||||
// same way the editor numbers them (every line except free-text/heading lines, including a
|
||||
// set header). Set-display collapsing is honoured only for the explicit SetOnly mode; every
|
||||
// other mode renders the items flat, so nothing is silently blanked or renumbered.
|
||||
var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions"));
|
||||
int pos = 1;
|
||||
foreach (var line in InvoiceSetPricing.Build(inv.InvoiceItems, setMode))
|
||||
int pos = 0;
|
||||
foreach (var block in inv.InvoiceBlocks)
|
||||
{
|
||||
var row = tbl.AddRow();
|
||||
row.HeightRule = RowHeightRule.Auto;
|
||||
row.Cells[0].AddParagraph(pos.ToString()).Style = "TblCell_Base";
|
||||
var titleCell = row.Cells[1].AddParagraph();
|
||||
titleCell.Style = "TblCell_RTitle";
|
||||
if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold);
|
||||
else titleCell.AddText(line.Title);
|
||||
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>");
|
||||
row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base";
|
||||
row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base";
|
||||
row.Cells[4].AddParagraph(line.ShowPrice ? Currency(line.TotalNet) : "").Style = "TblCell_RSum";
|
||||
row.Cells[2].Format.Alignment = ParagraphAlignment.Right;
|
||||
row.Cells[3].Format.Alignment = ParagraphAlignment.Right;
|
||||
row.Cells[4].Format.Alignment = ParagraphAlignment.Right;
|
||||
pos++;
|
||||
if (!string.IsNullOrWhiteSpace(block.Heading))
|
||||
{
|
||||
var hr = tbl.AddRow();
|
||||
hr.HeightRule = RowHeightRule.Auto;
|
||||
hr.Cells[1].MergeRight = 3; // span Bezeichnung … Gesamtpreis
|
||||
hr.Cells[1].AddParagraph().WithStyle("TblCell_RTitle").AddFormattedText(block.Heading, TextFormat.Bold);
|
||||
}
|
||||
|
||||
var lines = setMode == SetDisplayMode.SetOnly && InvoiceSetPricing.ContainsSets(block.Items)
|
||||
? InvoiceSetPricing.Build(block.Items, SetDisplayMode.SetOnly) // only this mode collapses members
|
||||
: block.Items.Select(MapItemToLine).ToList(); // flat: faithful mirror of the editor
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
bool numbered = line.IsSetHeader || line.ShowPrice; // free-text/heading lines carry no number
|
||||
var row = tbl.AddRow();
|
||||
row.HeightRule = RowHeightRule.Auto;
|
||||
row.Cells[0].AddParagraph(numbered ? (++pos).ToString() : "").Style = "TblCell_Base";
|
||||
if (!string.IsNullOrEmpty(line.Title)) // skip the empty paragraph that added a blank line before free text
|
||||
{
|
||||
var titleCell = row.Cells[1].AddParagraph();
|
||||
titleCell.Style = "TblCell_RTitle";
|
||||
if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold);
|
||||
else titleCell.AddText(line.Title);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>");
|
||||
row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base";
|
||||
row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base";
|
||||
row.Cells[4].AddParagraph(line.ShowPrice ? Currency(line.TotalNet) : "").Style = "TblCell_RSum";
|
||||
row.Cells[2].Format.Alignment = ParagraphAlignment.Right;
|
||||
row.Cells[3].Format.Alignment = ParagraphAlignment.Right;
|
||||
row.Cells[4].Format.Alignment = ParagraphAlignment.Right;
|
||||
}
|
||||
}
|
||||
|
||||
// Totals
|
||||
|
||||
@@ -68,6 +68,31 @@ public static class InvoiceDraftCalculator
|
||||
session.Sums = sums;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renumbers the visible line positions authoritatively (the port of the client-side
|
||||
/// numbering in <c>invSumUpdate</c>): priced lines are numbered sequentially across the whole
|
||||
/// invoice — matching the PDF's <c>Pos.</c> column — while heading/free-text lines
|
||||
/// (<c>typ</c> = "text"/"title") carry no number. The result is written onto each line's
|
||||
/// <c>p</c> field so it flows back to the browser (via the view state) and into the PDF; this
|
||||
/// keeps the online editor and the PDF preview showing the same position numbers, including
|
||||
/// after a reorder.
|
||||
/// </summary>
|
||||
public static void RecomputePositions(InvoiceDraftSession session)
|
||||
{
|
||||
int pos = 0;
|
||||
foreach (var blockTok in session.Req)
|
||||
{
|
||||
if (blockTok is not JObject block || block["itm"] is not JArray lines) continue;
|
||||
foreach (var lineTok in lines)
|
||||
{
|
||||
if (lineTok is not JObject co) continue;
|
||||
string typ = Str(co["typ"]).Trim().ToLowerInvariant();
|
||||
bool numbered = typ is not ("text" or "title"); // only headings/free-text carry no number (mirrors invSumUpdate)
|
||||
co["p"] = numbered ? (JToken)(++pos) : (JToken)"";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the draft's plausibility / consistency findings. "error" severity marks
|
||||
/// issues that should block a clean finalise; "warning" is advisory. Kept in German,
|
||||
|
||||
@@ -130,7 +130,7 @@ $inv.d = {
|
||||
let l = $inv.d.layout(); l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
|
||||
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes());
|
||||
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes()).data('dorder', $inv.d.order());
|
||||
$fis.draft.bind(r.token, {
|
||||
onReady: () => $inv.d.refresh(),
|
||||
onExpiring: (s) => $inv.d.warnExpiry(s),
|
||||
@@ -155,6 +155,17 @@ $inv.d = {
|
||||
tbl.data('dver', state.version).data('serverSums', state.sums);
|
||||
$inv.d.footer(tbl, state.sums || {}, state.admin || {});
|
||||
$inv.d.validation(state.validation || []);
|
||||
$inv.d.applyPositions(tbl, state.req || []);
|
||||
},
|
||||
/* Push the server's authoritative position numbers back onto the rendered rows so the online
|
||||
editor and the PDF preview always agree (the server numbers priced lines continuously; the
|
||||
browser must not keep its own numbering). Only the position cell is touched — no re-render. */
|
||||
applyPositions: function (tbl, req) {
|
||||
(req || []).forEach((b) => (b && b.itm || []).forEach((co) => {
|
||||
if (!co || (co.id || '') === '') { return; }
|
||||
let cell = tbl.find('#itm' + co.id + ' td.keep').first();
|
||||
if (cell.length) { cell.text(co.p != null ? co.p : ''); }
|
||||
}));
|
||||
},
|
||||
/* Send one change to the server; the draftReady signal and this success both refresh. */
|
||||
sync: function (delta) {
|
||||
@@ -166,16 +177,23 @@ $inv.d = {
|
||||
error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } }
|
||||
});
|
||||
},
|
||||
/* The current section id sequence (used to detect a reorder that changes no block content). */
|
||||
order: function () { return (($inv.d.tbl().data('bai')) || []).map((b) => (b.Id || '').toString()); },
|
||||
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the
|
||||
changed/removed blocks as granular block.replace / block.remove deltas. */
|
||||
changed/removed blocks as granular block.replace / block.remove deltas. A pure section
|
||||
reorder (same blocks, new sequence) changes no block hash, so it is sent separately as a
|
||||
block.order delta; the server reorders the cache, renumbers positions and pushes them back. */
|
||||
syncChanged: function (tbl) {
|
||||
if (($inv.d.token()) === '') { return; }
|
||||
let bai = tbl.data('bai') || [], prev = tbl.data('dhashes') || {}, next = {}, changed = [], removed = [];
|
||||
$.each(bai, (i, b) => { let id = (b.Id || '').toString(), h = JSON.stringify(b); next[id] = h; if (prev[id] !== h) { changed.push(b); } });
|
||||
$.each(prev, (id) => { if (next[id] === undefined) { removed.push(id); } });
|
||||
tbl.data('dhashes', next);
|
||||
let order = $inv.d.order(), prevOrder = tbl.data('dorder') || [];
|
||||
tbl.data('dhashes', next).data('dorder', order);
|
||||
changed.forEach((b) => $inv.d.sync({ Target: 'block.replace', Ref: (b.Id || '').toString(), Value: b }));
|
||||
removed.forEach((id) => $inv.d.sync({ Target: 'block.remove', Ref: id }));
|
||||
let sameSet = prevOrder.length === order.length && prevOrder.slice().sort().join(',') === order.slice().sort().join(',');
|
||||
if (sameSet && prevOrder.join(',') !== order.join(',')) { $inv.d.sync({ Target: 'block.order', Value: order }); }
|
||||
},
|
||||
/* Map an inline recipient field to its delta target and send it. */
|
||||
syncField: function (nme, val) {
|
||||
@@ -672,9 +690,12 @@ $inv.cSt = function (data) {
|
||||
};
|
||||
$inv.eHtml = function (ev) {
|
||||
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
|
||||
/* invoiceemail must stay plain text — using the TinyMCE/html editor here used to wrap the
|
||||
address in <p> tags, which then got posted and persisted verbatim into SendToEmail. */
|
||||
let isPlainText = ev.data.nme === 'invoiceemail';
|
||||
/* Single-line fields must stay plain text — the TinyMCE/html editor wraps the value in <p>
|
||||
tags, which used to get posted and persisted verbatim (e.g. <p>18.06.2026</p> in the
|
||||
Leistungsdatum). The backend sanitises HTML too (single source of truth, ADR 0006), but
|
||||
keeping these plain here avoids the UI briefly holding the wrapped value. Multi-line fields
|
||||
(invoiceaddress, loc) stay HTML-capable and are normalised to newlines server-side. */
|
||||
let isPlainText = ['invoiceemail', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
|
||||
let flds = isPlainText
|
||||
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
|
||||
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
|
||||
@@ -777,7 +798,11 @@ $inv.eRw = function(row, dta, flds) {
|
||||
}, typedvalues: true
|
||||
});
|
||||
};
|
||||
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', swapdone: (p1, p2, i1, i2) => { $inv.t_fds_inv(); } }) }
|
||||
/* Reorder items via drag. The DOM swap happens inside the Sortable during the drag; we commit
|
||||
the new order once, reliably, on drop (onend) — that recomputes positions/totals and pushes the
|
||||
changed block(s) to the backend session (t_fds_inv -> syncChanged). Committing on drop (rather
|
||||
than on every mid-drag hover-swap) avoids rebuilding the row that is currently being dragged. */
|
||||
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', onend: () => { $inv.t_fds_inv(); } }) }
|
||||
$inv.rrw = function () {
|
||||
let rw = $(this), dta = rw.data(), co = {}, ph = rw.is('.placeholder'), hn = rw.is('.hidenote');
|
||||
let oHtml = (e) => $$.d().append(e).html();
|
||||
|
||||
@@ -63,7 +63,8 @@ if (!Element.prototype.closest) {
|
||||
this._dragging = false;
|
||||
this._dragHandleClass = this._options.dragHandleClass || '';
|
||||
this._parentident = this._options.parentident || '';
|
||||
this._swapdone = typeof this._options.swapdone === "function" ? this._options._swapdone : null;
|
||||
this._swapdone = typeof this._options.swapdone === "function" ? this._options.swapdone : null;
|
||||
this._onend = typeof this._options.onend === "function" ? this._options.onend : null;
|
||||
|
||||
this._container.setAttribute("data-is-sortable", 1);
|
||||
this._container.classList.add("sortable");
|
||||
@@ -213,10 +214,17 @@ if (!Element.prototype.closest) {
|
||||
}
|
||||
},
|
||||
|
||||
// on item release/drop
|
||||
// on item release/drop
|
||||
_onRelease: function (e) {
|
||||
// Was THIS list mid-drag? (mouseup fires on every instance's window listener.)
|
||||
var wasDragging = this._dragging === true && this._clickItem !== null;
|
||||
this._dragging = false;
|
||||
this._trashDragItem();
|
||||
// Fire a single "drag finished" callback so callers can commit the new order once,
|
||||
// reliably, on drop — rather than relying on the per-hover _swapdone during the drag.
|
||||
if (wasDragging && typeof this._onend === 'function') {
|
||||
this._onend();
|
||||
}
|
||||
},
|
||||
|
||||
// on item drag/move
|
||||
|
||||
@@ -677,7 +677,7 @@ $inv.d = {
|
||||
let l = $inv.d.layout(); l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
|
||||
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes());
|
||||
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes()).data('dorder', $inv.d.order());
|
||||
$fis.draft.bind(r.token, {
|
||||
onReady: () => $inv.d.refresh(),
|
||||
onExpiring: (s) => $inv.d.warnExpiry(s),
|
||||
@@ -702,6 +702,17 @@ $inv.d = {
|
||||
tbl.data('dver', state.version).data('serverSums', state.sums);
|
||||
$inv.d.footer(tbl, state.sums || {}, state.admin || {});
|
||||
$inv.d.validation(state.validation || []);
|
||||
$inv.d.applyPositions(tbl, state.req || []);
|
||||
},
|
||||
/* Push the server's authoritative position numbers back onto the rendered rows so the online
|
||||
editor and the PDF preview always agree (the server numbers priced lines continuously; the
|
||||
browser must not keep its own numbering). Only the position cell is touched — no re-render. */
|
||||
applyPositions: function (tbl, req) {
|
||||
(req || []).forEach((b) => (b && b.itm || []).forEach((co) => {
|
||||
if (!co || (co.id || '') === '') { return; }
|
||||
let cell = tbl.find('#itm' + co.id + ' td.keep').first();
|
||||
if (cell.length) { cell.text(co.p != null ? co.p : ''); }
|
||||
}));
|
||||
},
|
||||
/* Send one change to the server; the draftReady signal and this success both refresh. */
|
||||
sync: function (delta) {
|
||||
@@ -713,16 +724,23 @@ $inv.d = {
|
||||
error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } }
|
||||
});
|
||||
},
|
||||
/* The current section id sequence (used to detect a reorder that changes no block content). */
|
||||
order: function () { return (($inv.d.tbl().data('bai')) || []).map((b) => (b.Id || '').toString()); },
|
||||
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the
|
||||
changed/removed blocks as granular block.replace / block.remove deltas. */
|
||||
changed/removed blocks as granular block.replace / block.remove deltas. A pure section
|
||||
reorder (same blocks, new sequence) changes no block hash, so it is sent separately as a
|
||||
block.order delta; the server reorders the cache, renumbers positions and pushes them back. */
|
||||
syncChanged: function (tbl) {
|
||||
if (($inv.d.token()) === '') { return; }
|
||||
let bai = tbl.data('bai') || [], prev = tbl.data('dhashes') || {}, next = {}, changed = [], removed = [];
|
||||
$.each(bai, (i, b) => { let id = (b.Id || '').toString(), h = JSON.stringify(b); next[id] = h; if (prev[id] !== h) { changed.push(b); } });
|
||||
$.each(prev, (id) => { if (next[id] === undefined) { removed.push(id); } });
|
||||
tbl.data('dhashes', next);
|
||||
let order = $inv.d.order(), prevOrder = tbl.data('dorder') || [];
|
||||
tbl.data('dhashes', next).data('dorder', order);
|
||||
changed.forEach((b) => $inv.d.sync({ Target: 'block.replace', Ref: (b.Id || '').toString(), Value: b }));
|
||||
removed.forEach((id) => $inv.d.sync({ Target: 'block.remove', Ref: id }));
|
||||
let sameSet = prevOrder.length === order.length && prevOrder.slice().sort().join(',') === order.slice().sort().join(',');
|
||||
if (sameSet && prevOrder.join(',') !== order.join(',')) { $inv.d.sync({ Target: 'block.order', Value: order }); }
|
||||
},
|
||||
/* Map an inline recipient field to its delta target and send it. */
|
||||
syncField: function (nme, val) {
|
||||
@@ -1219,9 +1237,12 @@ $inv.cSt = function (data) {
|
||||
};
|
||||
$inv.eHtml = function (ev) {
|
||||
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
|
||||
/* invoiceemail must stay plain text — using the TinyMCE/html editor here used to wrap the
|
||||
address in <p> tags, which then got posted and persisted verbatim into SendToEmail. */
|
||||
let isPlainText = ev.data.nme === 'invoiceemail';
|
||||
/* Single-line fields must stay plain text — the TinyMCE/html editor wraps the value in <p>
|
||||
tags, which used to get posted and persisted verbatim (e.g. <p>18.06.2026</p> in the
|
||||
Leistungsdatum). The backend sanitises HTML too (single source of truth, ADR 0006), but
|
||||
keeping these plain here avoids the UI briefly holding the wrapped value. Multi-line fields
|
||||
(invoiceaddress, loc) stay HTML-capable and are normalised to newlines server-side. */
|
||||
let isPlainText = ['invoiceemail', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
|
||||
let flds = isPlainText
|
||||
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
|
||||
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
|
||||
@@ -1324,7 +1345,11 @@ $inv.eRw = function(row, dta, flds) {
|
||||
}, typedvalues: true
|
||||
});
|
||||
};
|
||||
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', swapdone: (p1, p2, i1, i2) => { $inv.t_fds_inv(); } }) }
|
||||
/* Reorder items via drag. The DOM swap happens inside the Sortable during the drag; we commit
|
||||
the new order once, reliably, on drop (onend) — that recomputes positions/totals and pushes the
|
||||
changed block(s) to the backend session (t_fds_inv -> syncChanged). Committing on drop (rather
|
||||
than on every mid-drag hover-swap) avoids rebuilding the row that is currently being dragged. */
|
||||
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', onend: () => { $inv.t_fds_inv(); } }) }
|
||||
$inv.rrw = function () {
|
||||
let rw = $(this), dta = rw.data(), co = {}, ph = rw.is('.placeholder'), hn = rw.is('.hidenote');
|
||||
let oHtml = (e) => $$.d().append(e).html();
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -2533,7 +2533,8 @@ if (!Element.prototype.closest) {
|
||||
this._dragging = false;
|
||||
this._dragHandleClass = this._options.dragHandleClass || '';
|
||||
this._parentident = this._options.parentident || '';
|
||||
this._swapdone = typeof this._options.swapdone === "function" ? this._options._swapdone : null;
|
||||
this._swapdone = typeof this._options.swapdone === "function" ? this._options.swapdone : null;
|
||||
this._onend = typeof this._options.onend === "function" ? this._options.onend : null;
|
||||
|
||||
this._container.setAttribute("data-is-sortable", 1);
|
||||
this._container.classList.add("sortable");
|
||||
@@ -2683,10 +2684,17 @@ if (!Element.prototype.closest) {
|
||||
}
|
||||
},
|
||||
|
||||
// on item release/drop
|
||||
// on item release/drop
|
||||
_onRelease: function (e) {
|
||||
// Was THIS list mid-drag? (mouseup fires on every instance's window listener.)
|
||||
var wasDragging = this._dragging === true && this._clickItem !== null;
|
||||
this._dragging = false;
|
||||
this._trashDragItem();
|
||||
// Fire a single "drag finished" callback so callers can commit the new order once,
|
||||
// reliably, on drop — rather than relying on the per-hover _swapdone during the drag.
|
||||
if (wasDragging && typeof this._onend === 'function') {
|
||||
this._onend();
|
||||
}
|
||||
},
|
||||
|
||||
// on item drag/move
|
||||
|
||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -658,7 +658,7 @@ $inv.d = {
|
||||
let l = $inv.d.layout(); l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
|
||||
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes());
|
||||
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes()).data('dorder', $inv.d.order());
|
||||
$fis.draft.bind(r.token, {
|
||||
onReady: () => $inv.d.refresh(),
|
||||
onExpiring: (s) => $inv.d.warnExpiry(s),
|
||||
@@ -683,6 +683,17 @@ $inv.d = {
|
||||
tbl.data('dver', state.version).data('serverSums', state.sums);
|
||||
$inv.d.footer(tbl, state.sums || {}, state.admin || {});
|
||||
$inv.d.validation(state.validation || []);
|
||||
$inv.d.applyPositions(tbl, state.req || []);
|
||||
},
|
||||
/* Push the server's authoritative position numbers back onto the rendered rows so the online
|
||||
editor and the PDF preview always agree (the server numbers priced lines continuously; the
|
||||
browser must not keep its own numbering). Only the position cell is touched — no re-render. */
|
||||
applyPositions: function (tbl, req) {
|
||||
(req || []).forEach((b) => (b && b.itm || []).forEach((co) => {
|
||||
if (!co || (co.id || '') === '') { return; }
|
||||
let cell = tbl.find('#itm' + co.id + ' td.keep').first();
|
||||
if (cell.length) { cell.text(co.p != null ? co.p : ''); }
|
||||
}));
|
||||
},
|
||||
/* Send one change to the server; the draftReady signal and this success both refresh. */
|
||||
sync: function (delta) {
|
||||
@@ -694,16 +705,23 @@ $inv.d = {
|
||||
error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } }
|
||||
});
|
||||
},
|
||||
/* The current section id sequence (used to detect a reorder that changes no block content). */
|
||||
order: function () { return (($inv.d.tbl().data('bai')) || []).map((b) => (b.Id || '').toString()); },
|
||||
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the
|
||||
changed/removed blocks as granular block.replace / block.remove deltas. */
|
||||
changed/removed blocks as granular block.replace / block.remove deltas. A pure section
|
||||
reorder (same blocks, new sequence) changes no block hash, so it is sent separately as a
|
||||
block.order delta; the server reorders the cache, renumbers positions and pushes them back. */
|
||||
syncChanged: function (tbl) {
|
||||
if (($inv.d.token()) === '') { return; }
|
||||
let bai = tbl.data('bai') || [], prev = tbl.data('dhashes') || {}, next = {}, changed = [], removed = [];
|
||||
$.each(bai, (i, b) => { let id = (b.Id || '').toString(), h = JSON.stringify(b); next[id] = h; if (prev[id] !== h) { changed.push(b); } });
|
||||
$.each(prev, (id) => { if (next[id] === undefined) { removed.push(id); } });
|
||||
tbl.data('dhashes', next);
|
||||
let order = $inv.d.order(), prevOrder = tbl.data('dorder') || [];
|
||||
tbl.data('dhashes', next).data('dorder', order);
|
||||
changed.forEach((b) => $inv.d.sync({ Target: 'block.replace', Ref: (b.Id || '').toString(), Value: b }));
|
||||
removed.forEach((id) => $inv.d.sync({ Target: 'block.remove', Ref: id }));
|
||||
let sameSet = prevOrder.length === order.length && prevOrder.slice().sort().join(',') === order.slice().sort().join(',');
|
||||
if (sameSet && prevOrder.join(',') !== order.join(',')) { $inv.d.sync({ Target: 'block.order', Value: order }); }
|
||||
},
|
||||
/* Map an inline recipient field to its delta target and send it. */
|
||||
syncField: function (nme, val) {
|
||||
@@ -1200,9 +1218,12 @@ $inv.cSt = function (data) {
|
||||
};
|
||||
$inv.eHtml = function (ev) {
|
||||
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
|
||||
/* invoiceemail must stay plain text — using the TinyMCE/html editor here used to wrap the
|
||||
address in <p> tags, which then got posted and persisted verbatim into SendToEmail. */
|
||||
let isPlainText = ev.data.nme === 'invoiceemail';
|
||||
/* Single-line fields must stay plain text — the TinyMCE/html editor wraps the value in <p>
|
||||
tags, which used to get posted and persisted verbatim (e.g. <p>18.06.2026</p> in the
|
||||
Leistungsdatum). The backend sanitises HTML too (single source of truth, ADR 0006), but
|
||||
keeping these plain here avoids the UI briefly holding the wrapped value. Multi-line fields
|
||||
(invoiceaddress, loc) stay HTML-capable and are normalised to newlines server-side. */
|
||||
let isPlainText = ['invoiceemail', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
|
||||
let flds = isPlainText
|
||||
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
|
||||
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
|
||||
@@ -1305,7 +1326,11 @@ $inv.eRw = function(row, dta, flds) {
|
||||
}, typedvalues: true
|
||||
});
|
||||
};
|
||||
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', swapdone: (p1, p2, i1, i2) => { $inv.t_fds_inv(); } }) }
|
||||
/* Reorder items via drag. The DOM swap happens inside the Sortable during the drag; we commit
|
||||
the new order once, reliably, on drop (onend) — that recomputes positions/totals and pushes the
|
||||
changed block(s) to the backend session (t_fds_inv -> syncChanged). Committing on drop (rather
|
||||
than on every mid-drag hover-swap) avoids rebuilding the row that is currently being dragged. */
|
||||
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', onend: () => { $inv.t_fds_inv(); } }) }
|
||||
$inv.rrw = function () {
|
||||
let rw = $(this), dta = rw.data(), co = {}, ph = rw.is('.placeholder'), hn = rw.is('.hidenote');
|
||||
let oHtml = (e) => $$.d().append(e).html();
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user