Compare commits

..
5 Commits
Author SHA1 Message Date
Stefan 22d2ef9c1a Refactor code structure for improved readability and maintainability
Playwright Tests / test (push) Canceled after 0s
2026-09-10 21:17:35 +02:00
StefanandClaude Sonnet 5 f407341f5f Parallelize invoice image fetch, add telemetry, generalize HtmlConverter
- InvoiceService.ResolveInlineImagesAsync now fetches an invoice's
  distinct whitelisted images concurrently (SemaphoreSlim-capped at 4)
  instead of one at a time. It ran inline on every invoice render,
  including repeated draft-preview renders, so N sequential mfr-portal
  round-trips could otherwise add N x latency to a user-facing request.
- Add fuchs.invoice.inlineimages.fetches (counter, tagged by outcome:
  success/http_error/bad_content_type/oversized/exception) and
  fuchs.invoice.inlineimages.resolve.duration (histogram) to
  FuchsTelemetry, matching the project's observability convention for
  outbound-HTTP operations.
- MigraDocExtensions.HtmlConverter's pre-resolved-image parameter is
  renamed inlineImages -> resolvedImages with generic docs: the
  converter fetches nothing and knows nothing about invoices — which
  images to resolve, and any whitelist/network policy, is entirely the
  caller's concern (InvoiceService, ADR 0018 today; any future caller
  tomorrow).
- Add tests asserting fetches actually overlap (not just faster) and
  that the new counter/histogram are recorded.
- Update ADR 0018 to describe the concurrent fetch, telemetry, and the
  generalized converter parameter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 21:17:09 +02:00
StefanandClaude Sonnet 5 406c6f2860 Fix invoice-image code-review findings: aspect ratio, whitelist config, stale refs
- MigraDocExtensions: fix a bug where capping an oversized explicit
  width+height image re-derived the other dimension from the image's
  native pixel ratio instead of scaling both sides uniformly, silently
  distorting a deliberately non-native-ratio (e.g. stretched) image.
- InvoiceService: derive the mfr-portal image whitelist prefix from the
  existing Fds:MFR_host config setting (via MFRClientConfig.RestRoot)
  instead of a second hardcoded literal, so it can't drift from the
  actual configured host; parse <img> sources with HtmlAgilityPack
  (already used by the PDF's own HTML converter) instead of a regex, so
  a decoy attribute like data-src can't be picked up in place of the
  real src; dedup fetch URLs with a HashSet instead of O(n^2) List
  scanning; reuse FuchsPdf.MigraDocFilenameFromByteArray instead of
  re-spelling the "base64:" prefix.
- Fix a stale log message and comment left over from the
  GenerateInvoicePdf -> GenerateInvoicePdfAsync rename, and update
  INVOICE_LIFECYCLE.md's references to the old sync method name.
- Add tests for the config-derived whitelist, a decoy-attribute src,
  and the 20-image-per-invoice fetch cap boundary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 21:03:09 +02:00
StefanandClaude Sonnet 5 c182d4fd69 Add row context menus, fix glyphicon copy corruption, combine CC email editing
- Fuchs/gulpfile.js: pass encoding:false to gulp.src() in the copy task —
  vinyl-fs otherwise UTF-8-decodes binary files by default, which was
  silently corrupting the glyphicons webfont (and any other binary asset
  copyconfig.json matches) on every `gulp copy`/`gulp all` run.
- Invoice/reminder editor: replace the row's flat action icons (edit/
  delete/insert/set-price) with a single context button opening a
  dropdown menu, reusing the existing $ocms.menu toolbar mechanism.
- Fix "-1" leaking into the Pos. column for Text/Title rows: $inv.rrw's
  `dta.position || dta.SortOrder` fallback treated the numbering's
  intentional empty string as unset and fell back to SortOrder (-1 for
  such rows); now only a genuinely uncomputed position falls back.
- Zusatzempfänger (CC) is now edited together with the main invoice
  email in one combined dialog ($inv.emailFrm/$inv.eEmail), wired into
  all three entry points (create, continue, storno) instead of a
  separate hover-revealed row with its own edit target.
- Add tooltips to every row/header edit control, including several that
  had none before (header field pencils, the request/section title
  edit button), phrased as object+verb ($rct.frmTT).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:38:03 +02:00
StefanandClaude Sonnet 5 071982caec Render invoice-editor images (mfr portal only) into the PDF
The invoice line editor already shows <img> pictures pulled from the mfr
portal, but the PDF pipeline's HTML-to-MigraDoc converter had no <img>
handler, so they never appeared on the actual invoice.

