Compare commits
5
Commits
1a8a14791d
...
22d2ef9c1a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22d2ef9c1a | ||
|
|
f407341f5f | ||
|
|
406c6f2860 | ||
|
|
c182d4fd69 | ||
|
|
071982caec |
@@ -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]
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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 <img> 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <img src> (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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <img> 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 <img> 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.
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
namespace Fuchs.intranet;
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// Computes hierarchical invoice position labels ("1", "1.1", "1.2", "2", …) from a
|
||||
|
||||
@@ -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 <img> 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
|
||||
/// <img> 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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user