Refactor code structure for improved readability and maintainability
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
2026-07-08 19:33:23 +02:00
parent 4abf81cd7d
commit 59a2b86c09
23 changed files with 676 additions and 60 deletions
+2 -1
View File
@@ -3,6 +3,7 @@
"dotnet run": true,
"dotnet test": true,
"dotnet build": true,
"npx gulp": true
"npx gulp": true,
"ForEach-Object": true
}
}
+121
View File
@@ -0,0 +1,121 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.Notifications;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Tests for <see cref="EventService"/>, the single point every server-side
/// operation goes through to notify the user. Covers the two failure methods the
/// exception safety nets rely on (<c>UserIssueAsync</c> from
/// <c>IntranetController.Do</c>'s catch-all, and <c>InvoiceIssueAsync</c> from
/// <c>HandleInvoiceGet</c>) rendering as <c>"error"</c> notifications, plus a
/// contrasting success path rendering as <c>"info"</c> — see ADR 0003.
/// </summary>
public class EventServiceTests
{
// ── Test doubles: capture the GuiNotification pushed to Clients.All ─────────
private sealed class CapturingClientProxy : IClientProxy
{
public string? Method { get; private set; }
public object?[]? Args { get; private set; }
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
Method = method;
Args = args;
return Task.CompletedTask;
}
}
private sealed class StubHubClients : IHubClients
{
private readonly IClientProxy _all;
public StubHubClients(IClientProxy all) => _all = all;
public IClientProxy All => _all;
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => throw new System.NotImplementedException();
public IClientProxy Client(string connectionId) => throw new System.NotImplementedException();
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => throw new System.NotImplementedException();
public IClientProxy Group(string groupName) => throw new System.NotImplementedException();
public IClientProxy Groups(IReadOnlyList<string> groupNames) => throw new System.NotImplementedException();
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => throw new System.NotImplementedException();
public IClientProxy User(string userId) => throw new System.NotImplementedException();
public IClientProxy Users(IReadOnlyList<string> userIds) => throw new System.NotImplementedException();
}
private sealed class StubHubContext : IHubContext<NotificationHub>
{
public StubHubContext(IHubClients clients) => Clients = clients;
public IHubClients Clients { get; }
public IGroupManager Groups => throw new System.NotImplementedException();
}
private static (EventService svc, CapturingClientProxy proxy) CreateService()
{
var proxy = new CapturingClientProxy();
var hub = new StubHubContext(new StubHubClients(proxy));
return (new EventService(hub, NullLogger<EventService>.Instance), proxy);
}
private static GuiNotification Captured(CapturingClientProxy proxy)
{
Assert.Equal("notification", proxy.Method);
Assert.NotNull(proxy.Args);
var arg = Assert.Single(proxy.Args!);
return Assert.IsType<GuiNotification>(arg);
}
// ── Failure paths the exception safety nets use ─────────────────────────────
[Fact]
public async Task UserIssueAsync_PublishesErrorNotificationWithMessage()
{
var (svc, proxy) = CreateService();
await svc.UserIssueAsync(
"Aktion fehlgeschlagen",
"Die Aktion konnte aufgrund eines unerwarteten Fehlers nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
"user-42",
new Dictionary<string, object?> { ["fn"] = "inv" });
var n = Captured(proxy);
Assert.Equal("error", n.Severity);
Assert.Equal("Aktion fehlgeschlagen", n.Title);
Assert.Equal(DomainEventType.UserIssue.ToString(), n.Type);
Assert.Contains("nicht abgeschlossen werden", n.Message);
Assert.Equal("inv", n.Context["fn"]);
}
[Fact]
public async Task InvoiceIssueAsync_PublishesErrorNotificationCarryingInvoiceId()
{
var (svc, proxy) = CreateService();
await svc.InvoiceIssueAsync(
"Die Rechnung konnte aufgrund eines Fehlers nicht geladen werden.",
"user-42",
"INV-1001");
var n = Captured(proxy);
Assert.Equal("error", n.Severity);
Assert.Equal(DomainEventType.InvoiceCreationFailed.ToString(), n.Type);
Assert.Equal("Die Rechnung konnte aufgrund eines Fehlers nicht geladen werden.", n.Message);
Assert.Equal("INV-1001", n.Context["id"]);
}
// ── Contrasting success path renders as info, not error ─────────────────────
[Fact]
public async Task InvoiceMarkedSentAsync_PublishesInfoNotification()
{
var (svc, proxy) = CreateService();
await svc.InvoiceMarkedSentAsync("INV-1001", "R2026-0001", "user-42");
var n = Captured(proxy);
Assert.Equal("info", n.Severity);
Assert.Contains("R2026-0001", n.Message);
}
}
+93
View File
@@ -0,0 +1,93 @@
using System.Collections.Generic;
using Fuchs.Controllers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Covers <see cref="RequestValueHelper.Resolve"/>, which backs IntranetController's
/// Form()/HasForm() helpers. Endpoints in _allowedGet (e.g. req/idoc, rem/idoc) are invoked via
/// a plain GET (window.open with '?id=...'), so this must resolve from the query string without
/// ever touching an IFormCollection built from a non-form request (that would previously throw
/// InvalidOperationException in production - see Do() unhandled-exception log for fn=req id=idoc).
/// </summary>
public class RequestValueHelperTests
{
private static IFormCollection Form(params (string Key, string Value)[] pairs)
{
var dict = new Dictionary<string, StringValues>();
foreach (var (key, value) in pairs) dict[key] = value;
return new FormCollection(dict);
}
private static IQueryCollection Query(params (string Key, string Value)[] pairs)
{
var dict = new Dictionary<string, StringValues>();
foreach (var (key, value) in pairs) dict[key] = value;
return new QueryCollection(dict);
}
[Fact]
public void Resolve_FormContentTypeWithKey_ReturnsFormValue()
{
string? result = RequestValueHelper.Resolve(
hasFormContentType: true,
form: Form(("id", "abc123")),
query: Query(("id", "from-query")),
key: "id");
Assert.Equal("abc123", result);
}
[Fact]
public void Resolve_NoFormContentType_FallsBackToQuery()
{
// Simulates a GET request opened via window.open('?id=...'): no Content-Type header,
// so the (empty) form collection must not be consulted - only the query string.
string? result = RequestValueHelper.Resolve(
hasFormContentType: false,
form: FormCollection.Empty,
query: Query(("id", "7O32P")),
key: "id");
Assert.Equal("7O32P", result);
}
[Fact]
public void Resolve_FormContentTypeButKeyMissingFromForm_FallsBackToQuery()
{
string? result = RequestValueHelper.Resolve(
hasFormContentType: true,
form: Form(("other", "value")),
query: Query(("id", "7O32P")),
key: "id");
Assert.Equal("7O32P", result);
}
[Fact]
public void Resolve_KeyMissingFromBoth_ReturnsNull()
{
string? result = RequestValueHelper.Resolve(
hasFormContentType: true,
form: Form(("other", "value")),
query: Query(("other", "value")),
key: "id");
Assert.Null(result);
}
[Fact]
public void Resolve_NoFormContentTypeAndQueryEmpty_ReturnsNull()
{
string? result = RequestValueHelper.Resolve(
hasFormContentType: false,
form: FormCollection.Empty,
query: QueryCollection.Empty,
key: "id");
Assert.Null(result);
}
}
@@ -240,11 +240,22 @@ public partial class IntranetController
}
// ── Form helpers ─────────────────────────────────────────────────────────
// Reads from the posted form when available, falling back to the query string. This
// supports endpoints in _allowedGet (e.g. req/idoc, rem/idoc) that are invoked via a
// plain GET (window.open with '?id=...'), where Request.Form has no Content-Type and
// would otherwise throw InvalidOperationException.
protected bool HasForm(params string[] keys) =>
keys.All(k => Request.Form.ContainsKey(k) && !string.IsNullOrWhiteSpace(Request.Form[k]));
keys.All(k => !string.IsNullOrWhiteSpace(FormValue(k)));
protected string Form(string key, string fallback = "") =>
Request.Form.TryGetValue(key, out var v) ? v.ToString() : fallback;
FormValue(key) ?? fallback;
private string? FormValue(string key) =>
RequestValueHelper.Resolve(
Request.HasFormContentType,
Request.HasFormContentType ? Request.Form : Microsoft.AspNetCore.Http.FormCollection.Empty,
Request.Query,
key);
private static (DateTime? From, DateTime? To) BankingDateRange(System.Data.DataTable tbl)
{
@@ -87,7 +87,13 @@ public partial class IntranetController
}
catch (Exception ex)
{
_logger.LogError(ex, "HandleInvoiceGet failed for id={InvoiceId} user={User}", Form("id"), UserAccountID);
string invoiceId = Form("id");
_logger.LogError(ex, "HandleInvoiceGet failed for id={InvoiceId} user={User}", invoiceId, UserAccountID);
// This handler has its own catch (returns 500) and so never reaches the Do safety net;
// notify the user here so a failed invoice load is not silently swallowed.
await _events.InvoiceIssueAsync(
"Die Rechnung konnte aufgrund eines Fehlers nicht geladen werden.",
UserAccountID, invoiceId);
return StatusCode(500);
}
}
@@ -388,8 +394,22 @@ public partial class IntranetController
sqldset.Tables("itm").Columns.Contains("order") ? "order" : ""))
{
var d = sitm.toObjectDictionary();
double net = Convert.ToDouble(d.no("value_total", 0));
double vat = Convert.ToDouble(d.no("vat", 0));
double net = Convert.ToDouble(d.no("value_total", 0));
double vat = Convert.ToDouble(d.no("vat", 0));
double value = Convert.ToDouble(d.no("value", 0));
string quantityStr = d.nz("Quantity");
// quantityhours/UnitString reconstruct the hour-based quantity editor's raw
// fields (e.g. "5 Std" -> quantityhours=5, UnitString="Std") from the persisted
// "Quantity" string, mirroring the legacy fds__invoice_data ndic mapping — without
// this, re-editing a reloaded hour-based item showed an empty quantity field.
object quantityHours = "";
if (value != 0 && !string.IsNullOrEmpty(quantityStr))
{
long qh = (long)(net / value);
if (quantityStr.StartsWith(qh.ToString(CultureInfo.InvariantCulture) + " ", StringComparison.Ordinal))
quantityHours = qh;
}
string unitString = !string.IsNullOrEmpty(quantityStr) ? quantityStr.RightFromFirst(" ") : "";
itm.Add(new Dictionary<string, object?>
{
["Id"] = d["Id"],
@@ -399,8 +419,11 @@ public partial class IntranetController
["svcnet_val"] = d.no("value_service", 0),
["net"] = d.no("value", 0),
["quantity"] = d["Quantity"],
["quantityhours"] = quantityHours,
["UnitString"] = unitString,
["Type"] = d["Type"],
["Note"] = null,
["NameOrNumber"] = "",
["htmltext"] = d["Text"],
["position"] = d["Position"],
["SortOrder"] = d["SortOrder"]
@@ -22,7 +22,9 @@ public partial class IntranetController
{
case "get":
{
if (!HasForm("id")) return BadRequest400();
if (!HasForm("id")) { _logger.LogWarning("Reminder get: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogDebug("Reminder get: preparing reminder for invoice {InvId} type={Type} level={Level} user={User}",
Form("id"), Form("type"), Form("level"), UserAccountID);
var pl = StdParamlist(
SQL_VarChar("@InvId", Form("id")),
SQL_VarChar("@type", Form("type")),
@@ -32,17 +34,21 @@ public partial class IntranetController
_intranet.Intranet__SQLConnectionString, pl,
tablenames: new[] { "rem" },
Security: DbSec, options: SqlOpt(fn, id, code));
if (!string.IsNullOrEmpty(dset.Exception))
_logger.LogError("Reminder get: SQL error for invoice {InvId}: {SqlError}, user={User}", Form("id"), dset.Exception, UserAccountID);
return await JSONAsync(new { rm = dset.Table("rem").FirstRow.toObjectDictionary() });
}
case "prep":
{
if (!HasForm("remc")) return BadRequest400();
if (!HasForm("remc")) { _logger.LogWarning("Reminder prep: missing form field 'remc', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogInformation("Reminder prep: creating draft reminder, user={User}", UserAccountID);
var ctd = JsonConvert.DeserializeObject(Form("remc"))!;
var fdRem = await _reminders.RegisterReminderAsync(
new FdsReminderData(ctd), change: false, remId: "", UserAccountID, DbSec);
if (!string.IsNullOrEmpty(fdRem.Id))
{
_logger.LogInformation("Reminder prep: draft reminder {RemId} created, user={User}", fdRem.Id, UserAccountID);
await _events.ReminderDraftCreatedAsync(fdRem, UserAccountID);
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
@@ -54,7 +60,8 @@ public partial class IntranetController
case "srs":
{
if (!HasForm("id")) return BadRequest400();
if (!HasForm("id")) { _logger.LogWarning("Reminder srs: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogInformation("Reminder srs: marking reminder {RemId} as sent, user={User}", Form("id"), UserAccountID);
var pl = StdParamlist(SQL_VarChar("@Id", Form("id")), SQL_Bit("@auto", false));
var dt2 = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__setReminderSent] @Id, @auto, @authuser;",
@@ -63,17 +70,25 @@ public partial class IntranetController
if (string.IsNullOrEmpty(dt2.Exception))
await _events.ReminderMarkedSentAsync(Form("id"), Form("id"), UserAccountID);
else
{
_logger.LogError("Reminder srs: SQL error marking reminder {RemId} sent: {SqlError}, user={User}", Form("id"), dt2.Exception, UserAccountID);
await _events.ReminderIssueAsync(
$"Mahnung {Form("id")} konnte nicht als versandt markiert werden.",
UserAccountID, Form("id"));
}
return string.IsNullOrEmpty(dt2.Exception) ? await JSONAsync(new { ok = true }) : StatusCode(500);
}
case "rdoc":
{
if (!HasForm("id")) return BadRequest400();
if (!HasForm("id")) { _logger.LogWarning("Reminder rdoc: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogDebug("Reminder rdoc: fetching stored reminder document {RemId} typ={Typ} user={User}", Form("id"), Form("typ"), UserAccountID);
var (file, fc) = await _reminders.GetStoredFileAsync(Form("id"), UserAccountID, DbSec);
if (file == null || fc == null) return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
if (file == null || fc == null)
{
_logger.LogWarning("Reminder rdoc: document not found for reminder {RemId} user={User}", Form("id"), UserAccountID);
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
}
return Form("typ") != "img"
? await FileContentResultAsync(fc, file.MimeType(), file.Name)
: await JSONAsync(new { id = Form("id"), img = await BuildPdfImageArray(fc) });
@@ -84,13 +99,16 @@ public partial class IntranetController
case "lrem":
{
if (!HasForm("id")) return BadRequest400();
if (!HasForm("id")) { _logger.LogWarning("Reminder lrem: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogDebug("Reminder lrem: listing reminders for invoice {InvId} user={User}", Form("id"), UserAccountID);
var dset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__lookupReminders] @InvId, @authuser;",
_intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@InvId", Form("id"))),
tablenames: new[] { "ov", "rem" },
Security: DbSec, options: SqlOpt(fn, id, code));
if (!string.IsNullOrEmpty(dset.Exception))
_logger.LogError("Reminder lrem: SQL error for invoice {InvId}: {SqlError}, user={User}", Form("id"), dset.Exception, UserAccountID);
return await JSONAsync(new
{
ov = dset.Table("ov").FirstRow.toStringDictionary(),
@@ -98,18 +116,23 @@ public partial class IntranetController
});
}
default: return await JSONAsync(new { ok = true });
default:
_logger.LogWarning("Do_Process_Reminder: unhandled action id={Id}, user={User}", id, UserAccountID);
return await JSONAsync(new { ok = true });
}
}
private async Task<IActionResult> HandleReminderConf(string fn, string id, string code)
{
if (!HasForm("id")) return BadRequest400();
if (!HasForm("id")) { _logger.LogWarning("HandleReminderConf: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogInformation("HandleReminderConf: finalizing reminder {RemId} user={User}", Form("id"), UserAccountID);
var dt = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__setReminderFinal] @Id, @authuser;",
_intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@Id", Form("id"))),
Security: DbSec, options: SqlOpt(fn, id, code));
if (!string.IsNullOrEmpty(dt.Exception))
_logger.LogError("HandleReminderConf: SQL error finalizing reminder {RemId}: {SqlError}, user={User}", Form("id"), dt.Exception, UserAccountID);
var frdic = dt.FirstRow.toObjectDictionary();
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
{
@@ -167,9 +190,10 @@ public partial class IntranetController
private async Task<IActionResult> HandleReminderIdoc(string fn, string id, string code)
{
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) return StatusCode(404);
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleReminderIdoc: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); }
_logger.LogDebug("HandleReminderIdoc: reminderId={RemId} typ={Typ} create={Create} user={User}", Form("id"), Form("typ"), Form("create", "0"), UserAccountID);
var fdRem = await _reminders.LoadReminderAsync(Form("id"), UserAccountID, DbSec);
if (string.IsNullOrEmpty(fdRem.Id)) return StatusCode(404, new { error = "Erinnerung wurde nicht gefunden" });
if (string.IsNullOrEmpty(fdRem.Id)) { _logger.LogWarning("HandleReminderIdoc: reminder not found id={RemId} user={User}", Form("id"), UserAccountID); return StatusCode(404, new { error = "Erinnerung wurde nicht gefunden" }); }
string filename = fdRem.ReminderRegistration!.nz("DocumentName").ne($"Zahlungserinnerung_{fdRem.Id}.pdf");
if (Form("typ") != "img")
{
@@ -184,7 +208,8 @@ public partial class IntranetController
private async Task<IActionResult> HandleReminderResend(string fn, string id, string code)
{
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) return StatusCode(404);
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleReminderResend: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); }
_logger.LogInformation("HandleReminderResend: resending reminder {RemId} user={User}", Form("id"), UserAccountID);
var pl = StdParamlist(SQL_VarChar("@Id", Form("id")), new SqlParameter("@includefile", true));
var dset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getReminder] @Id, @includefile, @authuser;",
@@ -27,13 +27,16 @@ public partial class IntranetController
case "rthd":
{
if (!HasForm("id")) return BadRequest400();
if (!HasForm("id")) { _logger.LogWarning("Requests rthd: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogInformation("Requests rthd: toggling hidden state for request {ReqId}, user={User}", Form("id"), UserAccountID);
var sqldt = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__toggleRequestHidden] @Id, @authuser;",
_intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_BigInt("@Id", Form("id"))),
Security: DbSec, options: SqlOpt(fn, id, code));
if (sqldt.Count == 0) return StatusCode(404, new { error = "not found" });
if (!string.IsNullOrEmpty(sqldt.Exception))
_logger.LogError("Requests rthd: SQL error for request {ReqId}: {SqlError}, user={User}", Form("id"), sqldt.Exception, UserAccountID);
if (sqldt.Count == 0) { _logger.LogWarning("Requests rthd: request {ReqId} not found, user={User}", Form("id"), UserAccountID); return StatusCode(404, new { error = "not found" }); }
var dic = sqldt.FirstRow.toObjectDictionary();
return await JSONAsync(new { id = dic["EntityId"], visible = dic.no("hidden", false) is not true });
}
@@ -45,12 +48,17 @@ public partial class IntranetController
case "save":
{
if (!HasForm("invc")) return BadRequest400();
if (!HasForm("invc")) { _logger.LogWarning("Requests save: missing form field 'invc', user={User}", UserAccountID); return BadRequest400(); }
bool saveChange = !string.IsNullOrEmpty(Form("id"));
_logger.LogInformation("Requests save: saving invoice draft change={Change} invId={InvId} user={User}", saveChange, Form("id"), UserAccountID);
var fdInv = await _invoices.RegisterInvoiceAsync(
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
change: !string.IsNullOrEmpty(Form("id")), invId: Form("id"), UserAccountID, DbSec);
change: saveChange, invId: Form("id"), UserAccountID, DbSec);
if (!string.IsNullOrEmpty(fdInv.Id))
await _events.InvoiceDraftRegisteredAsync(fdInv, !string.IsNullOrEmpty(Form("id")), UserAccountID);
{
_logger.LogInformation("Requests save: invoice draft {InvId} saved, user={User}", fdInv.Id, UserAccountID);
await _events.InvoiceDraftRegisteredAsync(fdInv, saveChange, UserAccountID);
}
return !string.IsNullOrEmpty(fdInv.Id)
? await JSONAsync(new { id = fdInv.Id })
: await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht gespeichert werden.");
@@ -58,12 +66,14 @@ public partial class IntranetController
case "sprep":
{
if (!HasForm("invc")) return BadRequest400();
if (!HasForm("invc")) { _logger.LogWarning("Requests sprep: missing form field 'invc', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogInformation("Requests sprep: preparing new invoice draft, user={User}", UserAccountID);
var fdInv = await _invoices.RegisterInvoiceAsync(
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
change: false, invId: "", UserAccountID, DbSec);
if (!string.IsNullOrEmpty(fdInv.Id))
{
_logger.LogInformation("Requests sprep: invoice draft {InvId} created, user={User}", fdInv.Id, UserAccountID);
await _events.InvoiceDraftRegisteredAsync(fdInv, changed: false, userAccountId: UserAccountID);
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
@@ -73,12 +83,14 @@ public partial class IntranetController
case "sedit":
{
if (!HasForm("id", "invc")) return BadRequest400();
if (!HasForm("id", "invc")) { _logger.LogWarning("Requests sedit: missing form field 'id'/'invc', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogInformation("Requests sedit: updating invoice draft {InvId}, user={User}", Form("id"), UserAccountID);
var fdInv = await _invoices.RegisterInvoiceAsync(
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
change: true, invId: Form("id"), UserAccountID, DbSec);
if (!string.IsNullOrEmpty(fdInv.Id))
{
_logger.LogInformation("Requests sedit: invoice draft {InvId} updated, user={User}", fdInv.Id, UserAccountID);
await _events.InvoiceDraftRegisteredAsync(fdInv, changed: true, userAccountId: UserAccountID);
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
@@ -87,25 +99,36 @@ public partial class IntranetController
}
case "sdel":
if (!HasForm("id")) return BadRequest400();
await setSQLValue_async("EXECUTE [dbo].[fds__remInvoice] @Id, @authuser;",
{
if (!HasForm("id")) { _logger.LogWarning("Requests sdel: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogInformation("Requests sdel: deleting invoice draft {InvId}, user={User}", Form("id"), UserAccountID);
var ok = await setSQLValue_async("EXECUTE [dbo].[fds__remInvoice] @Id, @authuser;",
_intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@Id", Form("id"))),
Security: DbSec, options: SqlOpt(fn, id, code));
if (!ok)
{
_logger.LogError("Requests sdel: SQL failed deleting invoice draft {InvId}, user={User}", Form("id"), UserAccountID);
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht gelöscht werden.", Form("id"));
}
return await JSONAsync(new { ok = true });
}
case "sconf": return await HandleRequestSconf(fn, id, code);
case "idoc": return await HandleRequestIdoc(fn, id, code);
case "resend": return await HandleRequestResend(fn, id, code);
default: return await JSONAsync(new { ok = true });
default:
_logger.LogWarning("Do_Process_Requests: unhandled action id={Id}, user={User}", id, UserAccountID);
return await JSONAsync(new { ok = true });
}
}
private async Task<IActionResult> HandleRequestList(string fn, string id, string code)
{
if (!HasForm("mode")) return BadRequest400();
if (!HasForm("mode")) { _logger.LogWarning("HandleRequestList: missing form field 'mode', user={User}", UserAccountID); return BadRequest400(); }
string mode = Form("mode").ToLower();
_logger.LogDebug("HandleRequestList mode={Mode} tgt={Tgt} user={User}", mode, Form("tgt"), UserAccountID);
if (mode == "s" && Form("tgt").Contains(':'))
{
var pl = StdParamlist(
@@ -126,7 +149,10 @@ public partial class IntranetController
}
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var tgtdate))
{
_logger.LogWarning("HandleRequestList: invalid date format tgt='{Tgt}' user={User}", Form("tgt"), UserAccountID);
return BadRequest400();
}
{
var pl = StdParamlist(
SQL_Date("@tgtdate", tgtdate),
@@ -204,7 +230,8 @@ public partial class IntranetController
private async Task<IActionResult> HandleRequestGet(string fn, string id, string code)
{
if (!HasForm("id")) return BadRequest400();
if (!HasForm("id")) { _logger.LogWarning("HandleRequestGet: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogDebug("HandleRequestGet requestId={ReqId} mode={Mode} user={User}", Form("id"), Form("mode"), UserAccountID);
string modeVal = Form("mode").ne("ov");
string[] tn = modeVal switch
{
@@ -231,7 +258,8 @@ public partial class IntranetController
private async Task<IActionResult> HandleRequestIget(string fn, string id, string code)
{
if (!HasForm("id", "typ")) return BadRequest400();
if (!HasForm("id", "typ")) { _logger.LogWarning("HandleRequestIget: missing form field 'id'/'typ', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogDebug("HandleRequestIget requestId={ReqId} typ={Typ} mode={Mode} user={User}", Form("id"), Form("typ"), Form("mode"), UserAccountID);
var pl = StdParamlist(
SQL_BigInt("@servicerequestid", Form("id")),
SQL_VarChar("@mode", Form("mode").ne("ov")),
@@ -273,12 +301,15 @@ public partial class IntranetController
private async Task<IActionResult> HandleRequestSconf(string fn, string id, string code)
{
if (!HasForm("id")) return BadRequest400();
if (!HasForm("id")) { _logger.LogWarning("HandleRequestSconf: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
_logger.LogInformation("HandleRequestSconf: finalizing invoice {InvId} user={User}", Form("id"), UserAccountID);
var dt = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser;",
_intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@Id", Form("id"))),
Security: DbSec, options: SqlOpt(fn, id, code));
if (!string.IsNullOrEmpty(dt.Exception))
_logger.LogError("HandleRequestSconf: SQL error finalizing invoice {InvId}: {SqlError}, user={User}", Form("id"), dt.Exception, UserAccountID);
var frdic = dt.FirstRow.toObjectDictionary();
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
{
@@ -331,16 +362,19 @@ public partial class IntranetController
$"Die Rechnungs-PDF {frdic.nz("DocumentName").ne($"Rechnung_{invId}.pdf")} konnte nicht erstellt werden.",
UserAccountID, invId);
}
return await JSONAsync(new { ok = true });
// hasFile tells the frontend whether the PDF was actually stored, so it only opens the
// idoc preview popup when there is something to show (never on a failed render/store).
return await JSONAsync(new { ok = true, hasFile = filebyte.Length > 0 });
}
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
}
private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code)
{
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) return StatusCode(404);
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleRequestIdoc: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); }
_logger.LogDebug("HandleRequestIdoc: invoiceId={InvId} typ={Typ} create={Create} user={User}", Form("id"), Form("typ"), Form("create", "0"), UserAccountID);
var fdInv = await _invoices.LoadInvoiceAsync(Form("id"), UserAccountID, DbSec);
if (string.IsNullOrEmpty(fdInv.Id)) return StatusCode(404, new { error = "Rechnung wurde nicht gefunden" });
if (string.IsNullOrEmpty(fdInv.Id)) { _logger.LogWarning("HandleRequestIdoc: invoice not found id={InvId} user={User}", Form("id"), UserAccountID); return StatusCode(404, new { error = "Rechnung wurde nicht gefunden" }); }
string filename = fdInv.InvoiceRegistration!.nz("DocumentName").ne($"Rechnung_{fdInv.Id}.pdf");
if (Form("typ") != "img")
{
@@ -357,7 +391,8 @@ public partial class IntranetController
private async Task<IActionResult> HandleRequestResend(string fn, string id, string code)
{
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) return StatusCode(404);
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleRequestResend: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); }
_logger.LogInformation("HandleRequestResend: resending invoice {InvId} user={User}", Form("id"), UserAccountID);
var dtset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getInvoice] @Id, @authuser;",
_intranet.Intranet__SQLConnectionString,
+13
View File
@@ -174,6 +174,19 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
{
_logger.LogError(ex, "Unhandled exception in Do fn={Fn} id={Id} code={Code} user={User}",
fn, id, code, UserAccountID);
// Standing rule: an exception that interrupts a user-initiated action must reach the
// user as a notification, not only the log. This is the catch-all safety net for any
// Do_Process_* action that throws without first publishing its own (more specific)
// issue event. Pre-auth flows (login/logout, unauthenticated GETs) are skipped — there
// is no user session to notify and the HTTP status already conveys the failure.
if (UserIdent.IsAuthenticated)
{
await _events.UserIssueAsync(
"Aktion fehlgeschlagen",
"Die Aktion konnte aufgrund eines unerwarteten Fehlers nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
UserAccountID,
new Dictionary<string, object?> { ["fn"] = fn });
}
return ServerError();
}
}
+21
View File
@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Http;
namespace Fuchs.Controllers;
/// <summary>
/// Resolves a request value from the posted form, falling back to the query string.
/// Extracted as pure/testable logic: endpoints in <c>_allowedGet</c> (e.g. req/idoc, rem/idoc)
/// are invoked via a plain GET (window.open with '?id=...'), where <see cref="HttpRequest.Form"/>
/// has no Content-Type and throws <see cref="InvalidOperationException"/> if read directly.
/// </summary>
internal static class RequestValueHelper
{
internal static string? Resolve(bool hasFormContentType, IFormCollection form, IQueryCollection query, string key)
{
if (hasFormContentType && form.TryGetValue(key, out var formValue))
return formValue.ToString();
if (query.TryGetValue(key, out var queryValue))
return queryValue.ToString();
return null;
}
}
@@ -0,0 +1,72 @@
---
status: Accepted
date: 2026-07-08
applyTo:
- "Fuchs/Controllers/**"
- "Fuchs/Services/**"
- "Fuchs/Notifications/**"
supersededBy: ""
---
# 0003 — Any exception that interrupts a user action notifies the user (not just the log)
## Context
[0001](0001-domain-events-and-notification-triggers.md) requires the *modeled*
failure paths (invoice/reminder/banking create, send, import) to publish a
`*IssueAsync`/`*Failed` event. But an action can also fail through an
**unexpected/unmodeled** exception — a bug, a transient dependency error, an
edge case nobody wrote a specific failure event for. Those were only landing in
the log (`_logger.LogError` + an HTTP 500), so the user saw the action stop with
no explanation and no notification. The user asked that *whenever* an exception
interrupts a process they initiated, they be told via the notification system.
## Decision
Every exception that **interrupts a user-initiated action** must surface to the
user through `IEventService`, in addition to being logged. Concretely:
- **Catch-all safety net at the dispatcher.** `IntranetController.Do`'s
top-level `catch` publishes a generic `UserIssueAsync("Aktion fehlgeschlagen",
…)` for any `Do_Process_*` action that throws without having already published
its own (more specific) issue event. It is guarded by
`UserIdent.IsAuthenticated` — pre-auth flows (login/logout, anonymous GETs)
have no session to notify and the HTTP status already conveys the failure.
- **Handlers with their own `catch` must notify locally.** A handler that
swallows its exception (returns a 500/error result instead of rethrowing)
never reaches the `Do` net, so it must call the matching issue event itself —
e.g. `HandleInvoiceGet` calls `InvoiceIssueAsync` before returning 500. Prefer
the domain-specific method (`InvoiceIssueAsync`/`ReminderIssueAsync`/
`BankingImportIssueAsync`); fall back to `UserIssueAsync` when none fits.
- **Message stays user-readable and broadcast-safe.** Per
[0002](0002-gui-notification-delivery-signalr.md) notifications are broadcast
to every logged-in session, so the German `Message`/`Context` must never carry
the raw exception text or anything sensitive — diagnostics go to the log; the
user gets a plain "could not be completed" message.
This deliberately **excludes** operations that do not interrupt a discrete user
action: background/best-effort work (blob archiving, startup self-tests,
per-entry parse skips) stays log-only, and auto-refreshing read views (dashboard
widgets, report reloads) return their error status without a toast, because
notifying on every poll cycle would spam the user rather than inform them.
## Consequences
- New `catch` blocks on a request-handling path must be classified: does the
exception interrupt a user action? If yes → publish an issue event (specific
if one exists, else `UserIssueAsync`). If it is background/best-effort or an
auto-poll read → log only, and say so in a comment.
- The `Do` net is a backstop, not a replacement for specific events: modeled
failures should still publish their contextful `*IssueAsync` at the point of
failure so the message names the invoice/reminder/file involved.
- Because the net only fires on *unhandled* exceptions (handled flows return
rather than rethrow), it does not double-notify the flows that already report
their own failures.
## Alternatives considered
- **Rely solely on 0001's per-flow issue events**: rejected — it leaves every
unmodeled/unexpected exception silent, which is exactly the gap the user
reported.
- **Notify on every failing read/poll too (widgets, reports)**: rejected —
auto-refresh would turn a transient backend hiccup into a stream of toasts;
those paths surface failure via HTTP status instead.
- **Surface the raw exception message to the GUI**: rejected for the same
reason as 0001 — not user-understandable, leaks internals, and (per 0002) is
visible to every logged-in session.
+104 -4
View File
@@ -6,6 +6,7 @@ using Fuchs.Observability;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
using Newtonsoft.Json.Linq;
using OCORE.security;
using OCORE.SQL;
using static OCORE.commons;
@@ -48,11 +49,13 @@ public class InvoiceService : IInvoiceService
var pl = new List<SqlParameter>
{
SQL_VarChar("@authuser", userAccountId),
SQL_VarChar("@Id", id),
SQL_Bit("@includefile", false)
SQL_VarChar("@Id", id)
};
// fds__getInvoice only takes @Id and @authuser (no @includefile) - passing an extra
// parameter makes SQL Server throw "too many arguments", which previously left
// InvoiceRegistration empty (Id "") and silently broke downstream file storage/emailing.
var dset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getInvoice] @Id, @includefile, @authuser;",
"EXECUTE [dbo].[fds__getInvoice] @Id, @authuser;",
Conn, pl, tablenames: new[] { "admin", "inv", "req", "itm" },
Security: dbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(dset.Exception))
@@ -93,16 +96,113 @@ public class InvoiceService : IInvoiceService
}
var invdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
Conn, pl, tablenames: new[] { "inv", "det", "req", "itm" },
Conn, pl, tablenames: new[] { "inv", "det" },
Security: dbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(invdset.Exception))
_logger.LogError("RegisterInvoiceAsync sql exception: {Ex}", invdset.Exception);
invoice.InvoiceRegistration = new GenericObjectDictionary(invdset.Table("inv").FirstRow.toObjectDictionary());
_logger.LogInformation("RegisterInvoiceAsync registered id={Id} (change={Change})", invoice.Id, change);
if (!string.IsNullOrEmpty(invoice.Id) && invoice.Req != null)
await PersistInvoiceLineItemsAsync(invoice, userAccountId, dbSec);
return invoice;
}
/// <summary>
/// Persists the invoice's service-request groupings and their line items
/// (<c>fds__invoice_servicerequests</c> / <c>fds__invoice_items</c>). These are what
/// <c>fds__getInvoice</c> reads back when a draft is reloaded (e.g. after "Zwischenstand
/// speichern" or reopening a draft from the invoice list) — the preview/PDF itself renders
/// straight from the posted JSON, so without this the preview looked fine right after
/// posting but a reloaded draft showed no steps/positions at all (nothing had ever been
/// written for the service requests/items, only the invoice header row).
/// The whole set is cleared and rewritten on every call — simpler and safer than diffing —
/// mirroring the legacy <c>fds__invoice_data.RegisterInvoice_V1</c> (VB) behaviour.
/// </summary>
private async Task PersistInvoiceLineItemsAsync(FdsInvoiceData invoice, string userAccountId, DatabaseSecurity dbSec)
{
string invoiceId = invoice.Id;
var clearReqPl = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId), SQL_VarChar("@Id", invoiceId) };
await setSQLValue_async("EXECUTE [dbo].[fds__remInvoice_ServiceRequests] @Id, @authuser;",
Conn, clearReqPl, Security: dbSec, options: new FIS_SQLOptions());
var itemsTable = (await getSQLDatatable_async(
"SELECT TOP(0) * FROM [dbo].[fds__invoice_items];",
Conn, Security: dbSec, options: new FIS_SQLOptions())).DataTable;
int sortOrder = -1;
foreach (var reqEntry in invoice.Req!)
{
sortOrder++; // 0-based, matching legacy fds__invoice_data.RegisterInvoice_V1 (VB)'s "ri" loop index
var rdic = new GenericObjectDictionary(reqEntry);
float vnet = 0;
var valueNetParam = TryCastSingle(rdic.getItem("netval"), ref vnet)
? SQL_Float("@value_net", vnet)
: SQL_Float("@value_net", stringvalue: rdic.nz("netval"));
var reqPl = new List<SqlParameter>
{
SQL_VarChar("@authuser", userAccountId),
SQL_VarChar("@InvId", invoiceId),
SQL_BigInt("@mfr__servicerequest", rdic.nz("Id")),
SQL_NVarChar("@title", rdic.nz("text").ne(rdic.nz("nme"))),
valueNetParam,
SQL_Int("@SortOrder", sortOrder)
};
var reqDt = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__createInvoice_ServiceRequest] @InvId, @mfr__servicerequest, @title, @value_net, @SortOrder;",
Conn, reqPl, Security: dbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(reqDt.Exception))
_logger.LogError("PersistInvoiceLineItemsAsync: fds__createInvoice_ServiceRequest failed for invoice {Id}: {Ex}", invoiceId, reqDt.Exception);
string invRqId = reqDt.FirstRow.nz("Id");
if (string.IsNullOrEmpty(invRqId)) continue;
if (!rdic.TryGetValue("itm", out var itmObj) || itmObj is not JArray itmArray) continue;
int itemSort = 0;
foreach (var tok in itmArray)
{
itemSort++;
if (tok is not JObject itmJson) continue;
var itmDic = new GenericObjectDictionary(itmJson.ToObject<Dictionary<string, object>>()!);
if ((itmDic.nz("id") ?? "") == "" && (itmDic.nz("typ") ?? "") == "" && (itmDic.nz("t") ?? "") == "") continue;
var row = itemsTable.NewRow();
row["InvId"] = invoiceId;
row["InvRqId"] = invRqId;
if (long.TryParse(itmDic.nz("id"), out long mfrItem)) row["mfr__item"] = mfrItem;
row["Type"] = itmDic.nz("typ");
row["Position"] = itmDic.nz("p");
row["Quantity"] = itmDic.nz("q");
row["Text"] = itmDic.nz("t");
float v = 0;
if (TryCastSingle(itmDic.getItem("v"), ref v)) row["value"] = (decimal)v;
float vt = 0;
if (TryCastSingle(itmDic.getItem("vt"), ref vt)) row["value_total"] = (decimal)vt;
float vat = 0;
if (TryCastSingle(itmDic.nz("vat").Replace("%", ""), ref vat)) row["vat"] = (decimal)vat;
float vs = 0;
if (TryCastSingle(itmDic.getItem("vs"), ref vs)) row["value_service"] = (decimal)vs;
row["det"] = itmDic.no("det", false) is true;
row["SortOrder"] = (byte)Math.Clamp(itemSort, 0, 255);
itemsTable.Rows.Add(row);
}
}
await setSQLValue_async("EXECUTE [dbo].[fds__remInvoice_Items] @Id, @authuser;",
Conn, new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId), SQL_VarChar("@Id", invoiceId) },
Security: dbSec, options: new FIS_SQLOptions());
if (itemsTable.Rows.Count > 0)
{
var dtw = new DatatableWriterAsync(itemsTable, Conn, "[dbo].[fds__invoice_items]");
dtw.DoSubmit();
if (dtw.SubmitException != null)
_logger.LogError(dtw.SubmitException, "PersistInvoiceLineItemsAsync: bulk-copy of invoice items failed for invoice {Id}", invoiceId);
}
}
public Document GenerateInvoicePdf(FdsInvoiceData invoice, bool draft)
{
using var act = FuchsTelemetry.StartActivity("invoice.render");
+2 -1
View File
@@ -253,7 +253,8 @@ public class StartupSelfTestService : BackgroundService
using var check = new Spire.Pdf.PdfDocument();
check.LoadFromStream(ms);
string text = check.Pages[0].ExtractText();
var extractor = new Spire.Pdf.Texts.PdfTextExtractor(check.Pages[0]);
string text = extractor.ExtractText(new Spire.Pdf.Texts.PdfTextExtractOptions());
return !text.Contains("Evaluation Warning", StringComparison.OrdinalIgnoreCase)
&& !text.Contains("created with Spire.PDF", StringComparison.OrdinalIgnoreCase);
+5 -3
View File
@@ -6,9 +6,11 @@
},
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Debug",
"Microsoft.Hosting.Lifetime": "Debug",
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning",
"fds": "Debug",
"Fuchs.Controllers": "Debug"
}
+23 -7
View File
@@ -480,14 +480,22 @@ $inv.cSt = function (data) {
});
};
$inv.eHtml = function (ev) {
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t, flds = [
{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }
];
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
/* invoiceemail must stay plain text — using the TinyMCE/html editor here used to wrap the
address in <p> tags, which then got posted and persisted verbatim into SendToEmail. */
let isPlainText = ev.data.nme === 'invoiceemail';
let flds = isPlainText
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
let change = ev.data.change || null;
let sets = {
title: t.data('dialog') || '',
success: function (response) {
frmct.html(response.txt);
if (isPlainText) {
frmct.text(response.txt || '');
} else {
frmct.html(response.txt);
}
if (typeof change === 'function') {
change(response.txt);
}
@@ -962,7 +970,6 @@ $inv.sprev = (change) => {
}
$ocms.postXT({
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
if (invtp > 10) {
$$.dc('note warn', c).text($ict.tpe);
@@ -976,15 +983,20 @@ $inv.sprev = (change) => {
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI, confirm: function (e) {
let ct = $(this);
l.aC('freeze'); /* spinner while the invoice is finalized/emailed on the backend */
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: invid }, success: () => {
url: $ocms.url('req/sconf'), data: { id: invid }, success: (cresp) => {
ct.trigger('modal_close');
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab */
if (cresp.hasFile === true) {
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab, only if a file was actually created */
}
$ocms.init('req'); /* go back to request list */
$inv.rReload();
}, error: () => {
alert($t.f1);
ct.trigger('modal_close');
}, complete: () => {
l.rC('freeze');
}
});
}, cancel: function (e) {
@@ -999,6 +1011,10 @@ $inv.sprev = (change) => {
$inv.rReload();
}
});
}, error: () => {
alert($ict.eis);
}, complete: () => {
l.rC('freeze');
}
});
};
@@ -1,4 +1,8 @@
@keyframes fis_spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.edit_frm {
@@ -376,6 +380,22 @@
}
}
}
&.freeze::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 3rem;
height: 3rem;
margin: -1.5rem 0 0 -1.5rem;
border: 0.4rem solid rgba(255, 255, 255, .35);
border-top-color: #FFF;
border-radius: 50%;
z-index: 11;
pointer-events: none;
animation: fis_spin 0.8s linear infinite;
}
}
}
+23
View File
@@ -96,6 +96,14 @@ table.if td.num {
margin: 0.2rem 0;
}
@keyframes fis_spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.edit_frm .invoice_layout {
margin: 2rem 2rem 2rem auto; /* align right */
width: 50rem;
@@ -381,6 +389,21 @@ table.if td.num {
.edit_frm .invoice_layout.freeze::before tr:hover > td.aux > .axf {
display: inline-flex;
}
.edit_frm .invoice_layout.freeze::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 3rem;
height: 3rem;
margin: -1.5rem 0 0 -1.5rem;
border: 0.4rem solid rgba(255, 255, 255, 0.35);
border-top-color: #FFF;
border-radius: 50%;
z-index: 11;
pointer-events: none;
animation: fis_spin 0.8s linear infinite;
}
.modal-body .lstfrm {
display: block;
+11 -3
View File
@@ -1509,7 +1509,6 @@ $inv.sprev = (change) => {
}
$ocms.postXT({
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
if (invtp > 10) {
$$.dc('note warn', c).text($ict.tpe);
@@ -1523,15 +1522,20 @@ $inv.sprev = (change) => {
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI, confirm: function (e) {
let ct = $(this);
l.aC('freeze'); /* spinner while the invoice is finalized/emailed on the backend */
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: invid }, success: () => {
url: $ocms.url('req/sconf'), data: { id: invid }, success: (cresp) => {
ct.trigger('modal_close');
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab */
if (cresp.hasFile === true) {
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab, only if a file was actually created */
}
$ocms.init('req'); /* go back to request list */
$inv.rReload();
}, error: () => {
alert($t.f1);
ct.trigger('modal_close');
}, complete: () => {
l.rC('freeze');
}
});
}, cancel: function (e) {
@@ -1546,6 +1550,10 @@ $inv.sprev = (change) => {
$inv.rReload();
}
});
}, error: () => {
alert($ict.eis);
}, complete: () => {
l.rC('freeze');
}
});
};
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+23
View File
@@ -233,6 +233,14 @@ table.if th.keep, table.if td.keep {
content: "\e067";
}
@keyframes fis_spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.edit_frm .invoice_layout {
margin: 2rem 2rem 2rem auto; /* align right */
width: 50rem;
@@ -518,6 +526,21 @@ table.if th.keep, table.if td.keep {
.edit_frm .invoice_layout.freeze::before tr:hover > td.aux > .axf {
display: inline-flex;
}
.edit_frm .invoice_layout.freeze::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 3rem;
height: 3rem;
margin: -1.5rem 0 0 -1.5rem;
border: 0.4rem solid rgba(255, 255, 255, 0.35);
border-top-color: #FFF;
border-radius: 50%;
z-index: 11;
pointer-events: none;
animation: fis_spin 0.8s linear infinite;
}
.modal-body .lstfrm {
display: block;
+11 -3
View File
@@ -1490,7 +1490,6 @@ $inv.sprev = (change) => {
}
$ocms.postXT({
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
if (invtp > 10) {
$$.dc('note warn', c).text($ict.tpe);
@@ -1504,15 +1503,20 @@ $inv.sprev = (change) => {
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI, confirm: function (e) {
let ct = $(this);
l.aC('freeze'); /* spinner while the invoice is finalized/emailed on the backend */
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: invid }, success: () => {
url: $ocms.url('req/sconf'), data: { id: invid }, success: (cresp) => {
ct.trigger('modal_close');
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab */
if (cresp.hasFile === true) {
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab, only if a file was actually created */
}
$ocms.init('req'); /* go back to request list */
$inv.rReload();
}, error: () => {
alert($t.f1);
ct.trigger('modal_close');
}, complete: () => {
l.rC('freeze');
}
});
}, cancel: function (e) {
@@ -1527,6 +1531,10 @@ $inv.sprev = (change) => {
$inv.rReload();
}
});
}, error: () => {
alert($ict.eis);
}, complete: () => {
l.rC('freeze');
}
});
};
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long