InvoiceService now fetches only https://portal.mobilefieldreport.com/mfr/*
image sources before rendering (size/type/count capped, failures logged and
skipped) and hands the resolved images down through ApplyInvoice into the
HTML converter, which renders them inline at their original HTML position
and size (px converted to cm, capped against layout overflow). See ADR 0018.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:36:31 +02:00
37 changed files with 1290 additions and 179 deletions
+5 -5
View File
@@ -34,7 +34,7 @@ public class InvoiceDraftServiceTests
public FdsInvoiceData? PreviewInvoice;
public bool? PreviewDraft;
public Task<FdsInvoiceData> LoadInvoiceAsync(string id, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
public Document GenerateInvoicePdf(FdsInvoiceData i, bool d) { PreviewInvoice = i; PreviewDraft = d; return new Document(); }
public Task<Document> GenerateInvoicePdfAsync(FdsInvoiceData i, bool d) { PreviewInvoice = i; PreviewDraft = d; return Task.FromResult(new Document()); }
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData i, bool d) => throw new System.NotSupportedException();
public Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData i, bool d, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
public Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData i, bool d, fds.IFdsMfr m) => throw new System.NotSupportedException();
@@ -592,12 +592,12 @@ public class InvoiceDraftServiceTests
}
[Fact]
public void RenderPreview_SynthesizesDraftRegistrationFromSession()
public async Task RenderPreview_SynthesizesDraftRegistrationFromSession()
{
var (svc, inv, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var doc = svc.RenderPreview(s.Token);
var doc = await svc.RenderPreviewAsync(s.Token);
Assert.NotNull(doc);
Assert.True(inv.PreviewDraft); // always rendered as a draft
@@ -610,10 +610,10 @@ public class InvoiceDraftServiceTests
}
[Fact]
public void RenderPreview_UnknownToken_ReturnsNull()
public async Task RenderPreview_UnknownToken_ReturnsNull()
{
var (svc, _, _) = NewService();
Assert.Null(svc.RenderPreview("ghost"));
Assert.Null(await svc.RenderPreviewAsync("ghost"));
}
[Fact]
+303
View File
@@ -0,0 +1,303 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exercises <see cref="InvoiceService.ResolveInlineImagesAsync"/> — the whitelist-restricted image
/// fetch that lets invoice-editor picture links (mfr portal only) appear in the rendered PDF
/// (ADR 0018). Covers the whitelist boundary (derived from the <c>Fds:MFR_host</c> config entry,
/// not a second hardcoded host), fetch failure modes (each must just drop that one image, never
/// throw), and the per-invoice fetch cap.
/// </summary>
public class InvoiceInlineImageTests
{
private sealed class StubHandler : HttpMessageHandler
{
public readonly List<string> RequestedUrls = new();
private readonly Func<HttpRequestMessage, HttpResponseMessage> _respond;
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) => _respond = respond;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
RequestedUrls.Add(request.RequestUri!.ToString());
return Task.FromResult(_respond(request));
}
}
/// <summary>Delays each response and records how many requests were in flight at once, so a test
/// can assert fetches actually overlapped instead of inferring it from timing alone.</summary>
private sealed class ConcurrencyTrackingHandler : HttpMessageHandler
{
private int _inFlight;
public int MaxObservedConcurrent;
private readonly TimeSpan _delay;
private readonly Func<HttpRequestMessage, HttpResponseMessage> _respond;
public ConcurrencyTrackingHandler(TimeSpan delay, Func<HttpRequestMessage, HttpResponseMessage> respond)
{
_delay = delay;
_respond = respond;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
int now = Interlocked.Increment(ref _inFlight);
InterlockedMax(ref MaxObservedConcurrent, now);
try { await Task.Delay(_delay, ct); return _respond(request); }
finally { Interlocked.Decrement(ref _inFlight); }
}
private static void InterlockedMax(ref int target, int value)
{
int initial;
do { initial = target; if (value <= initial) return; }
while (Interlocked.CompareExchange(ref target, value, initial) != initial);
}
}
private sealed class StubFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public StubFactory(HttpMessageHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
private static IConfiguration TestConfig(string mfrHost = "portal.mobilefieldreport.com") =>
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Fds:MFR_host"] = mfrHost
}).Build();
private static InvoiceService MakeService(HttpMessageHandler handler, string mfrHost = "portal.mobilefieldreport.com") => new(
new Fuchs_intranet(new ConfigurationBuilder().Build()),
Mock.Of<IPdfService>(),
Mock.Of<IBlobStorageService>(),
Mock.Of<IEventService>(),
Mock.Of<IERechnungService>(),
new StubFactory(handler),
TestConfig(mfrHost),
NullLogger<InvoiceService>.Instance);
private static FdsInvoiceData InvoiceWithDesc(params string[] descs)
{
var items = new List<Dictionary<string, object>>();
foreach (var d in descs) items.Add(new Dictionary<string, object> { ["type"] = "material", ["desc"] = d });
return new FdsInvoiceData
{
Req = new List<Dictionary<string, object>> { new() { ["text"] = "Block", ["items"] = items } }
};
}
private static HttpResponseMessage ImageResponse(byte[] bytes) =>
new(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) { Headers = { ContentType = new("image/png") } } };
[Fact]
public void AllowedInlineImagePrefix_DerivedFromConfiguredMfrHost_NotHardcodedSecondTime()
{
var svc = MakeService(new StubHandler(_ => ImageResponse(Array.Empty<byte>())),
mfrHost: "other-mfr-host.example.com");
Assert.Equal("https://other-mfr-host.example.com/mfr/", svc.AllowedInlineImagePrefix);
}
[Fact]
public async Task ResolveInlineImagesAsync_WhitelistedUrl_ReturnsBase64DataForOriginalSrc()
{
byte[] bytes = { 1, 2, 3, 4 };
var handler = new StubHandler(_ => ImageResponse(bytes));
var svc = MakeService(handler);
string url = svc.AllowedInlineImagePrefix + "pic1.png";
var invoice = InvoiceWithDesc($"<p>Before</p><img src=\"{url}\"><p>After</p>");
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.True(result.ContainsKey(url));
Assert.Equal("base64:" + Convert.ToBase64String(bytes), result[url]);
Assert.Single(handler.RequestedUrls);
}
[Theory]
[InlineData("https://evil.example.com/mfr/pic1.png")]
[InlineData("https://portal.mobilefieldreport.com.evil.com/mfr/pic1.png")]
[InlineData("http://portal.mobilefieldreport.com/mfr/pic1.png")]
[InlineData("https://portal.mobilefieldreport.com/other/pic1.png")]
public async Task ResolveInlineImagesAsync_NonWhitelistedSrc_NeverFetched(string url)
{
var handler = new StubHandler(_ => ImageResponse(new byte[] { 1 }));
var invoice = InvoiceWithDesc($"<img src=\"{url}\">");
var result = await MakeService(handler).ResolveInlineImagesAsync(invoice);
Assert.Empty(result);
Assert.Empty(handler.RequestedUrls);
}
[Fact]
public async Task ResolveInlineImagesAsync_DecoyAttributeNextToRealSrc_FetchesOnlyRealSrc()
{
// A description containing something like data-src alongside the real src must never let
// the decoy be picked up in place of (or in addition to) the actual src attribute.
var handler = new StubHandler(_ => ImageResponse(new byte[] { 1 }));
var svc = MakeService(handler);
string real = svc.AllowedInlineImagePrefix + "real.png";
string decoy = svc.AllowedInlineImagePrefix + "decoy.png";
var invoice = InvoiceWithDesc($"<img data-src=\"{decoy}\" src=\"{real}\">");
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.Single(result);
Assert.True(result.ContainsKey(real));
Assert.False(result.ContainsKey(decoy));
Assert.DoesNotContain(decoy, handler.RequestedUrls);
}
[Fact]
public async Task ResolveInlineImagesAsync_NoImages_SkipsHttpEntirely()
{
var handler = new StubHandler(_ => ImageResponse(new byte[] { 1 }));
var invoice = InvoiceWithDesc("<p>Just text, no picture.</p>");
var result = await MakeService(handler).ResolveInlineImagesAsync(invoice);
Assert.Empty(result);
Assert.Empty(handler.RequestedUrls);
}
[Fact]
public async Task ResolveInlineImagesAsync_HttpErrorStatus_DropsImageWithoutThrowing()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
var svc = MakeService(handler);
string url = svc.AllowedInlineImagePrefix + "missing.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">");
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.Empty(result);
}
[Fact]
public async Task ResolveInlineImagesAsync_NonImageContentType_DropsImage()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(new byte[] { 1, 2 }) { Headers = { ContentType = new("text/html") } }
});
var svc = MakeService(handler);
string url = svc.AllowedInlineImagePrefix + "notreally.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">");
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.Empty(result);
}
[Fact]
public async Task ResolveInlineImagesAsync_ThrowingHandler_DropsImageWithoutThrowing()
{
var handler = new StubHandler(_ => throw new HttpRequestException("network down"));
var svc = MakeService(handler);
string url = svc.AllowedInlineImagePrefix + "boom.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">");
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.Empty(result);
}
[Fact]
public async Task ResolveInlineImagesAsync_DuplicateSrcAcrossLines_FetchedOnce()
{
var handler = new StubHandler(_ => ImageResponse(new byte[] { 9 }));
var svc = MakeService(handler);
string url = svc.AllowedInlineImagePrefix + "shared.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">", $"<img src=\"{url}\">");
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.Single(result);
Assert.Single(handler.RequestedUrls);
}
[Fact]
public async Task ResolveInlineImagesAsync_MoreThanCapDistinctImages_StopsAtCap()
{
var handler = new StubHandler(_ => ImageResponse(new byte[] { 1 }));
var svc = MakeService(handler);
// 25 distinct URLs on one invoice; the 20-image-per-invoice cap must stop fetching at 20.
var descs = Enumerable.Range(1, 25)
.Select(i => $"<img src=\"{svc.AllowedInlineImagePrefix}img{i}.png\">")
.ToArray();
var invoice = InvoiceWithDesc(descs);
var result = await svc.ResolveInlineImagesAsync(invoice);
Assert.Equal(20, result.Count);
Assert.Equal(20, handler.RequestedUrls.Count);
}
[Fact]
public async Task ResolveInlineImagesAsync_MultipleImages_FetchesConcurrentlyNotOneAtATime()
{
var handler = new ConcurrencyTrackingHandler(TimeSpan.FromMilliseconds(150), _ => ImageResponse(new byte[] { 1 }));
var svc = MakeService(handler);
var descs = Enumerable.Range(1, 4)
.Select(i => $"<img src=\"{svc.AllowedInlineImagePrefix}img{i}.png\">")
.ToArray();
var invoice = InvoiceWithDesc(descs);
await svc.ResolveInlineImagesAsync(invoice);
Assert.True(handler.MaxObservedConcurrent > 1,
$"expected overlapping fetches, but max observed concurrency was {handler.MaxObservedConcurrent}");
}
[Fact]
public async Task ResolveInlineImagesAsync_Success_RecordsSuccessCounterAndResolveDuration()
{
long fetchOutcomeDelta = 0;
int durationRecords = 0;
using var listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (inst.Meter.Name != "Fuchs.Intranet") return;
if (inst.Name is "fuchs.invoice.inlineimages.fetches" or "fuchs.invoice.inlineimages.resolve.duration")
l.EnableMeasurementEvents(inst);
}
};
listener.SetMeasurementEventCallback<long>((inst, value, tags, _) =>
{
if (inst.Name == "fuchs.invoice.inlineimages.fetches")
foreach (var t in tags)
if (t.Key == "outcome" && (string?)t.Value == "success")
Interlocked.Add(ref fetchOutcomeDelta, value);
});
listener.SetMeasurementEventCallback<double>((inst, _, _, _) =>
{
if (inst.Name == "fuchs.invoice.inlineimages.resolve.duration")
Interlocked.Increment(ref durationRecords);
});
listener.Start();
var handler = new StubHandler(_ => ImageResponse(new byte[] { 1 }));
var svc = MakeService(handler);
string url = svc.AllowedInlineImagePrefix + "pic.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">");
await svc.ResolveInlineImagesAsync(invoice);
Assert.Equal(1, fetchOutcomeDelta);
Assert.True(durationRecords >= 1, "resolve-duration histogram should have recorded one measurement.");
}
}
+109
View File
@@ -0,0 +1,109 @@
using System.Collections.Generic;
using System.Linq;
using MigraDoc.DocumentObjectModel;
using MigraDoc.DocumentObjectModel.Shapes;
using MigraDoc.DocumentObjectModel.Tables;
using MigraDoc.Extensions;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exercises the HTML→MigraDoc converter's &lt;img&gt; handling (added alongside the invoice-text
/// image feature, ADR 0018): only pre-resolved sources render, they land inline at the position the
/// HTML puts them (not appended/floating), and their size mirrors the HTML width/height, capped
/// against layout-breaking values.
/// </summary>
public class MigraDocHtmlImageTests
{
private const string DataSrc = "base64:AAAA";
private static Cell NewCell()
{
var doc = new Document();
var sec = doc.AddSection();
var table = sec.AddTable();
table.AddColumn();
var row = table.AddRow();
return row.Cells[0];
}
[Fact]
public void AddHtml_UnresolvedImgSrc_IsDropped()
{
var cell = NewCell();
cell.AddHtml("<div>Text <img src=\"https://not-fetched.example/x.png\"> more</div>", null);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
Assert.DoesNotContain(para.Elements.Cast<DocumentObject>(), e => e is Image);
}
[Fact]
public void AddHtml_ResolvedImgSrc_RendersInlineAtHtmlPosition()
{
var cell = NewCell();
var images = new Dictionary<string, string> { ["x"] = DataSrc };
cell.AddHtml("<div>Before <img src=\"x\"> After</div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var kinds = para.Elements.Cast<DocumentObject>().Select(e => e.GetType().Name).ToList();
int imgIdx = kinds.IndexOf(nameof(Image));
Assert.NotEqual(-1, imgIdx);
// The image must sit between the "Before " and " After" text runs, not appended after them.
Assert.True(imgIdx > 0 && imgIdx < kinds.Count - 1,
$"expected image between surrounding text, got element order: {string.Join(",", kinds)}");
}
[Fact]
public void AddHtml_WidthAndHeightAttributes_SetExplicitSizeFromPx()
{
var cell = NewCell();
var images = new Dictionary<string, string> { ["x"] = DataSrc };
cell.AddHtml("<div><img src=\"x\" width=\"96\" height=\"48\"></div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var img = para.Elements.Cast<DocumentObject>().OfType<Image>().Single();
// 96px / 48px at 96dpi → 2.54cm / 1.27cm.
Assert.Equal(2.54, img.Width.Centimeter, 2);
Assert.Equal(1.27, img.Height.Centimeter, 2);
}
[Fact]
public void AddHtml_StyleWidthHeight_SetExplicitSizeFromPx()
{
var cell = NewCell();
var images = new Dictionary<string, string> { ["x"] = DataSrc };
cell.AddHtml("<div><img src=\"x\" style=\"width: 192px; height: 96px;\"></div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var img = para.Elements.Cast<DocumentObject>().OfType<Image>().Single();
Assert.Equal(5.08, img.Width.Centimeter, 2);
Assert.Equal(2.54, img.Height.Centimeter, 2);
}
[Fact]
public void AddHtml_HugeHtmlSize_IsCappedToLayoutSafeMaximum()
{
var cell = NewCell();
var images = new Dictionary<string, string> { ["x"] = DataSrc };
// A huge (or hand-edited/malformed) size must never blow out the invoice table cell.
cell.AddHtml("<div><img src=\"x\" width=\"4000\" height=\"4000\"></div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var img = para.Elements.Cast<DocumentObject>().OfType<Image>().Single();
Assert.True(img.Width.Centimeter <= 7.0);
Assert.True(img.Height.Centimeter <= 10.0);
}
[Fact]
public void AddHtml_NoSizeGiven_KeepsAspectRatioLocked()
{
var cell = NewCell();
var images = new Dictionary<string, string> { ["x"] = DataSrc };
cell.AddHtml("<div><img src=\"x\"></div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var img = para.Elements.Cast<DocumentObject>().OfType<Image>().Single();
Assert.True(img.LockAspectRatio);
}
}
@@ -75,7 +75,7 @@ public partial class IntranetController
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
var doc = _invoiceDrafts.RenderPreview(Form("token"));
var doc = await _invoiceDrafts.RenderPreviewAsync(Form("token"));
if (doc == null) return DraftGone();
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
@@ -75,7 +75,7 @@ public partial class IntranetController
{
_logger.LogInformation("Requests sprep: invoice draft {InvId} created, user={User}", fdInv.Id, UserAccountID);
await _events.InvoiceDraftRegisteredAsync(fdInv, changed: false, userAccountId: UserAccountID);
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
}
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
@@ -92,7 +92,7 @@ public partial class IntranetController
{
_logger.LogInformation("Requests sedit: invoice draft {InvId} updated, user={User}", fdInv.Id, UserAccountID);
await _events.InvoiceDraftRegisteredAsync(fdInv, changed: true, userAccountId: UserAccountID);
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
}
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht aktualisiert werden.");
@@ -396,7 +396,7 @@ 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));
: _pdf.DocToPdfBytes(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
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
@@ -405,7 +405,7 @@ public partial class IntranetController
SetInlinePdfFilename(filename);
return File(ct, "application/pdf");
}
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
}
@@ -0,0 +1,83 @@
---
status: Accepted
date: 2026-09-10
applyTo:
- "Fuchs/code/MigraDocExtensions.cs"
- "Fuchs/code/FuchsPdf.cs"
- "Fuchs/Services/InvoiceService.cs"
- "Fuchs/Services/IInvoiceService.cs"
- "Fuchs/Services/IPdfService.cs"
- "Fuchs/Services/FuchsPdfService.cs"
supersededBy: ""
---
# 0018 — Invoice-text images are fetched at render time from a domain whitelist, not embedded as data URIs
## Context
The invoice line-item description is TinyMCE-authored HTML (`FdsInvoiceData`/`InvoiceBlock.Items`
item `desc`), and can contain `<img src="https://portal.mobilefieldreport.com/mfr/...">` picture
links pulled from the mfr ERP — the browser resolves and displays them in the editor. The PDF
pipeline, however, renders through a hand-rolled HTML→MigraDoc subset converter
(`MigraDoc.Extensions.Html.HtmlConverter` in `MigraDocExtensions.cs`), which had no `img` handler
at all: every `<img>`, remote or embedded, was silently dropped, so images the editor showed never
appeared on the actual invoice.
MigraDoc/PdfSharp does not fetch remote URLs itself, and the converter runs synchronously deep
inside the largely-synchronous `FuchsPdf` static rendering pipeline. Fetching an arbitrary
user-supplied URL server-side is also an SSRF surface — the invoice text is user/editor-controlled,
so a naive "download whatever `src` says" would let it reach internal/private network addresses.
## Decision
- Invoice images are inlined into the PDF only when their `src` starts with the mfr REST root
(`InvoiceService.AllowedInlineImagePrefix`, e.g. `https://portal.mobilefieldreport.com/mfr/`) —
the only origin the invoice editor's picture links come from. This is derived from the same
`Fds:MFR_host` configuration entry the MFR REST client itself uses (via
`MFR_RESTClient.MFRClientConfig.RestRoot`), not a second hardcoded literal, so it can't drift if
the configured mfr host ever changes per environment/tenant. Anything else (any other host,
`http://`, a different path) is left unrendered, exactly as before this feature — no generic
remote fetch.
- The fetch happens once per invoice render, in `InvoiceService.ResolveInlineImagesAsync`
(async, off a named `IHttpClientFactory` client `"InvoiceInlineImages"`, 10s timeout), *before*
the synchronous MigraDoc render starts. `InvoiceService.GenerateInvoicePdf` therefore became
`GenerateInvoicePdfAsync` — every caller already ran in an async context. `<img>` sources are
found by parsing the description HTML with HtmlAgilityPack (`ResolveInlineImagesAsync`) — the
same library the `HtmlConverter` itself parses the identical text with — rather than a regex, so
a decoy attribute next to the real `src` (e.g. `data-src="..."`) can't be picked up in its place.
Each fetched image is capped at 5MB, must report an `image/*` content-type, and the invoice
fetches at most 20 distinct images; failures (unreachable, wrong type, oversized) are logged,
counted (`FuchsTelemetry.InvoiceInlineImageFetches`, tagged by outcome), and just leave that one
image out — they never fail the whole render. The distinct URLs fetch concurrently (capped at 4
in flight via a `SemaphoreSlim`, not one-at-a-time) since this runs inline on every render,
including repeated draft-preview renders — total resolve time is recorded in
`FuchsTelemetry.InvoiceInlineImagesResolveDuration`.
- The resolved `{src → "base64:..."}` map flows down as a plain `IReadOnlyDictionary<string,string>?`
parameter through `IPdfService.ApplyInvoice` → `FuchsPdf.ApplyInvoice` → `Cell.AddHtml` into the
`HtmlConverter`, whose new `img` handler only emits an image for a `src` present in that map —
it does no fetching itself and never sees the network. The converter itself stays generic (its
constructor parameter is named `resolvedImages`, not invoice-specific) — resolving *which* images
go in the map, and any network/whitelist policy, is entirely InvoiceService's concern, not the
converter's. Image sizing mirrors the HTML: the handler reads `width`/`height` (attribute or
inline `style`, as TinyMCE's resize handles write them) and converts CSS px → cm, capped to
7×10cm so a malformed/huge size can't blow out the invoice table layout.
## Consequences
- Any future host serving invoice-editor images must be added to the whitelist deliberately (a
one-line prefix, or a small allowed-prefixes list if a second origin appears) — this is not
meant to grow into a generic "fetch any URL" facility.
- `IInvoiceService.GenerateInvoicePdf` → `GenerateInvoicePdfAsync`, and
`IInvoiceDraftService.RenderPreview` → `RenderPreviewAsync` (same signature change, same reason)
are breaking signature changes; anything implementing/mocking these interfaces needs updating.
- Image src values not covered by the whitelist — including a stray non-mfr remote URL, or a raw
`data:` URI a user might paste in — still render as nothing, matching the pre-existing (silent)
drop behaviour. If embedded `data:` images need support later, that's a separate, simpler
decision (no network fetch involved) and can reuse the same `img` handler contract.
## Alternatives considered
- **Fetch inside the HTML converter itself (sync-over-async `HttpClient.GetAsync(...).Result`)** —
rejected: violates the project's no-sync-over-async rule and keeps SSRF-relevant network code
inside a generic, reusable HTML converter instead of a single audited call site.
- **Fully embed images as `data:` base64 URIs at editor save time** — rejected: doubles storage
size per invoice edit, and the image is already resolved by the mfr portal itself; re-fetching a
small number of images once at render time is simpler and keeps the source of truth external.
- **Allow any remote `http(s)://` host** — rejected on SSRF grounds; every invoice image the editor
can currently produce is from the mfr portal, so a whitelist costs nothing today.
+4 -3
View File
@@ -116,7 +116,7 @@ vat }` plus set header/member tagging) that is posted to the server.
| User action | JS entry point | Endpoint | Controller handler | Effect |
|---|---|---|---|---|
| First save without preview | `$inv.ssave` | `req/save` | `Do_Process_Requests` case `save` | `RegisterInvoiceAsync(change: id present)` — creates or updates the draft row, **no PDF rendered** |
| Preview a **new** invoice | `$inv.sprev(false)` | `req/sprep` | case `sprep` | Registers draft, then `GenerateInvoicePdf` → renders preview images |
| Preview a **new** invoice | `$inv.sprev(false)` | `req/sprep` | case `sprep` | Registers draft, then `GenerateInvoicePdfAsync` → renders preview images |
| Preview an **existing** draft | `$inv.sprev(true)` (aka `$inv.sedit`) | `req/sedit` | case `sedit` | Same as `sprep` but requires `id`, updates existing draft |
| Cancel out of preview | dialog `cancel` handler | `req/sdel` | case `sdel` | `fds__remInvoice` — deletes the (unfinalised) draft row |
@@ -134,7 +134,8 @@ JSON into SQL parameters and calls, in one batch:
- **Existing draft**: `fds__setInvoice` (same parameter set, updates in place)
→ `fds__createInvoice_Details` again.
`GenerateInvoicePdf` (still in `InvoiceService`) builds `FuchsPdf.FdsTextBlocks`
`GenerateInvoicePdfAsync` (still in `InvoiceService`) builds `FuchsPdf.FdsTextBlocks`,
resolves any whitelisted mfr-portal images from the line-item text (ADR 0018),
and calls `IPdfService.WriteLetterAsync` + `ApplyInvoice`, which internally
uses `InvoiceSetPricing.Build(...)` to turn the posted item contract into
ordered, priced/unpriced lines. While the invoice is **not yet final**
@@ -160,7 +161,7 @@ sequenceDiagram
C->>S: RegisterInvoiceAsync(invoice, change, invId)
S->>DB: fds__createInvoice / fds__setInvoice + fds__createInvoice_Details
DB-->>S: registered invoice row (Id, ...)
S->>S: GenerateInvoicePdf (draft=true -> watermark, no GiroCode)
S->>S: GenerateInvoicePdfAsync (draft=true -> watermark, no GiroCode)
S-->>C: MigraDoc Document
C-->>JS: {id, img[] (base64 pages), total}
JS->>U: show PDF preview modal (confirm / cancel)
+6
View File
@@ -54,6 +54,9 @@ public static class FuchsTelemetry
public static readonly Counter<long> SystemProbes =
Meter.CreateCounter<long>("fuchs.systemstatus.probes", "{probe}",
"Admin system-status connectivity probes executed, tagged by component and outcome.");
public static readonly Counter<long> InvoiceInlineImageFetches =
Meter.CreateCounter<long>("fuchs.invoice.inlineimages.fetches", "{fetch}",
"Invoice-text image fetches for inline PDF rendering (ADR 0018), tagged by outcome.");
// ── Performance histograms (durations in milliseconds) ───────────────────
public static readonly Histogram<double> PdfRenderDuration =
@@ -62,6 +65,9 @@ public static class FuchsTelemetry
Meter.CreateHistogram<double>("fuchs.report.render.duration", "ms", "Report render duration.");
public static readonly Histogram<double> EmailSendDuration =
Meter.CreateHistogram<double>("fuchs.email.send.duration", "ms", "Email send round-trip duration.");
public static readonly Histogram<double> InvoiceInlineImagesResolveDuration =
Meter.CreateHistogram<double>("fuchs.invoice.inlineimages.resolve.duration", "ms",
"Total wall-clock time to fetch an invoice's inline images (all images in parallel).");
/// <summary>Starts a span on the application's <see cref="ActivitySource"/>.</summary>
public static Activity? StartActivity(string name, ActivityKind kind = ActivityKind.Internal) =>
+4
View File
@@ -128,6 +128,10 @@ public class Program
builder.Services.AddHttpClient(ProcessWebERechnungValidator.HttpClientName,
c => c.Timeout = TimeSpan.FromSeconds(90));
builder.Services.AddHttpClient("ProcessWebMailer");
// Fetches invoice-editor images (mfr:// picture links) for inline PDF rendering; whitelist-
// restricted to the mfr portal (see InvoiceService.AllowedInlineImagePrefix / ADR 0018).
builder.Services.AddHttpClient(InvoiceService.InlineImageHttpClientName,
c => c.Timeout = TimeSpan.FromSeconds(10));
builder.Services.AddScoped<IComService, ProcessWebComService>();
// Holds the one-shot startup self-test result for the lifetime of the process so the Admin
// module can display it as a non-refreshable widget (must be registered before the service).
+2 -2
View File
@@ -107,10 +107,10 @@ public class FuchsPdfService : IPdfService
}
public void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks,
FdsInvoiceData invoice, bool draft = false)
FdsInvoiceData invoice, bool draft = false, IReadOnlyDictionary<string, string>? inlineImages = null)
{
_logger.LogDebug("ApplyInvoice id={Id} draft={Draft}", invoice.Id, draft);
FuchsPdf.ApplyInvoice(doc, textBlocks, invoice, draft);
FuchsPdf.ApplyInvoice(doc, textBlocks, invoice, draft, inlineImages);
}
public void ApplyReminder(Document doc, FuchsPdf.FdsTextBlocks textBlocks,
+1 -1
View File
@@ -51,7 +51,7 @@ public interface IInvoiceDraftService
Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
Document? RenderPreview(string token);
Task<Document?> RenderPreviewAsync(string token);
/// <summary>Removes the session from the cache (explicit close/discard/finalise). Returns true if one was present.</summary>
bool Close(string token);
+2 -2
View File
@@ -17,8 +17,8 @@ public interface IInvoiceService
Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
string userAccountId, DatabaseSecurity dbSec);
/// <summary>Generates a PDF document for an invoice.</summary>
Document GenerateInvoicePdf(FdsInvoiceData invoice, bool draft);
/// <summary>Generates a PDF document for an invoice, inlining its whitelisted-origin images (ADR 0018).</summary>
Task<Document> GenerateInvoicePdfAsync(FdsInvoiceData invoice, bool draft);
/// <summary>Renders invoice PDF to bytes.</summary>
Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft);
+5 -1
View File
@@ -12,7 +12,11 @@ public interface IPdfService
Task<Document> WriteLetterAsync(FuchsPdf.FdsTextBlocks textBlocks, bool draft);
/// <summary>Applies invoice data to a letter document.</summary>
void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks, FdsInvoiceData invoice, bool draft = false);
/// <param name="inlineImages">Pre-fetched/vetted invoice-text images to inline into line
/// descriptions, keyed by their original &lt;img src&gt; (see InvoiceService.ResolveInlineImagesAsync
/// / ADR 0018). Null renders line descriptions without images, as before.</param>
void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks, FdsInvoiceData invoice, bool draft = false,
IReadOnlyDictionary<string, string>? inlineImages = null);
/// <summary>Applies reminder data to a letter document.</summary>
void ApplyReminder(Document doc, FuchsPdf.FdsTextBlocks textBlocks, FdsReminderData reminder, bool draft = false);
+2 -2
View File
@@ -527,14 +527,14 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
return reg;
}
public Document? RenderPreview(string token)
public async Task<Document?> RenderPreviewAsync(string token)
{
var session = _cache.Get(token);
if (session == null) return null;
var fds = BuildFdsData(session);
fds.InvoiceRegistration = SynthesizeRegistration(session);
fds.IsDraft = true;
return _invoices.GenerateInvoicePdf(fds, draft: true);
return await _invoices.GenerateInvoicePdfAsync(fds, draft: true);
}
public bool Close(string token) => _cache.Remove(token) != null;
+130 -7
View File
@@ -3,7 +3,10 @@ using System.Diagnostics;
using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Observability;
using HtmlAgilityPack;
using MFR_RESTClient;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
using Newtonsoft.Json.Linq;
@@ -22,22 +25,47 @@ namespace Fuchs.Services;
/// </summary>
public class InvoiceService : IInvoiceService
{
/// <summary>Named <see cref="IHttpClientFactory"/> client used to fetch invoice-text images.</summary>
public const string InlineImageHttpClientName = "InvoiceInlineImages";
private const int MaxInlineImageBytes = 5 * 1024 * 1024;
private const int MaxInlineImagesPerInvoice = 20;
private const int MaxConcurrentInlineImageFetches = 4;
private readonly Fuchs_intranet _intranet;
private readonly IPdfService _pdf;
private readonly IBlobStorageService _blobStorage;
private readonly IEventService _events;
private readonly IERechnungService _erechnung;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<InvoiceService> _logger;
/// <summary>
/// The only origin invoice-text &lt;img&gt; sources are fetched from for inline PDF rendering
/// (ADR 0018) — the mfr portal picture links the invoice editor already displays. Any other
/// src (including other remote hosts) is left unrendered, exactly as before this feature.
/// Derived from the same <c>Fds:MFR_host</c> setting the MFR REST client itself uses (via
/// <see cref="MFRClientConfig.RestRoot"/>), not a separately-hardcoded host, so it can't drift
/// from the actual configured mfr portal.
/// </summary>
internal readonly string AllowedInlineImagePrefix;
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
IEventService events, IERechnungService erechnung, ILogger<InvoiceService> logger)
IEventService events, IERechnungService erechnung, IHttpClientFactory httpClientFactory,
IConfiguration configuration, ILogger<InvoiceService> logger)
{
_intranet = intranet;
_pdf = pdf;
_blobStorage = blobStorage;
_events = events;
_erechnung = erechnung;
_httpClientFactory = httpClientFactory;
_logger = logger;
string? mfrHost = configuration["Fds:MFR_host"];
AllowedInlineImagePrefix = string.IsNullOrWhiteSpace(mfrHost)
? MFRClientConfig.DefaultHost + "/mfr/"
: new MFRClientConfig(mfrHost).RestRoot;
}
private string Conn => _intranet.Intranet__SQLConnectionString;
@@ -259,12 +287,12 @@ public class InvoiceService : IInvoiceService
}
}
public Document GenerateInvoicePdf(FdsInvoiceData invoice, bool draft)
public async Task<Document> GenerateInvoicePdfAsync(FdsInvoiceData invoice, bool draft)
{
using var act = FuchsTelemetry.StartActivity("invoice.render");
act?.SetTag("fuchs.invoice.id", invoice.Id);
act?.SetTag("fuchs.invoice.draft", draft);
_logger.LogDebug("GenerateInvoicePdf id={Id} draft={Draft}", invoice.Id, draft);
_logger.LogDebug("GenerateInvoicePdfAsync id={Id} draft={Draft}", invoice.Id, draft);
var reg = invoice.InvoiceRegistration;
var tb = new FuchsPdf.FdsTextBlocks
{
@@ -276,17 +304,112 @@ public class InvoiceService : IInvoiceService
AdminUserEmail = reg?.getString("UserEmailFinalized") ?? "",
AdminDatumValue = reg?.getString("DateCreated") is { Length: > 0 } dc ? DateTime.Parse(dc) : DateTime.Now
};
// WriteLetter is effectively synchronous; no Task.Run thread-hop (safe: no sync context in ASP.NET Core).
var doc = _pdf.WriteLetterAsync(tb, draft).GetAwaiter().GetResult();
var inlineImages = await ResolveInlineImagesAsync(invoice);
var doc = await _pdf.WriteLetterAsync(tb, draft);
doc.Info.Title = reg?.getString("InvoiceTitle") ?? "";
_pdf.ApplyInvoice(doc, tb, invoice, draft);
_pdf.ApplyInvoice(doc, tb, invoice, draft, inlineImages);
FuchsTelemetry.InvoicesRendered.Add(1, new KeyValuePair<string, object?>("draft", draft));
return doc;
}
/// <summary>
/// Fetches the invoice-text &lt;img&gt; sources that come from the mfr portal (the only origin
/// the invoice editor's images can come from) so they can be inlined into the PDF, which — unlike
/// the browser-rendered editor — cannot itself resolve remote URLs. See ADR 0018.
/// Fetch failures (unreachable, wrong content-type, oversized) are logged and simply leave that
/// image out of the PDF rather than failing the whole render.
/// </summary>
internal async Task<Dictionary<string, string>> ResolveInlineImagesAsync(FdsInvoiceData invoice)
{
var result = new Dictionary<string, string>();
// Parsed with HtmlAgilityPack (already a project dependency, and what the PDF's own HTML
// converter parses this same text with) rather than a regex, so a decoy attribute like
// data-src="..." next to the real src can never be picked up in its place.
var urls = new HashSet<string>();
foreach (var block in invoice.InvoiceBlocks)
foreach (var item in block.Items)
{
string desc = item.nz("desc", "");
if (desc.Length == 0) continue;
var doc = new HtmlDocument();
doc.LoadHtml(desc);
var imgNodes = doc.DocumentNode.SelectNodes("//img");
if (imgNodes == null) continue;
foreach (var node in imgNodes)
{
string src = node.GetAttributeValue("src", "");
if (src.Length > 0 && src.StartsWith(AllowedInlineImagePrefix, StringComparison.Ordinal)
&& urls.Count < MaxInlineImagesPerInvoice)
urls.Add(src);
}
}
if (urls.Count == 0) return result;
using var act = FuchsTelemetry.StartActivity("invoice.inlineimages.resolve");
act?.SetTag("fuchs.invoice.id", invoice.Id);
act?.SetTag("fuchs.inlineimages.requested", urls.Count);
var sw = Stopwatch.StartNew();
var client = _httpClientFactory.CreateClient(InlineImageHttpClientName);
// Fetches run concurrently (capped) rather than one-at-a-time: this executes inline on every
// invoice render, including repeated draft-preview renders, so N sequential round-trips to the
// mfr portal would otherwise add N x latency to a user-facing request.
using var gate = new SemaphoreSlim(MaxConcurrentInlineImageFetches);
var fetches = urls.Select(async url =>
{
await gate.WaitAsync();
try { return (url, source: await FetchInlineImageAsync(client, url)); }
finally { gate.Release(); }
});
foreach (var (url, source) in await Task.WhenAll(fetches))
if (source != null) result[url] = source;
act?.SetTag("fuchs.inlineimages.resolved", result.Count);
FuchsTelemetry.InvoiceInlineImagesResolveDuration.Record(sw.Elapsed.TotalMilliseconds);
return result;
}
/// <summary>Fetches and validates one whitelisted invoice image; null on any failure (logged +
/// counted, never thrown) so one bad image never fails the whole render.</summary>
private async Task<string?> FetchInlineImageAsync(HttpClient client, string url)
{
try
{
using var resp = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode)
{
_logger.LogWarning("ResolveInlineImagesAsync: {Url} returned {Status}", url, (int)resp.StatusCode);
FuchsTelemetry.InvoiceInlineImageFetches.Add(1, new KeyValuePair<string, object?>("outcome", "http_error"));
return null;
}
string contentType = resp.Content.Headers.ContentType?.MediaType ?? "";
if (!contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("ResolveInlineImagesAsync: {Url} returned non-image content-type '{ContentType}'", url, contentType);
FuchsTelemetry.InvoiceInlineImageFetches.Add(1, new KeyValuePair<string, object?>("outcome", "bad_content_type"));
return null;
}
var bytes = await resp.Content.ReadAsByteArrayAsync();
if (bytes.Length == 0 || bytes.Length > MaxInlineImageBytes)
{
_logger.LogWarning("ResolveInlineImagesAsync: {Url} image size {Size} out of bounds", url, bytes.Length);
FuchsTelemetry.InvoiceInlineImageFetches.Add(1, new KeyValuePair<string, object?>("outcome", "oversized"));
return null;
}
FuchsTelemetry.InvoiceInlineImageFetches.Add(1, new KeyValuePair<string, object?>("outcome", "success"));
return FuchsPdf.MigraDocFilenameFromByteArray(bytes);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ResolveInlineImagesAsync: failed to fetch invoice image {Url}", url);
FuchsTelemetry.InvoiceInlineImageFetches.Add(1, new KeyValuePair<string, object?>("outcome", "exception"));
return null;
}
}
public async Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
{
var doc = GenerateInvoicePdf(invoice, draft);
var doc = await GenerateInvoicePdfAsync(invoice, draft);
// Finalized invoices are emitted as a ZUGFeRD/Factur-X hybrid when eRechnung is enabled:
// eRechnungLib embeds the CII XML into the render-only visual PDF and owns the single
// PDF/A-3 layer (ADR 0012). Drafts/previews keep the plain PDF/A. Any failure falls back.
+5 -3
View File
@@ -709,7 +709,8 @@ public static class FuchsPdf
}
// ── ApplyInvoice ──────────────────────────────────────────────────────────
public static void ApplyInvoice(Document doc, FdsTextBlocks tb, FdsInvoiceData inv, bool draft = false)
public static void ApplyInvoice(Document doc, FdsTextBlocks tb, FdsInvoiceData inv, bool draft = false,
IReadOnlyDictionary<string, string>? inlineImages = null)
{
string invoiceType = inv.InvoiceType;
bool p13b = inv.InvoiceRegistration?.getString("InvoiceOptions")
@@ -813,7 +814,7 @@ public static class FuchsPdf
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>");
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>", inlineImages);
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";
@@ -1165,7 +1166,8 @@ public static class FuchsPdf
img.Top = ShapePosition.Top;
}
private static string MigraDocFilenameFromByteArray(byte[] image) =>
/// <summary>Formats bytes as a MigraDoc-recognized inline image source ("base64:...").</summary>
internal static string MigraDocFilenameFromByteArray(byte[] image) =>
"base64:" + Convert.ToBase64String(image);
private static byte[] ImageToByteArray(System.Drawing.Image img)
+1 -1
View File
@@ -1,4 +1,4 @@
namespace Fuchs.intranet;
namespace Fuchs.intranet;
/// <summary>
/// Computes hierarchical invoice position labels ("1", "1.1", "1.2", "2", …) from a
+88 -4
View File
@@ -2,7 +2,9 @@
using MigraDoc.DocumentObjectModel;
using MigraDoc.DocumentObjectModel.Shapes;
using MigraDoc.DocumentObjectModel.Tables;
using System.Globalization;
using System.Net;
using System.Text.RegularExpressions;
// Ported from migradoc_Extensions.vb + migradoc_HtmlConverter.vb
@@ -24,8 +26,14 @@ namespace MigraDoc.Extensions
public static Section AddHtml(this Section section, string html)
=> section.Add(html, new MigraDoc.Extensions.Html.HtmlConverter());
public static Cell AddHtml(this Cell cell, string html)
=> cell.Add(html, new MigraDoc.Extensions.Html.HtmlConverter());
/// <param name="resolvedImages">Maps an &lt;img&gt; element's original <c>src</c> to a MigraDoc
/// image source ("base64:...") for images the caller has already fetched/vetted — this converter
/// never fetches anything itself. A src not present here is dropped, matching the converter's
/// long-standing behaviour for unsupported elements. (One caller: InvoiceService resolves
/// whitelisted invoice-text image URLs this way — see ADR 0018.)</param>
public static Cell AddHtml(this Cell cell, string html,
IReadOnlyDictionary<string, string>? resolvedImages = null)
=> cell.Add(html, new MigraDoc.Extensions.Html.HtmlConverter(resolvedImages));
public static TextFrame AddHtml(this TextFrame frame, string html)
=> frame.Add(html, new MigraDoc.Extensions.Html.HtmlConverter());
@@ -55,13 +63,20 @@ namespace MigraDoc.Extensions
namespace MigraDoc.Extensions.Html
{
/// <summary>Converts a subset of HTML to MigraDoc document objects.</summary>
/// <summary>
/// Converts a subset of HTML to MigraDoc document objects. Fetches nothing itself: an
/// &lt;img&gt; only renders when its <c>src</c> is a key in the <c>resolvedImages</c> map the
/// caller supplies — any network/whitelist policy for resolving those images lives entirely
/// with the caller (e.g. <c>InvoiceService.ResolveInlineImagesAsync</c>, ADR 0018).
/// </summary>
public class HtmlConverter : MigraDoc.Extensions.IConverter
{
private readonly IDictionary<string, Func<HtmlNode, DocumentObject, DocumentObject>> _handlers;
private readonly IReadOnlyDictionary<string, string>? _resolvedImages;
public HtmlConverter()
public HtmlConverter(IReadOnlyDictionary<string, string>? resolvedImages = null)
{
_resolvedImages = resolvedImages;
_handlers = new Dictionary<string, Func<HtmlNode, DocumentObject, DocumentObject>>();
AddDefaultHandlers();
}
@@ -103,6 +118,50 @@ namespace MigraDoc.Extensions.Html
};
_handlers["hr"] = (n, p) => { var para = GetPara(p); if (para != null) para.Style = "HorizontalRule"; return (DocumentObject?)para ?? p; };
// Image — only src values pre-resolved by the caller (see ctor) are rendered; anything
// else (including any remote URL the caller didn't fetch/vet) is silently dropped, same
// as every other unsupported element. Size mirrors what the editor shows (the width/height
// TinyMCE writes onto the <img> from its resize handles), capped so a stray huge size from
// hand-edited HTML can't blow out the invoice table layout.
_handlers["img"] = (n, p) =>
{
string src = n.GetAttributeValue("src", "");
if (src.Length == 0 || _resolvedImages == null || !_resolvedImages.TryGetValue(src, out var source))
return p;
var para = GetPara(p);
if (para == null) return p;
var img = para.AddImage(source);
img.LockAspectRatio = true;
double? wPx = ReadPxDimension(n, "width");
double? hPx = ReadPxDimension(n, "height");
if (wPx is > 0 && hPx is > 0)
{
// Both explicit: honour the HTML's (possibly non-native, e.g. a stretched banner)
// aspect ratio. Enforcing the cap must scale both dimensions by the same factor —
// re-enabling LockAspectRatio here would make MigraDoc recompute the other side from
// the image's *native* pixel ratio instead, silently discarding the explicit value.
img.LockAspectRatio = false;
img.Width = PxToUnit(wPx.Value);
img.Height = PxToUnit(hPx.Value);
double scale = Math.Min(1.0, Math.Min(
MaxInlineImageWidth.Centimeter / img.Width.Centimeter,
MaxInlineImageHeight.Centimeter / img.Height.Centimeter));
if (scale < 1.0)
{
img.Width = Unit.FromCentimeter(img.Width.Centimeter * scale);
img.Height = Unit.FromCentimeter(img.Height.Centimeter * scale);
}
return para;
}
if (wPx is > 0) img.Width = PxToUnit(wPx.Value);
else if (hPx is > 0) img.Height = PxToUnit(hPx.Value);
if (img.Width > MaxInlineImageWidth) { img.LockAspectRatio = true; img.Width = MaxInlineImageWidth; }
if (img.Height > MaxInlineImageHeight) { img.LockAspectRatio = true; img.Height = MaxInlineImageHeight; }
return para;
};
// Headings
foreach (int i in Enumerable.Range(1, 6))
{
@@ -148,6 +207,31 @@ namespace MigraDoc.Extensions.Html
}
// ── Helpers ───────────────────────────────────────────────────────────
private const double CssPxPerCm = 96 / 2.54; // CSS px (96dpi) → cm, matches browser rendering
private static readonly Unit MaxInlineImageWidth = Unit.FromCentimeter(7);
private static readonly Unit MaxInlineImageHeight = Unit.FromCentimeter(10);
private static readonly Regex StyleSizeRegex =
new(@"(?<prop>width|height)\s*:\s*(?<val>[\d.]+)px", RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>Reads a pixel width/height from the HTML <c>width</c>/<c>height</c> attribute
/// (as TinyMCE's image resize handles write it), falling back to an inline <c>style</c>
/// declaration. Percentage/unit-less-other sizes and missing values return null.</summary>
private static double? ReadPxDimension(HtmlNode n, string attr)
{
string raw = n.GetAttributeValue(attr, "");
if (double.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out var px) && px > 0)
return px;
string style = n.GetAttributeValue("style", "");
foreach (Match m in StyleSizeRegex.Matches(style))
if (string.Equals(m.Groups["prop"].Value, attr, StringComparison.OrdinalIgnoreCase)
&& double.TryParse(m.Groups["val"].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var spx)
&& spx > 0)
return spx;
return null;
}
private static Unit PxToUnit(double px) => Unit.FromCentimeter(px / CssPxPerCm);
private static DocumentObject Fmt(DocumentObject parent, TextFormat fmt)
{
if (parent is FormattedText ft) return ft.AddFormattedText(fmt);
+27
View File
@@ -86,6 +86,33 @@ main nav ul > li a[role=button] {
}
}
.wdg_loading_ind {
color: #999;
font-size: 0.8rem;
margin-bottom: 10px;
padding-left: 1.3rem;
position: relative;
&::before {
animation: wdg_loading_spin 0.8s linear infinite;
border: 2px solid #ddd;
border-top-color: $fuchs_blau;
border-radius: 50%;
content: '';
height: 0.85rem;
left: 0;
position: absolute;
top: 0.05rem;
width: 0.85rem;
}
}
@keyframes wdg_loading_spin {
to {
transform: rotate(360deg);
}
}
.wdg_frame {
background-color: #FFF;
border: 1px solid #ccc;
+5 -1
View File
@@ -147,7 +147,11 @@ gulp.task("copy", async () => {
const jobs = [];
for (const cpy of copyconfig) {
jobs.push(
pipeline(gulp.src(cpy.src, { allowEmpty: true }), gulp.dest(cpy.dest), preservetimeSafe(), logSink("Copied: "))
/* encoding:false is required — vinyl-fs otherwise UTF-8-decodes every file by default,
which corrupts binary assets (fonts, etc.) by replacing invalid byte sequences with
U+FFFD. This is what silently mangled the glyphicons webfont before (see "Fix
corrupted glyphicons webfont files causing decode failures"). */
pipeline(gulp.src(cpy.src, { allowEmpty: true, encoding: false }), gulp.dest(cpy.dest), preservetimeSafe(), logSink("Copied: "))
);
}
await Promise.all(jobs);
+16 -5
View File
@@ -39,7 +39,7 @@ $fis.db = function () {
$ocms.postXT({
url: $ocms.url('wdg/my'), success: function (wx) {
$.each(wx, function (ii, wi) {
var wf = $$.dc('wdg_frame', ovf, { 'data-wdg': wi }).ldng(1);
var wf = $$.dc('wdg_frame', ovf, { 'data-wdg': wi }).hide();
$ocms.wdg.call(wf, { wdg: wi });
});
}, loading: ovf
@@ -210,12 +210,17 @@ $fis.wdg = function (options) {
if ($.inArray('reload_30min', wx.rendering_options) > -1 && wx.type !== 'html') {
setTimeout(function () { $fis.wdg.call(wf, { wdg: wi }); }, (30 * 60 * 1000));
}
wct.slideDown(150);
if (wf.is(':visible')) {
wct.slideDown(150);
} else {
wct.show();
wf.fadeIn(150);
}
}, error: function (jqXHR) {
wf.slideUp(150);
if (wf.is(':visible')) { wf.slideUp(150); }
$fis.failure.call(this, jqXHR);
}, complete: function () {
wf.ldng(0);
wf.ldng(0).trigger('wdg:settled');
}
});
};
@@ -225,8 +230,14 @@ $fis.ov = function () {
let cf = $('#contentframe').empty(), ovf = $$.d({ id: 'dashboard_frame' }).appendTo(cf);
$ocms.postXT({
url: $ocms.url('wdg/my'), success: function (wx) {
let ind = $$.dc('wdg_loading_ind', ovf).text($t.wldg || '').hide();
let pending = wx.length;
let settle = function () {
if (--pending <= 0) { ind.fadeOut(150); }
};
if (pending > 0) { ind.show(); }
$.each(wx, function (ii, wi) {
var wf = $$.dc('wdg_frame', ovf, { 'data-wdg': wi }).ldng(1);
var wf = $$.dc('wdg_frame', ovf, { 'data-wdg': wi }).hide().one('wdg:settled', settle);
$fis.wdg.call(wf, { wdg: wi });
});
}, loading: ovf
+1
View File
@@ -13,6 +13,7 @@
, pwr: 'Bitte wählen Sie ein starkes Passwort (min 8 Zeichen, davon jeweils min 2 Zahlen, kleine und große Buchstaben, Sonderzeichen sind optional).'
, smsc: 'Sie beötigen für diese Funktion einen SMS-Code.\nSoll dieser nun versandt werden?'
, wdc: 'Doppelt klicken, um die Box zu aktualisieren.'
, wldg: 'Widgets werden geladen…'
, wdg: {}
});
$t.rspf = { sms: 'Der SMS-Code konnte nicht bestätigt werden', valid: 'Das alte Passwort ist nicht korrekt', requirements: 'Das Passwort entspricht nicht den Anforderungen.\n' + $t.pwr}
+84 -21
View File
@@ -746,13 +746,13 @@ $inv.ccInv = function (ev) { //normale rechnung
let admin = response.admin || {}, fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', admin.invoicetitle, 'invoicetitle', 0, null);
fm('adrfrm', admin.invoiceaddress, 'invoiceaddress', 0, null);
fm('locfrm', '', 'loc', 1, { list: deepCopy(response.locations), lbl: 'ref', property: 'address' });
fm('emailfrm', admin.invoiceemail, 'invoiceemail', 0, null);
$inv.emailFrm(frm, rif, admin.invoiceemail, admin.invoiceemailcc);
$$.dc('sndfrm', frm).append($$.dc('content').text(admin.sender));
let admtxt, admlbl;
@@ -821,13 +821,13 @@ $inv.ccStInv = function (ev) { // Stornorechnung
let admin = response.admin || {}, fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', admin.invoicetitle, 'invoicetitle', 0, null);
fm('adrfrm', admin.invoiceaddress, 'invoiceaddress', 0, null);
fm('locfrm', admin.provisionlocation, 'loc', 1, { list: deepCopy(response.locations), lbl: 'ref', property: 'address' });
fm('emailfrm', admin.invoiceemail, 'invoiceemail', 0, null);
$inv.emailFrm(frm, rif, admin.invoiceemail, admin.invoiceemailcc);
$$.dc('sndfrm', frm).append($$.dc('content').text(admin.sender));
let admtxt, admlbl;
@@ -897,14 +897,13 @@ $inv.cntInv = function (data) { //invoice continuation
let fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', inv.InvoiceTitle, 'invoicetitle', 0, null);
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$inv.emailFrm(frm, rif, inv.SendToEmail, inv.SendToEmailCc);
$$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender));
fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null);
@@ -960,14 +959,13 @@ $inv.cSt = function (data) {
let fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', inv.InvoiceTitle, 'invoicetitle', 0, null);
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$inv.emailFrm(frm, rif, inv.SendToEmail, inv.SendToEmailCc);
$$.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);
@@ -1095,6 +1093,45 @@ $inv.eProvisionPeriod = function (ev) {
});
};
/* Main recipient + CC ("Zusatzempfänger") edited together in one dialog — there is no separate
pencil on the CC row, only on the email row (see $inv.emailFrm), which passes both content
spans through ev.data.t / ev.data.ccT. */
$inv.eEmail = function (ev) {
let flds = [
{ name: 'email', label: $rct.frm.invoiceemail, type: 'text', value: (ev.data.t || $$.s()).text() },
{ name: 'emailcc', label: $rct.frm.invoiceemailcc, type: 'text', value: (ev.data.ccT || $$.s()).text() }
];
$ocms.dlgform(flds, {
title: $rct.frm.invoiceemail,
success: function (res) {
let email = ('' + (res.email || '')).trim(), cc = ('' + (res.emailcc || '')).trim();
if (ev.data && ev.data.t) { ev.data.t.text(email); }
if (ev.data && ev.data.ccT) { ev.data.ccT.text(cc); }
if (typeof (ev.data || {}).change === 'function') { ev.data.change(email); }
if (typeof (ev.data || {}).changeCc === 'function') { ev.data.changeCc(cc); }
/* backend-authoritative: mirror both fields to the server session (see $inv.eHtml's
generic path, which this mirrors for a single field). */
$inv.d.syncField('invoiceemail', email);
$inv.rd.syncField('invoiceemail', email);
$inv.d.syncField('invoiceemailcc', cc);
$inv.rd.syncField('invoiceemailcc', cc);
}
});
};
/* Builds the email + CC ("Zusatzempfänger") header rows together: only the email row gets a
pencil, opening the combined $inv.eEmail dialog for both fields (see there). */
$inv.emailFrm = function (frm, rif, email, emailcc) {
let ccfr = $$.dc('inpfrm emailccfrm', frm), ccfc = $$.dc('content', ccfr).rwText(emailcc);
let efr = $$.dc('inpfrm emailfrm', frm), efc = $$.dc('content', efr).rwText(email);
$$.dc('axf', efr).append($$.dc('ibtn edit', { title: $rct.frmTT.invoiceemail }).append(gi('pencil')).click({
t: efc, ccT: ccfc, nme: 'invoiceemail',
change: (nv) => { rif.tbl.data('new').invoiceemail = nv; },
changeCc: (nv) => { rif.tbl.data('new').invoiceemailcc = nv; }
}, $inv.eHtml));
rif.tbl.data('new').invoiceemail = email;
rif.tbl.data('new').invoiceemailcc = emailcc;
};
$inv.eHtml = function (ev) {
/* The recipient address and the service date/period are edited through structured dialogs
whenever an invoice draft is active; reminders (no invoice draft token) keep the plain
@@ -1103,6 +1140,9 @@ $inv.eHtml = function (ev) {
if (ev.data.nme === 'invoiceaddress') { return $inv.eAddress.call(this, ev); }
if (ev.data.nme === 'provisionperiod') { return $inv.eProvisionPeriod.call(this, ev); }
}
/* The CC/Zusatzempfänger recipient is edited together with the main email, in one dialog —
there is no separate pencil on the CC row (see $inv.emailFrm). */
if (ev.data && !(ev.data instanceof jQuery) && ev.data.nme === 'invoiceemail' && ev.data.ccT) { return $inv.eEmail.call(this, ev); }
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
/* 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
@@ -1216,15 +1256,29 @@ $inv.eRw = function(row, dta, flds) {
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(); } }) }
/* Single "Kontext-Button" per row/field, opening a dropdown of the row's edit actions
(reuses $ocms.menu, the same mechanism that already drives the toolbar menus — see
$inv.eM/$inv.iMn). Calling .ocmsmenu() directly on a <nav> we built ourselves (rather than
letting $ocms.menu create one inside a bar) keeps our own sizing class instead of being
replaced. Every entry carries both its label and a matching `title` tooltip. */
$inv.rowCtxMenu = function (items) {
let nav = $$[0]('nav').aC('rowctx');
nav.ocmsmenu([{ glyph: 'glyphicon-option-vertical', aclass: 'ctxbtn', attr: { title: $rct.ctxM }, itm: items }]);
/* $ocms.menu puts `attr` (incl. our title tooltip) on the <li>, not the <a> a viewer actually
hovers — mirror it onto the <a> too so the native tooltip is guaranteed to fire regardless
of how the browser resolves title on a non-interactive ancestor. */
nav.find('li[title]').each(function () { let li = $(this); li.children('a').attr('title', li.attr('title')); });
return nav;
};
$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();
let bc = [
$$.dc('ibtn insb', { title: $rct.iRb }).append(gi('indent-left')).click(rw, $inv.inRow)
let mi = [
{ lbl: $rct.iRb, attr: { title: $rct.iRb }, data: rw, fnc: $inv.inRow }
];
if (ph === false) {
bc.unshift($$.dc('ibtn edit', { title: $rct.cP }).append(gi('pencil')).click(rw, $inv.eRow));
bc.push($$.dc('ibtn del', { title: $rct.dR }).append(gi('trash')).click(function (e) { if (confirm($rct.cD)) { rw.remove(); $inv.t_fds_inv(); } }));
mi.unshift({ lbl: $rct.cP, attr: { title: $rct.cP }, data: rw, fnc: $inv.eRow });
mi.push({ lbl: $rct.dR, attr: { title: $rct.dR }, fnc: function (e) { if (confirm($rct.cD)) { rw.remove(); $inv.t_fds_inv(); } } });
/* "Auf Setpreis umstellen": offered on a Set header item that does not yet carry a
price. fds__prepInvoice's [SetItmId] now anchors on the still-unconverted (price 0)
header, so the header row's own SetItmId self-references its own id rather than being
@@ -1233,10 +1287,10 @@ $inv.rrw = function () {
Irreversible: the actual sum/clear mutation happens server-side (backend-authoritative,
see InvoiceDraftEditService.ApplyItemSetPrice) via the "item.setprice" patch target. */
if ((dta.Type || '').toLowerCase() === 'set' && (dta.net_val || 0) === 0) {
bc.push($$.dc('ibtn tosetp', { title: $rct.toSetP }).append(gi('scale')).click(rw, $inv.toSetPrice));
mi.push({ lbl: $rct.toSetP, attr: { title: $rct.toSetP }, data: rw, fnc: $inv.toSetPrice });
}
}
let axf = $$.dc('axf').append(bc);
let axf = $$.dc('axf').append($inv.rowCtxMenu(mi));
if (ph === true) {
co = { id: '', typ: 'placeholder' };
} else if (rw.is('.itm.osum') === true) {
@@ -1245,7 +1299,12 @@ $inv.rrw = function () {
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
co.p = dta.position || (dta.SortOrder || '');
/* dta.position is '' (not unset) for a Text/Title row once invSumUpdate has numbered the
invoice (ADR 0017) — an intentional "no number", not a missing value, so it must NOT
fall through to SortOrder (which is -1 for such rows and would leak "-1" into Pos.).
Only an as-yet-uncomputed row (freshly inserted, before the next invSumUpdate pass)
has position === undefined and should still fall back to SortOrder as a placeholder. */
co.p = dta.position != null ? dta.position : (dta.SortOrder || '');
if (co.id === '') {
co.t = '';
} else if (['Text', 'Title'].includes(co.typ) && (dta.net_val || 0) === 0) {
@@ -1259,7 +1318,7 @@ $inv.rrw = function () {
}
}
if ((dta.Note || '') !== '') {
$$.dc('ibtn add', axf).append(gi('object-align-left')).click(function (e) {
$$.dc('ibtn add', axf, { title: $rct.noteToggle }).append(gi('object-align-left')).click(function (e) {
$inv.rrw.call(rw.tC('hidenote'));
});
}
@@ -1404,7 +1463,7 @@ $inv.rendersrq = function () {
$$.dc('ibtn insb', aux, { title: $rct.iRb }).append(gi('indent-left')).click(rtr, $inv.inRow);
let stxt = $$.sc('text', rx.text);
let nme = $$.td(rtr, { colspan: onesum ? 4 : 5 }).append(stxt), oidta, numkeys = ['net_val', 'vat_val', 'svcnet_val', 'svcvat_val', 'net'];
$$.dc('ibtn edit', aux).data('dialog', $rcol.req.lbl().Name).append(gi('pencil')).click({ t: stxt, change: (nv) => { rx.text = nv; $inv.t_fds_inv(); } }, $inv.eHtml)
$$.dc('ibtn edit', aux, { title: $rct.sectionTitleEditTT }).data('dialog', $rcol.req.lbl().Name).append(gi('pencil')).click({ t: stxt, change: (nv) => { rx.text = nv; $inv.t_fds_inv(); } }, $inv.eHtml)
if (onesum) {
$$.tdc('currency isumval', rtr);
oidta = { Id: rx.Id.toString() + '_osum', net_val: 0, vat_val: 0, svcnet_val: 0, svcvat_val: 0, net: 0 };
@@ -1781,7 +1840,8 @@ $inv.ccRem = function (id, InvoiceId) { //reminder creation
$inv.rRemRw = function (dta) {
let tr = $(this), rem = dta.rm || {};
tr.empty().data({ invoiceid: rem.invoiceid, invoicedate: rem.invoicedate, amount: rem.amount, amount_payed: rem.amount_payed });
let axf = $$.dc('axf').append($$.dc('ibtn edit', { title: $rct.cP }).append(gi('pencil')).click(tr, $inv.eRowR));
let mi = [{ lbl: $rct.cP, attr: { title: $rct.cP }, data: tr, fnc: $inv.eRowR }];
let axf = $$.dc('axf').append($inv.rowCtxMenu(mi));
tr.append([$$.tdc('aux').append(axf), $$.tdc('keep', rem.invoiceid), $$.tdc('keep', fdt(rem.invoicedate, 'dd.MM.yy')), $$.tdc('currency', fnum(rem.amount, $rct.cst)), $$.tdc('currency', fnum(rem.amount_payed, $rct.cst)), $$.tdc('currency', fnum(rem.amount - rem.amount_payed, $rct.cst))]);
};
$inv.eRowR = function (ev) {
@@ -1822,9 +1882,12 @@ $inv.ccRem_s2 = function (id, sets) { //reminder creation
$inv.rRemRw.call($$.tr(rif.bdy), rif.tbl.data());
rif.ft.appendTo(rif.tbl);
/* Reminders have no CC/Zusatzempfänger field (see $inv.emailFrm, invoice-only), so the
email pencil here keeps a plain tooltip instead of $rct.frmTT.invoiceemail. */
let remTT = { invoiceemail: 'Email-Adresse ändern', subject: 'Betreff ändern' };
let fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: remTT[nme] || $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', rem.subject, 'subject', 0, null);
+48 -20
View File
@@ -30,7 +30,7 @@
top: 0;
width: auto;
max-height: 1.5rem;
overflow: hidden;
overflow: visible; /* the .rowctx dropdown popout must escape this pill-shaped bar */
padding: 0.1rem;
> .ibtn {
@@ -40,6 +40,51 @@
display: inline-block;
position: relative;
}
/* Single row "Kontext-Button" opening a dropdown of edit actions (see $inv.rowCtxMenu).
Resets the generic full-bar <nav> styling (meant for #topbar/#sidebar) down to a
small inline trigger; the dropdown popout itself keeps the shared nav/dropdown-menu
look (background, shadow, open/close via $nuf) so it matches every other menu in the app. */
nav.rowctx {
position: relative;
display: inline-block;
width: 1.5rem;
height: 100%;
min-height: 0;
max-height: none;
background: transparent;
z-index: auto;
vertical-align: top;
> ul {
height: 100%;
> li {
height: 100%;
float: none;
> a.ctxbtn {
padding: 0;
width: 1.5rem;
height: 100%;
text-align: center;
line-height: 1.3rem;
> .caret {
display: none; /* single kebab trigger, no caret needed */
}
}
&.dropdown > ul.dropdown-menu {
top: 100%;
left: auto;
right: 0;
font-size: 0.85rem;
white-space: nowrap;
}
}
}
}
}
.sndfrm {
@@ -71,7 +116,6 @@
min-width: 5rem;
height: 1rem;
display: block;
position: absolute;
}
&:hover {
@@ -81,6 +125,8 @@
}
}
/* Read-only display: edited together with the email above, via that row's pencil
(see $inv.emailFrm / $inv.eEmail) — no pencil/hover-reveal of its own. */
.emailccfrm {
position: absolute;
font-size: 0.7rem;
@@ -88,28 +134,10 @@
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 {
+15 -2
View File
@@ -31,6 +31,9 @@
sV: 'USt festlegen',
cD: 'Löschen?',
mR: 'Zeile verschieben',
ctxM: 'Weitere Aktionen',
noteToggle: 'Notiz ein-/ausblenden',
sectionTitleEditTT: 'Auftragsbezeichnung ändern',
svcPart: 'Service-Anteil',
vat: 'Umsatzsteuer',
combP: 'Positionen zusammenfassen',
@@ -62,11 +65,21 @@
, '-': '-'
}, invHR: ['Pos.', 'Menge', 'Artikelbezeichnung', 'VK', 'Summe']
, frm: {
invoiceaddress: 'Adresse'
invoicetitle: 'Titel'
, invoiceaddress: 'Adresse'
, loc: 'Leistungsort / Lieferadresse'
, invoiceemail: 'Email'
, invoiceemailcc: 'Zusatzempfänger (CC)'
, provisionperiod: 'Leistungsdatum / -zeitraum'
}
/* Tooltip text for each header-field pencil — object+verb phrasing ("<was> <tun>"),
distinct from frm[] above (used for dialog titles / field labels, which stay short nouns). */
, frmTT: {
invoicetitle: 'Rechnungstitel ändern'
, invoiceaddress: 'Rechnungsempfänger-Adresse ändern'
, loc: 'Leistungsort oder Lieferadresse ändern'
, invoiceemail: 'Email-Adresse ändern und zusätzliche Email-Empfänger festlegen'
, provisionperiod: 'Leistungsdatum oder -zeitraum ändern'
}
}, $rcol = {
req: new fields_definition('Auftrag', 'Aufträge', [
+25
View File
@@ -2282,6 +2282,31 @@ main nav ul > li a[role=button]:hover::after, main nav ul > li a[role=button].fb
padding: 0 0.25rem;
}
.wdg_loading_ind {
color: #999;
font-size: 0.8rem;
margin-bottom: 10px;
padding-left: 1.3rem;
position: relative;
}
.wdg_loading_ind::before {
animation: wdg_loading_spin 0.8s linear infinite;
border: 2px solid #ddd;
border-top-color: rgb(27, 67, 121);
border-radius: 50%;
content: "";
height: 0.85rem;
left: 0;
position: absolute;
top: 0.05rem;
width: 0.85rem;
}
@keyframes wdg_loading_spin {
to {
transform: rotate(360deg);
}
}
.wdg_frame {
background-color: #FFF;
border: 1px solid #ccc;
+46 -18
View File
@@ -128,7 +128,7 @@ table.if td.num {
top: 0;
width: auto;
max-height: 1.5rem;
overflow: hidden;
overflow: visible; /* the .rowctx dropdown popout must escape this pill-shaped bar */
padding: 0.1rem;
}
.edit_frm .invoice_layout .axf > .ibtn {
@@ -138,6 +138,47 @@ table.if td.num {
display: inline-block;
position: relative;
}
.edit_frm .invoice_layout .axf {
/* Single row "Kontext-Button" opening a dropdown of edit actions (see $inv.rowCtxMenu).
Resets the generic full-bar <nav> styling (meant for #topbar/#sidebar) down to a
small inline trigger; the dropdown popout itself keeps the shared nav/dropdown-menu
look (background, shadow, open/close via $nuf) so it matches every other menu in the app. */
}
.edit_frm .invoice_layout .axf nav.rowctx {
position: relative;
display: inline-block;
width: 1.5rem;
height: 100%;
min-height: 0;
max-height: none;
background: transparent;
z-index: auto;
vertical-align: top;
}
.edit_frm .invoice_layout .axf nav.rowctx > ul {
height: 100%;
}
.edit_frm .invoice_layout .axf nav.rowctx > ul > li {
height: 100%;
float: none;
}
.edit_frm .invoice_layout .axf nav.rowctx > ul > li > a.ctxbtn {
padding: 0;
width: 1.5rem;
height: 100%;
text-align: center;
line-height: 1.3rem;
}
.edit_frm .invoice_layout .axf nav.rowctx > ul > li > a.ctxbtn > .caret {
display: none; /* single kebab trigger, no caret needed */
}
.edit_frm .invoice_layout .axf nav.rowctx > ul > li.dropdown > ul.dropdown-menu {
top: 100%;
left: auto;
right: 0;
font-size: 0.85rem;
white-space: nowrap;
}
.edit_frm .invoice_layout .sndfrm {
position: absolute;
font-size: 0.7rem;
@@ -165,11 +206,14 @@ table.if td.num {
min-width: 5rem;
height: 1rem;
display: block;
position: absolute;
}
.edit_frm .invoice_layout .emailfrm:hover div.axf {
display: block;
}
.edit_frm .invoice_layout {
/* Read-only display: edited together with the email above, via that row's pencil
(see $inv.emailFrm / $inv.eEmail) — no pencil/hover-reveal of its own. */
}
.edit_frm .invoice_layout .emailccfrm {
position: absolute;
font-size: 0.7rem;
@@ -177,26 +221,10 @@ table.if td.num {
left: 0.5rem;
width: auto;
height: auto;
cursor: pointer;
}
.edit_frm .invoice_layout .emailccfrm .content {
color: #000;
}
.edit_frm .invoice_layout .emailccfrm {
/* Unlike the other optional fields (plain "-" placeholder), this one names the action —
there is no default "Zusatzempfänger" from MFR, so an empty state must read as an
offer to add one, not as missing data. */
}
.edit_frm .invoice_layout .emailccfrm .content:empty::before {
content: "Zusatzempfänger hinzufügen";
min-width: 5rem;
height: 1rem;
display: block;
position: absolute;
}
.edit_frm .invoice_layout .emailccfrm:hover div.axf {
display: block;
}
.edit_frm .invoice_layout .admfrm {
position: absolute;
right: 3rem;
+99 -23
View File
@@ -31,6 +31,9 @@ let $rct = {
sV: 'USt festlegen',
cD: 'Löschen?',
mR: 'Zeile verschieben',
ctxM: 'Weitere Aktionen',
noteToggle: 'Notiz ein-/ausblenden',
sectionTitleEditTT: 'Auftragsbezeichnung ändern',
svcPart: 'Service-Anteil',
vat: 'Umsatzsteuer',
combP: 'Positionen zusammenfassen',
@@ -62,11 +65,21 @@ let $rct = {
, '-': '-'
}, invHR: ['Pos.', 'Menge', 'Artikelbezeichnung', 'VK', 'Summe']
, frm: {
invoiceaddress: 'Adresse'
invoicetitle: 'Titel'
, invoiceaddress: 'Adresse'
, loc: 'Leistungsort / Lieferadresse'
, invoiceemail: 'Email'
, invoiceemailcc: 'Zusatzempfänger (CC)'
, provisionperiod: 'Leistungsdatum / -zeitraum'
}
/* Tooltip text for each header-field pencil — object+verb phrasing ("<was> <tun>"),
distinct from frm[] above (used for dialog titles / field labels, which stay short nouns). */
, frmTT: {
invoicetitle: 'Rechnungstitel ändern'
, invoiceaddress: 'Rechnungsempfänger-Adresse ändern'
, loc: 'Leistungsort oder Lieferadresse ändern'
, invoiceemail: 'Email-Adresse ändern und zusätzliche Email-Empfänger festlegen'
, provisionperiod: 'Leistungsdatum oder -zeitraum ändern'
}
}, $rcol = {
req: new fields_definition('Auftrag', 'Aufträge', [
@@ -1308,13 +1321,13 @@ $inv.ccInv = function (ev) { //normale rechnung
let admin = response.admin || {}, fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', admin.invoicetitle, 'invoicetitle', 0, null);
fm('adrfrm', admin.invoiceaddress, 'invoiceaddress', 0, null);
fm('locfrm', '', 'loc', 1, { list: deepCopy(response.locations), lbl: 'ref', property: 'address' });
fm('emailfrm', admin.invoiceemail, 'invoiceemail', 0, null);
$inv.emailFrm(frm, rif, admin.invoiceemail, admin.invoiceemailcc);
$$.dc('sndfrm', frm).append($$.dc('content').text(admin.sender));
let admtxt, admlbl;
@@ -1383,13 +1396,13 @@ $inv.ccStInv = function (ev) { // Stornorechnung
let admin = response.admin || {}, fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', admin.invoicetitle, 'invoicetitle', 0, null);
fm('adrfrm', admin.invoiceaddress, 'invoiceaddress', 0, null);
fm('locfrm', admin.provisionlocation, 'loc', 1, { list: deepCopy(response.locations), lbl: 'ref', property: 'address' });
fm('emailfrm', admin.invoiceemail, 'invoiceemail', 0, null);
$inv.emailFrm(frm, rif, admin.invoiceemail, admin.invoiceemailcc);
$$.dc('sndfrm', frm).append($$.dc('content').text(admin.sender));
let admtxt, admlbl;
@@ -1459,14 +1472,13 @@ $inv.cntInv = function (data) { //invoice continuation
let fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', inv.InvoiceTitle, 'invoicetitle', 0, null);
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$inv.emailFrm(frm, rif, inv.SendToEmail, inv.SendToEmailCc);
$$.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);
@@ -1522,14 +1534,13 @@ $inv.cSt = function (data) {
let fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', inv.InvoiceTitle, 'invoicetitle', 0, null);
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$inv.emailFrm(frm, rif, inv.SendToEmail, inv.SendToEmailCc);
$$.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);
@@ -1657,6 +1668,45 @@ $inv.eProvisionPeriod = function (ev) {
});
};
/* Main recipient + CC ("Zusatzempfänger") edited together in one dialog — there is no separate
pencil on the CC row, only on the email row (see $inv.emailFrm), which passes both content
spans through ev.data.t / ev.data.ccT. */
$inv.eEmail = function (ev) {
let flds = [
{ name: 'email', label: $rct.frm.invoiceemail, type: 'text', value: (ev.data.t || $$.s()).text() },
{ name: 'emailcc', label: $rct.frm.invoiceemailcc, type: 'text', value: (ev.data.ccT || $$.s()).text() }
];
$ocms.dlgform(flds, {
title: $rct.frm.invoiceemail,
success: function (res) {
let email = ('' + (res.email || '')).trim(), cc = ('' + (res.emailcc || '')).trim();
if (ev.data && ev.data.t) { ev.data.t.text(email); }
if (ev.data && ev.data.ccT) { ev.data.ccT.text(cc); }
if (typeof (ev.data || {}).change === 'function') { ev.data.change(email); }
if (typeof (ev.data || {}).changeCc === 'function') { ev.data.changeCc(cc); }
/* backend-authoritative: mirror both fields to the server session (see $inv.eHtml's
generic path, which this mirrors for a single field). */
$inv.d.syncField('invoiceemail', email);
$inv.rd.syncField('invoiceemail', email);
$inv.d.syncField('invoiceemailcc', cc);
$inv.rd.syncField('invoiceemailcc', cc);
}
});
};
/* Builds the email + CC ("Zusatzempfänger") header rows together: only the email row gets a
pencil, opening the combined $inv.eEmail dialog for both fields (see there). */
$inv.emailFrm = function (frm, rif, email, emailcc) {
let ccfr = $$.dc('inpfrm emailccfrm', frm), ccfc = $$.dc('content', ccfr).rwText(emailcc);
let efr = $$.dc('inpfrm emailfrm', frm), efc = $$.dc('content', efr).rwText(email);
$$.dc('axf', efr).append($$.dc('ibtn edit', { title: $rct.frmTT.invoiceemail }).append(gi('pencil')).click({
t: efc, ccT: ccfc, nme: 'invoiceemail',
change: (nv) => { rif.tbl.data('new').invoiceemail = nv; },
changeCc: (nv) => { rif.tbl.data('new').invoiceemailcc = nv; }
}, $inv.eHtml));
rif.tbl.data('new').invoiceemail = email;
rif.tbl.data('new').invoiceemailcc = emailcc;
};
$inv.eHtml = function (ev) {
/* The recipient address and the service date/period are edited through structured dialogs
whenever an invoice draft is active; reminders (no invoice draft token) keep the plain
@@ -1665,6 +1715,9 @@ $inv.eHtml = function (ev) {
if (ev.data.nme === 'invoiceaddress') { return $inv.eAddress.call(this, ev); }
if (ev.data.nme === 'provisionperiod') { return $inv.eProvisionPeriod.call(this, ev); }
}
/* The CC/Zusatzempfänger recipient is edited together with the main email, in one dialog —
there is no separate pencil on the CC row (see $inv.emailFrm). */
if (ev.data && !(ev.data instanceof jQuery) && ev.data.nme === 'invoiceemail' && ev.data.ccT) { return $inv.eEmail.call(this, ev); }
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
/* 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
@@ -1778,15 +1831,29 @@ $inv.eRw = function(row, dta, flds) {
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(); } }) }
/* Single "Kontext-Button" per row/field, opening a dropdown of the row's edit actions
(reuses $ocms.menu, the same mechanism that already drives the toolbar menus — see
$inv.eM/$inv.iMn). Calling .ocmsmenu() directly on a <nav> we built ourselves (rather than
letting $ocms.menu create one inside a bar) keeps our own sizing class instead of being
replaced. Every entry carries both its label and a matching `title` tooltip. */
$inv.rowCtxMenu = function (items) {
let nav = $$[0]('nav').aC('rowctx');
nav.ocmsmenu([{ glyph: 'glyphicon-option-vertical', aclass: 'ctxbtn', attr: { title: $rct.ctxM }, itm: items }]);
/* $ocms.menu puts `attr` (incl. our title tooltip) on the <li>, not the <a> a viewer actually
hovers — mirror it onto the <a> too so the native tooltip is guaranteed to fire regardless
of how the browser resolves title on a non-interactive ancestor. */
nav.find('li[title]').each(function () { let li = $(this); li.children('a').attr('title', li.attr('title')); });
return nav;
};
$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();
let bc = [
$$.dc('ibtn insb', { title: $rct.iRb }).append(gi('indent-left')).click(rw, $inv.inRow)
let mi = [
{ lbl: $rct.iRb, attr: { title: $rct.iRb }, data: rw, fnc: $inv.inRow }
];
if (ph === false) {
bc.unshift($$.dc('ibtn edit', { title: $rct.cP }).append(gi('pencil')).click(rw, $inv.eRow));
bc.push($$.dc('ibtn del', { title: $rct.dR }).append(gi('trash')).click(function (e) { if (confirm($rct.cD)) { rw.remove(); $inv.t_fds_inv(); } }));
mi.unshift({ lbl: $rct.cP, attr: { title: $rct.cP }, data: rw, fnc: $inv.eRow });
mi.push({ lbl: $rct.dR, attr: { title: $rct.dR }, fnc: function (e) { if (confirm($rct.cD)) { rw.remove(); $inv.t_fds_inv(); } } });
/* "Auf Setpreis umstellen": offered on a Set header item that does not yet carry a
price. fds__prepInvoice's [SetItmId] now anchors on the still-unconverted (price 0)
header, so the header row's own SetItmId self-references its own id rather than being
@@ -1795,10 +1862,10 @@ $inv.rrw = function () {
Irreversible: the actual sum/clear mutation happens server-side (backend-authoritative,
see InvoiceDraftEditService.ApplyItemSetPrice) via the "item.setprice" patch target. */
if ((dta.Type || '').toLowerCase() === 'set' && (dta.net_val || 0) === 0) {
bc.push($$.dc('ibtn tosetp', { title: $rct.toSetP }).append(gi('scale')).click(rw, $inv.toSetPrice));
mi.push({ lbl: $rct.toSetP, attr: { title: $rct.toSetP }, data: rw, fnc: $inv.toSetPrice });
}
}
let axf = $$.dc('axf').append(bc);
let axf = $$.dc('axf').append($inv.rowCtxMenu(mi));
if (ph === true) {
co = { id: '', typ: 'placeholder' };
} else if (rw.is('.itm.osum') === true) {
@@ -1807,7 +1874,12 @@ $inv.rrw = function () {
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
co.p = dta.position || (dta.SortOrder || '');
/* dta.position is '' (not unset) for a Text/Title row once invSumUpdate has numbered the
invoice (ADR 0017) — an intentional "no number", not a missing value, so it must NOT
fall through to SortOrder (which is -1 for such rows and would leak "-1" into Pos.).
Only an as-yet-uncomputed row (freshly inserted, before the next invSumUpdate pass)
has position === undefined and should still fall back to SortOrder as a placeholder. */
co.p = dta.position != null ? dta.position : (dta.SortOrder || '');
if (co.id === '') {
co.t = '';
} else if (['Text', 'Title'].includes(co.typ) && (dta.net_val || 0) === 0) {
@@ -1821,7 +1893,7 @@ $inv.rrw = function () {
}
}
if ((dta.Note || '') !== '') {
$$.dc('ibtn add', axf).append(gi('object-align-left')).click(function (e) {
$$.dc('ibtn add', axf, { title: $rct.noteToggle }).append(gi('object-align-left')).click(function (e) {
$inv.rrw.call(rw.tC('hidenote'));
});
}
@@ -1966,7 +2038,7 @@ $inv.rendersrq = function () {
$$.dc('ibtn insb', aux, { title: $rct.iRb }).append(gi('indent-left')).click(rtr, $inv.inRow);
let stxt = $$.sc('text', rx.text);
let nme = $$.td(rtr, { colspan: onesum ? 4 : 5 }).append(stxt), oidta, numkeys = ['net_val', 'vat_val', 'svcnet_val', 'svcvat_val', 'net'];
$$.dc('ibtn edit', aux).data('dialog', $rcol.req.lbl().Name).append(gi('pencil')).click({ t: stxt, change: (nv) => { rx.text = nv; $inv.t_fds_inv(); } }, $inv.eHtml)
$$.dc('ibtn edit', aux, { title: $rct.sectionTitleEditTT }).data('dialog', $rcol.req.lbl().Name).append(gi('pencil')).click({ t: stxt, change: (nv) => { rx.text = nv; $inv.t_fds_inv(); } }, $inv.eHtml)
if (onesum) {
$$.tdc('currency isumval', rtr);
oidta = { Id: rx.Id.toString() + '_osum', net_val: 0, vat_val: 0, svcnet_val: 0, svcvat_val: 0, net: 0 };
@@ -2343,7 +2415,8 @@ $inv.ccRem = function (id, InvoiceId) { //reminder creation
$inv.rRemRw = function (dta) {
let tr = $(this), rem = dta.rm || {};
tr.empty().data({ invoiceid: rem.invoiceid, invoicedate: rem.invoicedate, amount: rem.amount, amount_payed: rem.amount_payed });
let axf = $$.dc('axf').append($$.dc('ibtn edit', { title: $rct.cP }).append(gi('pencil')).click(tr, $inv.eRowR));
let mi = [{ lbl: $rct.cP, attr: { title: $rct.cP }, data: tr, fnc: $inv.eRowR }];
let axf = $$.dc('axf').append($inv.rowCtxMenu(mi));
tr.append([$$.tdc('aux').append(axf), $$.tdc('keep', rem.invoiceid), $$.tdc('keep', fdt(rem.invoicedate, 'dd.MM.yy')), $$.tdc('currency', fnum(rem.amount, $rct.cst)), $$.tdc('currency', fnum(rem.amount_payed, $rct.cst)), $$.tdc('currency', fnum(rem.amount - rem.amount_payed, $rct.cst))]);
};
$inv.eRowR = function (ev) {
@@ -2384,9 +2457,12 @@ $inv.ccRem_s2 = function (id, sets) { //reminder creation
$inv.rRemRw.call($$.tr(rif.bdy), rif.tbl.data());
rif.ft.appendTo(rif.tbl);
/* Reminders have no CC/Zusatzempfänger field (see $inv.emailFrm, invoice-only), so the
email pencil here keeps a plain tooltip instead of $rct.frmTT.invoiceemail. */
let remTT = { invoiceemail: 'Email-Adresse ändern', subject: 'Betreff ändern' };
let fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: remTT[nme] || $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', rem.subject, 'subject', 0, null);
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+17 -5
View File
@@ -2851,6 +2851,7 @@ $.extend($t, {
, pwr: 'Bitte wählen Sie ein starkes Passwort (min 8 Zeichen, davon jeweils min 2 Zahlen, kleine und große Buchstaben, Sonderzeichen sind optional).'
, smsc: 'Sie beötigen für diese Funktion einen SMS-Code.\nSoll dieser nun versandt werden?'
, wdc: 'Doppelt klicken, um die Box zu aktualisieren.'
, wldg: 'Widgets werden geladen…'
, wdg: {}
});
$t.rspf = { sms: 'Der SMS-Code konnte nicht bestätigt werden', valid: 'Das alte Passwort ist nicht korrekt', requirements: 'Das Passwort entspricht nicht den Anforderungen.\n' + $t.pwr}
@@ -2904,7 +2905,7 @@ $fis.db = function () {
$ocms.postXT({
url: $ocms.url('wdg/my'), success: function (wx) {
$.each(wx, function (ii, wi) {
var wf = $$.dc('wdg_frame', ovf, { 'data-wdg': wi }).ldng(1);
var wf = $$.dc('wdg_frame', ovf, { 'data-wdg': wi }).hide();
$ocms.wdg.call(wf, { wdg: wi });
});
}, loading: ovf
@@ -3075,12 +3076,17 @@ $fis.wdg = function (options) {
if ($.inArray('reload_30min', wx.rendering_options) > -1 && wx.type !== 'html') {
setTimeout(function () { $fis.wdg.call(wf, { wdg: wi }); }, (30 * 60 * 1000));
}
wct.slideDown(150);
if (wf.is(':visible')) {
wct.slideDown(150);
} else {
wct.show();
wf.fadeIn(150);
}
}, error: function (jqXHR) {
wf.slideUp(150);
if (wf.is(':visible')) { wf.slideUp(150); }
$fis.failure.call(this, jqXHR);
}, complete: function () {
wf.ldng(0);
wf.ldng(0).trigger('wdg:settled');
}
});
};
@@ -3090,8 +3096,14 @@ $fis.ov = function () {
let cf = $('#contentframe').empty(), ovf = $$.d({ id: 'dashboard_frame' }).appendTo(cf);
$ocms.postXT({
url: $ocms.url('wdg/my'), success: function (wx) {
let ind = $$.dc('wdg_loading_ind', ovf).text($t.wldg || '').hide();
let pending = wx.length;
let settle = function () {
if (--pending <= 0) { ind.fadeOut(150); }
};
if (pending > 0) { ind.show(); }
$.each(wx, function (ii, wi) {
var wf = $$.dc('wdg_frame', ovf, { 'data-wdg': wi }).ldng(1);
var wf = $$.dc('wdg_frame', ovf, { 'data-wdg': wi }).hide().one('wdg:settled', settle);
$fis.wdg.call(wf, { wdg: wi });
});
}, loading: ovf
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+46 -18
View File
@@ -265,7 +265,7 @@ table.if th.keep, table.if td.keep {
top: 0;
width: auto;
max-height: 1.5rem;
overflow: hidden;
overflow: visible; /* the .rowctx dropdown popout must escape this pill-shaped bar */
padding: 0.1rem;
}
.edit_frm .invoice_layout .axf > .ibtn {
@@ -275,6 +275,47 @@ table.if th.keep, table.if td.keep {
display: inline-block;
position: relative;
}
.edit_frm .invoice_layout .axf {
/* Single row "Kontext-Button" opening a dropdown of edit actions (see $inv.rowCtxMenu).
Resets the generic full-bar <nav> styling (meant for #topbar/#sidebar) down to a
small inline trigger; the dropdown popout itself keeps the shared nav/dropdown-menu
look (background, shadow, open/close via $nuf) so it matches every other menu in the app. */
}
.edit_frm .invoice_layout .axf nav.rowctx {
position: relative;
display: inline-block;
width: 1.5rem;
height: 100%;
min-height: 0;
max-height: none;
background: transparent;
z-index: auto;
vertical-align: top;
}
.edit_frm .invoice_layout .axf nav.rowctx > ul {
height: 100%;
}
.edit_frm .invoice_layout .axf nav.rowctx > ul > li {
height: 100%;
float: none;
}
.edit_frm .invoice_layout .axf nav.rowctx > ul > li > a.ctxbtn {
padding: 0;
width: 1.5rem;
height: 100%;
text-align: center;
line-height: 1.3rem;
}
.edit_frm .invoice_layout .axf nav.rowctx > ul > li > a.ctxbtn > .caret {
display: none; /* single kebab trigger, no caret needed */
}
.edit_frm .invoice_layout .axf nav.rowctx > ul > li.dropdown > ul.dropdown-menu {
top: 100%;
left: auto;
right: 0;
font-size: 0.85rem;
white-space: nowrap;
}
.edit_frm .invoice_layout .sndfrm {
position: absolute;
font-size: 0.7rem;
@@ -302,11 +343,14 @@ table.if th.keep, table.if td.keep {
min-width: 5rem;
height: 1rem;
display: block;
position: absolute;
}
.edit_frm .invoice_layout .emailfrm:hover div.axf {
display: block;
}
.edit_frm .invoice_layout {
/* Read-only display: edited together with the email above, via that row's pencil
(see $inv.emailFrm / $inv.eEmail) — no pencil/hover-reveal of its own. */
}
.edit_frm .invoice_layout .emailccfrm {
position: absolute;
font-size: 0.7rem;
@@ -314,26 +358,10 @@ table.if th.keep, table.if td.keep {
left: 0.5rem;
width: auto;
height: auto;
cursor: pointer;
}
.edit_frm .invoice_layout .emailccfrm .content {
color: #000;
}
.edit_frm .invoice_layout .emailccfrm {
/* Unlike the other optional fields (plain "-" placeholder), this one names the action —
there is no default "Zusatzempfänger" from MFR, so an empty state must read as an
offer to add one, not as missing data. */
}
.edit_frm .invoice_layout .emailccfrm .content:empty::before {
content: "Zusatzempfänger hinzufügen";
min-width: 5rem;
height: 1rem;
display: block;
position: absolute;
}
.edit_frm .invoice_layout .emailccfrm:hover div.axf {
display: block;
}
.edit_frm .invoice_layout .admfrm {
position: absolute;
right: 3rem;
+99 -23
View File
@@ -31,6 +31,9 @@ let $rct = {
sV: 'USt festlegen',
cD: 'Löschen?',
mR: 'Zeile verschieben',
ctxM: 'Weitere Aktionen',
noteToggle: 'Notiz ein-/ausblenden',
sectionTitleEditTT: 'Auftragsbezeichnung ändern',
svcPart: 'Service-Anteil',
vat: 'Umsatzsteuer',
combP: 'Positionen zusammenfassen',
@@ -62,11 +65,21 @@ let $rct = {
, '-': '-'
}, invHR: ['Pos.', 'Menge', 'Artikelbezeichnung', 'VK', 'Summe']
, frm: {
invoiceaddress: 'Adresse'
invoicetitle: 'Titel'
, invoiceaddress: 'Adresse'
, loc: 'Leistungsort / Lieferadresse'
, invoiceemail: 'Email'
, invoiceemailcc: 'Zusatzempfänger (CC)'
, provisionperiod: 'Leistungsdatum / -zeitraum'
}
/* Tooltip text for each header-field pencil — object+verb phrasing ("<was> <tun>"),
distinct from frm[] above (used for dialog titles / field labels, which stay short nouns). */
, frmTT: {
invoicetitle: 'Rechnungstitel ändern'
, invoiceaddress: 'Rechnungsempfänger-Adresse ändern'
, loc: 'Leistungsort oder Lieferadresse ändern'
, invoiceemail: 'Email-Adresse ändern und zusätzliche Email-Empfänger festlegen'
, provisionperiod: 'Leistungsdatum oder -zeitraum ändern'
}
}, $rcol = {
req: new fields_definition('Auftrag', 'Aufträge', [
@@ -1289,13 +1302,13 @@ $inv.ccInv = function (ev) { //normale rechnung
let admin = response.admin || {}, fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', admin.invoicetitle, 'invoicetitle', 0, null);
fm('adrfrm', admin.invoiceaddress, 'invoiceaddress', 0, null);
fm('locfrm', '', 'loc', 1, { list: deepCopy(response.locations), lbl: 'ref', property: 'address' });
fm('emailfrm', admin.invoiceemail, 'invoiceemail', 0, null);
$inv.emailFrm(frm, rif, admin.invoiceemail, admin.invoiceemailcc);
$$.dc('sndfrm', frm).append($$.dc('content').text(admin.sender));
let admtxt, admlbl;
@@ -1364,13 +1377,13 @@ $inv.ccStInv = function (ev) { // Stornorechnung
let admin = response.admin || {}, fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', admin.invoicetitle, 'invoicetitle', 0, null);
fm('adrfrm', admin.invoiceaddress, 'invoiceaddress', 0, null);
fm('locfrm', admin.provisionlocation, 'loc', 1, { list: deepCopy(response.locations), lbl: 'ref', property: 'address' });
fm('emailfrm', admin.invoiceemail, 'invoiceemail', 0, null);
$inv.emailFrm(frm, rif, admin.invoiceemail, admin.invoiceemailcc);
$$.dc('sndfrm', frm).append($$.dc('content').text(admin.sender));
let admtxt, admlbl;
@@ -1440,14 +1453,13 @@ $inv.cntInv = function (data) { //invoice continuation
let fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', inv.InvoiceTitle, 'invoicetitle', 0, null);
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$inv.emailFrm(frm, rif, inv.SendToEmail, inv.SendToEmailCc);
$$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender));
fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null);
@@ -1503,14 +1515,13 @@ $inv.cSt = function (data) {
let fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', inv.InvoiceTitle, 'invoicetitle', 0, null);
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$inv.emailFrm(frm, rif, inv.SendToEmail, inv.SendToEmailCc);
$$.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);
@@ -1638,6 +1649,45 @@ $inv.eProvisionPeriod = function (ev) {
});
};
/* Main recipient + CC ("Zusatzempfänger") edited together in one dialog — there is no separate
pencil on the CC row, only on the email row (see $inv.emailFrm), which passes both content
spans through ev.data.t / ev.data.ccT. */
$inv.eEmail = function (ev) {
let flds = [
{ name: 'email', label: $rct.frm.invoiceemail, type: 'text', value: (ev.data.t || $$.s()).text() },
{ name: 'emailcc', label: $rct.frm.invoiceemailcc, type: 'text', value: (ev.data.ccT || $$.s()).text() }
];
$ocms.dlgform(flds, {
title: $rct.frm.invoiceemail,
success: function (res) {
let email = ('' + (res.email || '')).trim(), cc = ('' + (res.emailcc || '')).trim();
if (ev.data && ev.data.t) { ev.data.t.text(email); }
if (ev.data && ev.data.ccT) { ev.data.ccT.text(cc); }
if (typeof (ev.data || {}).change === 'function') { ev.data.change(email); }
if (typeof (ev.data || {}).changeCc === 'function') { ev.data.changeCc(cc); }
/* backend-authoritative: mirror both fields to the server session (see $inv.eHtml's
generic path, which this mirrors for a single field). */
$inv.d.syncField('invoiceemail', email);
$inv.rd.syncField('invoiceemail', email);
$inv.d.syncField('invoiceemailcc', cc);
$inv.rd.syncField('invoiceemailcc', cc);
}
});
};
/* Builds the email + CC ("Zusatzempfänger") header rows together: only the email row gets a
pencil, opening the combined $inv.eEmail dialog for both fields (see there). */
$inv.emailFrm = function (frm, rif, email, emailcc) {
let ccfr = $$.dc('inpfrm emailccfrm', frm), ccfc = $$.dc('content', ccfr).rwText(emailcc);
let efr = $$.dc('inpfrm emailfrm', frm), efc = $$.dc('content', efr).rwText(email);
$$.dc('axf', efr).append($$.dc('ibtn edit', { title: $rct.frmTT.invoiceemail }).append(gi('pencil')).click({
t: efc, ccT: ccfc, nme: 'invoiceemail',
change: (nv) => { rif.tbl.data('new').invoiceemail = nv; },
changeCc: (nv) => { rif.tbl.data('new').invoiceemailcc = nv; }
}, $inv.eHtml));
rif.tbl.data('new').invoiceemail = email;
rif.tbl.data('new').invoiceemailcc = emailcc;
};
$inv.eHtml = function (ev) {
/* The recipient address and the service date/period are edited through structured dialogs
whenever an invoice draft is active; reminders (no invoice draft token) keep the plain
@@ -1646,6 +1696,9 @@ $inv.eHtml = function (ev) {
if (ev.data.nme === 'invoiceaddress') { return $inv.eAddress.call(this, ev); }
if (ev.data.nme === 'provisionperiod') { return $inv.eProvisionPeriod.call(this, ev); }
}
/* The CC/Zusatzempfänger recipient is edited together with the main email, in one dialog —
there is no separate pencil on the CC row (see $inv.emailFrm). */
if (ev.data && !(ev.data instanceof jQuery) && ev.data.nme === 'invoiceemail' && ev.data.ccT) { return $inv.eEmail.call(this, ev); }
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
/* 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
@@ -1759,15 +1812,29 @@ $inv.eRw = function(row, dta, flds) {
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(); } }) }
/* Single "Kontext-Button" per row/field, opening a dropdown of the row's edit actions
(reuses $ocms.menu, the same mechanism that already drives the toolbar menus — see
$inv.eM/$inv.iMn). Calling .ocmsmenu() directly on a <nav> we built ourselves (rather than
letting $ocms.menu create one inside a bar) keeps our own sizing class instead of being
replaced. Every entry carries both its label and a matching `title` tooltip. */
$inv.rowCtxMenu = function (items) {
let nav = $$[0]('nav').aC('rowctx');
nav.ocmsmenu([{ glyph: 'glyphicon-option-vertical', aclass: 'ctxbtn', attr: { title: $rct.ctxM }, itm: items }]);
/* $ocms.menu puts `attr` (incl. our title tooltip) on the <li>, not the <a> a viewer actually
hovers — mirror it onto the <a> too so the native tooltip is guaranteed to fire regardless
of how the browser resolves title on a non-interactive ancestor. */
nav.find('li[title]').each(function () { let li = $(this); li.children('a').attr('title', li.attr('title')); });
return nav;
};
$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();
let bc = [
$$.dc('ibtn insb', { title: $rct.iRb }).append(gi('indent-left')).click(rw, $inv.inRow)
let mi = [
{ lbl: $rct.iRb, attr: { title: $rct.iRb }, data: rw, fnc: $inv.inRow }
];
if (ph === false) {
bc.unshift($$.dc('ibtn edit', { title: $rct.cP }).append(gi('pencil')).click(rw, $inv.eRow));
bc.push($$.dc('ibtn del', { title: $rct.dR }).append(gi('trash')).click(function (e) { if (confirm($rct.cD)) { rw.remove(); $inv.t_fds_inv(); } }));
mi.unshift({ lbl: $rct.cP, attr: { title: $rct.cP }, data: rw, fnc: $inv.eRow });
mi.push({ lbl: $rct.dR, attr: { title: $rct.dR }, fnc: function (e) { if (confirm($rct.cD)) { rw.remove(); $inv.t_fds_inv(); } } });
/* "Auf Setpreis umstellen": offered on a Set header item that does not yet carry a
price. fds__prepInvoice's [SetItmId] now anchors on the still-unconverted (price 0)
header, so the header row's own SetItmId self-references its own id rather than being
@@ -1776,10 +1843,10 @@ $inv.rrw = function () {
Irreversible: the actual sum/clear mutation happens server-side (backend-authoritative,
see InvoiceDraftEditService.ApplyItemSetPrice) via the "item.setprice" patch target. */
if ((dta.Type || '').toLowerCase() === 'set' && (dta.net_val || 0) === 0) {
bc.push($$.dc('ibtn tosetp', { title: $rct.toSetP }).append(gi('scale')).click(rw, $inv.toSetPrice));
mi.push({ lbl: $rct.toSetP, attr: { title: $rct.toSetP }, data: rw, fnc: $inv.toSetPrice });
}
}
let axf = $$.dc('axf').append(bc);
let axf = $$.dc('axf').append($inv.rowCtxMenu(mi));
if (ph === true) {
co = { id: '', typ: 'placeholder' };
} else if (rw.is('.itm.osum') === true) {
@@ -1788,7 +1855,12 @@ $inv.rrw = function () {
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
co.p = dta.position || (dta.SortOrder || '');
/* dta.position is '' (not unset) for a Text/Title row once invSumUpdate has numbered the
invoice (ADR 0017) — an intentional "no number", not a missing value, so it must NOT
fall through to SortOrder (which is -1 for such rows and would leak "-1" into Pos.).
Only an as-yet-uncomputed row (freshly inserted, before the next invSumUpdate pass)
has position === undefined and should still fall back to SortOrder as a placeholder. */
co.p = dta.position != null ? dta.position : (dta.SortOrder || '');
if (co.id === '') {
co.t = '';
} else if (['Text', 'Title'].includes(co.typ) && (dta.net_val || 0) === 0) {
@@ -1802,7 +1874,7 @@ $inv.rrw = function () {
}
}
if ((dta.Note || '') !== '') {
$$.dc('ibtn add', axf).append(gi('object-align-left')).click(function (e) {
$$.dc('ibtn add', axf, { title: $rct.noteToggle }).append(gi('object-align-left')).click(function (e) {
$inv.rrw.call(rw.tC('hidenote'));
});
}
@@ -1947,7 +2019,7 @@ $inv.rendersrq = function () {
$$.dc('ibtn insb', aux, { title: $rct.iRb }).append(gi('indent-left')).click(rtr, $inv.inRow);
let stxt = $$.sc('text', rx.text);
let nme = $$.td(rtr, { colspan: onesum ? 4 : 5 }).append(stxt), oidta, numkeys = ['net_val', 'vat_val', 'svcnet_val', 'svcvat_val', 'net'];
$$.dc('ibtn edit', aux).data('dialog', $rcol.req.lbl().Name).append(gi('pencil')).click({ t: stxt, change: (nv) => { rx.text = nv; $inv.t_fds_inv(); } }, $inv.eHtml)
$$.dc('ibtn edit', aux, { title: $rct.sectionTitleEditTT }).data('dialog', $rcol.req.lbl().Name).append(gi('pencil')).click({ t: stxt, change: (nv) => { rx.text = nv; $inv.t_fds_inv(); } }, $inv.eHtml)
if (onesum) {
$$.tdc('currency isumval', rtr);
oidta = { Id: rx.Id.toString() + '_osum', net_val: 0, vat_val: 0, svcnet_val: 0, svcvat_val: 0, net: 0 };
@@ -2324,7 +2396,8 @@ $inv.ccRem = function (id, InvoiceId) { //reminder creation
$inv.rRemRw = function (dta) {
let tr = $(this), rem = dta.rm || {};
tr.empty().data({ invoiceid: rem.invoiceid, invoicedate: rem.invoicedate, amount: rem.amount, amount_payed: rem.amount_payed });
let axf = $$.dc('axf').append($$.dc('ibtn edit', { title: $rct.cP }).append(gi('pencil')).click(tr, $inv.eRowR));
let mi = [{ lbl: $rct.cP, attr: { title: $rct.cP }, data: tr, fnc: $inv.eRowR }];
let axf = $$.dc('axf').append($inv.rowCtxMenu(mi));
tr.append([$$.tdc('aux').append(axf), $$.tdc('keep', rem.invoiceid), $$.tdc('keep', fdt(rem.invoicedate, 'dd.MM.yy')), $$.tdc('currency', fnum(rem.amount, $rct.cst)), $$.tdc('currency', fnum(rem.amount_payed, $rct.cst)), $$.tdc('currency', fnum(rem.amount - rem.amount_payed, $rct.cst))]);
};
$inv.eRowR = function (ev) {
@@ -2365,9 +2438,12 @@ $inv.ccRem_s2 = function (id, sets) { //reminder creation
$inv.rRemRw.call($$.tr(rif.bdy), rif.tbl.data());
rif.ft.appendTo(rif.tbl);
/* Reminders have no CC/Zusatzempfänger field (see $inv.emailFrm, invoice-only), so the
email pencil here keeps a plain tooltip instead of $rct.frmTT.invoiceemail. */
let remTT = { invoiceemail: 'Email-Adresse ändern', subject: 'Betreff ändern' };
let fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: remTT[nme] || $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', rem.subject, 'subject', 0, null);
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long