Add backend-authoritative invoice draft editing (ADR 0006/0007) #1
@@ -0,0 +1,92 @@
|
|||||||
|
using Fuchs.intranet;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exhaustively exercises the pure reminder-draft aggregation/validation (ADR 0006,
|
||||||
|
/// the reminder mirror of <see cref="InvoiceDraftCalculatorTests"/>). Being static/pure,
|
||||||
|
/// the open-amount math and the plausibility checks are unit-testable without a DB.
|
||||||
|
/// </summary>
|
||||||
|
public class ReminderDraftCalculatorTests
|
||||||
|
{
|
||||||
|
private static ReminderDraftSession Session(string newJson) =>
|
||||||
|
new() { New = JObject.Parse(newJson) };
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("{'amount':119,'amount_payed':0}", 119, 0, 119)]
|
||||||
|
[InlineData("{'amount':119,'amount_payed':20}", 119, 20, 99)]
|
||||||
|
[InlineData("{'amount':'119,50','amount_payed':'19,50'}", 119.50, 19.50, 100)] // German decimals
|
||||||
|
[InlineData("{'amount':'100.00','amount_payed':'40.00'}", 100, 40, 60)] // invariant decimals
|
||||||
|
[InlineData("{}", 0, 0, 0)] // missing → 0
|
||||||
|
[InlineData("{'amount':50,'amount_payed':80}", 50, 80, -30)] // overpaid → negative
|
||||||
|
public void RecomputeTotals_ComputesOpenAmount(string newJson, double total, double payed, double open)
|
||||||
|
{
|
||||||
|
var s = Session(newJson);
|
||||||
|
ReminderDraftCalculator.RecomputeTotals(s);
|
||||||
|
Assert.Equal((decimal)total, s.Sums.AmountTotal);
|
||||||
|
Assert.Equal((decimal)payed, s.Sums.AmountPayed);
|
||||||
|
Assert.Equal((decimal)open, s.Sums.AmountOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_EmptyEmail_Warns()
|
||||||
|
{
|
||||||
|
var s = Session("{'amount':119,'invoiceaddress':'Weg 1','subject':'X'}");
|
||||||
|
ReminderDraftCalculator.RecomputeTotals(s);
|
||||||
|
ReminderDraftCalculator.Validate(s);
|
||||||
|
Assert.Contains(s.ValidationMessages, m => m.Field == "email" && m.Severity == "warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("bad")]
|
||||||
|
[InlineData("no-at-sign.de")]
|
||||||
|
[InlineData("trailing@dot.")]
|
||||||
|
public void Validate_InvalidEmail_Errors(string email)
|
||||||
|
{
|
||||||
|
var s = Session($"{{'amount':119,'invoiceemail':'{email}','invoiceaddress':'Weg 1','subject':'X'}}");
|
||||||
|
ReminderDraftCalculator.RecomputeTotals(s);
|
||||||
|
ReminderDraftCalculator.Validate(s);
|
||||||
|
Assert.Contains(s.ValidationMessages, m => m.Field == "email" && m.Severity == "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_ValidEmail_NoEmailMessage()
|
||||||
|
{
|
||||||
|
var s = Session("{'amount':119,'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'X'}");
|
||||||
|
ReminderDraftCalculator.RecomputeTotals(s);
|
||||||
|
ReminderDraftCalculator.Validate(s);
|
||||||
|
Assert.DoesNotContain(s.ValidationMessages, m => m.Field == "email");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_EmptyAddressAndSubject_Warn()
|
||||||
|
{
|
||||||
|
var s = Session("{'amount':119,'invoiceemail':'a@b.de'}");
|
||||||
|
ReminderDraftCalculator.RecomputeTotals(s);
|
||||||
|
ReminderDraftCalculator.Validate(s);
|
||||||
|
Assert.Contains(s.ValidationMessages, m => m.Field == "address" && m.Severity == "warning");
|
||||||
|
Assert.Contains(s.ValidationMessages, m => m.Field == "subject" && m.Severity == "warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("{'amount':0,'amount_payed':0,'invoiceemail':'a@b.de','invoiceaddress':'W','subject':'X'}")]
|
||||||
|
[InlineData("{'amount':50,'amount_payed':80,'invoiceemail':'a@b.de','invoiceaddress':'W','subject':'X'}")]
|
||||||
|
public void Validate_NonPositiveOpenAmount_Warns(string newJson)
|
||||||
|
{
|
||||||
|
var s = Session(newJson);
|
||||||
|
ReminderDraftCalculator.RecomputeTotals(s);
|
||||||
|
ReminderDraftCalculator.Validate(s);
|
||||||
|
Assert.Contains(s.ValidationMessages, m => m.Field == "amount" && m.Severity == "warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_HealthyDraft_HasNoMessages()
|
||||||
|
{
|
||||||
|
var s = Session("{'amount':119,'amount_payed':0,'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'Zahlungserinnerung'}");
|
||||||
|
ReminderDraftCalculator.RecomputeTotals(s);
|
||||||
|
ReminderDraftCalculator.Validate(s);
|
||||||
|
Assert.Empty(s.ValidationMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using MigraDoc.DocumentObjectModel;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using OCORE.security;
|
||||||
|
using Xunit;
|
||||||
|
using static OCORE.OCORE_dictionaries;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises the reminder draft edit orchestrator's pure paths (open/patch/history/flush)
|
||||||
|
/// without a database — the reminder mirror of <see cref="InvoiceDraftServiceTests"/>,
|
||||||
|
/// proving the backend-authoritative model behaves correctly at the service seam (ADR 0006).
|
||||||
|
/// </summary>
|
||||||
|
public class ReminderDraftServiceTests
|
||||||
|
{
|
||||||
|
/// <summary>Captures the reminder handed to registration and returns it with a fake DB id — no SQL.</summary>
|
||||||
|
private sealed class FakeReminderService : IReminderService
|
||||||
|
{
|
||||||
|
public FdsReminderData? Registered;
|
||||||
|
public bool? LastChange;
|
||||||
|
public FdsReminderData? PreviewReminder;
|
||||||
|
public bool? PreviewDraft;
|
||||||
|
|
||||||
|
public Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId, string userAccountId, DatabaseSecurity dbSec)
|
||||||
|
{
|
||||||
|
Registered = reminder;
|
||||||
|
LastChange = change;
|
||||||
|
reminder.ReminderRegistration = new GenericObjectDictionary(new System.Collections.Generic.Dictionary<string, object> { ["Id"] = "REM42" });
|
||||||
|
return Task.FromResult(reminder);
|
||||||
|
}
|
||||||
|
public Document GenerateReminderPdf(FdsReminderData reminder, bool draft) { PreviewReminder = reminder; PreviewDraft = draft; return new Document(); }
|
||||||
|
public Task<FdsReminderData> LoadReminderAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||||
|
public Task<byte[]> RenderReminderPdfBytesAsync(FdsReminderData r, bool d) => throw new NotSupportedException();
|
||||||
|
public Task<byte[]> StoreReminderDocumentFileAsync(FdsReminderData r, bool d, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||||
|
public Task<byte[]> GetReminderFileAsync(FdsReminderData r, bool d, fds.IFdsMfr m, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||||
|
public Task<(System.IO.FileInfo? file, byte[]? content)> GetStoredFileAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (ReminderDraftEditService svc, FakeReminderService rem) NewService()
|
||||||
|
{
|
||||||
|
var cache = new ReminderDraftCache(new ConfigurationBuilder().Build());
|
||||||
|
var rem = new FakeReminderService();
|
||||||
|
var svc = new ReminderDraftEditService(cache, rem, NullLogger<ReminderDraftEditService>.Instance);
|
||||||
|
return (svc, rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JObject Payload() => JObject.Parse(@"{
|
||||||
|
'rem':{'invid':'INV5','type':'R','invoiceid':'R2026-1','invoicedate':'2026-06-01'},
|
||||||
|
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'Zahlungserinnerung','amount':119,'amount_payed':0}
|
||||||
|
}");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenFromPayload_SeedsSessionAndComputesOpenAmount()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
Assert.False(string.IsNullOrEmpty(s.Token));
|
||||||
|
Assert.Equal(0, s.Version);
|
||||||
|
Assert.Equal(119m, s.Sums.AmountTotal);
|
||||||
|
Assert.Equal(119m, s.Sums.AmountOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_Email_MutatesBumpsVersionAndRecordsHistory()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "email", Value = JToken.FromObject("neu@x.de") });
|
||||||
|
|
||||||
|
Assert.NotNull(s2);
|
||||||
|
Assert.Equal(1, s2!.Version);
|
||||||
|
Assert.Equal("neu@x.de", s2.New["invoiceemail"]!.Value<string>());
|
||||||
|
var h = Assert.Single(s2.History);
|
||||||
|
Assert.Equal("email", h.Target);
|
||||||
|
Assert.Equal("a@b.de", h.OldValue);
|
||||||
|
Assert.Equal("neu@x.de", h.NewValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_Amount_RecomputesOpenAmount()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount", Value = JToken.FromObject(200) });
|
||||||
|
|
||||||
|
Assert.Equal(200m, s2!.Sums.AmountTotal);
|
||||||
|
Assert.Equal(200m, s2.Sums.AmountOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_AmountPayed_RecomputesOpenAmount()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount_payed", Value = JToken.FromObject(19) });
|
||||||
|
|
||||||
|
Assert.Equal(100m, s2!.Sums.AmountOpen); // 119 - 19
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_AmountFromGermanString_NormalisesToInvariant()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount", Value = JToken.FromObject("249,90") });
|
||||||
|
|
||||||
|
Assert.Equal("249.90", s2!.New["amount"]!.Value<string>()); // stored invariant
|
||||||
|
Assert.Equal(249.90m, s2.Sums.AmountTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_UnknownToken_ReturnsNull()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
Assert.Null(svc.ApplyPatch("ghost", new ReminderDraftDelta { Target = "email", Value = JToken.FromObject("x@y.de") }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_UnknownTarget_IsNoOp()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "nonsense", Value = JToken.FromObject("x") });
|
||||||
|
|
||||||
|
Assert.Equal(0, s2!.Version);
|
||||||
|
Assert.Empty(s2.History);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("subject", "subject")]
|
||||||
|
[InlineData("address", "invoiceaddress")]
|
||||||
|
[InlineData("text", "text")]
|
||||||
|
public void ApplyPatch_ScalarFieldDeltas_UpdateNew(string target, string newKey)
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = target, Value = JToken.FromObject("X-VALUE") });
|
||||||
|
|
||||||
|
Assert.Equal("X-VALUE", s2!.New[newKey]!.Value<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("subject", "subject")]
|
||||||
|
[InlineData("email", "invoiceemail")]
|
||||||
|
public void ApplyPatch_ScalarField_StripsHtmlWrapper(string target, string newKey)
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = target, Value = JToken.FromObject("<p>clean me</p>") });
|
||||||
|
|
||||||
|
Assert.Equal("clean me", s2!.New[newKey]!.Value<string>());
|
||||||
|
Assert.DoesNotContain("<", s2.New[newKey]!.Value<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_Address_MultilineHtml_KeepsLineBreaks()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta
|
||||||
|
{
|
||||||
|
Target = "address",
|
||||||
|
Value = JToken.FromObject("<p>Firma AG</p><p>Weg 1<br>40000 Düsseldorf</p>")
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("Firma AG\nWeg 1\n40000 Düsseldorf", s2!.New["invoiceaddress"]!.Value<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_Contact_BuildsCustomValuesJson()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta
|
||||||
|
{
|
||||||
|
Target = "contact",
|
||||||
|
Value = JObject.Parse(@"{'name':'Max Mustermann','email':'max@kunde.de'}")
|
||||||
|
});
|
||||||
|
|
||||||
|
var cv = JObject.Parse(s2!.New["CustomValues"]!.Value<string>()!);
|
||||||
|
Assert.Equal("Max Mustermann", cv["contactName"]!.Value<string>());
|
||||||
|
Assert.Equal("max@kunde.de", cv["contactEmail"]!.Value<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyPatch_MultipleEdits_AccumulateHistoryInOrder()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "email", Value = JToken.FromObject("a1@x.de") });
|
||||||
|
svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "subject", Value = JToken.FromObject("Mahnung 2") });
|
||||||
|
var s3 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount", Value = JToken.FromObject(200) });
|
||||||
|
|
||||||
|
Assert.Equal(3, s3!.Version);
|
||||||
|
Assert.Equal(new[] { "email", "subject", "amount" }, s3.History.Select(h => h.Target).ToArray());
|
||||||
|
Assert.Equal(new[] { 1, 2, 3 }, s3.History.Select(h => h.Version).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildState_ExposesPayloadSumsValidationAndVersion()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount_payed", Value = JToken.FromObject(19) });
|
||||||
|
|
||||||
|
var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!));
|
||||||
|
|
||||||
|
Assert.Equal(1, state["version"]!.Value<int>());
|
||||||
|
Assert.Equal(119m, state["sums"]!["amount_total"]!.Value<decimal>());
|
||||||
|
Assert.Equal(100m, state["sums"]!["amount_open"]!.Value<decimal>());
|
||||||
|
Assert.Equal(1, state["historyCount"]!.Value<int>());
|
||||||
|
Assert.NotNull(state["validation"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FlushToDbAsync_RegistersAndSetsRemId_CreatePath()
|
||||||
|
{
|
||||||
|
var (svc, rem) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("REM42", result!.Id);
|
||||||
|
Assert.False(rem.LastChange); // new draft (no prior RemId) → create
|
||||||
|
Assert.Equal("REM42", svc.Get(s.Token)!.RemId);
|
||||||
|
// the email/subject the editor set must reach registration
|
||||||
|
Assert.Equal("a@b.de", rem.Registered!.RawInvoiceEmail);
|
||||||
|
Assert.Equal("Zahlungserinnerung", rem.Registered!.NewValues!.getString("subject"));
|
||||||
|
Assert.Equal("INV5", rem.Registered!.RawInvId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FlushToDbAsync_ExistingRemId_UpdatePath()
|
||||||
|
{
|
||||||
|
var (svc, rem) = NewService();
|
||||||
|
var payload = Payload();
|
||||||
|
payload["remid"] = "REM7";
|
||||||
|
var s = svc.OpenFromPayload(payload, "user1");
|
||||||
|
|
||||||
|
await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||||
|
|
||||||
|
Assert.True(rem.LastChange); // prior RemId → update path
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenderPreview_SynthesizesDraftRegistrationFromSession()
|
||||||
|
{
|
||||||
|
var (svc, rem) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
var doc = svc.RenderPreview(s.Token);
|
||||||
|
|
||||||
|
Assert.NotNull(doc);
|
||||||
|
Assert.True(rem.PreviewDraft);
|
||||||
|
Assert.True(rem.PreviewReminder!.IsDraft);
|
||||||
|
var reg = rem.PreviewReminder!.ReminderRegistration!;
|
||||||
|
Assert.Equal("Zahlungserinnerung", reg.getString("subject"));
|
||||||
|
Assert.Equal("Weg 1", reg.getString("SendToAddress"));
|
||||||
|
Assert.Equal("a@b.de", reg.getString("SendToEmail"));
|
||||||
|
Assert.Equal("R2026-1", reg.getString("InvoiceId"));
|
||||||
|
// the synthesised single-invoice row the reminder table renders
|
||||||
|
Assert.Single(rem.PreviewReminder!.ReminderItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RenderPreview_UnknownToken_ReturnsNull()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
Assert.Null(svc.RenderPreview("ghost"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetHistory_UnknownToken_IsEmpty()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
Assert.Empty(svc.GetHistory("ghost"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Close_RemovesSession_ThenReportsFalse()
|
||||||
|
{
|
||||||
|
var (svc, _) = NewService();
|
||||||
|
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||||
|
|
||||||
|
Assert.True(svc.Close(s.Token));
|
||||||
|
Assert.Null(svc.Get(s.Token));
|
||||||
|
Assert.False(svc.Close(s.Token));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,15 @@ public partial class IntranetController
|
|||||||
case "idoc": return await HandleReminderIdoc(fn, id, code);
|
case "idoc": return await HandleReminderIdoc(fn, id, code);
|
||||||
case "resend": return await HandleReminderResend(fn, id, code);
|
case "resend": return await HandleReminderResend(fn, id, code);
|
||||||
|
|
||||||
|
// ── Live backend-authoritative draft editing (ADR 0006) ───────────
|
||||||
|
case "dopen": return await HandleReminderDraftOpen(fn, id, code);
|
||||||
|
case "dstate": return await HandleReminderDraftState(fn, id, code);
|
||||||
|
case "dpatch": return await HandleReminderDraftPatch(fn, id, code);
|
||||||
|
case "dpreview": return await HandleReminderDraftPreview(fn, id, code);
|
||||||
|
case "dsave": return await HandleReminderDraftSave(fn, id, code);
|
||||||
|
case "dhistory": return await HandleReminderDraftHistory(fn, id, code);
|
||||||
|
case "dclose": return await HandleReminderDraftClose(fn, id, code);
|
||||||
|
|
||||||
case "lrem":
|
case "lrem":
|
||||||
{
|
{
|
||||||
if (!HasForm("id")) { _logger.LogWarning("Reminder lrem: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
if (!HasForm("id")) { _logger.LogWarning("Reminder lrem: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using static OCORE.web.mvc_helper_async;
|
||||||
|
|
||||||
|
namespace Fuchs.Controllers;
|
||||||
|
|
||||||
|
// Partial class: live, backend-authoritative reminder draft editing (ADR 0006) — the
|
||||||
|
// reminder mirror of IntranetController.InvoiceDraft.cs. The browser posts single edits
|
||||||
|
// here; the server mutates the in-memory session (the source of truth), recomputes the
|
||||||
|
// open amount / validates, and pings the editing browser over the shared DraftPreviewHub
|
||||||
|
// (draftReady) to re-fetch. Commands are ordinary POSTs — the hub carries only signals.
|
||||||
|
public partial class IntranetController
|
||||||
|
{
|
||||||
|
// POST rem/dopen — { payload } → { token, version }
|
||||||
|
private async Task<IActionResult> HandleReminderDraftOpen(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("payload"))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Reminder draft dopen: 'payload' missing user={User}", UserAccountID);
|
||||||
|
return BadRequest400();
|
||||||
|
}
|
||||||
|
JObject payload;
|
||||||
|
try { payload = JObject.Parse(Form("payload")); }
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Reminder draft dopen: invalid payload JSON user={User}", UserAccountID);
|
||||||
|
return BadRequest400();
|
||||||
|
}
|
||||||
|
var session = _reminderDrafts.OpenFromPayload(payload, UserAccountID);
|
||||||
|
_logger.LogInformation("Reminder draft dopen: session {Token} (remId={RemId}) user={User}", session.Token, session.RemId, UserAccountID);
|
||||||
|
// The browser holds the token from this response and fetches dstate directly; there is
|
||||||
|
// no server 'draftReady' on open (it would race the client's group-join).
|
||||||
|
return await JSONAsync(new { token = session.Token, version = session.Version });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST rem/dstate — { token } → full view state
|
||||||
|
private async Task<IActionResult> HandleReminderDraftState(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
var session = _reminderDrafts.Get(Form("token"));
|
||||||
|
if (session == null) return DraftGone();
|
||||||
|
return await JSONAsync(_reminderDrafts.BuildState(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST rem/dpatch — { token, delta } → { ok, version }; signals draftReady
|
||||||
|
private async Task<IActionResult> HandleReminderDraftPatch(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token", "delta")) return BadRequest400();
|
||||||
|
ReminderDraftDelta? delta;
|
||||||
|
try { delta = JsonConvert.DeserializeObject<ReminderDraftDelta>(Form("delta")); }
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Reminder draft dpatch: invalid delta JSON user={User}", UserAccountID);
|
||||||
|
return BadRequest400();
|
||||||
|
}
|
||||||
|
if (delta == null || string.IsNullOrEmpty(delta.Target)) return BadRequest400();
|
||||||
|
|
||||||
|
var session = _reminderDrafts.ApplyPatch(Form("token"), delta);
|
||||||
|
if (session == null) return DraftGone();
|
||||||
|
await _draftNotifier.SignalDraftReadyAsync(session.Token, session.Version);
|
||||||
|
return await JSONAsync(new { ok = true, version = session.Version });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST rem/dpreview — { token } → { img[], total } (rendered straight from the cache)
|
||||||
|
private async Task<IActionResult> HandleReminderDraftPreview(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
var doc = _reminderDrafts.RenderPreview(Form("token"));
|
||||||
|
if (doc == null) return DraftGone();
|
||||||
|
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||||
|
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST rem/dsave — { token } → { ok, remid }; flush cache→DB + business event + draftReady
|
||||||
|
private async Task<IActionResult> HandleReminderDraftSave(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
string token = Form("token");
|
||||||
|
var before = _reminderDrafts.Get(token);
|
||||||
|
if (before == null) return DraftGone();
|
||||||
|
bool existed = !string.IsNullOrEmpty(before.RemId);
|
||||||
|
|
||||||
|
var fdRem = await _reminderDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
||||||
|
if (fdRem == null) return DraftGone();
|
||||||
|
if (string.IsNullOrEmpty(fdRem.Id))
|
||||||
|
return await ReminderIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
||||||
|
|
||||||
|
await _events.ReminderDraftRegisteredAsync(fdRem, existed, UserAccountID);
|
||||||
|
var after = _reminderDrafts.Get(token);
|
||||||
|
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
|
||||||
|
return await JSONAsync(new { ok = true, remid = fdRem.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST rem/dhistory — { token } → { history[] }
|
||||||
|
private async Task<IActionResult> HandleReminderDraftHistory(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
if (_reminderDrafts.Get(Form("token")) == null) return DraftGone();
|
||||||
|
var history = _reminderDrafts.GetHistory(Form("token"))
|
||||||
|
.Select(h => new
|
||||||
|
{
|
||||||
|
timestamp = h.TimestampUtc,
|
||||||
|
target = h.Target,
|
||||||
|
@ref = h.Ref,
|
||||||
|
oldValue = h.OldValue,
|
||||||
|
newValue = h.NewValue,
|
||||||
|
version = h.Version
|
||||||
|
});
|
||||||
|
return await JSONAsync(new { history });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST rem/dclose — { token } → { ok }
|
||||||
|
private async Task<IActionResult> HandleReminderDraftClose(string fn, string id, string code)
|
||||||
|
{
|
||||||
|
if (!HasForm("token")) return BadRequest400();
|
||||||
|
bool ok = _reminderDrafts.Close(Form("token"));
|
||||||
|
_logger.LogDebug("Reminder draft dclose token={Token} removed={Removed} user={User}", Form("token"), ok, UserAccountID);
|
||||||
|
return await JSONAsync(new { ok });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
|||||||
private readonly IReminderService _reminders;
|
private readonly IReminderService _reminders;
|
||||||
private readonly IEventService _events;
|
private readonly IEventService _events;
|
||||||
private readonly IInvoiceDraftService _invoiceDrafts;
|
private readonly IInvoiceDraftService _invoiceDrafts;
|
||||||
|
private readonly IReminderDraftService _reminderDrafts;
|
||||||
private readonly IDraftNotifier _draftNotifier;
|
private readonly IDraftNotifier _draftNotifier;
|
||||||
private readonly List<string> _allowedNonAuth = new() { "spwc", "spw" };
|
private readonly List<string> _allowedNonAuth = new() { "spwc", "spw" };
|
||||||
private readonly List<string> _allowedGet = new()
|
private readonly List<string> _allowedGet = new()
|
||||||
@@ -66,6 +67,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
|||||||
IReminderService reminders,
|
IReminderService reminders,
|
||||||
IEventService events,
|
IEventService events,
|
||||||
IInvoiceDraftService invoiceDrafts,
|
IInvoiceDraftService invoiceDrafts,
|
||||||
|
IReminderDraftService reminderDrafts,
|
||||||
IDraftNotifier draftNotifier)
|
IDraftNotifier draftNotifier)
|
||||||
{
|
{
|
||||||
_intranet = intranet;
|
_intranet = intranet;
|
||||||
@@ -81,6 +83,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
|||||||
_reminders = reminders;
|
_reminders = reminders;
|
||||||
_events = events;
|
_events = events;
|
||||||
_invoiceDrafts = invoiceDrafts;
|
_invoiceDrafts = invoiceDrafts;
|
||||||
|
_reminderDrafts = reminderDrafts;
|
||||||
_draftNotifier = draftNotifier;
|
_draftNotifier = draftNotifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ lastUpdated: 2026-07-10
|
|||||||
applyTo:
|
applyTo:
|
||||||
- "Fuchs/Services/InvoiceDraft*"
|
- "Fuchs/Services/InvoiceDraft*"
|
||||||
- "Fuchs/Services/IInvoiceDraft*"
|
- "Fuchs/Services/IInvoiceDraft*"
|
||||||
|
- "Fuchs/Services/ReminderDraft*"
|
||||||
|
- "Fuchs/Services/IReminderDraft*"
|
||||||
- "Fuchs/code/InvoiceDraftSession.cs"
|
- "Fuchs/code/InvoiceDraftSession.cs"
|
||||||
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||||
|
- "Fuchs/code/ReminderDraftSession.cs"
|
||||||
|
- "Fuchs/code/ReminderDraftCalculator.cs"
|
||||||
- "Fuchs/Notifications/DraftPreviewHub.cs"
|
- "Fuchs/Notifications/DraftPreviewHub.cs"
|
||||||
- "Fuchs/Notifications/*DraftNotifier*"
|
- "Fuchs/Notifications/*DraftNotifier*"
|
||||||
- "Fuchs/Controllers/IntranetController.InvoiceDraft.cs"
|
- "Fuchs/Controllers/IntranetController.InvoiceDraft.cs"
|
||||||
|
- "Fuchs/Controllers/IntranetController.ReminderDraft.cs"
|
||||||
- "Fuchs/js/intranet/**"
|
- "Fuchs/js/intranet/**"
|
||||||
relatedDecisions:
|
relatedDecisions:
|
||||||
- "0006-backend-authoritative-draft-editing.md"
|
- "0006-backend-authoritative-draft-editing.md"
|
||||||
@@ -23,8 +28,8 @@ server memory, not in the browser. The browser posts single edits, the server mu
|
|||||||
the cached record, recomputes totals/VAT and re-validates, then pushes a "state changed"
|
the cached record, recomputes totals/VAT and re-validates, then pushes a "state changed"
|
||||||
signal so the browser re-fetches and re-renders. This makes the backend the single source
|
signal so the browser re-fetches and re-renders. This makes the backend the single source
|
||||||
of truth (server-computed sums, consistency checks, in-place PDF preview, change history,
|
of truth (server-computed sums, consistency checks, in-place PDF preview, change history,
|
||||||
explicit discard), reversing the earlier stateless editor. Invoices are the pilot;
|
explicit discard), reversing the earlier stateless editor. Invoices were the pilot;
|
||||||
reminders are intended to mirror the same design.
|
reminders now mirror the same design (see "Reminders" below).
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -92,6 +97,50 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
|
|||||||
- `Fuchs/Controllers/IntranetController.InvoiceDraft.cs` — `inv/d*` endpoints.
|
- `Fuchs/Controllers/IntranetController.InvoiceDraft.cs` — `inv/d*` endpoints.
|
||||||
- `Fuchs/js/intranet/fis_main.js`, `Fuchs/js/intranet/modules/fis.inv_shared.js` — client.
|
- `Fuchs/js/intranet/fis_main.js`, `Fuchs/js/intranet/modules/fis.inv_shared.js` — client.
|
||||||
|
|
||||||
|
## Reminders (Zahlungserinnerung)
|
||||||
|
|
||||||
|
Reminders mirror the same backend-authoritative model with a reminder-shaped session. A
|
||||||
|
reminder chases a single invoiced amount, so the machinery is simpler than an invoice's:
|
||||||
|
there are no line-item blocks, VAT grouping or reordering — just recipient fields and the
|
||||||
|
amount pair.
|
||||||
|
|
||||||
|
- **Endpoints** are `rem/d*` (`dopen`/`dstate`/`dpatch`/`dpreview`/`dsave`/`dhistory`/`dclose`),
|
||||||
|
dispatched from `Do_Process_Reminder`. Finalise + email still runs through the existing
|
||||||
|
`rem/conf` (`HandleReminderConf`), exactly as invoices finalise through `req/sconf`.
|
||||||
|
- **Session** (`ReminderDraftSession`) holds the editor's `new` (subject / invoiceaddress /
|
||||||
|
invoiceemail / text / amount / amount_payed / CustomValues) and `rem` (invid / type /
|
||||||
|
invoiceid / invoicedate) blocks, plus server-computed `Sums` (`AmountTotal`, `AmountPayed`,
|
||||||
|
`AmountOpen`). It reuses the shared `ChangeHistoryEntry`; validation uses
|
||||||
|
`ReminderDraftValidationMessage`.
|
||||||
|
- **Calculation** (`ReminderDraftCalculator`, static/pure): `AmountOpen = AmountTotal − AmountPayed`,
|
||||||
|
plus email/address/subject/open-amount plausibility checks. Exhaustively unit-tested.
|
||||||
|
- **Deltas** (`ReminderDraftDelta`): scalar `email`/`address`/`subject`/`text` (HTML-sanitised via
|
||||||
|
the shared `InvoiceDraftEditService.HtmlToPlain`), the numeric `amount`/`amount_payed`
|
||||||
|
(normalised to an invariant decimal string), and `contact` (→ `CustomValues`).
|
||||||
|
- **Orchestration** (`ReminderDraftEditService`, scoped) flushes to the DB by reusing
|
||||||
|
`IReminderService.RegisterReminderAsync`, and renders previews from a synthesised
|
||||||
|
`ReminderRegistration` (including the single-invoice `invoices` row the reminder PDF table
|
||||||
|
renders) so a preview needs no DB round-trip. **Note:** `RegisterReminderAsync` is create-only
|
||||||
|
(there is no `fds__setReminder` update proc), so a re-saved reminder draft does not update the
|
||||||
|
prior DB row — the primary flow (preview → confirm) flushes once immediately before finalising.
|
||||||
|
- **Cache/expiry** (`ReminderDraftCache` singleton + `ReminderDraftExpiryService`) mirror the
|
||||||
|
invoice ones and share the same `Fuchs:DraftEditing` TTL config.
|
||||||
|
- **Signals** reuse the shared `DraftPreviewHub` + `IDraftNotifier` unchanged — the token-keyed
|
||||||
|
groups serve invoice and reminder drafts alike.
|
||||||
|
- **Frontend** (`$inv.rd` in `fis.inv_shared.js`) opens/joins on `rem/dopen`, posts one delta per
|
||||||
|
inline edit and per item-row amount change, renders the open-amount footer + validation from the
|
||||||
|
server state, and previews/finalises through `rem/dpreview` → `rem/dsave` → `rem/conf`. It shares
|
||||||
|
the invoice editor DOM; `$inv.d` and `$inv.rd` each key off their own token, so the shared inline
|
||||||
|
editor safely no-ops for whichever mode is inactive.
|
||||||
|
|
||||||
|
### Reminder key files
|
||||||
|
- `Fuchs/code/ReminderDraftSession.cs` — session + `ReminderDraftSums` + `ReminderDraftValidationMessage`.
|
||||||
|
- `Fuchs/code/ReminderDraftCalculator.cs` — pure open-amount recompute + validate.
|
||||||
|
- `Fuchs/Services/ReminderDraftCache.cs` / `IReminderDraftCache.cs` — in-memory store + TTL.
|
||||||
|
- `Fuchs/Services/ReminderDraftEditService.cs` / `IReminderDraftService.cs` — orchestration + delta contract.
|
||||||
|
- `Fuchs/Services/ReminderDraftExpiryService.cs` — idle warn/evict monitor.
|
||||||
|
- `Fuchs/Controllers/IntranetController.ReminderDraft.cs` — `rem/d*` endpoints.
|
||||||
|
|
||||||
## Related decisions
|
## Related decisions
|
||||||
- [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.md)
|
- [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.md)
|
||||||
- [0007 — Targeted draft SignalR groups](../Decisions/0007-targeted-draft-signalr-groups.md)
|
- [0007 — Targeted draft SignalR groups](../Decisions/0007-targeted-draft-signalr-groups.md)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public enum DomainEventType
|
|||||||
InvoiceFileCreationFailed,
|
InvoiceFileCreationFailed,
|
||||||
InvoiceSendFailed,
|
InvoiceSendFailed,
|
||||||
ReminderDraftCreated,
|
ReminderDraftCreated,
|
||||||
|
ReminderDraftUpdated,
|
||||||
ReminderFileCreated,
|
ReminderFileCreated,
|
||||||
ReminderSentToCustomer,
|
ReminderSentToCustomer,
|
||||||
ReminderResentToCustomer,
|
ReminderResentToCustomer,
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ public sealed class EventService : IEventService
|
|||||||
public Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId)
|
public Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId)
|
||||||
=> PublishAsync(new DomainEvent(DomainEventType.ReminderDraftCreated, userAccountId, "Mahnentwurf", ReminderContext(reminder)));
|
=> PublishAsync(new DomainEvent(DomainEventType.ReminderDraftCreated, userAccountId, "Mahnentwurf", ReminderContext(reminder)));
|
||||||
|
|
||||||
|
public Task ReminderDraftRegisteredAsync(FdsReminderData reminder, bool changed, string userAccountId)
|
||||||
|
{
|
||||||
|
var type = changed ? DomainEventType.ReminderDraftUpdated : DomainEventType.ReminderDraftCreated;
|
||||||
|
return PublishAsync(new DomainEvent(type, userAccountId, "Mahnentwurf", ReminderContext(reminder)));
|
||||||
|
}
|
||||||
|
|
||||||
public Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId)
|
public Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId)
|
||||||
{
|
{
|
||||||
var ctx = ReminderContext(reminder);
|
var ctx = ReminderContext(reminder);
|
||||||
@@ -176,6 +182,8 @@ public sealed class EventService : IEventService
|
|||||||
Ctx(domainEvent, "message"),
|
Ctx(domainEvent, "message"),
|
||||||
DomainEventType.ReminderDraftCreated =>
|
DomainEventType.ReminderDraftCreated =>
|
||||||
$"Mahnentwurf {Ctx(domainEvent, "title")} wurde erstellt.",
|
$"Mahnentwurf {Ctx(domainEvent, "title")} wurde erstellt.",
|
||||||
|
DomainEventType.ReminderDraftUpdated =>
|
||||||
|
$"Mahnentwurf {Ctx(domainEvent, "title")} wurde aktualisiert.",
|
||||||
DomainEventType.ReminderFileCreated =>
|
DomainEventType.ReminderFileCreated =>
|
||||||
$"Mahndatei {Ctx(domainEvent, "fileName")} wurde erstellt.",
|
$"Mahndatei {Ctx(domainEvent, "fileName")} wurde erstellt.",
|
||||||
DomainEventType.ReminderSentToCustomer =>
|
DomainEventType.ReminderSentToCustomer =>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public interface IEventService
|
|||||||
Task InvoiceIssueAsync(string message, string userAccountId, string invoiceId = "");
|
Task InvoiceIssueAsync(string message, string userAccountId, string invoiceId = "");
|
||||||
|
|
||||||
Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId);
|
Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId);
|
||||||
|
Task ReminderDraftRegisteredAsync(FdsReminderData reminder, bool changed, string userAccountId);
|
||||||
Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId);
|
Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId);
|
||||||
Task ReminderSentToCustomerAsync(FdsReminderData reminder, string email, string userAccountId, bool resent = false);
|
Task ReminderSentToCustomerAsync(FdsReminderData reminder, string email, string userAccountId, bool resent = false);
|
||||||
Task ReminderMarkedSentAsync(string reminderId, string reminderTitle, string userAccountId);
|
Task ReminderMarkedSentAsync(string reminderId, string reminderTitle, string userAccountId);
|
||||||
|
|||||||
@@ -118,6 +118,12 @@ public class Program
|
|||||||
builder.Services.AddScoped<IInvoiceDraftService, InvoiceDraftEditService>();
|
builder.Services.AddScoped<IInvoiceDraftService, InvoiceDraftEditService>();
|
||||||
builder.Services.AddHostedService<InvoiceDraftExpiryService>();
|
builder.Services.AddHostedService<InvoiceDraftExpiryService>();
|
||||||
|
|
||||||
|
// Live, backend-authoritative reminder draft editing (ADR 0006) — the reminder
|
||||||
|
// mirror of the invoice draft services above, sharing the DraftPreviewHub/notifier.
|
||||||
|
builder.Services.AddSingleton<IReminderDraftCache, ReminderDraftCache>();
|
||||||
|
builder.Services.AddScoped<IReminderDraftService, ReminderDraftEditService>();
|
||||||
|
builder.Services.AddHostedService<ReminderDraftExpiryService>();
|
||||||
|
|
||||||
// Secondary archive: invoice/reminder PDFs additionally stored in Azure Blob Storage.
|
// Secondary archive: invoice/reminder PDFs additionally stored in Azure Blob Storage.
|
||||||
// Disabled by default (Fuchs:AzureStorage:Enabled) — see AzureBlobStorageService.
|
// Disabled by default (Fuchs:AzureStorage:Enabled) — see AzureBlobStorageService.
|
||||||
builder.Services.Configure<AzureBlobStorageSettings>(builder.Configuration.GetSection("Fuchs:AzureStorage"));
|
builder.Services.Configure<AzureBlobStorageSettings>(builder.Configuration.GetSection("Fuchs:AzureStorage"));
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Fuchs.intranet;
|
||||||
|
|
||||||
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory store of live reminder draft editing sessions (see ADR 0006, mirroring
|
||||||
|
/// <see cref="IInvoiceDraftCache"/>). Singleton, single-instance only — scale-out would
|
||||||
|
/// need a distributed cache / sticky sessions (documented limitation). Keyed by the
|
||||||
|
/// session token.
|
||||||
|
/// </summary>
|
||||||
|
public interface IReminderDraftCache
|
||||||
|
{
|
||||||
|
/// <summary>Stores (or replaces) a session under its token.</summary>
|
||||||
|
void Set(ReminderDraftSession session);
|
||||||
|
|
||||||
|
/// <summary>Returns the session for the token, or null if absent/evicted. Touches <c>LastAccessUtc</c> on hit.</summary>
|
||||||
|
ReminderDraftSession? Get(string token);
|
||||||
|
|
||||||
|
/// <summary>Removes the session (explicit close/discard/finalise). Returns the removed session, if any.</summary>
|
||||||
|
ReminderDraftSession? Remove(string token);
|
||||||
|
|
||||||
|
/// <summary>Snapshot of all live sessions — used by the expiry monitor. Does not touch access time.</summary>
|
||||||
|
IReadOnlyList<ReminderDraftSession> Snapshot();
|
||||||
|
|
||||||
|
/// <summary>The configured idle time-to-live before a session is eligible for eviction.</summary>
|
||||||
|
TimeSpan IdleTtl { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using Fuchs.intranet;
|
||||||
|
using MigraDoc.DocumentObjectModel;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using OCORE.security;
|
||||||
|
|
||||||
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orchestrates a live, backend-authoritative reminder draft editing session (ADR 0006,
|
||||||
|
/// mirroring <see cref="IInvoiceDraftService"/>). Owns the lifecycle around a
|
||||||
|
/// <see cref="ReminderDraftSession"/>: open (seed the cache), apply single edits, build the
|
||||||
|
/// view state, render a PDF preview from the cache, flush to the DB ("Zwischenspeichern")
|
||||||
|
/// and expose the change history. The open amount is aggregated by
|
||||||
|
/// <see cref="ReminderDraftCalculator"/> — the browser never sums.
|
||||||
|
///
|
||||||
|
/// Reload/discard is handled by the client (re-fetch the DB draft / prep data via the
|
||||||
|
/// existing <c>rem/get</c> path and re-seed), so there is no server-side DB reshaping here.
|
||||||
|
/// </summary>
|
||||||
|
public interface IReminderDraftService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds a new cache session from the editor's assembled payload (<c>new</c> / <c>rem</c>
|
||||||
|
/// blocks). Computes the open amount + validation and returns the session (with its fresh
|
||||||
|
/// token/version). A <c>remid</c> in the payload marks it as an update of an existing DB draft.
|
||||||
|
/// </summary>
|
||||||
|
ReminderDraftSession OpenFromPayload(JObject payload, string userAccountId);
|
||||||
|
|
||||||
|
/// <summary>Returns the cached session for the token (touching its TTL), or null if absent/expired.</summary>
|
||||||
|
ReminderDraftSession? Get(string token);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies one editor change to the cached session: mutates the payload, re-aggregates the
|
||||||
|
/// open amount, re-validates, appends a history entry and bumps the version. Returns the
|
||||||
|
/// mutated session, or null if the token is unknown.
|
||||||
|
/// </summary>
|
||||||
|
ReminderDraftSession? ApplyPatch(string token, ReminderDraftDelta delta);
|
||||||
|
|
||||||
|
/// <summary>Builds the JSON view-state DTO the frontend renders (payload + server sums + validation + version).</summary>
|
||||||
|
object BuildState(ReminderDraftSession session);
|
||||||
|
|
||||||
|
/// <summary>The draft's change history for the "Änderungshistorie" dialog (empty if the token is unknown).</summary>
|
||||||
|
IReadOnlyList<ChangeHistoryEntry> GetHistory(string token);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the cached session to the DB via the existing reminder registration path
|
||||||
|
/// ("Zwischenspeichern"). Sets <see cref="ReminderDraftSession.RemId"/> on success.
|
||||||
|
/// Returns the registered reminder data (for the success event), or null if the token is unknown.
|
||||||
|
/// </summary>
|
||||||
|
Task<FdsReminderData?> 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);
|
||||||
|
|
||||||
|
/// <summary>Removes the session from the cache (explicit close/discard/finalise). Returns true if one was present.</summary>
|
||||||
|
bool Close(string token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single editor change posted to <c>rem/dpatch</c>. <see cref="Target"/> names the
|
||||||
|
/// field/operation (e.g. "email", "subject", "amount"); <see cref="Ref"/> is reserved for
|
||||||
|
/// future per-item edits; <see cref="Value"/> is the new value (a scalar for fields, or a
|
||||||
|
/// small object for <c>contact</c>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReminderDraftDelta
|
||||||
|
{
|
||||||
|
public string Target { get; set; } = "";
|
||||||
|
public string Ref { get; set; } = "";
|
||||||
|
public JToken? Value { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The new value as a string (empty when null), for history and simple field assignments.</summary>
|
||||||
|
public string ValueString =>
|
||||||
|
Value == null || Value.Type == JTokenType.Null ? "" : Value.Type == JTokenType.String ? Value.Value<string>() ?? "" : Value.ToString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single-instance, in-memory implementation of <see cref="IReminderDraftCache"/> backed
|
||||||
|
/// by a <see cref="ConcurrentDictionary{TKey,TValue}"/> keyed by session token — the
|
||||||
|
/// reminder mirror of <see cref="InvoiceDraftCache"/>. A plain dictionary (rather than
|
||||||
|
/// <c>IMemoryCache</c>) is used on purpose: the <see cref="ReminderDraftExpiryService"/>
|
||||||
|
/// needs to enumerate sessions and warn the user <b>before</b> eviction, which opaque
|
||||||
|
/// cache-entry expiry does not allow.
|
||||||
|
///
|
||||||
|
/// Idle TTL and the pre-expiry warning lead time are shared with invoices under
|
||||||
|
/// <c>Fuchs:DraftEditing</c> (<c>IdleMinutes</c> / <c>ExpiryWarnMinutes</c>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReminderDraftCache : IReminderDraftCache
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, ReminderDraftSession> _sessions = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public TimeSpan IdleTtl { get; }
|
||||||
|
/// <summary>How long before the idle TTL a warning is emitted to the user.</summary>
|
||||||
|
public TimeSpan ExpiryWarnLead { get; }
|
||||||
|
|
||||||
|
public ReminderDraftCache(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
int idleMinutes = configuration.GetValue("Fuchs:DraftEditing:IdleMinutes", 30);
|
||||||
|
int warnMinutes = configuration.GetValue("Fuchs:DraftEditing:ExpiryWarnMinutes", 5);
|
||||||
|
IdleTtl = TimeSpan.FromMinutes(Math.Max(1, idleMinutes));
|
||||||
|
ExpiryWarnLead = TimeSpan.FromMinutes(Math.Clamp(warnMinutes, 1, Math.Max(1, idleMinutes - 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Set(ReminderDraftSession session)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(session.Token)) throw new ArgumentException("Session has no token.", nameof(session));
|
||||||
|
session.Touch();
|
||||||
|
_sessions[session.Token] = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReminderDraftSession? Get(string token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(token)) return null;
|
||||||
|
if (_sessions.TryGetValue(token, out var s))
|
||||||
|
{
|
||||||
|
s.Touch();
|
||||||
|
// A touch resets the idle window, so a fresh warning is due next time it lapses.
|
||||||
|
s.ExpiryWarningSent = false;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReminderDraftSession? Remove(string token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(token)) return null;
|
||||||
|
return _sessions.TryRemove(token, out var s) ? s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<ReminderDraftSession> Snapshot() => _sessions.Values.ToList();
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using MigraDoc.DocumentObjectModel;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using OCORE.security;
|
||||||
|
using static OCORE.commons;
|
||||||
|
using static OCORE.OCORE_dictionaries;
|
||||||
|
|
||||||
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Backend-authoritative reminder draft editing (ADR 0006) — the reminder mirror of
|
||||||
|
/// <see cref="InvoiceDraftEditService"/>. Holds the truth in a
|
||||||
|
/// <see cref="ReminderDraftSession"/> (via <see cref="IReminderDraftCache"/>), applies
|
||||||
|
/// single edits, aggregates the open amount with <see cref="ReminderDraftCalculator"/>,
|
||||||
|
/// renders previews and flushes to the DB by reusing the existing
|
||||||
|
/// <see cref="IReminderService"/> registration path — no new persistence. The session
|
||||||
|
/// stores the editor's own block shape (<c>new</c>/<c>rem</c>), which the PDF/persistence
|
||||||
|
/// already consume, so nothing is re-shaped server-side.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReminderDraftEditService : IReminderDraftService
|
||||||
|
{
|
||||||
|
private readonly IReminderDraftCache _cache;
|
||||||
|
private readonly IReminderService _reminders;
|
||||||
|
private readonly ILogger<ReminderDraftEditService> _logger;
|
||||||
|
|
||||||
|
public ReminderDraftEditService(IReminderDraftCache cache, IReminderService reminders,
|
||||||
|
ILogger<ReminderDraftEditService> logger)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
_reminders = reminders;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Open ─────────────────────────────────────────────────────────────────
|
||||||
|
public ReminderDraftSession OpenFromPayload(JObject payload, string userAccountId)
|
||||||
|
{
|
||||||
|
var session = new ReminderDraftSession
|
||||||
|
{
|
||||||
|
Token = NewToken(),
|
||||||
|
UserAccountId = userAccountId,
|
||||||
|
RemId = payload["remid"]?.Value<string>() ?? payload["id"]?.Value<string>() ?? ""
|
||||||
|
};
|
||||||
|
session.New = payload["new"] as JObject ?? new JObject();
|
||||||
|
session.Rem = payload["rem"] as JObject ?? new JObject();
|
||||||
|
Refresh(session);
|
||||||
|
_cache.Set(session);
|
||||||
|
_logger.LogInformation("Reminder draft session {Token} opened from payload (remId={RemId}, user={User})",
|
||||||
|
session.Token, session.RemId, userAccountId);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReminderDraftSession? Get(string token) => _cache.Get(token);
|
||||||
|
|
||||||
|
// ── Patch ──────────────────────────────────────────────────────────────────
|
||||||
|
public ReminderDraftSession? ApplyPatch(string token, ReminderDraftDelta delta)
|
||||||
|
{
|
||||||
|
var session = _cache.Get(token);
|
||||||
|
if (session == null) return null;
|
||||||
|
|
||||||
|
string oldValue = "", newValue = "";
|
||||||
|
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
||||||
|
if (!mutated)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Reminder draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
Refresh(session);
|
||||||
|
session.Version++;
|
||||||
|
session.History.Add(new ChangeHistoryEntry
|
||||||
|
{
|
||||||
|
UserAccountId = session.UserAccountId,
|
||||||
|
Target = delta.Target,
|
||||||
|
Ref = delta.Ref,
|
||||||
|
OldValue = oldValue,
|
||||||
|
NewValue = newValue,
|
||||||
|
Version = session.Version
|
||||||
|
});
|
||||||
|
_cache.Set(session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies one delta to the payload; returns whether anything changed and captures the prior
|
||||||
|
/// and new value for the change history. Scalar text fields are sanitised from the editor's
|
||||||
|
/// HTML (TinyMCE wraps inline edits in <c><p>…</p></c>) to plain text (via
|
||||||
|
/// <see cref="InvoiceDraftEditService.HtmlToPlain"/>) — the backend is the single source of
|
||||||
|
/// truth (ADR 0006), so no HTML ever reaches the DB or the PDF.
|
||||||
|
/// </summary>
|
||||||
|
private static bool ApplyDelta(ReminderDraftSession s, ReminderDraftDelta d, ref string oldValue, ref string newValue)
|
||||||
|
{
|
||||||
|
switch (d.Target)
|
||||||
|
{
|
||||||
|
case "email": return SetNewText(s, "invoiceemail", d, ref oldValue, ref newValue);
|
||||||
|
case "address": return SetNewText(s, "invoiceaddress", d, ref oldValue, ref newValue);
|
||||||
|
case "subject": return SetNewText(s, "subject", d, ref oldValue, ref newValue);
|
||||||
|
case "text": return SetNewText(s, "text", d, ref oldValue, ref newValue);
|
||||||
|
case "amount": return SetNewNumber(s, "amount", d, ref oldValue, ref newValue);
|
||||||
|
case "amount_payed": return SetNewNumber(s, "amount_payed", d, ref oldValue, ref newValue);
|
||||||
|
case "contact": return SetContact(s, d, ref oldValue, ref newValue);
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SetNewText(ReminderDraftSession s, string key, ReminderDraftDelta d, ref string oldValue, ref string newValue)
|
||||||
|
{
|
||||||
|
oldValue = Str(s.New[key]);
|
||||||
|
newValue = InvoiceDraftEditService.HtmlToPlain(d.ValueString);
|
||||||
|
s.New[key] = newValue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stores a numeric field, normalising German/invariant input to an invariant decimal string.</summary>
|
||||||
|
private static bool SetNewNumber(ReminderDraftSession s, string key, ReminderDraftDelta d, ref string oldValue, ref string newValue)
|
||||||
|
{
|
||||||
|
oldValue = Str(s.New[key]);
|
||||||
|
decimal parsed = ReminderDraftCalculator.Dec(d.Value ?? JValue.CreateString(InvoiceDraftEditService.HtmlToPlain(d.ValueString)));
|
||||||
|
newValue = parsed.ToString(CultureInfo.InvariantCulture);
|
||||||
|
s.New[key] = newValue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SetContact(ReminderDraftSession s, ReminderDraftDelta d, ref string oldValue, ref string newValue)
|
||||||
|
{
|
||||||
|
JObject prev = TryParseObject(Str(s.New["CustomValues"]));
|
||||||
|
oldValue = ContactLabel(Str(prev["contactName"]), Str(prev["contactEmail"]));
|
||||||
|
JObject cvo = (JObject)prev.DeepClone();
|
||||||
|
if (d.Value is JObject vo)
|
||||||
|
{
|
||||||
|
cvo["contactName"] = vo["name"] ?? vo["contactName"] ?? "";
|
||||||
|
cvo["contactEmail"] = vo["email"] ?? vo["contactEmail"] ?? "";
|
||||||
|
}
|
||||||
|
s.New["CustomValues"] = cvo.ToString(Newtonsoft.Json.Formatting.None);
|
||||||
|
newValue = ContactLabel(Str(cvo["contactName"]), Str(cvo["contactEmail"]));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ContactLabel(string name, string email) =>
|
||||||
|
string.IsNullOrEmpty(name) ? email : string.IsNullOrEmpty(email) ? name : $"{name} <{email}>";
|
||||||
|
|
||||||
|
// ── View state / history ────────────────────────────────────────────────
|
||||||
|
public object BuildState(ReminderDraftSession session)
|
||||||
|
{
|
||||||
|
session.Touch();
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
token = session.Token,
|
||||||
|
version = session.Version,
|
||||||
|
remid = session.RemId,
|
||||||
|
isDraft = session.IsDraft,
|
||||||
|
@new = session.New,
|
||||||
|
rem = session.Rem,
|
||||||
|
sums = new
|
||||||
|
{
|
||||||
|
amount_total = session.Sums.AmountTotal,
|
||||||
|
amount_payed = session.Sums.AmountPayed,
|
||||||
|
amount_open = session.Sums.AmountOpen
|
||||||
|
},
|
||||||
|
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
|
||||||
|
historyCount = session.History.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
|
||||||
|
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
|
||||||
|
|
||||||
|
// ── Flush / preview ────────────────────────────────────────────────────────
|
||||||
|
public async Task<FdsReminderData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
||||||
|
{
|
||||||
|
var session = _cache.Get(token);
|
||||||
|
if (session == null) return null;
|
||||||
|
|
||||||
|
var fds = BuildReminderData(session);
|
||||||
|
bool change = !string.IsNullOrEmpty(session.RemId);
|
||||||
|
var reg = await _reminders.RegisterReminderAsync(fds, change, session.RemId, userAccountId, dbSec);
|
||||||
|
if (!string.IsNullOrEmpty(reg.Id))
|
||||||
|
{
|
||||||
|
session.RemId = reg.Id;
|
||||||
|
_cache.Set(session);
|
||||||
|
_logger.LogInformation("Reminder draft {Token} flushed to DB reminder {RemId} (change={Change}, user={User})",
|
||||||
|
token, reg.Id, change, userAccountId);
|
||||||
|
}
|
||||||
|
return reg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Document? RenderPreview(string token)
|
||||||
|
{
|
||||||
|
var session = _cache.Get(token);
|
||||||
|
if (session == null) return null;
|
||||||
|
var fds = BuildReminderData(session);
|
||||||
|
fds.ReminderRegistration = SynthesizeRegistration(session);
|
||||||
|
fds.IsDraft = true;
|
||||||
|
return _reminders.GenerateReminderPdf(fds, draft: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Close(string token) => _cache.Remove(token) != null;
|
||||||
|
|
||||||
|
// ── Internals ──────────────────────────────────────────────────────────────
|
||||||
|
private static void Refresh(ReminderDraftSession session)
|
||||||
|
{
|
||||||
|
ReminderDraftCalculator.RecomputeTotals(session);
|
||||||
|
ReminderDraftCalculator.Validate(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NewToken() => Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the <see cref="FdsReminderData"/> from the session — the server-side equivalent of
|
||||||
|
/// the editor's <c>remc</c> payload. The session already holds the editor's <c>new</c>/<c>rem</c>
|
||||||
|
/// shape that registration consumes, so the blocks pass through unchanged.
|
||||||
|
/// </summary>
|
||||||
|
private static FdsReminderData BuildReminderData(ReminderDraftSession session)
|
||||||
|
{
|
||||||
|
var jobj = new JObject
|
||||||
|
{
|
||||||
|
["new"] = session.New.DeepClone(),
|
||||||
|
["rem"] = session.Rem.DeepClone()
|
||||||
|
};
|
||||||
|
return new FdsReminderData(jobj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Synthesises the <c>ReminderRegistration</c> dictionary a draft PDF render needs, straight
|
||||||
|
/// from the cached session — so a preview requires no DB round-trip and no client upload.
|
||||||
|
/// Mirrors the columns <c>fds__getReminder</c>/<c>fds__createReminder</c> would return for a
|
||||||
|
/// draft, including the single-invoice <c>invoices</c> row the reminder table renders.
|
||||||
|
/// </summary>
|
||||||
|
private static GenericObjectDictionary SynthesizeRegistration(ReminderDraftSession session)
|
||||||
|
{
|
||||||
|
string invoiceId = Str(session.Rem["invoiceid"]).ne(Str(session.Rem["InvoiceId"]));
|
||||||
|
var invoices = new JArray
|
||||||
|
{
|
||||||
|
new JObject
|
||||||
|
{
|
||||||
|
["InvoiceDate"] = Str(session.Rem["invoicedate"]),
|
||||||
|
["DocumentName"] = "",
|
||||||
|
["InvoiceTitle"] = string.IsNullOrEmpty(invoiceId) ? "" : $"Rechnung {invoiceId}",
|
||||||
|
["InvoiceBalance"] = session.Sums.AmountTotal,
|
||||||
|
["amount_open"] = session.Sums.AmountOpen
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var d = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["Id"] = session.RemId,
|
||||||
|
["type"] = Str(session.Rem["type"]).ne("R"),
|
||||||
|
["subject"] = Str(session.New["subject"]),
|
||||||
|
["SendToAddress"] = Str(session.New["invoiceaddress"]),
|
||||||
|
["SendToEmail"] = Str(session.New["invoiceemail"]),
|
||||||
|
["InvoiceId"] = invoiceId,
|
||||||
|
["amount_open"] = session.Sums.AmountOpen,
|
||||||
|
["PaymentTerm"] = Str(session.Rem["paymentterm"]),
|
||||||
|
["invoices"] = invoices,
|
||||||
|
["CustomValues"] = Str(session.New["CustomValues"]),
|
||||||
|
["IsFinal"] = false,
|
||||||
|
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||||
|
};
|
||||||
|
return new GenericObjectDictionary(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── token helpers ─────────────────────────────────────────────────────────
|
||||||
|
private static string Str(JToken? t) =>
|
||||||
|
t == null || t.Type == JTokenType.Null ? "" : t.Type == JTokenType.String ? t.Value<string>() ?? "" : t.ToString();
|
||||||
|
|
||||||
|
private static JObject TryParseObject(string json)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(json) && json.TrimStart().StartsWith('{'))
|
||||||
|
{
|
||||||
|
try { return JObject.Parse(json); } catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
return new JObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using Fuchs.Notifications;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background monitor for the reminder draft cache (ADR 0006) — the reminder mirror of
|
||||||
|
/// <see cref="InvoiceDraftExpiryService"/>. Because a draft's truth lives only in server
|
||||||
|
/// memory until the user saves, idle sessions must not vanish silently: this service warns
|
||||||
|
/// the editing browser <b>before</b> a session's idle TTL lapses ("bitte zwischenspeichern"),
|
||||||
|
/// and when the TTL is finally reached it evicts the session and tells the browser to close
|
||||||
|
/// the editor with a reason. All hints travel over the shared <see cref="DraftPreviewHub"/>
|
||||||
|
/// via <see cref="IDraftNotifier"/> (the token-keyed groups serve invoices and reminders alike).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReminderDraftExpiryService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IReminderDraftCache _cache;
|
||||||
|
private readonly IDraftNotifier _notifier;
|
||||||
|
private readonly ILogger<ReminderDraftExpiryService> _logger;
|
||||||
|
private readonly TimeSpan _warnLead;
|
||||||
|
private readonly TimeSpan _interval;
|
||||||
|
|
||||||
|
public ReminderDraftExpiryService(IReminderDraftCache cache, IDraftNotifier notifier,
|
||||||
|
IConfiguration configuration, ILogger<ReminderDraftExpiryService> logger)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
_notifier = notifier;
|
||||||
|
_logger = logger;
|
||||||
|
int warnMinutes = configuration.GetValue("Fuchs:DraftEditing:ExpiryWarnMinutes", 5);
|
||||||
|
_warnLead = TimeSpan.FromMinutes(Math.Clamp(warnMinutes, 1, Math.Max(1, (int)cache.IdleTtl.TotalMinutes - 1)));
|
||||||
|
_interval = TimeSpan.FromSeconds(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(_interval);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||||
|
await SweepAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { /* shutting down */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One pass over all live sessions. Internal so it can be driven directly from unit tests.</summary>
|
||||||
|
internal async Task SweepAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
DateTime now = DateTime.UtcNow;
|
||||||
|
foreach (var session in _cache.Snapshot())
|
||||||
|
{
|
||||||
|
TimeSpan idle = now - session.LastAccessUtc;
|
||||||
|
if (idle >= _cache.IdleTtl)
|
||||||
|
{
|
||||||
|
_cache.Remove(session.Token);
|
||||||
|
_logger.LogInformation("Reminder draft {Token} evicted after {Idle} idle (user={User})",
|
||||||
|
session.Token, idle, session.UserAccountId);
|
||||||
|
await _notifier.SignalClosedAsync(session.Token, "expired", cancellationToken);
|
||||||
|
}
|
||||||
|
else if (idle >= _cache.IdleTtl - _warnLead && !session.ExpiryWarningSent)
|
||||||
|
{
|
||||||
|
session.ExpiryWarningSent = true;
|
||||||
|
int secondsLeft = (int)Math.Max(0, (_cache.IdleTtl - idle).TotalSeconds);
|
||||||
|
await _notifier.SignalExpiringAsync(session.Token, secondsLeft, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
"CheckMfr": false,
|
"CheckMfr": false,
|
||||||
"CheckPdfLicense": true
|
"CheckPdfLicense": true
|
||||||
},
|
},
|
||||||
|
"Mailer": {
|
||||||
|
"Enabled": true
|
||||||
|
},
|
||||||
"Email": {
|
"Email": {
|
||||||
"OverrideRecipient": "service@emails.processweb.de"
|
"OverrideRecipient": "service@emails.processweb.de"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace Fuchs.intranet;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side, pure aggregation of a reminder draft's open amount — the authoritative
|
||||||
|
/// replacement for the browser's inline figure (ADR 0006, mirroring
|
||||||
|
/// <see cref="InvoiceDraftCalculator"/>). The user's requirement is that the computed
|
||||||
|
/// figure lives in the backend cache, not the frontend.
|
||||||
|
///
|
||||||
|
/// A reminder chases a single invoiced amount: <c>AmountOpen = AmountTotal - AmountPayed</c>
|
||||||
|
/// (both read from the editor's <c>new</c> block). Static/pure, hence exhaustively
|
||||||
|
/// unit-testable.
|
||||||
|
/// </summary>
|
||||||
|
public static class ReminderDraftCalculator
|
||||||
|
{
|
||||||
|
/// <summary>Recomputes the reminder's open amount from the edited <c>amount</c> / <c>amount_payed</c>.</summary>
|
||||||
|
public static void RecomputeTotals(ReminderDraftSession session)
|
||||||
|
{
|
||||||
|
decimal total = Dec(session.New["amount"]);
|
||||||
|
decimal payed = Dec(session.New["amount_payed"]);
|
||||||
|
session.Sums = new ReminderDraftSums
|
||||||
|
{
|
||||||
|
AmountTotal = total,
|
||||||
|
AmountPayed = payed,
|
||||||
|
AmountOpen = total - payed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refreshes the draft's plausibility / consistency findings. "error" severity marks
|
||||||
|
/// issues that should block a clean finalise; "warning" is advisory. Kept in German,
|
||||||
|
/// user-readable, so the frontend can render them directly.
|
||||||
|
/// </summary>
|
||||||
|
public static void Validate(ReminderDraftSession session)
|
||||||
|
{
|
||||||
|
session.ValidationMessages.Clear();
|
||||||
|
void Add(string field, string sev, string msg) =>
|
||||||
|
session.ValidationMessages.Add(new ReminderDraftValidationMessage(field, sev, msg));
|
||||||
|
|
||||||
|
string email = Str(session.New["invoiceemail"]).Trim();
|
||||||
|
if (email.Length == 0)
|
||||||
|
Add("email", "warning", "Es ist keine E-Mail-Adresse hinterlegt — die Mahnung kann nicht per E-Mail versandt werden.");
|
||||||
|
else if (!IsValidEmail(email))
|
||||||
|
Add("email", "error", "Die E-Mail-Adresse ist ungültig.");
|
||||||
|
|
||||||
|
if (Str(session.New["invoiceaddress"]).Trim().Length == 0)
|
||||||
|
Add("address", "warning", "Es ist keine Anschrift hinterlegt.");
|
||||||
|
|
||||||
|
if (Str(session.New["subject"]).Trim().Length == 0)
|
||||||
|
Add("subject", "warning", "Es ist kein Betreff hinterlegt.");
|
||||||
|
|
||||||
|
if (session.Sums.AmountOpen <= 0)
|
||||||
|
Add("amount", "warning", "Der offene Betrag ist null oder negativ — es besteht keine offene Forderung.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
/// <summary>Parses a JToken to a decimal, tolerating German ("12,50" / "1.234,56") and invariant ("12.50") strings.</summary>
|
||||||
|
internal static decimal Dec(JToken? token)
|
||||||
|
{
|
||||||
|
if (token == null || token.Type == JTokenType.Null) return 0;
|
||||||
|
if (token.Type is JTokenType.Float or JTokenType.Integer) return token.Value<decimal>();
|
||||||
|
return ParseAmount(Str(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a currency string, resolving the German/invariant ambiguity: a value with both
|
||||||
|
/// separators treats "." as thousands and "," as decimal ("1.234,56"); a value with only ","
|
||||||
|
/// treats it as the decimal separator ("12,50"); otherwise it is parsed invariant ("1234.56").
|
||||||
|
/// </summary>
|
||||||
|
internal static decimal ParseAmount(string? raw)
|
||||||
|
{
|
||||||
|
string s = (raw ?? "").Trim();
|
||||||
|
if (s.Length == 0) return 0;
|
||||||
|
bool hasComma = s.Contains(','), hasDot = s.Contains('.');
|
||||||
|
if (hasComma && hasDot) s = s.Replace(".", "").Replace(',', '.'); // German "1.234,56"
|
||||||
|
else if (hasComma) s = s.Replace(',', '.'); // German "12,50"
|
||||||
|
return decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal d) ? d : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Str(JToken? token) =>
|
||||||
|
token == null || token.Type == JTokenType.Null ? "" : token.Type == JTokenType.String ? token.Value<string>() ?? "" : token.ToString();
|
||||||
|
|
||||||
|
private static bool IsValidEmail(string email)
|
||||||
|
{
|
||||||
|
int at = email.IndexOf('@');
|
||||||
|
if (at <= 0 || at != email.LastIndexOf('@')) return false;
|
||||||
|
int dot = email.IndexOf('.', at);
|
||||||
|
return dot > at + 1 && dot < email.Length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace Fuchs.intranet;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side, in-memory editing state for a single reminder (Zahlungserinnerung)
|
||||||
|
/// draft — the authoritative source of truth while a back-office user edits a draft in
|
||||||
|
/// the browser. This mirrors <see cref="InvoiceDraftSession"/> for reminders (ADR 0006):
|
||||||
|
/// the browser is a pure view/input layer that posts single changes
|
||||||
|
/// (<see cref="Fuchs.Services.ReminderDraftDelta"/>); the server mutates this session,
|
||||||
|
/// recomputes the open amount and validates, then signals the browser to re-fetch.
|
||||||
|
///
|
||||||
|
/// This is a <b>data holder</b> only — all calculation, validation, persistence and
|
||||||
|
/// rendering live in <see cref="Fuchs.Services.IReminderDraftService"/> (mirroring the
|
||||||
|
/// <see cref="FdsReminderData"/> / <see cref="Fuchs.Services.IReminderService"/> split).
|
||||||
|
/// The editable payload is kept as the exact JSON shape the editor already speaks
|
||||||
|
/// (<c>new</c> / <c>rem</c>), so flushing to the DB can reuse
|
||||||
|
/// <see cref="Fuchs.Services.IReminderService.RegisterReminderAsync"/> unchanged.
|
||||||
|
/// The change-history record type (<see cref="ChangeHistoryEntry"/>) is shared with the
|
||||||
|
/// invoice draft; validation messages use the reminder-specific
|
||||||
|
/// <see cref="ReminderDraftValidationMessage"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReminderDraftSession
|
||||||
|
{
|
||||||
|
/// <summary>Opaque per-editor token; also the SignalR group name for targeted signals.</summary>
|
||||||
|
public string Token { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Owning user account id (drafts are single-user; used for auth + events).</summary>
|
||||||
|
public string UserAccountId { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>DB reminder id once the session has been flushed (Zwischenspeichern); empty while cache-only.</summary>
|
||||||
|
public string RemId { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Always true here — sessions only ever hold unfinalised drafts.</summary>
|
||||||
|
public bool IsDraft { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Bumped on every applied mutation; the browser refetches when the signalled version changes.</summary>
|
||||||
|
public int Version { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UTC of the last read/write; drives the idle sliding-TTL and expiry warnings.</summary>
|
||||||
|
public DateTime LastAccessUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Guards against sending more than one expiry warning per idle window.</summary>
|
||||||
|
public bool ExpiryWarningSent { get; set; }
|
||||||
|
|
||||||
|
// ── Editable payload (exact editor JSON shape) ───────────────────────────
|
||||||
|
/// <summary>Recipient/new fields: subject, invoiceaddress, invoiceemail, text, amount, amount_payed, CustomValues…</summary>
|
||||||
|
public JObject New { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Reference fields: invid, type, level, invoiceid, invoicedate, sender…</summary>
|
||||||
|
public JObject Rem { get; set; } = new();
|
||||||
|
|
||||||
|
// ── Computed (by the draft service; never trusted from the client) ───────
|
||||||
|
/// <summary>Server-computed open-amount aggregation — the values the client used to compute inline.</summary>
|
||||||
|
public ReminderDraftSums Sums { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Plausibility / consistency results, refreshed on every recompute.</summary>
|
||||||
|
public List<ReminderDraftValidationMessage> ValidationMessages { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Automatic change history, appended on every applied patch. Cache-only (never persisted).</summary>
|
||||||
|
public List<ChangeHistoryEntry> History { get; } = new();
|
||||||
|
|
||||||
|
public void Touch() => LastAccessUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Server-computed reminder totals — the authoritative open-amount for the draft.</summary>
|
||||||
|
public sealed class ReminderDraftSums
|
||||||
|
{
|
||||||
|
/// <summary>Invoiced amount (gross) the reminder chases.</summary>
|
||||||
|
public decimal AmountTotal { get; set; }
|
||||||
|
/// <summary>Amount already paid against the invoice.</summary>
|
||||||
|
public decimal AmountPayed { get; set; }
|
||||||
|
/// <summary>Still-open amount (<c>AmountTotal - AmountPayed</c>) — the reminder's headline figure.</summary>
|
||||||
|
public decimal AmountOpen { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A single plausibility/consistency finding for the reminder draft.</summary>
|
||||||
|
/// <param name="Field">Logical field the message relates to (e.g. "email", "address", "amount").</param>
|
||||||
|
/// <param name="Severity">"error" blocks a clean finalise; "warning"/"info" are advisory.</param>
|
||||||
|
/// <param name="Message">German, user-readable text.</param>
|
||||||
|
public readonly record struct ReminderDraftValidationMessage(string Field, string Severity, string Message);
|
||||||
@@ -313,6 +313,161 @@ $inv.d = {
|
|||||||
$inv.d.tbl().removeData('dtoken');
|
$inv.d.tbl().removeData('dtoken');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
/* ── Backend-authoritative reminder draft editing (ADR 0006/0007) ─────────────
|
||||||
|
The reminder mirror of $inv.d: the server holds the truth for a reminder draft in
|
||||||
|
an in-memory session; this object seeds it (rem/dopen), sends single edits as deltas
|
||||||
|
(rem/dpatch), and renders the open-amount footer + validation from the authoritative
|
||||||
|
server state (rem/dstate). Preview renders straight from the cache (rem/dpreview);
|
||||||
|
confirm flushes (rem/dsave) then finalises + emails (rem/conf). It coexists with $inv.d
|
||||||
|
on the same DOM: each keys off its own token (rdtoken vs dtoken), so the shared inline
|
||||||
|
editor safely no-ops for the mode that is not active. */
|
||||||
|
$inv.rd = {
|
||||||
|
tbl: () => $('div.invoice_layout table.invi'),
|
||||||
|
layout: () => $('div.invoice_layout'),
|
||||||
|
token: function () { return $inv.rd.tbl().data('rdtoken') || ''; },
|
||||||
|
/* Seed the authoritative server session from the assembled reminder editor payload. */
|
||||||
|
seed: function (payload) {
|
||||||
|
let l = $inv.rd.layout(); l.aC('freeze');
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
|
||||||
|
$inv.rd.tbl().data('rdtoken', r.token).data('rdver', r.version);
|
||||||
|
$fis.draft.bind(r.token, {
|
||||||
|
onReady: () => $inv.rd.refresh(),
|
||||||
|
onExpiring: (s) => $inv.rd.warnExpiry(s),
|
||||||
|
onClosed: (reason) => $inv.rd.closed(reason)
|
||||||
|
});
|
||||||
|
$inv.rd.refresh();
|
||||||
|
}, error: () => { l.rC('freeze'); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/* Re-fetch the authoritative state and render the open-amount footer + validation from it. */
|
||||||
|
refresh: function (cb) {
|
||||||
|
let t = $inv.rd.token(); if (t === '') { return; }
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dstate'), data: { token: t }, success: (state) => {
|
||||||
|
$inv.rd.applyState(state); if (typeof cb === 'function') { cb(state); }
|
||||||
|
}, error: (xhr) => { if (xhr && xhr.status === 410) { $inv.rd.closed('expired'); } },
|
||||||
|
complete: () => { $inv.rd.layout().rC('freeze'); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
applyState: function (state) {
|
||||||
|
let tbl = $inv.rd.tbl(); if (tbl.length < 1) { return; }
|
||||||
|
tbl.data('rdver', state.version).data('serverSums', state.sums).data('remid', state.remid || '');
|
||||||
|
$inv.rd.footer(tbl, state.sums || {});
|
||||||
|
$inv.rd.validation(state.validation || []);
|
||||||
|
},
|
||||||
|
/* Send one change to the server; the draftReady signal and this success both refresh. */
|
||||||
|
sync: function (delta) {
|
||||||
|
let t = $inv.rd.token(); if (t === '') { return; }
|
||||||
|
$inv.rd.layout().aC('freeze');
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dpatch'), data: { token: t, delta: JSON.stringify(delta) },
|
||||||
|
success: () => { $inv.rd.refresh(); },
|
||||||
|
error: (xhr) => { $inv.rd.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.rd.closed('expired'); } }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/* Map an inline recipient field to its delta target and send it. */
|
||||||
|
syncField: function (nme, val) {
|
||||||
|
if ($inv.rd.token() === '') { return; }
|
||||||
|
let map = { subject: 'subject', invoiceaddress: 'address', invoiceemail: 'email', text: 'text' };
|
||||||
|
let target = map[nme]; if (!target) { return; }
|
||||||
|
$inv.rd.sync({ Target: target, Value: val });
|
||||||
|
},
|
||||||
|
/* Amount / amount-paid come from the item-row dialog; send both as their own deltas. */
|
||||||
|
syncAmount: function (amount, amount_payed) {
|
||||||
|
if ($inv.rd.token() === '') { return; }
|
||||||
|
$inv.rd.sync({ Target: 'amount', Value: (amount != null ? amount : 0).toString() });
|
||||||
|
$inv.rd.sync({ Target: 'amount_payed', Value: (amount_payed != null ? amount_payed : 0).toString() });
|
||||||
|
},
|
||||||
|
/* Render the open-amount footer from the server sums. */
|
||||||
|
footer: function (tbl, sums) {
|
||||||
|
let ft = tbl.children('tfoot').empty();
|
||||||
|
let tr = $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 3 }).text('Offener Betrag')]);
|
||||||
|
$$.tdc('currency', tr, fnum(sums.amount_open || 0, $rct.cst));
|
||||||
|
},
|
||||||
|
validation: function (msgs) {
|
||||||
|
let frm = $inv.rd.layout(); if (frm.length < 1) { return; }
|
||||||
|
let box = frm.children('.dvalidation');
|
||||||
|
if (box.length < 1) { box = $$.dc('dvalidation'); frm.prepend(box); }
|
||||||
|
box.empty().tC('hidden', (msgs || []).length < 1);
|
||||||
|
$.each(msgs || [], (i, m) => $$.dc('dvmsg', box).aC(m.severity).text(m.message));
|
||||||
|
},
|
||||||
|
/* PDF preview straight from the cache; confirm = flush + finalise + email, cancel = discard. */
|
||||||
|
preview: function () {
|
||||||
|
let t = $inv.rd.token(); if (t === '') { return; }
|
||||||
|
let l = $inv.rd.layout();
|
||||||
|
let email = (($inv.rd.tbl().data('new') || {}).invoiceemail) || '';
|
||||||
|
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
|
||||||
|
l.aC('freeze');
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dpreview'), data: { token: t }, success: (response) => {
|
||||||
|
l.rC('freeze');
|
||||||
|
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88);
|
||||||
|
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
|
||||||
|
$ocms.dlg(c, {
|
||||||
|
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $ict.remd,
|
||||||
|
confirm: function (e) {
|
||||||
|
let ct = $(this); l.aC('freeze');
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dsave'), data: { token: t }, success: (sv) => {
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/conf'), data: { id: sv.remid }, success: () => {
|
||||||
|
ct.trigger('modal_close');
|
||||||
|
window.open($ocms.url('rem/idoc') + '?id=' + sv.remid, '_blank');
|
||||||
|
$inv.rd.close();
|
||||||
|
$ocms.init('req'); $inv.rReload();
|
||||||
|
}, error: () => { alert($t.f1); ct.trigger('modal_close'); }, complete: () => { l.rC('freeze'); }
|
||||||
|
});
|
||||||
|
}, error: () => { l.rC('freeze'); alert($t.f1); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel: function (e) { if (confirm($ict.cdI)) { $inv.rd.close(); $inv.rReload(); } }
|
||||||
|
});
|
||||||
|
}, error: () => { l.rC('freeze'); alert($t.f1); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
|
||||||
|
save: function () {
|
||||||
|
let t = $inv.rd.token(); if (t === '') { return; }
|
||||||
|
let l = $inv.rd.layout(); l.aC('freeze');
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dsave'), data: { token: t }, success: (r) => { $inv.rd.tbl().data('remid', r.remid); },
|
||||||
|
error: () => { alert($t.f1); }, complete: () => { l.rC('freeze'); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
history: function () {
|
||||||
|
let t = $inv.rd.token(); if (t === '') { return; }
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dhistory'), data: { token: t }, success: (r) => {
|
||||||
|
let c = $$.dc('dhist');
|
||||||
|
if ((r.history || []).length < 1) { $$.dc('note', c).text('Noch keine Änderungen erfasst.'); }
|
||||||
|
else {
|
||||||
|
let ts = $$.tblset({ class: 'invtbl fullwidth' }, c);
|
||||||
|
$$.tr(ts.hd).append([$$.th().text('Zeit'), $$.th().text('Feld'), $$.th().text('Alt'), $$.th().text('Neu')]);
|
||||||
|
$.each(r.history, (i, h) => $$.tr(ts.bdy).append([$$.tdc('keep', fdt(h.timestamp)), $$.td().text(h.target), $$.td().text(h.oldValue), $$.td().text(h.newValue)]));
|
||||||
|
}
|
||||||
|
$ocms.dlg(c, { width: 800, form: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
warnExpiry: function (secondsLeft) {
|
||||||
|
let mins = Math.max(1, Math.round((secondsLeft || 0) / 60));
|
||||||
|
$fis.notifications.push({ severity: 'info', title: 'Entwurf läuft ab', message: 'Der Mahnentwurf läuft in etwa ' + mins + ' Minute(n) ab. Bitte zwischenspeichern, sonst gehen die Änderungen verloren.' });
|
||||||
|
},
|
||||||
|
closed: function (reason) {
|
||||||
|
let t = $inv.rd.token();
|
||||||
|
$inv.rd.tbl().removeData('rdtoken');
|
||||||
|
if (t !== '') { $fis.draft.release(t); }
|
||||||
|
$fis.frm_edit().remove(); $fis.lf(true);
|
||||||
|
$fis.notifications.push({ severity: 'error', title: 'Entwurf geschlossen', message: reason === 'expired' ? 'Der Mahnentwurf ist wegen Inaktivität abgelaufen. Nicht gespeicherte Änderungen sind verloren.' : 'Der Mahnentwurf wurde geschlossen.' });
|
||||||
|
try { $inv.rReload(); } catch (e) { }
|
||||||
|
},
|
||||||
|
close: function () {
|
||||||
|
let t = $inv.rd.token();
|
||||||
|
if (t !== '') { $ocms.postXT({ url: $ocms.url('rem/dclose'), data: { token: t } }); $fis.draft.release(t); }
|
||||||
|
$inv.rd.tbl().removeData('rdtoken');
|
||||||
|
}
|
||||||
|
};
|
||||||
$inv.cInv2 = function (data) {
|
$inv.cInv2 = function (data) {
|
||||||
let fr = $$.dc('rfrm').ldng(1);
|
let fr = $$.dc('rfrm').ldng(1);
|
||||||
let o = $ocms.dlg(fr, { width: 1000 });
|
let o = $ocms.dlg(fr, { width: 1000 });
|
||||||
@@ -711,8 +866,11 @@ $inv.eHtml = function (ev) {
|
|||||||
if (typeof change === 'function') {
|
if (typeof change === 'function') {
|
||||||
change(response.txt);
|
change(response.txt);
|
||||||
}
|
}
|
||||||
/* backend-authoritative: mirror the inline recipient-field edit to the server session */
|
/* backend-authoritative: mirror the inline recipient-field edit to the server session.
|
||||||
|
Invoice and reminder editors share this DOM; each syncField no-ops unless its own
|
||||||
|
draft token is present, so only the active mode's session receives the delta. */
|
||||||
$inv.d.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
|
$inv.d.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
|
||||||
|
$inv.rd.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
|
||||||
},
|
},
|
||||||
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
|
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
|
||||||
}
|
}
|
||||||
@@ -1367,6 +1525,8 @@ $inv.eRowR = function (ev) {
|
|||||||
$.extend(tdta.rm, res);
|
$.extend(tdta.rm, res);
|
||||||
tbl.data(tdta);
|
tbl.data(tdta);
|
||||||
$inv.rRemRw.call(row, tdta);
|
$inv.rRemRw.call(row, tdta);
|
||||||
|
/* backend-authoritative: mirror the edited amount / amount-paid to the server session */
|
||||||
|
$inv.rd.syncAmount(tdta.rm.amount, tdta.rm.amount_payed);
|
||||||
}, typedvalues: true
|
}, typedvalues: true
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1407,57 +1567,26 @@ $inv.ccRem_s2 = function (id, sets) { //reminder creation
|
|||||||
rif.tbl.children('tbody').each($inv.bdysort);
|
rif.tbl.children('tbody').each($inv.bdysort);
|
||||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||||
|
|
||||||
|
/* Seed the authoritative server session (ADR 0006). Amounts join the recipient
|
||||||
|
fields in the 'new' block; the reference invoice data goes into 'rem'. From here
|
||||||
|
the backend owns the open-amount computation and validation; inline edits and the
|
||||||
|
item-row dialog post single deltas (see $inv.rd). */
|
||||||
|
let nw = rif.tbl.data('new');
|
||||||
|
nw.amount = rem.amount; nw.amount_payed = rem.amount_payed;
|
||||||
|
$inv.rd.seed({
|
||||||
|
rem: { invid: rem.invid, type: rem.type, invoiceid: rem.invoiceid, invoicedate: rem.invoicedate },
|
||||||
|
new: nw
|
||||||
|
});
|
||||||
}, complete: () => {
|
}, complete: () => {
|
||||||
//o.c.trigger('modal_close');
|
//o.c.trigger('modal_close');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
$inv.rprev = () => {
|
$inv.rprev = () => {
|
||||||
var l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
/* Preview + finalise now run through the backend-authoritative session ($inv.rd):
|
||||||
$.extend(d.new, tbl.find('tbody > tr:first').data());
|
the PDF renders straight from the server cache (no rem/prep DB write), and confirm
|
||||||
l.aC('freeze');
|
flushes (rem/dsave) then finalises + emails (rem/conf). */
|
||||||
//console.debug({ rem: d.rm, new: d.new });
|
$inv.rd.preview();
|
||||||
if ($fis.ValidateEmail(d.new.invoiceemail || '') === false) {
|
|
||||||
if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) {
|
|
||||||
l.rC('freeze');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$ocms.postXT({
|
|
||||||
url: $ocms.url('rem/prep'), data: { remc: JSON.stringify({ rem: d.rm, new: d.new }), id: d.invid || '' }, success: (response) => {
|
|
||||||
l.rC('freeze');
|
|
||||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), remid = response.id;
|
|
||||||
$.each(response.img || [], function (ii, img) {
|
|
||||||
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
|
|
||||||
});
|
|
||||||
$ocms.dlg(c, {
|
|
||||||
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $ict.remd, confirm: function (e) {
|
|
||||||
let ct = $(this);
|
|
||||||
$ocms.postXT({
|
|
||||||
url: $ocms.url('rem/conf'), data: { id: remid }, success: () => {
|
|
||||||
ct.trigger('modal_close');
|
|
||||||
window.open($ocms.url('rem/idoc') + '?id=' + remid, '_blank'); /* open pdf in new tab */
|
|
||||||
$ocms.init('req'); /* go back to request list */
|
|
||||||
$inv.rReload();
|
|
||||||
}, error: () => {
|
|
||||||
alert($t.f1);
|
|
||||||
ct.trigger('modal_close');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, cancel: function (e) {
|
|
||||||
let ct = $(this);
|
|
||||||
if (confirm($ict.cdI)) {
|
|
||||||
$ocms.postXT({
|
|
||||||
url: $ocms.url('rem/del'), data: {
|
|
||||||
id: remid
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
$inv.rReload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
$inv.sis = (id) => {
|
$inv.sis = (id) => {
|
||||||
if (confirm($ict.sisc)) {
|
if (confirm($ict.sisc)) {
|
||||||
|
|||||||
+175
-46
@@ -860,6 +860,161 @@ $inv.d = {
|
|||||||
$inv.d.tbl().removeData('dtoken');
|
$inv.d.tbl().removeData('dtoken');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
/* ── Backend-authoritative reminder draft editing (ADR 0006/0007) ─────────────
|
||||||
|
The reminder mirror of $inv.d: the server holds the truth for a reminder draft in
|
||||||
|
an in-memory session; this object seeds it (rem/dopen), sends single edits as deltas
|
||||||
|
(rem/dpatch), and renders the open-amount footer + validation from the authoritative
|
||||||
|
server state (rem/dstate). Preview renders straight from the cache (rem/dpreview);
|
||||||
|
confirm flushes (rem/dsave) then finalises + emails (rem/conf). It coexists with $inv.d
|
||||||
|
on the same DOM: each keys off its own token (rdtoken vs dtoken), so the shared inline
|
||||||
|
editor safely no-ops for the mode that is not active. */
|
||||||
|
$inv.rd = {
|
||||||
|
tbl: () => $('div.invoice_layout table.invi'),
|
||||||
|
layout: () => $('div.invoice_layout'),
|
||||||
|
token: function () { return $inv.rd.tbl().data('rdtoken') || ''; },
|
||||||
|
/* Seed the authoritative server session from the assembled reminder editor payload. */
|
||||||
|
seed: function (payload) {
|
||||||
|
let l = $inv.rd.layout(); l.aC('freeze');
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
|
||||||
|
$inv.rd.tbl().data('rdtoken', r.token).data('rdver', r.version);
|
||||||
|
$fis.draft.bind(r.token, {
|
||||||
|
onReady: () => $inv.rd.refresh(),
|
||||||
|
onExpiring: (s) => $inv.rd.warnExpiry(s),
|
||||||
|
onClosed: (reason) => $inv.rd.closed(reason)
|
||||||
|
});
|
||||||
|
$inv.rd.refresh();
|
||||||
|
}, error: () => { l.rC('freeze'); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/* Re-fetch the authoritative state and render the open-amount footer + validation from it. */
|
||||||
|
refresh: function (cb) {
|
||||||
|
let t = $inv.rd.token(); if (t === '') { return; }
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dstate'), data: { token: t }, success: (state) => {
|
||||||
|
$inv.rd.applyState(state); if (typeof cb === 'function') { cb(state); }
|
||||||
|
}, error: (xhr) => { if (xhr && xhr.status === 410) { $inv.rd.closed('expired'); } },
|
||||||
|
complete: () => { $inv.rd.layout().rC('freeze'); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
applyState: function (state) {
|
||||||
|
let tbl = $inv.rd.tbl(); if (tbl.length < 1) { return; }
|
||||||
|
tbl.data('rdver', state.version).data('serverSums', state.sums).data('remid', state.remid || '');
|
||||||
|
$inv.rd.footer(tbl, state.sums || {});
|
||||||
|
$inv.rd.validation(state.validation || []);
|
||||||
|
},
|
||||||
|
/* Send one change to the server; the draftReady signal and this success both refresh. */
|
||||||
|
sync: function (delta) {
|
||||||
|
let t = $inv.rd.token(); if (t === '') { return; }
|
||||||
|
$inv.rd.layout().aC('freeze');
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dpatch'), data: { token: t, delta: JSON.stringify(delta) },
|
||||||
|
success: () => { $inv.rd.refresh(); },
|
||||||
|
error: (xhr) => { $inv.rd.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.rd.closed('expired'); } }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/* Map an inline recipient field to its delta target and send it. */
|
||||||
|
syncField: function (nme, val) {
|
||||||
|
if ($inv.rd.token() === '') { return; }
|
||||||
|
let map = { subject: 'subject', invoiceaddress: 'address', invoiceemail: 'email', text: 'text' };
|
||||||
|
let target = map[nme]; if (!target) { return; }
|
||||||
|
$inv.rd.sync({ Target: target, Value: val });
|
||||||
|
},
|
||||||
|
/* Amount / amount-paid come from the item-row dialog; send both as their own deltas. */
|
||||||
|
syncAmount: function (amount, amount_payed) {
|
||||||
|
if ($inv.rd.token() === '') { return; }
|
||||||
|
$inv.rd.sync({ Target: 'amount', Value: (amount != null ? amount : 0).toString() });
|
||||||
|
$inv.rd.sync({ Target: 'amount_payed', Value: (amount_payed != null ? amount_payed : 0).toString() });
|
||||||
|
},
|
||||||
|
/* Render the open-amount footer from the server sums. */
|
||||||
|
footer: function (tbl, sums) {
|
||||||
|
let ft = tbl.children('tfoot').empty();
|
||||||
|
let tr = $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 3 }).text('Offener Betrag')]);
|
||||||
|
$$.tdc('currency', tr, fnum(sums.amount_open || 0, $rct.cst));
|
||||||
|
},
|
||||||
|
validation: function (msgs) {
|
||||||
|
let frm = $inv.rd.layout(); if (frm.length < 1) { return; }
|
||||||
|
let box = frm.children('.dvalidation');
|
||||||
|
if (box.length < 1) { box = $$.dc('dvalidation'); frm.prepend(box); }
|
||||||
|
box.empty().tC('hidden', (msgs || []).length < 1);
|
||||||
|
$.each(msgs || [], (i, m) => $$.dc('dvmsg', box).aC(m.severity).text(m.message));
|
||||||
|
},
|
||||||
|
/* PDF preview straight from the cache; confirm = flush + finalise + email, cancel = discard. */
|
||||||
|
preview: function () {
|
||||||
|
let t = $inv.rd.token(); if (t === '') { return; }
|
||||||
|
let l = $inv.rd.layout();
|
||||||
|
let email = (($inv.rd.tbl().data('new') || {}).invoiceemail) || '';
|
||||||
|
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
|
||||||
|
l.aC('freeze');
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dpreview'), data: { token: t }, success: (response) => {
|
||||||
|
l.rC('freeze');
|
||||||
|
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88);
|
||||||
|
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
|
||||||
|
$ocms.dlg(c, {
|
||||||
|
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $ict.remd,
|
||||||
|
confirm: function (e) {
|
||||||
|
let ct = $(this); l.aC('freeze');
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dsave'), data: { token: t }, success: (sv) => {
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/conf'), data: { id: sv.remid }, success: () => {
|
||||||
|
ct.trigger('modal_close');
|
||||||
|
window.open($ocms.url('rem/idoc') + '?id=' + sv.remid, '_blank');
|
||||||
|
$inv.rd.close();
|
||||||
|
$ocms.init('req'); $inv.rReload();
|
||||||
|
}, error: () => { alert($t.f1); ct.trigger('modal_close'); }, complete: () => { l.rC('freeze'); }
|
||||||
|
});
|
||||||
|
}, error: () => { l.rC('freeze'); alert($t.f1); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel: function (e) { if (confirm($ict.cdI)) { $inv.rd.close(); $inv.rReload(); } }
|
||||||
|
});
|
||||||
|
}, error: () => { l.rC('freeze'); alert($t.f1); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
|
||||||
|
save: function () {
|
||||||
|
let t = $inv.rd.token(); if (t === '') { return; }
|
||||||
|
let l = $inv.rd.layout(); l.aC('freeze');
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dsave'), data: { token: t }, success: (r) => { $inv.rd.tbl().data('remid', r.remid); },
|
||||||
|
error: () => { alert($t.f1); }, complete: () => { l.rC('freeze'); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
history: function () {
|
||||||
|
let t = $inv.rd.token(); if (t === '') { return; }
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dhistory'), data: { token: t }, success: (r) => {
|
||||||
|
let c = $$.dc('dhist');
|
||||||
|
if ((r.history || []).length < 1) { $$.dc('note', c).text('Noch keine Änderungen erfasst.'); }
|
||||||
|
else {
|
||||||
|
let ts = $$.tblset({ class: 'invtbl fullwidth' }, c);
|
||||||
|
$$.tr(ts.hd).append([$$.th().text('Zeit'), $$.th().text('Feld'), $$.th().text('Alt'), $$.th().text('Neu')]);
|
||||||
|
$.each(r.history, (i, h) => $$.tr(ts.bdy).append([$$.tdc('keep', fdt(h.timestamp)), $$.td().text(h.target), $$.td().text(h.oldValue), $$.td().text(h.newValue)]));
|
||||||
|
}
|
||||||
|
$ocms.dlg(c, { width: 800, form: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
warnExpiry: function (secondsLeft) {
|
||||||
|
let mins = Math.max(1, Math.round((secondsLeft || 0) / 60));
|
||||||
|
$fis.notifications.push({ severity: 'info', title: 'Entwurf läuft ab', message: 'Der Mahnentwurf läuft in etwa ' + mins + ' Minute(n) ab. Bitte zwischenspeichern, sonst gehen die Änderungen verloren.' });
|
||||||
|
},
|
||||||
|
closed: function (reason) {
|
||||||
|
let t = $inv.rd.token();
|
||||||
|
$inv.rd.tbl().removeData('rdtoken');
|
||||||
|
if (t !== '') { $fis.draft.release(t); }
|
||||||
|
$fis.frm_edit().remove(); $fis.lf(true);
|
||||||
|
$fis.notifications.push({ severity: 'error', title: 'Entwurf geschlossen', message: reason === 'expired' ? 'Der Mahnentwurf ist wegen Inaktivität abgelaufen. Nicht gespeicherte Änderungen sind verloren.' : 'Der Mahnentwurf wurde geschlossen.' });
|
||||||
|
try { $inv.rReload(); } catch (e) { }
|
||||||
|
},
|
||||||
|
close: function () {
|
||||||
|
let t = $inv.rd.token();
|
||||||
|
if (t !== '') { $ocms.postXT({ url: $ocms.url('rem/dclose'), data: { token: t } }); $fis.draft.release(t); }
|
||||||
|
$inv.rd.tbl().removeData('rdtoken');
|
||||||
|
}
|
||||||
|
};
|
||||||
$inv.cInv2 = function (data) {
|
$inv.cInv2 = function (data) {
|
||||||
let fr = $$.dc('rfrm').ldng(1);
|
let fr = $$.dc('rfrm').ldng(1);
|
||||||
let o = $ocms.dlg(fr, { width: 1000 });
|
let o = $ocms.dlg(fr, { width: 1000 });
|
||||||
@@ -1258,8 +1413,11 @@ $inv.eHtml = function (ev) {
|
|||||||
if (typeof change === 'function') {
|
if (typeof change === 'function') {
|
||||||
change(response.txt);
|
change(response.txt);
|
||||||
}
|
}
|
||||||
/* backend-authoritative: mirror the inline recipient-field edit to the server session */
|
/* backend-authoritative: mirror the inline recipient-field edit to the server session.
|
||||||
|
Invoice and reminder editors share this DOM; each syncField no-ops unless its own
|
||||||
|
draft token is present, so only the active mode's session receives the delta. */
|
||||||
$inv.d.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
|
$inv.d.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
|
||||||
|
$inv.rd.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
|
||||||
},
|
},
|
||||||
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
|
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
|
||||||
}
|
}
|
||||||
@@ -1914,6 +2072,8 @@ $inv.eRowR = function (ev) {
|
|||||||
$.extend(tdta.rm, res);
|
$.extend(tdta.rm, res);
|
||||||
tbl.data(tdta);
|
tbl.data(tdta);
|
||||||
$inv.rRemRw.call(row, tdta);
|
$inv.rRemRw.call(row, tdta);
|
||||||
|
/* backend-authoritative: mirror the edited amount / amount-paid to the server session */
|
||||||
|
$inv.rd.syncAmount(tdta.rm.amount, tdta.rm.amount_payed);
|
||||||
}, typedvalues: true
|
}, typedvalues: true
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1954,57 +2114,26 @@ $inv.ccRem_s2 = function (id, sets) { //reminder creation
|
|||||||
rif.tbl.children('tbody').each($inv.bdysort);
|
rif.tbl.children('tbody').each($inv.bdysort);
|
||||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||||
|
|
||||||
|
/* Seed the authoritative server session (ADR 0006). Amounts join the recipient
|
||||||
|
fields in the 'new' block; the reference invoice data goes into 'rem'. From here
|
||||||
|
the backend owns the open-amount computation and validation; inline edits and the
|
||||||
|
item-row dialog post single deltas (see $inv.rd). */
|
||||||
|
let nw = rif.tbl.data('new');
|
||||||
|
nw.amount = rem.amount; nw.amount_payed = rem.amount_payed;
|
||||||
|
$inv.rd.seed({
|
||||||
|
rem: { invid: rem.invid, type: rem.type, invoiceid: rem.invoiceid, invoicedate: rem.invoicedate },
|
||||||
|
new: nw
|
||||||
|
});
|
||||||
}, complete: () => {
|
}, complete: () => {
|
||||||
//o.c.trigger('modal_close');
|
//o.c.trigger('modal_close');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
$inv.rprev = () => {
|
$inv.rprev = () => {
|
||||||
var l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
/* Preview + finalise now run through the backend-authoritative session ($inv.rd):
|
||||||
$.extend(d.new, tbl.find('tbody > tr:first').data());
|
the PDF renders straight from the server cache (no rem/prep DB write), and confirm
|
||||||
l.aC('freeze');
|
flushes (rem/dsave) then finalises + emails (rem/conf). */
|
||||||
//console.debug({ rem: d.rm, new: d.new });
|
$inv.rd.preview();
|
||||||
if ($fis.ValidateEmail(d.new.invoiceemail || '') === false) {
|
|
||||||
if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) {
|
|
||||||
l.rC('freeze');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$ocms.postXT({
|
|
||||||
url: $ocms.url('rem/prep'), data: { remc: JSON.stringify({ rem: d.rm, new: d.new }), id: d.invid || '' }, success: (response) => {
|
|
||||||
l.rC('freeze');
|
|
||||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), remid = response.id;
|
|
||||||
$.each(response.img || [], function (ii, img) {
|
|
||||||
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
|
|
||||||
});
|
|
||||||
$ocms.dlg(c, {
|
|
||||||
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $ict.remd, confirm: function (e) {
|
|
||||||
let ct = $(this);
|
|
||||||
$ocms.postXT({
|
|
||||||
url: $ocms.url('rem/conf'), data: { id: remid }, success: () => {
|
|
||||||
ct.trigger('modal_close');
|
|
||||||
window.open($ocms.url('rem/idoc') + '?id=' + remid, '_blank'); /* open pdf in new tab */
|
|
||||||
$ocms.init('req'); /* go back to request list */
|
|
||||||
$inv.rReload();
|
|
||||||
}, error: () => {
|
|
||||||
alert($t.f1);
|
|
||||||
ct.trigger('modal_close');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, cancel: function (e) {
|
|
||||||
let ct = $(this);
|
|
||||||
if (confirm($ict.cdI)) {
|
|
||||||
$ocms.postXT({
|
|
||||||
url: $ocms.url('rem/del'), data: {
|
|
||||||
id: remid
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
$inv.rReload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
$inv.sis = (id) => {
|
$inv.sis = (id) => {
|
||||||
if (confirm($ict.sisc)) {
|
if (confirm($ict.sisc)) {
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+175
-46
@@ -841,6 +841,161 @@ $inv.d = {
|
|||||||
$inv.d.tbl().removeData('dtoken');
|
$inv.d.tbl().removeData('dtoken');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
/* ── Backend-authoritative reminder draft editing (ADR 0006/0007) ─────────────
|
||||||
|
The reminder mirror of $inv.d: the server holds the truth for a reminder draft in
|
||||||
|
an in-memory session; this object seeds it (rem/dopen), sends single edits as deltas
|
||||||
|
(rem/dpatch), and renders the open-amount footer + validation from the authoritative
|
||||||
|
server state (rem/dstate). Preview renders straight from the cache (rem/dpreview);
|
||||||
|
confirm flushes (rem/dsave) then finalises + emails (rem/conf). It coexists with $inv.d
|
||||||
|
on the same DOM: each keys off its own token (rdtoken vs dtoken), so the shared inline
|
||||||
|
editor safely no-ops for the mode that is not active. */
|
||||||
|
$inv.rd = {
|
||||||
|
tbl: () => $('div.invoice_layout table.invi'),
|
||||||
|
layout: () => $('div.invoice_layout'),
|
||||||
|
token: function () { return $inv.rd.tbl().data('rdtoken') || ''; },
|
||||||
|
/* Seed the authoritative server session from the assembled reminder editor payload. */
|
||||||
|
seed: function (payload) {
|
||||||
|
let l = $inv.rd.layout(); l.aC('freeze');
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
|
||||||
|
$inv.rd.tbl().data('rdtoken', r.token).data('rdver', r.version);
|
||||||
|
$fis.draft.bind(r.token, {
|
||||||
|
onReady: () => $inv.rd.refresh(),
|
||||||
|
onExpiring: (s) => $inv.rd.warnExpiry(s),
|
||||||
|
onClosed: (reason) => $inv.rd.closed(reason)
|
||||||
|
});
|
||||||
|
$inv.rd.refresh();
|
||||||
|
}, error: () => { l.rC('freeze'); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/* Re-fetch the authoritative state and render the open-amount footer + validation from it. */
|
||||||
|
refresh: function (cb) {
|
||||||
|
let t = $inv.rd.token(); if (t === '') { return; }
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dstate'), data: { token: t }, success: (state) => {
|
||||||
|
$inv.rd.applyState(state); if (typeof cb === 'function') { cb(state); }
|
||||||
|
}, error: (xhr) => { if (xhr && xhr.status === 410) { $inv.rd.closed('expired'); } },
|
||||||
|
complete: () => { $inv.rd.layout().rC('freeze'); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
applyState: function (state) {
|
||||||
|
let tbl = $inv.rd.tbl(); if (tbl.length < 1) { return; }
|
||||||
|
tbl.data('rdver', state.version).data('serverSums', state.sums).data('remid', state.remid || '');
|
||||||
|
$inv.rd.footer(tbl, state.sums || {});
|
||||||
|
$inv.rd.validation(state.validation || []);
|
||||||
|
},
|
||||||
|
/* Send one change to the server; the draftReady signal and this success both refresh. */
|
||||||
|
sync: function (delta) {
|
||||||
|
let t = $inv.rd.token(); if (t === '') { return; }
|
||||||
|
$inv.rd.layout().aC('freeze');
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dpatch'), data: { token: t, delta: JSON.stringify(delta) },
|
||||||
|
success: () => { $inv.rd.refresh(); },
|
||||||
|
error: (xhr) => { $inv.rd.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.rd.closed('expired'); } }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/* Map an inline recipient field to its delta target and send it. */
|
||||||
|
syncField: function (nme, val) {
|
||||||
|
if ($inv.rd.token() === '') { return; }
|
||||||
|
let map = { subject: 'subject', invoiceaddress: 'address', invoiceemail: 'email', text: 'text' };
|
||||||
|
let target = map[nme]; if (!target) { return; }
|
||||||
|
$inv.rd.sync({ Target: target, Value: val });
|
||||||
|
},
|
||||||
|
/* Amount / amount-paid come from the item-row dialog; send both as their own deltas. */
|
||||||
|
syncAmount: function (amount, amount_payed) {
|
||||||
|
if ($inv.rd.token() === '') { return; }
|
||||||
|
$inv.rd.sync({ Target: 'amount', Value: (amount != null ? amount : 0).toString() });
|
||||||
|
$inv.rd.sync({ Target: 'amount_payed', Value: (amount_payed != null ? amount_payed : 0).toString() });
|
||||||
|
},
|
||||||
|
/* Render the open-amount footer from the server sums. */
|
||||||
|
footer: function (tbl, sums) {
|
||||||
|
let ft = tbl.children('tfoot').empty();
|
||||||
|
let tr = $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 3 }).text('Offener Betrag')]);
|
||||||
|
$$.tdc('currency', tr, fnum(sums.amount_open || 0, $rct.cst));
|
||||||
|
},
|
||||||
|
validation: function (msgs) {
|
||||||
|
let frm = $inv.rd.layout(); if (frm.length < 1) { return; }
|
||||||
|
let box = frm.children('.dvalidation');
|
||||||
|
if (box.length < 1) { box = $$.dc('dvalidation'); frm.prepend(box); }
|
||||||
|
box.empty().tC('hidden', (msgs || []).length < 1);
|
||||||
|
$.each(msgs || [], (i, m) => $$.dc('dvmsg', box).aC(m.severity).text(m.message));
|
||||||
|
},
|
||||||
|
/* PDF preview straight from the cache; confirm = flush + finalise + email, cancel = discard. */
|
||||||
|
preview: function () {
|
||||||
|
let t = $inv.rd.token(); if (t === '') { return; }
|
||||||
|
let l = $inv.rd.layout();
|
||||||
|
let email = (($inv.rd.tbl().data('new') || {}).invoiceemail) || '';
|
||||||
|
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
|
||||||
|
l.aC('freeze');
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dpreview'), data: { token: t }, success: (response) => {
|
||||||
|
l.rC('freeze');
|
||||||
|
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88);
|
||||||
|
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
|
||||||
|
$ocms.dlg(c, {
|
||||||
|
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $ict.remd,
|
||||||
|
confirm: function (e) {
|
||||||
|
let ct = $(this); l.aC('freeze');
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dsave'), data: { token: t }, success: (sv) => {
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/conf'), data: { id: sv.remid }, success: () => {
|
||||||
|
ct.trigger('modal_close');
|
||||||
|
window.open($ocms.url('rem/idoc') + '?id=' + sv.remid, '_blank');
|
||||||
|
$inv.rd.close();
|
||||||
|
$ocms.init('req'); $inv.rReload();
|
||||||
|
}, error: () => { alert($t.f1); ct.trigger('modal_close'); }, complete: () => { l.rC('freeze'); }
|
||||||
|
});
|
||||||
|
}, error: () => { l.rC('freeze'); alert($t.f1); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel: function (e) { if (confirm($ict.cdI)) { $inv.rd.close(); $inv.rReload(); } }
|
||||||
|
});
|
||||||
|
}, error: () => { l.rC('freeze'); alert($t.f1); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
|
||||||
|
save: function () {
|
||||||
|
let t = $inv.rd.token(); if (t === '') { return; }
|
||||||
|
let l = $inv.rd.layout(); l.aC('freeze');
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dsave'), data: { token: t }, success: (r) => { $inv.rd.tbl().data('remid', r.remid); },
|
||||||
|
error: () => { alert($t.f1); }, complete: () => { l.rC('freeze'); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
history: function () {
|
||||||
|
let t = $inv.rd.token(); if (t === '') { return; }
|
||||||
|
$ocms.postXT({
|
||||||
|
url: $ocms.url('rem/dhistory'), data: { token: t }, success: (r) => {
|
||||||
|
let c = $$.dc('dhist');
|
||||||
|
if ((r.history || []).length < 1) { $$.dc('note', c).text('Noch keine Änderungen erfasst.'); }
|
||||||
|
else {
|
||||||
|
let ts = $$.tblset({ class: 'invtbl fullwidth' }, c);
|
||||||
|
$$.tr(ts.hd).append([$$.th().text('Zeit'), $$.th().text('Feld'), $$.th().text('Alt'), $$.th().text('Neu')]);
|
||||||
|
$.each(r.history, (i, h) => $$.tr(ts.bdy).append([$$.tdc('keep', fdt(h.timestamp)), $$.td().text(h.target), $$.td().text(h.oldValue), $$.td().text(h.newValue)]));
|
||||||
|
}
|
||||||
|
$ocms.dlg(c, { width: 800, form: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
warnExpiry: function (secondsLeft) {
|
||||||
|
let mins = Math.max(1, Math.round((secondsLeft || 0) / 60));
|
||||||
|
$fis.notifications.push({ severity: 'info', title: 'Entwurf läuft ab', message: 'Der Mahnentwurf läuft in etwa ' + mins + ' Minute(n) ab. Bitte zwischenspeichern, sonst gehen die Änderungen verloren.' });
|
||||||
|
},
|
||||||
|
closed: function (reason) {
|
||||||
|
let t = $inv.rd.token();
|
||||||
|
$inv.rd.tbl().removeData('rdtoken');
|
||||||
|
if (t !== '') { $fis.draft.release(t); }
|
||||||
|
$fis.frm_edit().remove(); $fis.lf(true);
|
||||||
|
$fis.notifications.push({ severity: 'error', title: 'Entwurf geschlossen', message: reason === 'expired' ? 'Der Mahnentwurf ist wegen Inaktivität abgelaufen. Nicht gespeicherte Änderungen sind verloren.' : 'Der Mahnentwurf wurde geschlossen.' });
|
||||||
|
try { $inv.rReload(); } catch (e) { }
|
||||||
|
},
|
||||||
|
close: function () {
|
||||||
|
let t = $inv.rd.token();
|
||||||
|
if (t !== '') { $ocms.postXT({ url: $ocms.url('rem/dclose'), data: { token: t } }); $fis.draft.release(t); }
|
||||||
|
$inv.rd.tbl().removeData('rdtoken');
|
||||||
|
}
|
||||||
|
};
|
||||||
$inv.cInv2 = function (data) {
|
$inv.cInv2 = function (data) {
|
||||||
let fr = $$.dc('rfrm').ldng(1);
|
let fr = $$.dc('rfrm').ldng(1);
|
||||||
let o = $ocms.dlg(fr, { width: 1000 });
|
let o = $ocms.dlg(fr, { width: 1000 });
|
||||||
@@ -1239,8 +1394,11 @@ $inv.eHtml = function (ev) {
|
|||||||
if (typeof change === 'function') {
|
if (typeof change === 'function') {
|
||||||
change(response.txt);
|
change(response.txt);
|
||||||
}
|
}
|
||||||
/* backend-authoritative: mirror the inline recipient-field edit to the server session */
|
/* backend-authoritative: mirror the inline recipient-field edit to the server session.
|
||||||
|
Invoice and reminder editors share this DOM; each syncField no-ops unless its own
|
||||||
|
draft token is present, so only the active mode's session receives the delta. */
|
||||||
$inv.d.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
|
$inv.d.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
|
||||||
|
$inv.rd.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
|
||||||
},
|
},
|
||||||
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
|
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
|
||||||
}
|
}
|
||||||
@@ -1895,6 +2053,8 @@ $inv.eRowR = function (ev) {
|
|||||||
$.extend(tdta.rm, res);
|
$.extend(tdta.rm, res);
|
||||||
tbl.data(tdta);
|
tbl.data(tdta);
|
||||||
$inv.rRemRw.call(row, tdta);
|
$inv.rRemRw.call(row, tdta);
|
||||||
|
/* backend-authoritative: mirror the edited amount / amount-paid to the server session */
|
||||||
|
$inv.rd.syncAmount(tdta.rm.amount, tdta.rm.amount_payed);
|
||||||
}, typedvalues: true
|
}, typedvalues: true
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1935,57 +2095,26 @@ $inv.ccRem_s2 = function (id, sets) { //reminder creation
|
|||||||
rif.tbl.children('tbody').each($inv.bdysort);
|
rif.tbl.children('tbody').each($inv.bdysort);
|
||||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||||
|
|
||||||
|
/* Seed the authoritative server session (ADR 0006). Amounts join the recipient
|
||||||
|
fields in the 'new' block; the reference invoice data goes into 'rem'. From here
|
||||||
|
the backend owns the open-amount computation and validation; inline edits and the
|
||||||
|
item-row dialog post single deltas (see $inv.rd). */
|
||||||
|
let nw = rif.tbl.data('new');
|
||||||
|
nw.amount = rem.amount; nw.amount_payed = rem.amount_payed;
|
||||||
|
$inv.rd.seed({
|
||||||
|
rem: { invid: rem.invid, type: rem.type, invoiceid: rem.invoiceid, invoicedate: rem.invoicedate },
|
||||||
|
new: nw
|
||||||
|
});
|
||||||
}, complete: () => {
|
}, complete: () => {
|
||||||
//o.c.trigger('modal_close');
|
//o.c.trigger('modal_close');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
$inv.rprev = () => {
|
$inv.rprev = () => {
|
||||||
var l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
/* Preview + finalise now run through the backend-authoritative session ($inv.rd):
|
||||||
$.extend(d.new, tbl.find('tbody > tr:first').data());
|
the PDF renders straight from the server cache (no rem/prep DB write), and confirm
|
||||||
l.aC('freeze');
|
flushes (rem/dsave) then finalises + emails (rem/conf). */
|
||||||
//console.debug({ rem: d.rm, new: d.new });
|
$inv.rd.preview();
|
||||||
if ($fis.ValidateEmail(d.new.invoiceemail || '') === false) {
|
|
||||||
if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) {
|
|
||||||
l.rC('freeze');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$ocms.postXT({
|
|
||||||
url: $ocms.url('rem/prep'), data: { remc: JSON.stringify({ rem: d.rm, new: d.new }), id: d.invid || '' }, success: (response) => {
|
|
||||||
l.rC('freeze');
|
|
||||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), remid = response.id;
|
|
||||||
$.each(response.img || [], function (ii, img) {
|
|
||||||
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
|
|
||||||
});
|
|
||||||
$ocms.dlg(c, {
|
|
||||||
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $ict.remd, confirm: function (e) {
|
|
||||||
let ct = $(this);
|
|
||||||
$ocms.postXT({
|
|
||||||
url: $ocms.url('rem/conf'), data: { id: remid }, success: () => {
|
|
||||||
ct.trigger('modal_close');
|
|
||||||
window.open($ocms.url('rem/idoc') + '?id=' + remid, '_blank'); /* open pdf in new tab */
|
|
||||||
$ocms.init('req'); /* go back to request list */
|
|
||||||
$inv.rReload();
|
|
||||||
}, error: () => {
|
|
||||||
alert($t.f1);
|
|
||||||
ct.trigger('modal_close');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, cancel: function (e) {
|
|
||||||
let ct = $(this);
|
|
||||||
if (confirm($ict.cdI)) {
|
|
||||||
$ocms.postXT({
|
|
||||||
url: $ocms.url('rem/del'), data: {
|
|
||||||
id: remid
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
$inv.rReload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
$inv.sis = (id) => {
|
$inv.sis = (id) => {
|
||||||
if (confirm($ict.sisc)) {
|
if (confirm($ict.sisc)) {
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user