diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1598105..3098f14 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,16 +1,20 @@ # Copilot Instructions > ## ⚠️ Instruction Sync -> This file (`.github/copilot-instructions.md`) and the Claude Code instructions -> (`/CLAUDE.md`) are **two views of the same project rules and must stay in sync**. -> Whenever you change one, make the equivalent change in the other in the same -> commit. `CLAUDE.md` may add tool-specific workflow notes, but the shared +> This file (`.github/copilot-instructions.md`), the Claude Code instructions +> (`/CLAUDE.md`), and the Codex instructions (`/CODEX.md`) are **three views of +> the same project rules and must stay in sync**. +> Whenever you change one, make the equivalent change in the other two in the same +> commit. `CLAUDE.md` and `CODEX.md` may add tool-specific workflow notes, but the shared > project facts (architecture, coding standards, configuration, libraries, > secrets, observability) must match. ## Project Overview - **Fuchs Intranet** is an ASP.NET Core (.NET 10) web application — the intranet IS the entire website, served from `/`. - Routes: `/{fn?}/{id?}/{code?}` → `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` → `IntranetController.Do`. +- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. +- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves. +- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`. - Project structure (relative to `Fuchs/`): - `Controllers/` — `IntranetController` partials (no area) - `code/` — business logic, PDF, email, widgets, data models @@ -74,6 +78,11 @@ - Name tests `MethodName_Scenario_ExpectedResult`. - DB-bound paths that can't be unit-tested should at least have their pure logic covered. +## Decisions & Concepts +- `Fuchs/Docs/Decisions/` holds immutable ADRs (architecture decision records); `Fuchs/Docs/Concepts/` holds living design write-ups kept in sync with the code. Each folder's `README.md` explains the format, naming, and required YAML frontmatter — **read it before creating or editing entries there.** +- **Accepted decisions must be followed.** Before working in an area covered by a decision, read it and conform to it; don't silently deviate. Every file's YAML frontmatter has an `applyTo` glob — scan frontmatter across the folder first (cheap) and only read the full body of entries relevant to the files you're touching. +- **Capture new decisions and concepts as they happen.** When a non-obvious architectural or cross-cutting choice gets settled (by the user or in the course of implementation), add a decision in `Docs/Decisions` in the same change, and create/update the matching concept doc in `Docs/Concepts` if the subsystem's design is otherwise non-obvious from the code. + ## Azure Key Vault — Secret Naming - Secret names must satisfy the pattern `^[0-9a-zA-Z-]+$` (alphanumerics and hyphens only; no underscores, dots, or spaces). - Hierarchy levels are separated by `--` (double hyphen), which maps to `:` in `IConfiguration`. diff --git a/.gitignore b/.gitignore index d7baaa0..ec5af9c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ bin/ obj/ +# Scratch / build-verification output +/tmp/ + # SSDT / SQL database project caches (regenerated) *.dbmdl *.jfm diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c9dd596 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,28 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Fuchs: Debug ASP.NET Core", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build Fuchs", + "program": "${workspaceFolder}/Fuchs/bin/Debug/net10.0/Fuchs.dll", + "args": [], + "cwd": "${workspaceFolder}/Fuchs", + "stopAtEntry": false, + "justMyCode": true, + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)", + "uriFormat": "%s" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "https://localhost:63661;http://localhost:63662" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Fuchs/Views" + } + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..2eafdfa --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,129 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build Fuchs", + "type": "process", + "command": "dotnet", + "args": [ + "build", + "${workspaceFolder}/Fuchs/Fuchs.csproj", + "--configuration", + "Debug", + "/p:RestoreIgnoreFailedSources=true", + "/p:RestoreConfigFile=${workspaceFolder}/NuGet.config" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "gulp: all", + "type": "shell", + "command": "npx", + "args": [ + "gulp", + "all" + ], + "options": { + "cwd": "${workspaceFolder}/Fuchs" + }, + "problemMatcher": [] + }, + { + "label": "gulp: min", + "type": "shell", + "command": "npx", + "args": [ + "gulp", + "min" + ], + "options": { + "cwd": "${workspaceFolder}/Fuchs" + }, + "problemMatcher": [], + "group": "build" + }, + { + "label": "gulp: copy", + "type": "shell", + "command": "npx", + "args": [ + "gulp", + "copy" + ], + "options": { + "cwd": "${workspaceFolder}/Fuchs" + }, + "problemMatcher": [] + }, + { + "label": "gulp: min:js", + "type": "shell", + "command": "npx", + "args": [ + "gulp", + "min:js" + ], + "options": { + "cwd": "${workspaceFolder}/Fuchs" + }, + "problemMatcher": [] + }, + { + "label": "gulp: min:css", + "type": "shell", + "command": "npx", + "args": [ + "gulp", + "min:css" + ], + "options": { + "cwd": "${workspaceFolder}/Fuchs" + }, + "problemMatcher": [] + }, + { + "label": "gulp: min:scss", + "type": "shell", + "command": "npx", + "args": [ + "gulp", + "min:scss" + ], + "options": { + "cwd": "${workspaceFolder}/Fuchs" + }, + "problemMatcher": [] + }, + { + "label": "gulp: min:html", + "type": "shell", + "command": "npx", + "args": [ + "gulp", + "min:html" + ], + "options": { + "cwd": "${workspaceFolder}/Fuchs" + }, + "problemMatcher": [] + }, + { + "label": "gulp: watch", + "type": "shell", + "command": "npx", + "args": [ + "gulp", + "watch" + ], + "options": { + "cwd": "${workspaceFolder}/Fuchs" + }, + "isBackground": true, + "problemMatcher": [] + } + ] +} diff --git a/CAMTParser/CamtParser.cs b/CAMTParser/CamtParser.cs index 0adf713..a8fde52 100644 --- a/CAMTParser/CamtParser.cs +++ b/CAMTParser/CamtParser.cs @@ -51,34 +51,49 @@ public sealed class CamtParser /// /// Parses all CAMT XML files found inside a ZIP archive and returns - /// the combined list of statements. Non-XML entries and malformed XML - /// entries are silently skipped. Used for camt.052 deliveries where the + /// the combined list of statements. Non-XML entries and malformed XML + /// entries are skipped. Used for camt.052 deliveries where the /// bank wraps one or more intraday reports in a single ZIP file. /// - public List ParseZip(byte[] bytes) - { - using var ms = new MemoryStream(bytes); - return ParseZip(ms); - } + public List ParseZip(byte[] bytes) => ParseZip(bytes, out _); /// - public List ParseZip(Stream stream) + public List ParseZip(Stream stream) => ParseZip(stream, out _); + + /// + /// Same as , but also reports which entries were + /// skipped and why (non-.xml name, non-XML content, malformed XML), so callers + /// can log a reason instead of silently losing part of a delivery. + /// + public List ParseZip(byte[] bytes, out List skippedEntries) + { + using var ms = new MemoryStream(bytes); + return ParseZip(ms, out skippedEntries); + } + + /// + public List ParseZip(Stream stream, out List skippedEntries) { var result = new List(); + var skipped = new List(); using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); foreach (var entry in archive.Entries) { if (!entry.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)) - continue; + continue; // non-XML entries (e.g. checksums, manifests) are expected, not worth reporting using var entryStream = entry.Open(); using var buffer = new MemoryStream(); entryStream.CopyTo(buffer); var entryBytes = buffer.ToArray(); if (!LooksLikeXml(entryBytes)) + { + skipped.Add($"{entry.Name}: not XML content"); continue; + } try { result.AddRange(Parse(entryBytes)); } - catch (FormatException) { /* skip malformed XML entries */ } + catch (FormatException ex) { skipped.Add($"{entry.Name}: {ex.Message}"); } } + skippedEntries = skipped; return result; } diff --git a/CLAUDE.md b/CLAUDE.md index aa9f714..a7bbac7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,12 +1,12 @@ # CLAUDE.md — Project instructions for Claude Code > ## ⚠️ Instruction Sync -> This file and **`.github/copilot-instructions.md`** are two views of the same -> project rules and **must stay in sync**. When you change a shared rule +> This file, **`CODEX.md`**, and **`.github/copilot-instructions.md`** are three +> views of the same project rules and **must stay in sync**. When you change a shared rule > (architecture, coding standards, configuration, libraries, secrets, -> observability, testing), make the equivalent change in **both files in the +> observability, testing), make the equivalent change in **all three files in the > same commit**. This file may add Claude Code / workflow specifics; the shared -> project facts must match `copilot-instructions.md`. +> project facts must match `CODEX.md` and `copilot-instructions.md`. ## Project Overview - **Fuchs Intranet** — ASP.NET Core (**.NET 10**) web app; the intranet IS the whole website, served from `/`. @@ -15,6 +15,7 @@ ## Build & Test (workflow) - Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. +- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves. - Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`. - Always build **and** run the test suite before committing. The build emits many pre-existing analyzer/platform warnings (CA1416 etc.) — those are expected; only treat `: error` lines as failures. - Commit only when asked. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 `. @@ -69,8 +70,15 @@ ## Secrets (Azure Key Vault) - Full naming rules live in `.github/copilot-instructions.md` (kept in sync). In short: names match `^[0-9a-zA-Z-]+$`, hierarchy via `--` (→ `:`), underscores → `-`, app prefix `fuchs`; register new keys in `ManagedSecretKeys` in `appsettings.json`. +## Decisions & Concepts +- `Fuchs/Docs/Decisions/` holds immutable ADRs (architecture decision records); `Fuchs/Docs/Concepts/` holds living design write-ups kept in sync with the code. Each folder's `README.md` explains the format, naming, and required YAML frontmatter — **read it before creating or editing entries there.** +- **Accepted decisions must be followed.** Before working in an area covered by a decision, read it and conform to it; don't silently deviate. Every file's YAML frontmatter has an `applyTo` glob — scan frontmatter across the folder first (cheap) and only read the full body of entries relevant to the files you're touching. +- **Capture new decisions and concepts as they happen.** When a non-obvious architectural or cross-cutting choice gets settled (by the user or in the course of implementation), add a decision in `Docs/Decisions` in the same change, and create/update the matching concept doc in `Docs/Concepts` if the subsystem's design is otherwise non-obvious from the code. + ## Documentation map - `Fuchs/Docs/ARCHITECTURE.md` — solution architecture (keep current when structure changes). - `Fuchs/Docs/USER_GUIDE.md` — end-user process guide. +- `Fuchs/Docs/Decisions/` — ADRs; see `Decisions & Concepts` above. +- `Fuchs/Docs/Concepts/` — living subsystem design docs; see `Decisions & Concepts` above. - `MFR_RESTClient/Docs/mfr_interface_description.md` — mfr ERP REST/OData interface contract. - `.github/instructions/*.instructions.md` — domain-specific contributor guidance. diff --git a/CODEX.md b/CODEX.md new file mode 100644 index 0000000..e83a569 --- /dev/null +++ b/CODEX.md @@ -0,0 +1,84 @@ +# CODEX.md — Project instructions for Codex + +> ## Instruction Sync +> This file, **`CLAUDE.md`**, and **`.github/copilot-instructions.md`** are three +> views of the same project rules and **must stay in sync**. When you change a shared rule +> (architecture, coding standards, configuration, libraries, secrets, +> observability, testing), make the equivalent change in **all three files in the +> same commit**. This file may add Codex / workflow specifics; the shared +> project facts must match `CLAUDE.md` and `copilot-instructions.md`. + +## Project Overview +- **Fuchs Intranet** — ASP.NET Core (**.NET 10**) web app; the intranet IS the whole website, served from `/`. +- Routes: `/{fn?}/{id?}/{code?}` -> `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` -> `IntranetController.Do` (dispatches by `fn` to `Do_Process_*`). +- Solution `Fuchs_Intranet.slnx`. Key projects: `Fuchs` (web), `Fuchs_DataService` (MFR sync worker), `MFR_RESTClient`, `CAMTParser`, `Fuchs.Tests`, and the OCORE submodules (`OCORE`, `OCORE_web`, `OCORE_web_pdf`, `OCORE_Charting`). `MT940Parser` is an external referenced project. + +## Build & Test (workflow) +- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. +- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves. +- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`. +- Always build **and** run the test suite before committing. The build emits many pre-existing analyzer/platform warnings (CA1416 etc.) — those are expected; only treat `: error` lines as failures. +- Commit only when asked. Co-author trailer, when requested: `Co-Authored-By: Codex `. +- The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths. + +## Coding Standards +- C# only. Modern, performance-oriented .NET 10 (async/await, LINQ, DI). +- Keep files <= 400 (max 600) lines; refactor larger files into focused classes. +- PascalCase types/methods, camelCase locals/params. + +## Configuration +- All settings in `Fuchs/appsettings.json` — **never** `Web.config` / `System.Configuration.ConfigurationManager`. App settings nested under `"Fuchs"`; connection strings under `"ConnectionStrings"`. +- `FuchsOcmsIntranet.Initialize(configuration)` runs in `Program.cs` before DI registration. +- `appsettings.Development.json` (git-ignored) overrides secrets locally. +- `Fuchs:Email:OverrideRecipient` (`IOptions`, section `Fuchs:Email`) is a dev/test safety net: when non-empty, `ProcessWebComService.SendEmailAsync` discards the real recipient of every outbound email and redirects it to this single address instead, so a locally-enabled mailer can never reach a real tenant-owner or end-customer. Set only in `appsettings.Development.json` — must stay empty in Production. + +## Libraries +- Do **not** upgrade Spire.PDF beyond 8.10.5. Prefer OCORE / OCORE_web / OCORE_web_pdf helpers over rewriting. Do not use OCMS/OCMS_sharp — OCORE only. + +## Services & Dependency Injection +- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers. +- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`. Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`). +- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async. +- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core). + +## MFR ERP integration +- `MFR_RESTClient` is the REST/OData client for the **mfr (Mobile Field Report)** ERP. Its contract (base URLs, auth, OData conventions, pagination, error/retry, deep-create + document-upload) is in **`MFR_RESTClient/Docs/mfr_interface_description.md`** — read it before changing the client. +- HTTP Basic auth; configurable timeout; idempotent GETs retry on transient errors (429/5xx, network/timeout) with backoff. Create clients via `IMfrClientFactory`. Active project is `MFR_RESTClient.csproj` (legacy `.vbproj` removed). + +## Database +- Schema source of truth: **`Fuchs_Database`** SSDT project. SQL-first backend (stored procs, table types e.g. `fds__tt__bankingtransactions`, functions via OCORE — no EF Core). +- Changing a proc signature or table type -> update the SSDT project **and** the calling C# together; verify every `[dbo].[…]` the backend calls exists in `Fuchs_Database`. + +## Bank statement parsing (MT940 + CAMT) +- `MT940Parser` (external, SWIFT text) and **`CAMTParser`** (in-repo, ISO 20022 camt.052/053/054 XML) feed the same pipeline. +- `BankingService.ParseToDatatable` auto-detects (XML -> CAMT, else MT940) -> `fds__tt__bankingtransactions`. `bam/up` + the frontend accept both formats. `CAMTParser` is namespace-agnostic. Keep both column mappings aligned with the banking schema. + +## Observability +- OpenTelemetry. Instrumentation is centralised in `Fuchs/Observability/FuchsTelemetry.cs` (one `ActivitySource`, one `Meter`). +- For meaningful operations: start an activity, record the matching counter/histogram, and log entry/result/timing/errors via injected `ILogger` using **structured** placeholders (never interpolated strings). +- Always collected; OTLP export opt-in via `Fuchs:Telemetry:OtlpEndpoint`. No exporters that hard-fail without a collector. + +## Testing +- xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial: + - For each service/handler change, cover **both** an intentionally succeeding and an intentionally failing path where feasible (stubs/mocks; `InternalsVisibleTo` is enabled). + - Cover edge cases and boundary conditions (empty/null/invalid input, disabled/feature-flag-off states, API/network errors) via `[Theory]`/`[InlineData]`/`[MemberData]` rather than a single happy-path `[Fact]`. + - When behavior depends on configuration (e.g. `appsettings.json` vs `appsettings.Development.json`), prefer loading the **real** files layered the same way ASP.NET Core does (`ConfigurationBuilder` + `AddJsonFile`) over hand-typed literals only, so drift in the actual files is caught (see `ProcessWebComServiceTests.SendEmailAsync_OverrideRecipientFromRealAppsettings_ClearsRecipientWheneverConfigured` for the pattern). + - Assert on the observable contract (e.g. the outgoing request payload) and on emitted telemetry (counters/histograms) when the code records them — not just the boolean return value. + - Name tests `MethodName_Scenario_ExpectedResult`. + - Cover pure logic for DB-bound paths that can't be unit-tested. + +## Secrets (Azure Key Vault) +- Full naming rules live in `.github/copilot-instructions.md` (kept in sync). In short: names match `^[0-9a-zA-Z-]+$`, hierarchy via `--` (-> `:`), underscores -> `-`, app prefix `fuchs`; register new keys in `ManagedSecretKeys` in `appsettings.json`. + +## Decisions & Concepts +- `Fuchs/Docs/Decisions/` holds immutable ADRs (architecture decision records); `Fuchs/Docs/Concepts/` holds living design write-ups kept in sync with the code. Each folder's `README.md` explains the format, naming, and required YAML frontmatter — **read it before creating or editing entries there.** +- **Accepted decisions must be followed.** Before working in an area covered by a decision, read it and conform to it; don't silently deviate. Every file's YAML frontmatter has an `applyTo` glob — scan frontmatter across the folder first (cheap) and only read the full body of entries relevant to the files you're touching. +- **Capture new decisions and concepts as they happen.** When a non-obvious architectural or cross-cutting choice gets settled (by the user or in the course of implementation), add a decision in `Docs/Decisions` in the same change, and create/update the matching concept doc in `Docs/Concepts` if the subsystem's design is otherwise non-obvious from the code. + +## Documentation map +- `Fuchs/Docs/ARCHITECTURE.md` — solution architecture (keep current when structure changes). +- `Fuchs/Docs/USER_GUIDE.md` — end-user process guide. +- `Fuchs/Docs/Decisions/` — ADRs; see `Decisions & Concepts` above. +- `Fuchs/Docs/Concepts/` — living subsystem design docs; see `Decisions & Concepts` above. +- `MFR_RESTClient/Docs/mfr_interface_description.md` — mfr ERP REST/OData interface contract. +- `.github/instructions/*.instructions.md` — domain-specific contributor guidance. diff --git a/Fuchs.Tests/CamtParserTests.cs b/Fuchs.Tests/CamtParserTests.cs index d893db4..4203471 100644 --- a/Fuchs.Tests/CamtParserTests.cs +++ b/Fuchs.Tests/CamtParserTests.cs @@ -377,4 +377,70 @@ public class BankingDualFormatTests Assert.Equal("ZIP Sender", t.Rows[0]["NameOfPayer"]); Assert.Equal("ZIP Zahlung", t.Rows[0]["SepaRemittanceInformation"]); } + + /// + /// Schema whose string columns carry the same MaxLength as the real + /// fds__tt__bankingtransactions table type. Building it here lets the tests + /// catch width-overflow bugs that (which + /// uses unconstrained strings) cannot. Only the columns exercised below are constrained. + /// + private static DataTable DbLikeSchema() + { + var t = new DataTable(); + t.Columns.Add("AccountIdentification", typeof(string)).MaxLength = 50; + t.Columns.Add("Amount", typeof(decimal)); + t.Columns.Add("ValueDate", typeof(DateTime)); + t.Columns.Add("FundsCode", typeof(string)).MaxLength = 1; // VARCHAR(1) — currency "EUR" must not land here + t.Columns.Add("NameOfPayer", typeof(string)).MaxLength = 60; + t.Columns.Add("PostingText", typeof(string)).MaxLength = 30; + t.Columns.Add("TransactionTypeIdCode", typeof(string)).MaxLength = 3; + t.Columns.Add("SepaRemittanceInformation", typeof(string)).MaxLength = 200; + t.Columns.Add("DebitCreditMark", typeof(string)).MaxLength = 2; + return t; + } + + [Fact] + public void ParseToDatatable_Camt_WithDbWidthSchema_StillImportsRows() + { + // Regression: CAMT wrote the ISO currency ("EUR") into FundsCode (VARCHAR(1)). + // Against the real DB widths that overflowed and — swallowed per entry — dropped + // every transaction, so nothing reached the database. + using var s = ToStream(Camt053); + var t = Svc.ParseToDatatable(s, schemaDatatable: DbLikeSchema()); + + Assert.Equal(1, t.Rows.Count); + Assert.Equal("DE12345678901234567890", t.Rows[0]["AccountIdentification"]); + Assert.Equal(500.00m, t.Rows[0]["Amount"]); + // Currency is no longer forced into the 1-char FundsCode column. + Assert.Equal(DBNull.Value, t.Rows[0]["FundsCode"]); + } + + [Fact] + public void ParseToDatatable_Camt_OverlongFields_AreTruncatedNotDropped() + { + const string longName = "Ein sehr langer Zahlungspflichtiger Name der weit ueber sechzig Zeichen hinausgeht GmbH Co KG"; + string camt = """ + + + + DE12345678901234567890EUR + + 500.00CRDT +
2023-01-15
+ + {NAME} + Rechnung + +
+
+
+ """.Replace("{NAME}", longName); + + using var s = ToStream(camt); + var t = Svc.ParseToDatatable(s, schemaDatatable: DbLikeSchema()); + + Assert.Equal(1, t.Rows.Count); + Assert.Equal(60, ((string)t.Rows[0]["NameOfPayer"]).Length); // truncated to column width, row kept + Assert.Equal(longName[..60], t.Rows[0]["NameOfPayer"]); + } } diff --git a/Fuchs/Controllers/IntranetController.Banking.cs b/Fuchs/Controllers/IntranetController.Banking.cs index 5894d18..6e6d545 100644 --- a/Fuchs/Controllers/IntranetController.Banking.cs +++ b/Fuchs/Controllers/IntranetController.Banking.cs @@ -22,8 +22,9 @@ public partial class IntranetController return await JSONAsync(new { manage = 1 }); case "up": - _logger.LogInformation("Banking MT940 upload: {FileCount} file(s) user={User}", + _logger.LogInformation("Banking statement upload: {FileCount} file(s) user={User}", Request.Form.Files.Count, UserAccountID); + var uploadResults = new List(); foreach (var fle in Request.Form.Files) { using var stream = fle.OpenReadStream(); @@ -34,6 +35,9 @@ public partial class IntranetController var tbl = _banking.ParseToDatatable(stream, schemaDt); var tmptbl = "bs_" + Guid.NewGuid().ToString().Replace("-", ""); + var (importFrom, importTo) = BankingDateRange(tbl); + bool importFailed = false; + string importFailure = ""; var dtwa = new DatatableWriterAsync(tbl, _intranet.Intranet__SQLConnectionString) { @@ -48,16 +52,80 @@ public partial class IntranetController dtwa.CommandAfterError = new SqlCommand( $"SELECT * INTO [{tmptbl}] FROM {dtwa.DestinationTableName};"); dtwa.OnError += (_, exc, _) => + { + importFailed = true; + importFailure = exc.Message; + _logger.LogError(exc, + "Banking upload SQL exception — file={File} destTable={DestTable} user={User}", + fle.FileName, dtwa.DestinationTableName, UserAccountID); _intranet.debug_log("IntranetController.bam.up - sql exception", exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl }); + }; dtwa.OnCommandAfterError += (_, exc) => + { + importFailed = true; + importFailure = exc.Message; + _logger.LogError(exc, + "Banking upload merge-command exception — file={File} destTable={DestTable} " + + "rescueTable={RescueTable} user={User}", + fle.FileName, dtwa.DestinationTableName, tmptbl, UserAccountID); _intranet.debug_log("IntranetController.bam.up - command-after exception", exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl }); + }; _logger.LogDebug("Banking upload parsed {Rows} rows → temp table submit (user={User})", tbl.Rows.Count, UserAccountID); dtwa.DoSubmit(); + if (dtwa.SubmitException != null) + { + importFailed = true; + importFailure = dtwa.SubmitException.Message; + _logger.LogError(dtwa.SubmitException, + "Banking upload submit exception — file={File} destTable={DestTable} user={User}", + fle.FileName, dtwa.DestinationTableName, UserAccountID); + } + + if (importFailed) + { + _logger.LogError( + "Banking import failed — file={File} rows={Rows} reason={Reason} user={User}", + fle.FileName, tbl.Rows.Count, importFailure, UserAccountID); + await _events.BankingImportIssueAsync( + $"Kontobewegungen aus {fle.FileName} konnten nicht importiert werden: {importFailure}", + fle.FileName, UserAccountID); + } + else if (tbl.Rows.Count == 0) + { + // Parsing produced zero rows — check the preceding "Bank statement parsed" + // warning from BankingService for the reason (missing account element, + // unsupported schema variant, empty file, ...). + _logger.LogWarning( + "Banking import: 0 rows parsed from {File} — nothing to import. user={User}", + fle.FileName, UserAccountID); + await _events.BankingImportIssueAsync( + $"Aus {fle.FileName} konnten keine Kontobewegungen importiert werden.", + fle.FileName, UserAccountID); + } + else + { + _logger.LogInformation( + "Banking import succeeded — file={File} rows={Rows} from={From} to={To} user={User}", + fle.FileName, tbl.Rows.Count, importFrom, importTo, UserAccountID); + await _events.BankingTransactionsImportedAsync( + importFrom, importTo, tbl.Rows.Count, fle.FileName, UserAccountID); + } + + uploadResults.Add(new + { + fileName = fle.FileName, + rows = tbl.Rows.Count, + success = !importFailed && tbl.Rows.Count > 0, + error = importFailed ? importFailure : "" + }); } - return Ok(); + // Return a JSON body: the frontend posts with dataType 'json', so an + // empty 200 would be reported as a parse error and surface the generic + // "auth failed" alert even though the import actually succeeded. + return await JSONAsync(new { ok = true, files = uploadResults }); case "qtl": { @@ -126,7 +194,9 @@ public partial class IntranetController "EXECUTE [dbo].[fds__setBankingtransaction_done] @taID, @authuser;", _intranet.Intranet__SQLConnectionString, pl, Security: DbSec, options: SqlOpt(fn, id, code)); - return res.Result is true ? Ok() : StatusCode(500, new { error = "not successful" }); + return res.Result is true + ? await JSONAsync(new { ok = true }) + : StatusCode(500, new { error = "not successful" }); } case "ati": @@ -139,7 +209,9 @@ public partial class IntranetController "EXECUTE [dbo].[fds__setBankingtransaction_assignToIvoice] @taID, @invoice_id, @authuser;", _intranet.Intranet__SQLConnectionString, pl, Security: DbSec, options: SqlOpt(fn, id, code)); - return res.Result is true ? Ok() : StatusCode(500, new { error = "not successful" }); + return res.Result is true + ? await JSONAsync(new { ok = true }) + : StatusCode(500, new { error = "not successful" }); } case "vfi": @@ -165,4 +237,26 @@ public partial class IntranetController protected string Form(string key, string fallback = "") => Request.Form.TryGetValue(key, out var v) ? v.ToString() : fallback; + + private static (DateTime? From, DateTime? To) BankingDateRange(System.Data.DataTable tbl) + { + DateTime? from = null; + DateTime? to = null; + foreach (System.Data.DataRow row in tbl.Rows) + { + DateTime? date = BankingRowDate(row, tbl.Columns.Contains("EntryDate") ? "EntryDate" : "") + ?? BankingRowDate(row, tbl.Columns.Contains("ValueDate") ? "ValueDate" : ""); + if (date == null) continue; + from = from == null || date.Value < from.Value ? date.Value : from; + to = to == null || date.Value > to.Value ? date.Value : to; + } + return (from, to); + } + + private static DateTime? BankingRowDate(System.Data.DataRow row, string column) + { + if (string.IsNullOrEmpty(column) || row[column] == DBNull.Value) return null; + if (row[column] is DateTime dt) return dt.Date; + return DateTime.TryParse(row[column]?.ToString(), out var parsed) ? parsed.Date : null; + } } diff --git a/Fuchs/Controllers/IntranetController.Invoices.cs b/Fuchs/Controllers/IntranetController.Invoices.cs index 328a2d7..a4ccf3e 100644 --- a/Fuchs/Controllers/IntranetController.Invoices.cs +++ b/Fuchs/Controllers/IntranetController.Invoices.cs @@ -86,7 +86,14 @@ public partial class IntranetController _intranet.Intranet__SQLConnectionString, pl, Security: DbSec, options: SqlOpt(fn, id, code)); if (!string.IsNullOrEmpty(dt2.Exception)) + { _logger.LogError("sis: SQL error for invoice {InvoiceId}: {SqlError}, user={User}", invoiceId, dt2.Exception, UserAccountID); + await _events.InvoiceIssueAsync( + $"Rechnung {invoiceId} konnte nicht als versandt markiert werden.", + UserAccountID, invoiceId); + } + else + await _events.InvoiceMarkedSentAsync(invoiceId, invoiceId, UserAccountID); return string.IsNullOrEmpty(dt2.Exception) ? Ok() : StatusCode(500); } diff --git a/Fuchs/Controllers/IntranetController.Reminder.cs b/Fuchs/Controllers/IntranetController.Reminder.cs index 6dd22d3..45b42d8 100644 --- a/Fuchs/Controllers/IntranetController.Reminder.cs +++ b/Fuchs/Controllers/IntranetController.Reminder.cs @@ -43,10 +43,11 @@ public partial class IntranetController new FdsReminderData(ctd), change: false, remId: "", UserAccountID, DbSec); if (!string.IsNullOrEmpty(fdRem.Id)) { + 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 }); } - return StatusCode(500, new { error = "Erinnerung wurde nicht registriert" }); + return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht erstellt werden."); } case "conf": return await HandleReminderConf(fn, id, code); @@ -59,6 +60,12 @@ public partial class IntranetController "EXECUTE [dbo].[fds__setReminderSent] @Id, @auto, @authuser;", _intranet.Intranet__SQLConnectionString, pl, Security: DbSec, options: SqlOpt(fn, id, code)); + if (string.IsNullOrEmpty(dt2.Exception)) + await _events.ReminderMarkedSentAsync(Form("id"), Form("id"), UserAccountID); + else + await _events.ReminderIssueAsync( + $"Mahnung {Form("id")} konnte nicht als versandt markiert werden.", + UserAccountID, Form("id")); return string.IsNullOrEmpty(dt2.Exception) ? Ok() : StatusCode(500); } @@ -127,16 +134,35 @@ public partial class IntranetController email.Trim(), "", remdoc); if (sent) { + await _events.ReminderSentToCustomerAsync(fdRem, email.Trim(), UserAccountID); var pls = StdParamlist(SQL_VarChar("@Id", remId), SQL_Bit("@auto", true)); await getSQLDatatable_async( "EXECUTE [dbo].[fds__setReminderSent] @Id, @auto, @authuser;", _intranet.Intranet__SQLConnectionString, pls, Security: DbSec, options: SqlOpt(fn, id, code)); } + else + { + _logger.LogError( + "Reminder email send failed — reminderId={ReminderId} email={Email} user={User}", + remId, email.Trim(), UserAccountID); + await _events.ReminderIssueAsync( + $"Mahnung {frdic.nz("subject").ne(remId)} konnte nicht an {email.Trim()} versandt werden.", + UserAccountID, remId); + } + } + else if (filebyte.Length == 0) + { + _logger.LogError( + "Reminder PDF render returned 0 bytes — reminderId={ReminderId} user={User}", + remId, UserAccountID); + await _events.ReminderIssueAsync( + $"Die Mahn-PDF {frdic.nz("DocumentName", "").ne($"Zahlungserinnerung_{remId}.pdf")} konnte nicht erstellt werden.", + UserAccountID, remId); } return Ok(); } - return StatusCode(500, new { error = "Aktion war nicht erfolgreich" }); + return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht erstellt werden."); } private async Task HandleReminderIdoc(string fn, string id, string code) @@ -178,14 +204,39 @@ public partial class IntranetController if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) && frdic.no("InvoiceFile", null!) is byte[] invFile) remdoc[frdic.nz("InvoiceFileName")] = invFile; - await _comService.SendEmailAsync($"rem_{remId}", + bool sent = await _comService.SendEmailAsync($"rem_{remId}", $"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}", BuildReminderBody(Convert.ToDouble(frdic.no("amount_open", 0))), email.Trim(), "", remdoc); + if (sent) + { + var fdRem = await _reminders.LoadReminderAsync(remId, UserAccountID, DbSec); + await _events.ReminderSentToCustomerAsync(fdRem, email.Trim(), UserAccountID, resent: true); + } + else + { + _logger.LogError( + "Reminder resend email send failed — reminderId={ReminderId} email={Email} user={User}", + remId, email.Trim(), UserAccountID); + await _events.ReminderIssueAsync( + $"Mahnung {frdic.nz("subject").ne(remId)} konnte nicht erneut an {email.Trim()} versandt werden.", + UserAccountID, remId); + } } return Ok(); } - return StatusCode(500, new { error = "Aktion war nicht erfolgreich" }); + return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht versandt werden."); + } + + private async Task ReminderIssueResult(string message, string reminderId = "") + { + // Mirrors the SignalR toast in a durable app log: without this, a reminder + // save/create/send failure was only visible as a GUI notification nobody was + // necessarily watching at the time. + _logger.LogError("Reminder issue — reminderId={ReminderId} user={User} message={Message}", + reminderId, UserAccountID, message); + await _events.ReminderIssueAsync(message, UserAccountID, reminderId); + return StatusCode(500, new { error = message }); } private static string BuildReminderBody(double amountOpen) => diff --git a/Fuchs/Controllers/IntranetController.Reports.cs b/Fuchs/Controllers/IntranetController.Reports.cs index d86999a..5b00e15 100644 --- a/Fuchs/Controllers/IntranetController.Reports.cs +++ b/Fuchs/Controllers/IntranetController.Reports.cs @@ -36,7 +36,15 @@ public partial class IntranetController ri["params"] = dset.Tables("params") .toArrayofObjectDictionaries($"[object_id] = {ri["object_id"]} AND [name] <> '@authuser'"); } - catch { ri["params"] = Array.Empty>(); } + catch (Exception ex) + { + // Without this, a genuinely broken params filter/query is indistinguishable + // from the expected "this report has no params" case in the response. + _logger.LogWarning(ex, + "Report catalog: failed to load params for object_id={ObjectId} user={User}", + ri["object_id"], UserAccountID); + ri["params"] = Array.Empty>(); + } } return await JSONAsync(new { diff --git a/Fuchs/Controllers/IntranetController.Requests.cs b/Fuchs/Controllers/IntranetController.Requests.cs index 3d06df1..26d818d 100644 --- a/Fuchs/Controllers/IntranetController.Requests.cs +++ b/Fuchs/Controllers/IntranetController.Requests.cs @@ -49,9 +49,11 @@ public partial class IntranetController var fdInv = await _invoices.RegisterInvoiceAsync( new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!), change: !string.IsNullOrEmpty(Form("id")), invId: Form("id"), UserAccountID, DbSec); + if (!string.IsNullOrEmpty(fdInv.Id)) + await _events.InvoiceDraftRegisteredAsync(fdInv, !string.IsNullOrEmpty(Form("id")), UserAccountID); return !string.IsNullOrEmpty(fdInv.Id) ? await JSONAsync(new { id = fdInv.Id }) - : StatusCode(500, new { error = "Rechnung wurde nicht gespeichert" }); + : await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht gespeichert werden."); } case "sprep": @@ -62,10 +64,11 @@ public partial class IntranetController change: false, invId: "", UserAccountID, DbSec); if (!string.IsNullOrEmpty(fdInv.Id)) { + 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 }); } - return StatusCode(500, new { error = "Rechnung wurde nicht registriert" }); + return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden."); } case "sedit": @@ -76,10 +79,11 @@ public partial class IntranetController change: true, invId: Form("id"), UserAccountID, DbSec); if (!string.IsNullOrEmpty(fdInv.Id)) { + 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 }); } - return StatusCode(500, new { error = "Rechnung wurde nicht registriert" }); + return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht aktualisiert werden."); } case "sdel": @@ -141,13 +145,20 @@ public partial class IntranetController } } - private static List> AttachReports(SQLDataSet dset) + private List> AttachReports(SQLDataSet dset) { var req = new List>(dset.Tables("requests").toArrayofObjectDictionaries()!); foreach (var r in req) { try { r["reports"] = dset.Tables("reports").toArrayofObjectDictionaries($"[requestID] = {r["Id"]}"); } - catch { /* no reports table */ } + catch (Exception ex) + { + // "reports" table absent is expected for some queries; but a real failure while + // joining (e.g. malformed filter) looked identical to that with no way to tell them apart. + _logger.LogWarning(ex, + "AttachReports: failed to join reports for requestId={RequestId} user={User}", + r["Id"], UserAccountID); + } } return req; } @@ -285,15 +296,34 @@ public partial class IntranetController body, email.Trim(), "", inv); if (sent) { + await _events.InvoiceSentToCustomerAsync(fdInv, email.Trim(), UserAccountID); var pls = StdParamlist(SQL_VarChar("@Id", invId), SQL_Bit("@auto", true)); await getSQLDatatable_async("EXECUTE [dbo].[fds__setInvoiceSent] @Id, @auto, @authuser;", _intranet.Intranet__SQLConnectionString, pls, Security: DbSec, options: SqlOpt(fn, id, code)); } + else + { + _logger.LogError( + "Invoice email send failed — invoiceId={InvoiceId} email={Email} user={User}", + invId, email.Trim(), UserAccountID); + await _events.InvoiceIssueAsync( + $"Rechnung {frdic.nz("InvoiceId").ne(invId)} konnte nicht an {email.Trim()} versandt werden.", + UserAccountID, invId); + } + } + else if (filebyte.Length == 0) + { + _logger.LogError( + "Invoice PDF render returned 0 bytes — invoiceId={InvoiceId} user={User}", + invId, UserAccountID); + await _events.InvoiceIssueAsync( + $"Die Rechnungs-PDF {frdic.nz("DocumentName").ne($"Rechnung_{invId}.pdf")} konnte nicht erstellt werden.", + UserAccountID, invId); } return Ok(); } - return StatusCode(500, new { error = "Aktion war nicht erfolgreich" }); + return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden."); } private async Task HandleRequestIdoc(string fn, string id, string code) @@ -309,7 +339,7 @@ public partial class IntranetController : _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft)); return ct != null ? await FileContentResultAsync(ct, "application/pdf", filename, inline: true) - : StatusCode(500, new { error = "Rechnungs-PDF konnte nicht erstellt werden" }); + : await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id); } var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft)); return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages }); @@ -335,14 +365,35 @@ public partial class IntranetController { double bal = Convert.ToDouble(frdic.no("InvoiceBalance", 0)); string terms = fdInv.PaymentTerms.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen"); - await _comService.SendEmailAsync( + bool sent = await _comService.SendEmailAsync( $"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}", BuildInvoiceBody(bal, terms), email.Trim(), "", new Dictionary { [frdic.nz("DocumentName")] = filebyte }); + if (sent) + await _events.InvoiceSentToCustomerAsync(fdInv, email.Trim(), UserAccountID, resent: true); + else + { + _logger.LogError( + "Invoice resend email send failed — invoiceId={InvoiceId} email={Email} user={User}", + invId, email.Trim(), UserAccountID); + await _events.InvoiceIssueAsync( + $"Rechnung {frdic.nz("InvoiceId").ne(invId)} konnte nicht erneut an {email.Trim()} versandt werden.", + UserAccountID, invId); + } } return Ok(); } - return StatusCode(500, new { error = "Aktion war nicht erfolgreich" }); + return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht versandt werden."); + } + + private async Task InvoiceIssueResult(string message, string invoiceId = "") + { + // Mirrors the SignalR toast in a durable app log: without this, an invoice save/create/send + // failure was only visible as a GUI notification nobody was necessarily watching at the time. + _logger.LogError("Invoice issue — invoiceId={InvoiceId} user={User} message={Message}", + invoiceId, UserAccountID, message); + await _events.InvoiceIssueAsync(message, UserAccountID, invoiceId); + return StatusCode(500, new { error = message }); } private static string BuildInvoiceBody(double balance, string paymentTerms) => diff --git a/Fuchs/Controllers/IntranetController.cs b/Fuchs/Controllers/IntranetController.cs index 4ec74b7..c516489 100644 --- a/Fuchs/Controllers/IntranetController.cs +++ b/Fuchs/Controllers/IntranetController.cs @@ -1,5 +1,6 @@ using System.Web; using Fuchs.intranet; +using Fuchs.Notifications; using Fuchs.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; @@ -33,6 +34,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller private readonly IReportService _reports; private readonly IInvoiceService _invoices; private readonly IReminderService _reminders; + private readonly IEventService _events; private readonly List _allowedNonAuth = new() { "spwc", "spw" }; private readonly List _allowedGet = new() { @@ -59,7 +61,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller IWidgetService widgets, IReportService reports, IInvoiceService invoices, - IReminderService reminders) + IReminderService reminders, + IEventService events) { _intranet = intranet; _mfr = mfr; @@ -72,6 +75,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller _reports = reports; _invoices = invoices; _reminders = reminders; + _events = events; } /// Merged query-string + form parameters (form wins) for report processing. @@ -102,7 +106,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller public DatabaseSecurity DbSec => _intranet.GetDbSecurity(UserAccountID); public FIS_SQLOptions SqlOpt(string fn, string id, string code) => - new(new Dictionary { ["fn"] = fn, ["id"] = id, ["code"] = code }); + new(new Dictionary { ["fn"] = fn, ["id"] = id, ["code"] = code }, _logger); // ── Action helpers ──────────────────────────────────────────────────────── protected IActionResult Unauthorized401() => StatusCode(401); diff --git a/Fuchs/Docs/Concepts/README.md b/Fuchs/Docs/Concepts/README.md new file mode 100644 index 0000000..40ad2f6 --- /dev/null +++ b/Fuchs/Docs/Concepts/README.md @@ -0,0 +1,66 @@ +# Concepts + +This folder holds **living design write-ups** of how a subsystem currently +works: its moving parts, data flow, and how they fit together. Unlike +[`../Decisions`](../Decisions/README.md), concept docs are **not** immutable +— keep them in sync with the implementation as it evolves. + +## What belongs here + +"How does the notification pipeline work end to end" is a concept doc. "Why +did we choose SignalR over polling for it" is a decision. A single feature +area typically has one concept doc and may reference several decisions that +shaped it. + +## File naming + +`kebab-case-topic.md` (no numbering — concepts aren't sequential events). + +## Required YAML frontmatter + +```yaml +--- +status: Active # Active | Deprecated +lastUpdated: 2026-07-03 +applyTo: # glob(s) — files/areas this concept describes + - "Fuchs/Notifications/**" +relatedDecisions: # filenames in ../Decisions this concept implements + - "0001-domain-events-and-notification-triggers.md" +--- +``` + +**Agents must scan the YAML frontmatter of every file in this folder first** +and only read the full body of concepts whose `applyTo` glob matches the +files they're about to touch, or whose subject is otherwise clearly relevant. + +## Body template + +```markdown +# Topic + +## Summary +One paragraph: what this subsystem does and why it exists. + +## How it works +The mechanics — components, data flow, sequencing. Diagrams (ASCII/mermaid) +welcome where they clarify. + +## Key files +Bullet list of the primary files/classes involved. + +## Related decisions +Links to the ADRs in `../Decisions` that shaped this design. +``` + +## Rules + +- **Keep concepts current.** When you materially change how a documented + subsystem works, update its concept doc in the same change — don't let it + drift from the code. +- **Create a concept doc for new non-trivial subsystems.** If you build + something a future agent would need a paragraph of context to safely + modify, write that paragraph here instead of making them re-derive it from + the diff. +- Concepts describe **current** behavior. If something changes, edit the + doc in place — don't append a changelog inside it (git history is the + changelog). diff --git a/Fuchs/Docs/Decisions/0001-domain-events-and-notification-triggers.md b/Fuchs/Docs/Decisions/0001-domain-events-and-notification-triggers.md new file mode 100644 index 0000000..390a977 --- /dev/null +++ b/Fuchs/Docs/Decisions/0001-domain-events-and-notification-triggers.md @@ -0,0 +1,67 @@ +--- +status: Accepted +date: 2026-07-03 +applyTo: + - "Fuchs/Notifications/**" + - "Fuchs/Services/**" + - "Fuchs/Controllers/**" +supersededBy: "" +--- + +# 0001 — Domain events (success and failure) trigger user-understandable notifications + +## Context +Business operations (invoice creation, sending, marking sent, reminders, +banking import) happen server-side, often outside a synchronous request the +user is watching (background jobs, long-running sends). Users had no +reliable way to learn that an operation they cared about — or one that +failed — actually happened, short of refreshing lists or checking logs. + +## Decision +Every meaningful business outcome, success **and** failure, is modeled as a +`DomainEvent` (`Fuchs/Notifications/DomainEvent.cs`) with: +- a `DomainEventType` enum value identifying what happened, +- the acting `UserAccountId`, +- a `Title`, and +- a `Context` dictionary of the data needed to render a human-readable + message (invoice number, email address, file name, row counts, etc.). + +Services call the corresponding method on `IEventService` +(`Fuchs/Notifications/IEventService.cs`, implemented by `EventService`) +at the point the outcome is known — e.g. +`InvoiceSentToCustomerAsync(invoice, email, userAccountId)` or +`InvoiceIssueAsync(message, userAccountId, invoiceId)` on failure. +`EventService.PublishAsync` renders the event into a `GuiNotification` with a +German, end-user-readable `Message` (e.g. *"Rechnung R2026-0001 wurde an den +Kunden mit der E-Mail test@test.de versandt."*) and pushes it — see +[0002](0002-gui-notification-delivery-signalr.md) for delivery. + +Every new business operation with a user-visible outcome (created, sent, +failed, imported, etc.) must add a `DomainEventType` value and a matching +`IEventService` method, and call it from the service at the point of success +**and** the point of failure. + +## Consequences +- `IEventService` is injected into services that perform user-facing + operations (`InvoiceService`, `ReminderService`, `BankingService` callers) + — never bypass it by writing directly to `NotificationHub`. +- Failure paths must call the `*IssueAsync`/`*Failed` event too, not just + succeed-path events — silent failures are the problem this exists to + prevent. +- Messages are built server-side in `EventService.BuildNotification`, in + German, using only `Context` values — keep `Context` populated with + everything the message needs (don't rely on the client to look anything + up). +- Adding a new event type means updating the enum, the `IEventService` + interface + `EventService` implementation (trigger method + message + branch + `IsFailure` if it's a failure type), and the calling service — + in the same change. + +## Alternatives considered +- **Polling a status endpoint from the client**: rejected — adds latency, + extra load, and doesn't generalize to background/multi-tab flows as + cleanly as a push model. +- **Raw exception messages surfaced to the GUI**: rejected — not + user-understandable and leaks internal details; `Context` + a rendered + German message keeps the boundary between internal errors and + user-facing text explicit. diff --git a/Fuchs/Docs/Decisions/0002-gui-notification-delivery-signalr.md b/Fuchs/Docs/Decisions/0002-gui-notification-delivery-signalr.md new file mode 100644 index 0000000..89e53a9 --- /dev/null +++ b/Fuchs/Docs/Decisions/0002-gui-notification-delivery-signalr.md @@ -0,0 +1,60 @@ +--- +status: Accepted +date: 2026-07-03 +applyTo: + - "Fuchs/Notifications/**" + - "Fuchs/js/intranet/**" + - "Fuchs/wwwroot/web/**" + - "Fuchs/Program.cs" +supersededBy: "" +--- + +# 0002 — Backend notifications reach the GUI via a SignalR push to every logged-in session + +## Context +Domain events (see [0001](0001-domain-events-and-notification-triggers.md)) +need to reach whichever browser session(s) a user has open, in near +real time, without the client polling. + +## Decision +- `NotificationHub` (`Fuchs/Notifications/NotificationHub.cs`) is an + `[Authorize]` SignalR `Hub` mapped at `/notifications` in `Program.cs` + (`app.MapHub("/notifications")`). +- `EventService.PublishAsync` sends every `GuiNotification` to + `_hub.Clients.All.SendAsync("notification", notification, ...)`. Delivery + is currently broadcast to all connected (authenticated) clients, not + targeted per-user — any logged-in session receives every notification. +- Publish failures are caught and logged (`_logger.LogWarning`) rather than + thrown — a notification-delivery failure must never fail the underlying + business operation that triggered it. +- On the client, `$fis.notifications` (`Fuchs/js/intranet/fis_main.js`) + opens the SignalR connection once a logged-in `useraccount_id` is known, + listens for the `"notification"` event, and calls `push(notification)` + to render a dismissible toast into `#notification_frame`. The toast is + styled by `notification.severity` (`"error"` vs `"info"`), giving failures + a distinct highlighted appearance from successes. +- `GuiNotification.Severity` is derived by `EventService.IsFailure` from the + `DomainEventType` — failure event types render as `"error"`, everything + else as `"info"`. + +## Consequences +- Any new `DomainEventType` that represents a failure must be added to + `EventService.IsFailure` or it will render as a plain info toast instead + of being visually flagged. +- Because delivery is broadcast (not user-scoped), notifications are not a + substitute for private/sensitive data — `Context`/`Message` content must + stay appropriate for any logged-in user to see. If per-user targeting + becomes necessary, that is a new decision (SignalR groups keyed by user + ID), not a silent change to this one. +- The hub requires authentication; unauthenticated sessions never connect + and never receive notifications. +- Frontend rendering logic lives in `fis_main.js`/`fis.js` — keep the built + `wwwroot/web/fis.js`/`fis.min.js` in sync via the gulp build (see + `CLAUDE.md` Build & Test) whenever the notification client code changes. + +## Alternatives considered +- **Per-user SignalR groups**: more correct long-term but adds group + join/leave lifecycle management; deferred until a concrete need for + private notifications arises. +- **Server-Sent Events / long polling**: rejected — SignalR was already the + chosen real-time transport and needs no extra infrastructure. diff --git a/Fuchs/Docs/Decisions/0003-structured-diagnostic-logging.md b/Fuchs/Docs/Decisions/0003-structured-diagnostic-logging.md new file mode 100644 index 0000000..3e8ed6b --- /dev/null +++ b/Fuchs/Docs/Decisions/0003-structured-diagnostic-logging.md @@ -0,0 +1,56 @@ +--- +status: Accepted +date: 2026-07-03 +applyTo: + - "Fuchs/Logging/**" + - "Fuchs/Program.cs" +supersededBy: "" +--- + +# 0003 — The solution is equipped with structured diagnostic logging + +## Context +Diagnosing issues in a deployed intranet instance requires a durable, +inspectable log of what the application did, independent of whether an +OpenTelemetry collector is attached (see +[0004](0004-opentelemetry-observability.md)) — logging must work +out-of-the-box on every environment with zero external dependencies. + +## Decision +- `Fuchs/Logging/FuchsLoggerProvider.cs` implements a custom + `ILoggerProvider`/`ILogger` registered via `builder.Logging.AddFuchsLogging()` + in `Program.cs`, with `SetMinimumLevel(LogLevel.Debug)`. +- Every log line always goes to `Debug.WriteLine` **and** to a rolling text + file under `/logs/` — `AppLog.txt` for + `Debug`/`Information`/`Warning`, `ErrorLog.txt` for `Error`/`Critical` — + so a failure investigation never depends on a debugger being attached. +- Log lines are structured with timestamp, level tag, category, message, and + (when present) the exception message + stack trace on continuation lines. +- Database logging (`fuchs__admin_logdebug`) is **prepared but disabled** by + default (`FuchsLoggerProvider.DatabaseLoggingEnabled = false`) — flip it + on only where DB-durable diagnostics are specifically needed, since it + adds a DB round-trip per log call. +- All logger calls elsewhere in the codebase use `ILogger` injected via + DI with **structured** placeholders (`_logger.LogInformation("Sent {InvoiceNumber} to {Email}", ...)`), + never interpolated strings — this is enforced project-wide (see Coding + Standards / Observability in `CLAUDE.md`). +- File writes are best-effort: `AppendToFile` swallows its own exceptions — + a logging failure must never crash or interrupt the operation being + logged. + +## Consequences +- New code must inject `ILogger` and log entry/result/timing/errors for + meaningful operations (see [0004](0004-opentelemetry-observability.md) for + the matching tracing/metrics requirement) rather than adding ad-hoc + `Console.WriteLine`/`Debug.Print` calls. +- Because logs always write to `logs/AppLog.txt` and `ErrorLog.txt` + regardless of telemetry configuration, these files are the first place to + check when OTLP export isn't configured for an environment. +- Enabling `DatabaseLoggingEnabled` is a deliberate, explicit choice per + environment, not a default — it has a per-call DB cost. + +## Alternatives considered +- **Third-party logging framework (Serilog/NLog)**: rejected for now to + avoid an extra dependency for a need the in-box `ILogger` abstraction plus + a small custom provider already satisfies; revisit if requirements (e.g. + structured JSON sinks, log shipping) outgrow this. diff --git a/Fuchs/Docs/Decisions/0004-opentelemetry-observability.md b/Fuchs/Docs/Decisions/0004-opentelemetry-observability.md new file mode 100644 index 0000000..81c2eb9 --- /dev/null +++ b/Fuchs/Docs/Decisions/0004-opentelemetry-observability.md @@ -0,0 +1,65 @@ +--- +status: Accepted +date: 2026-07-03 +applyTo: + - "Fuchs/Observability/**" + - "Fuchs/Program.cs" + - "Fuchs/Services/**" +supersededBy: "" +--- + +# 0004 — OpenTelemetry is wired in extensively, without compromising performance + +## Context +Beyond text logs (see [0003](0003-structured-diagnostic-logging.md)), the +solution needs distributed tracing and metrics to understand performance and +behavior in production (PDF render durations, email send outcomes, MFR call +volume, banking import throughput) without depending on a debugger or manual +log-grepping — while never letting the absence of a collector break or slow +down the app. + +## Decision +- All instrumentation is centralized in `Fuchs/Observability/FuchsTelemetry.cs`: + one `ActivitySource` (`Fuchs.Intranet`) for tracing and one `Meter` for + metrics, exposing named `Counter`/`Histogram` instruments + (invoices/reminders/reports rendered, emails/SMS sent/failed, MT940 rows + parsed, banking entries skipped/truncated, MFR calls, blob upload + success/failure, PDF/report/email durations) plus a `StartActivity` helper. +- Wired in `Program.cs` behind `Fuchs:Telemetry:Enabled` (default `true`): + `AddOpenTelemetry()` with `AddAspNetCoreInstrumentation`, + `AddHttpClientInstrumentation`, `AddSqlClientInstrumentation` for tracing, + and `AddAspNetCoreInstrumentation`, `AddHttpClientInstrumentation`, + `AddRuntimeInstrumentation` for metrics. +- **Collection is always on; export is opt-in.** The OTLP exporter is only + added when `Fuchs:Telemetry:OtlpEndpoint` is configured — with no + collector present, spans/metrics are simply collected in-process and + discarded, so a missing collector can never cause startup failures, + exceptions, or blocking calls. Setting `Fuchs:Telemetry:Enabled=false` + disables instrumentation entirely. +- Per the project-wide Observability standard: every meaningful operation + starts an activity via `FuchsTelemetry.StartActivity(...)`, records the + matching counter/histogram, and logs entry/result/timing/errors via + injected `ILogger` with structured placeholders — this is enforced for + new service/handler code, not just the initial wiring. + +## Consequences +- New business operations worth observing must add a named instrument to + `FuchsTelemetry.cs` rather than creating ad-hoc `ActivitySource`/`Meter` + instances elsewhere — one source, one meter, keeps exporters and + dashboards simple. +- Because export is opt-in, local/dev environments get full in-process + instrumentation with zero setup; wiring an OTLP collector is purely an + ops-side configuration change (`Fuchs:Telemetry:OtlpEndpoint`), not a + code change. +- Instrumentation must stay cheap on the hot path — use the existing + counters/histograms rather than allocating new tags/dictionaries per call + where avoidable, and never make a business operation depend on the + exporter succeeding. + +## Alternatives considered +- **Always-on OTLP exporter requiring a collector**: rejected — would make + local dev and any environment without a collector fail hard or add + latency/timeouts trying to reach one. +- **Per-service ActivitySource/Meter instances**: rejected in favor of one + centralized `FuchsTelemetry` — avoids scattered instrument names and + duplicate registration boilerplate in `Program.cs`. diff --git a/Fuchs/Docs/Decisions/README.md b/Fuchs/Docs/Decisions/README.md new file mode 100644 index 0000000..b3d90b4 --- /dev/null +++ b/Fuchs/Docs/Decisions/README.md @@ -0,0 +1,71 @@ +# Decisions + +This folder holds **Architecture Decision Records (ADRs)** — short, immutable +records of a specific technical choice, why it was made, and what it implies +going forward. + +## What belongs here + +A decision, not a how-to. If it answers "why do we do X this way, and what +else did we consider," it's a decision. If it explains "how subsystem X +currently works," that belongs in [`../Concepts`](../Concepts/README.md) +instead (and a decision often triggers a concept doc to be created/updated). + +## File naming + +`NNNN-kebab-case-title.md`, four-digit zero-padded, sequential across the +whole folder (`0001-...`, `0002-...`). Never reuse or renumber. + +## Required YAML frontmatter + +Every decision file starts with: + +```yaml +--- +status: Accepted # Proposed | Accepted | Superseded +date: 2026-07-03 # date the decision was accepted +applyTo: # glob(s) — files/areas this decision governs + - "Fuchs/Notifications/**" +supersededBy: "" # filename of the decision that replaced this one, if any +--- +``` + +**Agents (Claude, Copilot, Codex) must scan the YAML frontmatter of every file +in this folder first** (cheap — no need to read the body) and only read the +full body of decisions whose `applyTo` glob matches the files they're about +to touch, or whose subject is otherwise clearly relevant to the task. This +keeps decision-following cheap even as the folder grows. + +## Body template + +```markdown +# NNNN — Title + +## Context +What problem/situation forced a choice. + +## Decision +What was decided, stated plainly. + +## Consequences +What this implies for future code — constraints, follow-ups, trade-offs +accepted knowingly. + +## Alternatives considered +Options that were rejected and why (optional but preferred). +``` + +## Rules + +- **Decisions are immutable once `Accepted`.** Do not edit the Decision/ + Consequences of an existing file to reverse it. Instead, write a new + decision, set its `applyTo`/subject accordingly, and set the old file's + `status: Superseded` + `supersededBy: NNNN-new-file.md`. +- **Follow existing decisions.** Before implementing anything in an area + covered by an `Accepted` decision, read it and conform to it. If you + believe a decision is wrong, raise it with the user rather than silently + deviating. +- **Capture new decisions as they happen.** Whenever the user (or the code + you're writing) settles a non-obvious architectural or cross-cutting + choice — not a routine implementation detail — add a decision here in the + same change, and create/update the matching concept doc in `../Concepts`. diff --git a/Fuchs/Docs/Notes/ToDo.md b/Fuchs/Docs/Notes/ToDo.md new file mode 100644 index 0000000..2b45195 --- /dev/null +++ b/Fuchs/Docs/Notes/ToDo.md @@ -0,0 +1,11 @@ + +The items, if completed, should be ticked / checked as done. + +[x] Add note to `claude.md`, `copilot-instructions.md`, and `Codex.md` that the Decisions ind \Docs\Decisions must be followed +[x] Add note to `claude.md`, `copilot-instructions.md`, and `Codex.md` that whenever relevant new decisions should be captured, concept files should be created / updated +[x] Add note to `claude.md`, `copilot-instructions.md`, and `Codex.md` that the readme.md files in \Docs\Concept and \Docs\Decisions explain how to create, update, interpret the documents. Create those readme.md files. make sure that any concepts or decisions have a yaml header that contains applyTo key. The agents should scan those yaml headers first (saving tokens) and decide based on that if included/considered +[x] Add a first decision, that domain events (success and fails) should be identified and equiped with triggers that trigger notification to the EventService and pass on a context that allows a user understandable message like "Rechnung R2026-0001 wurde and Kunden unter test@test.de per Email versandt". +[x] Add a decision that reflects the current concept and implementation of Notification from Service in Backend over SignalR push to any logged in session to display in GUI. (failues with highlighting) +[x] Add a decision that the solution must be equiped with logging so that the diagnostics is possible without requiring a debugger or OTel collector. +[x] Add OpenTelemetry to the solution. Wire it in extensively without compromising performance. + diff --git a/Fuchs/Notifications/DomainEvent.cs b/Fuchs/Notifications/DomainEvent.cs new file mode 100644 index 0000000..17cd4a2 --- /dev/null +++ b/Fuchs/Notifications/DomainEvent.cs @@ -0,0 +1,34 @@ +namespace Fuchs.Notifications; + +public enum DomainEventType +{ + InvoiceDraftCreated, + InvoiceDraftUpdated, + InvoiceFileCreated, + InvoiceSentToCustomer, + InvoiceResentToCustomer, + InvoiceMarkedSent, + InvoiceCreationFailed, + InvoiceFileCreationFailed, + InvoiceSendFailed, + ReminderDraftCreated, + ReminderFileCreated, + ReminderSentToCustomer, + ReminderResentToCustomer, + ReminderMarkedSent, + ReminderCreationFailed, + ReminderFileCreationFailed, + ReminderSendFailed, + BankingTransactionsImported, + BankingImportFailed, + UserIssue +} + +public sealed record DomainEvent( + DomainEventType Type, + string UserAccountId, + string Title, + IReadOnlyDictionary Context) +{ + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; +} diff --git a/Fuchs/Notifications/EventService.cs b/Fuchs/Notifications/EventService.cs new file mode 100644 index 0000000..609ba0f --- /dev/null +++ b/Fuchs/Notifications/EventService.cs @@ -0,0 +1,275 @@ +using Fuchs.intranet; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using static OCORE.OCORE_dictionaries; + +namespace Fuchs.Notifications; + +public sealed class EventService : IEventService +{ + private readonly IHubContext _hub; + private readonly ILogger _logger; + + public EventService(IHubContext hub, ILogger logger) + { + _hub = hub; + _logger = logger; + } + + public async Task PublishAsync(DomainEvent domainEvent, CancellationToken cancellationToken = default) + { + try + { + GuiNotification notification = BuildNotification(domainEvent); + await _hub.Clients + .All + .SendAsync("notification", notification, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Notification publish failed for {EventType}", domainEvent.Type); + } + } + + public Task InvoiceDraftRegisteredAsync(FdsInvoiceData invoice, bool changed, string userAccountId) + { + var type = changed ? DomainEventType.InvoiceDraftUpdated : DomainEventType.InvoiceDraftCreated; + return PublishAsync(new DomainEvent(type, userAccountId, "Rechnungsentwurf", InvoiceContext(invoice))); + } + + public Task InvoiceFileCreatedAsync(FdsInvoiceData invoice, string fileName, string userAccountId) + { + var ctx = InvoiceContext(invoice); + ctx["fileName"] = fileName; + return PublishAsync(new DomainEvent(DomainEventType.InvoiceFileCreated, userAccountId, "Rechnungsdatei", ctx)); + } + + public Task InvoiceSentToCustomerAsync(FdsInvoiceData invoice, string email, string userAccountId, bool resent = false) + { + var ctx = InvoiceContext(invoice); + ctx["email"] = email; + return PublishAsync(new DomainEvent( + resent ? DomainEventType.InvoiceResentToCustomer : DomainEventType.InvoiceSentToCustomer, + userAccountId, + "Rechnung versandt", + ctx)); + } + + public Task InvoiceMarkedSentAsync(string invoiceId, string invoiceNumber, string userAccountId) + { + Dictionary ctx = new() + { + ["id"] = invoiceId, + ["invoiceNumber"] = string.IsNullOrWhiteSpace(invoiceNumber) ? invoiceId : invoiceNumber + }; + return PublishAsync(new DomainEvent(DomainEventType.InvoiceMarkedSent, userAccountId, "Rechnung markiert", ctx)); + } + + public Task InvoiceIssueAsync(string message, string userAccountId, string invoiceId = "") + => PublishAsync(new DomainEvent( + DomainEventType.InvoiceCreationFailed, + userAccountId, + "Rechnung", + new Dictionary { ["id"] = invoiceId, ["message"] = message })); + + public Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId) + => PublishAsync(new DomainEvent(DomainEventType.ReminderDraftCreated, userAccountId, "Mahnentwurf", ReminderContext(reminder))); + + public Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId) + { + var ctx = ReminderContext(reminder); + ctx["fileName"] = fileName; + return PublishAsync(new DomainEvent(DomainEventType.ReminderFileCreated, userAccountId, "Mahndatei", ctx)); + } + + public Task ReminderSentToCustomerAsync(FdsReminderData reminder, string email, string userAccountId, bool resent = false) + { + var ctx = ReminderContext(reminder); + ctx["email"] = email; + return PublishAsync(new DomainEvent( + resent ? DomainEventType.ReminderResentToCustomer : DomainEventType.ReminderSentToCustomer, + userAccountId, + "Mahnung versandt", + ctx)); + } + + public Task ReminderMarkedSentAsync(string reminderId, string reminderTitle, string userAccountId) + { + Dictionary ctx = new() + { + ["id"] = reminderId, + ["title"] = string.IsNullOrWhiteSpace(reminderTitle) ? reminderId : reminderTitle + }; + return PublishAsync(new DomainEvent(DomainEventType.ReminderMarkedSent, userAccountId, "Mahnung markiert", ctx)); + } + + public Task ReminderIssueAsync(string message, string userAccountId, string reminderId = "") + => PublishAsync(new DomainEvent( + DomainEventType.ReminderCreationFailed, + userAccountId, + "Mahnung", + new Dictionary { ["id"] = reminderId, ["message"] = message })); + + public Task BankingTransactionsImportedAsync(DateTime? from, DateTime? to, int rows, string fileName, string userAccountId) + => PublishAsync(new DomainEvent( + DomainEventType.BankingTransactionsImported, + userAccountId, + "Banking", + new Dictionary + { + ["from"] = from, + ["to"] = to, + ["rows"] = rows, + ["fileName"] = fileName + })); + + public Task BankingImportIssueAsync(string message, string fileName, string userAccountId) + => PublishAsync(new DomainEvent( + DomainEventType.BankingImportFailed, + userAccountId, + "Banking", + new Dictionary { ["fileName"] = fileName, ["message"] = message })); + + public Task UserIssueAsync(string title, string message, string userAccountId, IReadOnlyDictionary? context = null) + { + Dictionary ctx = context == null + ? new Dictionary() + : new Dictionary(context); + ctx["message"] = message; + return PublishAsync(new DomainEvent(DomainEventType.UserIssue, userAccountId, title, ctx)); + } + + private static GuiNotification BuildNotification(DomainEvent domainEvent) + { + string message = domainEvent.Type switch + { + DomainEventType.InvoiceDraftCreated => + $"Rechnungsentwurf {Ctx(domainEvent, "invoiceNumber")} wurde erstellt.", + DomainEventType.InvoiceDraftUpdated => + $"Rechnungsentwurf {Ctx(domainEvent, "invoiceNumber")} wurde aktualisiert.", + DomainEventType.InvoiceFileCreated => + $"Rechnungsdatei {Ctx(domainEvent, "fileName")} wurde erstellt.", + DomainEventType.InvoiceSentToCustomer => + $"Rechnung {Ctx(domainEvent, "invoiceNumber")} wurde an den Kunden mit der E-Mail {Ctx(domainEvent, "email")} versandt.", + DomainEventType.InvoiceResentToCustomer => + $"Rechnung {Ctx(domainEvent, "invoiceNumber")} wurde erneut an {Ctx(domainEvent, "email")} versandt.", + DomainEventType.InvoiceMarkedSent => + $"Rechnung {Ctx(domainEvent, "invoiceNumber")} wurde als versandt markiert.", + DomainEventType.InvoiceCreationFailed => + Ctx(domainEvent, "message"), + DomainEventType.InvoiceFileCreationFailed => + Ctx(domainEvent, "message"), + DomainEventType.InvoiceSendFailed => + Ctx(domainEvent, "message"), + DomainEventType.ReminderDraftCreated => + $"Mahnentwurf {Ctx(domainEvent, "title")} wurde erstellt.", + DomainEventType.ReminderFileCreated => + $"Mahndatei {Ctx(domainEvent, "fileName")} wurde erstellt.", + DomainEventType.ReminderSentToCustomer => + $"Mahnung {Ctx(domainEvent, "title")} wurde an den Kunden mit der E-Mail {Ctx(domainEvent, "email")} versandt.", + DomainEventType.ReminderResentToCustomer => + $"Mahnung {Ctx(domainEvent, "title")} wurde erneut an {Ctx(domainEvent, "email")} versandt.", + DomainEventType.ReminderMarkedSent => + $"Mahnung {Ctx(domainEvent, "title")} wurde als versandt markiert.", + DomainEventType.ReminderCreationFailed => + Ctx(domainEvent, "message"), + DomainEventType.ReminderFileCreationFailed => + Ctx(domainEvent, "message"), + DomainEventType.ReminderSendFailed => + Ctx(domainEvent, "message"), + DomainEventType.BankingTransactionsImported => + BankingImportMessage(domainEvent), + DomainEventType.BankingImportFailed => + Ctx(domainEvent, "message"), + DomainEventType.UserIssue => + Ctx(domainEvent, "message"), + _ => domainEvent.Title + }; + + return new GuiNotification( + Guid.NewGuid().ToString("N"), + domainEvent.Type.ToString(), + domainEvent.Title, + message, + IsFailure(domainEvent.Type) ? "error" : "info", + domainEvent.CreatedAt, + domainEvent.Context); + } + + private static bool IsFailure(DomainEventType type) => + type is DomainEventType.InvoiceCreationFailed + or DomainEventType.InvoiceFileCreationFailed + or DomainEventType.InvoiceSendFailed + or DomainEventType.ReminderCreationFailed + or DomainEventType.ReminderFileCreationFailed + or DomainEventType.ReminderSendFailed + or DomainEventType.BankingImportFailed + or DomainEventType.UserIssue; + + private static string BankingImportMessage(DomainEvent domainEvent) + { + int rows = int.TryParse(Ctx(domainEvent, "rows"), out int r) ? r : 0; + string movement = rows == 1 ? "Kontobewegung" : "Kontobewegungen"; + string period = BankingPeriod(domainEvent); + return string.IsNullOrEmpty(period) + ? $"{rows} {movement} wurden importiert." + : $"{movement} für {period} wurden importiert."; + } + + private static string BankingPeriod(DomainEvent domainEvent) + { + DateTime? from = DateCtx(domainEvent, "from"); + DateTime? to = DateCtx(domainEvent, "to"); + if (from == null && to == null) return ""; + if (from != null && to != null) + { + string fromFmt = from.Value.Year == to.Value.Year + ? from.Value.ToString("d.M.") + : from.Value.ToString("d.M.yyyy"); + string toFmt = from.Value.Year == to.Value.Year + ? to.Value.ToString("dd.MM.") + : to.Value.ToString("dd.MM.yyyy"); + return $"{fromFmt} - {toFmt}"; + } + return (from ?? to)!.Value.ToString("dd.MM.yyyy"); + } + + private static DateTime? DateCtx(DomainEvent domainEvent, string key) + { + if (!domainEvent.Context.TryGetValue(key, out var value) || value == null) return null; + if (value is DateTime dt) return dt; + if (value is DateTimeOffset dto) return dto.DateTime; + return DateTime.TryParse(value.ToString(), out var parsed) ? parsed : null; + } + + private static Dictionary InvoiceContext(FdsInvoiceData invoice) + { + string invoiceNumber = invoice.InvoiceId; + return new Dictionary + { + ["id"] = invoice.Id, + ["invoiceNumber"] = string.IsNullOrWhiteSpace(invoiceNumber) ? invoice.Id : invoiceNumber, + ["documentName"] = invoice.InvoiceRegistration?.getString("DocumentName") ?? "", + ["email"] = invoice.InvoiceRegistration?.getString("SendToEmail") ?? "", + ["title"] = invoice.InvoiceTitle + }; + } + + private static Dictionary ReminderContext(FdsReminderData reminder) + { + return new Dictionary + { + ["id"] = reminder.Id, + ["invoiceNumber"] = reminder.InvoiceId, + ["title"] = string.IsNullOrWhiteSpace(reminder.ReminderTitle) ? reminder.Id : reminder.ReminderTitle, + ["documentName"] = reminder.ReminderRegistration?.getString("DocumentName") ?? "", + ["email"] = reminder.InvoiceEmail + }; + } + + private static string Ctx(DomainEvent domainEvent, string key) + { + if (!domainEvent.Context.TryGetValue(key, out var value)) return ""; + return value?.ToString() ?? ""; + } +} diff --git a/Fuchs/Notifications/GuiNotification.cs b/Fuchs/Notifications/GuiNotification.cs new file mode 100644 index 0000000..c838c7a --- /dev/null +++ b/Fuchs/Notifications/GuiNotification.cs @@ -0,0 +1,10 @@ +namespace Fuchs.Notifications; + +public sealed record GuiNotification( + string Id, + string Type, + string Title, + string Message, + string Severity, + DateTimeOffset CreatedAt, + IReadOnlyDictionary Context); diff --git a/Fuchs/Notifications/IEventService.cs b/Fuchs/Notifications/IEventService.cs new file mode 100644 index 0000000..98bad10 --- /dev/null +++ b/Fuchs/Notifications/IEventService.cs @@ -0,0 +1,25 @@ +using Fuchs.intranet; + +namespace Fuchs.Notifications; + +public interface IEventService +{ + Task PublishAsync(DomainEvent domainEvent, CancellationToken cancellationToken = default); + + Task InvoiceDraftRegisteredAsync(FdsInvoiceData invoice, bool changed, string userAccountId); + Task InvoiceFileCreatedAsync(FdsInvoiceData invoice, string fileName, string userAccountId); + Task InvoiceSentToCustomerAsync(FdsInvoiceData invoice, string email, string userAccountId, bool resent = false); + Task InvoiceMarkedSentAsync(string invoiceId, string invoiceNumber, string userAccountId); + Task InvoiceIssueAsync(string message, string userAccountId, string invoiceId = ""); + + Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId); + Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId); + Task ReminderSentToCustomerAsync(FdsReminderData reminder, string email, string userAccountId, bool resent = false); + Task ReminderMarkedSentAsync(string reminderId, string reminderTitle, string userAccountId); + Task ReminderIssueAsync(string message, string userAccountId, string reminderId = ""); + + Task BankingTransactionsImportedAsync(DateTime? from, DateTime? to, int rows, string fileName, string userAccountId); + Task BankingImportIssueAsync(string message, string fileName, string userAccountId); + + Task UserIssueAsync(string title, string message, string userAccountId, IReadOnlyDictionary? context = null); +} diff --git a/Fuchs/Notifications/NotificationHub.cs b/Fuchs/Notifications/NotificationHub.cs new file mode 100644 index 0000000..792e95f --- /dev/null +++ b/Fuchs/Notifications/NotificationHub.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace Fuchs.Notifications; + +[Authorize] +public sealed class NotificationHub : Hub +{ +} diff --git a/Fuchs/Observability/FuchsTelemetry.cs b/Fuchs/Observability/FuchsTelemetry.cs index 2920d33..01f48c8 100644 --- a/Fuchs/Observability/FuchsTelemetry.cs +++ b/Fuchs/Observability/FuchsTelemetry.cs @@ -39,6 +39,12 @@ public static class FuchsTelemetry Meter.CreateCounter("fuchs.sms.sent", "{sms}", "Number of SMS messages sent."); public static readonly Counter Mt940RowsParsed = Meter.CreateCounter("fuchs.banking.mt940.rows", "{row}", "Number of MT940 transaction lines parsed."); + public static readonly Counter BankingEntriesSkipped = + Meter.CreateCounter("fuchs.banking.entries.skipped", "{entry}", + "Number of bank statement entries/statements dropped during parsing, tagged by reason."); + public static readonly Counter BankingFieldsTruncated = + Meter.CreateCounter("fuchs.banking.fields.truncated", "{field}", + "Number of parsed fields truncated to fit the destination column width."); public static readonly Counter MfrCalls = Meter.CreateCounter("fuchs.mfr.calls", "{call}", "Number of MFR ERP client calls initiated."); public static readonly Counter BlobUploadsSucceeded = diff --git a/Fuchs/Program.cs b/Fuchs/Program.cs index 3003852..3b51cad 100644 --- a/Fuchs/Program.cs +++ b/Fuchs/Program.cs @@ -1,5 +1,6 @@ using Fuchs.intranet; using Fuchs.Logging; +using Fuchs.Notifications; using Fuchs.Observability; using OCORE_web.Secrets; using Fuchs.Services; @@ -52,6 +53,7 @@ public class Program // MVC with Razor view support builder.Services.AddControllersWithViews(); + builder.Services.AddSignalR(); // Fuchs intranet singleton builder.Services.AddSingleton(_ => FuchsOcmsIntranet.Instance); @@ -96,6 +98,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); // Secondary archive: invoice/reminder PDFs additionally stored in Azure Blob Storage. // Disabled by default (Fuchs:AzureStorage:Enabled) — see AzureBlobStorageService. @@ -165,6 +168,7 @@ public class Program app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); + app.MapHub("/notifications"); // Intranet routes (root-level — this IS the website) app.MapControllerRoute( diff --git a/Fuchs/Services/BankingService.cs b/Fuchs/Services/BankingService.cs index 88da3ea..5ee3918 100644 --- a/Fuchs/Services/BankingService.cs +++ b/Fuchs/Services/BankingService.cs @@ -1,5 +1,6 @@ using System.Data; using System.Diagnostics; +using System.Linq; using CAMTParser; using Fuchs.Observability; using Microsoft.Extensions.Logging; @@ -35,6 +36,7 @@ public class BankingService : IBankingService using var act = FuchsTelemetry.StartActivity("banking.parse"); var sw = Stopwatch.StartNew(); var tbl = schemaDatatable?.Clone() ?? BuildDefaultSchema(); + var diag = new ParseDiagnostics(); // Buffer once so we can sniff the format and (re)parse from the bytes. byte[] bytes; @@ -48,46 +50,125 @@ public class BankingService : IBankingService if (CamtParser.LooksLikeZip(bytes)) { format = "camt.zip"; - try { MapCamtEntries(tbl, new CamtParser().ParseZip(bytes)); } + try + { + var statements = new CamtParser().ParseZip(bytes, out var skippedEntries); + if (skippedEntries.Count > 0) + { + diag.ZipEntriesSkipped = skippedEntries.Count; + _logger.LogWarning( + "CAMT ZIP: {Count} entry(ies) skipped: {Entries}", + skippedEntries.Count, string.Join("; ", skippedEntries)); + } + MapCamtEntries(tbl, statements, diag); + } catch (Exception ex) { _logger.LogError(ex, "CAMT ZIP statement parse failed."); } } else if (CamtParser.LooksLikeXml(bytes)) { format = "camt"; - try { MapCamtEntries(tbl, new CamtParser().Parse(bytes)); } + try { MapCamtEntries(tbl, new CamtParser().Parse(bytes), diag); } catch (Exception ex) { _logger.LogError(ex, "CAMT statement parse failed."); } } else { format = "mt940"; using var msMt = new MemoryStream(bytes); - FillFromMt940(tbl, msMt); + FillFromMt940(tbl, msMt, diag); } tbl.AcceptChanges(); sw.Stop(); + FuchsTelemetry.Mt940RowsParsed.Add(tbl.Rows.Count, new KeyValuePair("format", format)); + if (diag.StatementsSkippedNoAccount > 0) + FuchsTelemetry.BankingEntriesSkipped.Add(diag.StatementsSkippedNoAccount, + new KeyValuePair("reason", "noAccount")); + if (diag.EntriesSkippedError > 0) + FuchsTelemetry.BankingEntriesSkipped.Add(diag.EntriesSkippedError, + new KeyValuePair("reason", "error")); + if (diag.ZipEntriesSkipped > 0) + FuchsTelemetry.BankingEntriesSkipped.Add(diag.ZipEntriesSkipped, + new KeyValuePair("reason", "zipEntry")); + if (diag.FieldsTruncated > 0) + FuchsTelemetry.BankingFieldsTruncated.Add(diag.FieldsTruncated); + act?.SetTag("fuchs.banking.format", format); act?.SetTag("fuchs.banking.rows", tbl.Rows.Count); - _logger.LogInformation("Bank statement parsed: format={Format} rows={Rows} in {Ms} ms", - format, tbl.Rows.Count, sw.ElapsedMilliseconds); + act?.SetTag("fuchs.banking.statements_skipped_no_account", diag.StatementsSkippedNoAccount); + act?.SetTag("fuchs.banking.entries_skipped_error", diag.EntriesSkippedError); + act?.SetTag("fuchs.banking.fields_truncated", diag.FieldsTruncated); + + // A statement/upload that yields zero rows almost always means the import silently + // failed upstream (wrong account element for this bank's schema variant, empty file, + // unsupported CAMT flavor) rather than that the statement legitimately had no bookings. + // Surface that as a warning so it doesn't require deliberately grepping info-level logs. + var logLevel = tbl.Rows.Count == 0 ? LogLevel.Warning : LogLevel.Information; + _logger.Log(logLevel, + "Bank statement parsed: format={Format} rows={Rows} statementsSkippedNoAccount={StatementsSkipped} " + + "entriesSkippedError={EntriesSkipped} zipEntriesSkipped={ZipSkipped} fieldsTruncated={Truncated} in {Ms} ms", + format, tbl.Rows.Count, diag.StatementsSkippedNoAccount, diag.EntriesSkippedError, + diag.ZipEntriesSkipped, diag.FieldsTruncated, sw.ElapsedMilliseconds); + + if (diag.TruncatedByColumn.Count > 0) + _logger.LogWarning("Bank statement fields truncated to column width: {Columns}", + string.Join(", ", diag.TruncatedByColumn.Select(kv => $"{kv.Key}×{kv.Value}"))); + return tbl; } - // ── MT940 ───────────────────────────────────────────────────────────────── - private void FillFromMt940(DataTable tbl, Stream stream) + /// Per-parse counters used to summarize what got dropped or altered, so a single log line can explain a zero- or low-row result. + private sealed class ParseDiagnostics { - void SetNfo(DataRow nr, string key, object? value) + public int StatementsSkippedNoAccount; + public int EntriesSkippedError; + public int ZipEntriesSkipped; + public int FieldsTruncated; + public readonly Dictionary TruncatedByColumn = new(); + } + + /// + /// Assigns a value to a row cell, but only if the column exists and the value + /// is non-null. String values are truncated to the column's + /// so that an over-long field (e.g. a remittance line, a long counterparty name, or a + /// foreign IBAN) can never overflow the destination column. Without this guard, a single + /// over-long value throws on assignment and — because the per-entry mapping is wrapped in a + /// catch — silently drops the whole transaction, which can empty an entire import. + /// Truncations are counted in rather than logged per-cell, to avoid + /// flooding the log on a file with many long fields. + /// + private static void SetCell(DataTable tbl, DataRow nr, string key, object? value, ParseDiagnostics diag) + { + if (value == null || !tbl.Columns.Contains(key)) return; + var col = tbl.Columns[key]!; + if (col.DataType == typeof(string) && col.MaxLength > 0 && + value is string s && s.Length > col.MaxLength) { - if (tbl.Columns.Contains(key) && value != null) nr[key] = value; + value = s[..col.MaxLength]; + diag.FieldsTruncated++; + diag.TruncatedByColumn[key] = diag.TruncatedByColumn.GetValueOrDefault(key) + 1; } + nr[key] = value; + } + + // ── MT940 ───────────────────────────────────────────────────────────────── + private void FillFromMt940(DataTable tbl, Stream stream, ParseDiagnostics diag) + { + void SetNfo(DataRow nr, string key, object? value) => SetCell(tbl, nr, key, value, diag); using var ps = new Parser(stream: stream); try { foreach (var statement in ps.Parse()) { - if (string.IsNullOrEmpty(statement.AccountIdentification)) continue; + if (string.IsNullOrEmpty(statement.AccountIdentification)) + { + diag.StatementsSkippedNoAccount++; + _logger.LogWarning( + "MT940 statement skipped: no AccountIdentification ({LineCount} line(s) dropped).", + statement.Lines.Count); + continue; + } foreach (var line in statement.Lines) { try @@ -128,7 +209,13 @@ public class BankingService : IBankingService tbl.Rows.Add(nr); } - catch (Exception ex) { _logger.LogWarning(ex, "MT940 line parse error — account={Account}", statement.AccountIdentification); } + catch (Exception ex) + { + diag.EntriesSkippedError++; + _logger.LogWarning(ex, + "MT940 line parse error — account={Account} entryDate={EntryDate} amount={Amount}: dropped.", + statement.AccountIdentification, line.EntryDate, line.Amount); + } } } } @@ -136,16 +223,20 @@ public class BankingService : IBankingService } // ── CAMT (ISO 20022) ─────────────────────────────────────────────────────── - private void MapCamtEntries(DataTable tbl, List statements) + private void MapCamtEntries(DataTable tbl, List statements, ParseDiagnostics diag) { - void SetNfo(DataRow nr, string key, object? value) - { - if (tbl.Columns.Contains(key) && value != null) nr[key] = value; - } + void SetNfo(DataRow nr, string key, object? value) => SetCell(tbl, nr, key, value, diag); foreach (var stmt in statements) { - if (string.IsNullOrEmpty(stmt.AccountIdentification)) continue; + if (string.IsNullOrEmpty(stmt.AccountIdentification)) + { + diag.StatementsSkippedNoAccount++; + _logger.LogWarning( + "CAMT statement skipped: no AccountIdentification ({EntryCount} entrie(s) dropped, docType={DocType}).", + stmt.Entries.Count, stmt.DocumentType); + continue; + } foreach (var e in stmt.Entries) { try @@ -155,7 +246,10 @@ public class BankingService : IBankingService if (e.Amount.HasValue) SetNfo(nr, "Amount", e.Amount); if (e.EntryDate.HasValue) SetNfo(nr, "EntryDate", e.EntryDate); if (e.ValueDate.HasValue) SetNfo(nr, "ValueDate", e.ValueDate); - SetNfo(nr, "FundsCode", e.Currency); + // FundsCode is a single-character MT940 funds code (VARCHAR(1)); CAMT has no + // equivalent, so it is left unset. The ISO currency (e.Currency, e.g. "EUR") + // must NOT be written here — it overflows the 1-char column and, before the + // width guard in SetCell, silently dropped every CAMT transaction. SetNfo(nr, "DebitCreditMark", e.MarkAbbreviation); SetNfo(nr, "BankReference", e.BankReference); SetNfo(nr, "EndToEndReference", e.EndToEndReference); @@ -175,7 +269,13 @@ public class BankingService : IBankingService tbl.Rows.Add(nr); } - catch (Exception ex) { _logger.LogWarning(ex, "CAMT entry parse error — account={Account}", stmt.AccountIdentification); } + catch (Exception ex) + { + diag.EntriesSkippedError++; + _logger.LogWarning(ex, + "CAMT entry parse error — account={Account} entryDate={EntryDate} amount={Amount}: dropped.", + stmt.AccountIdentification, e.EntryDate, e.Amount); + } } } } diff --git a/Fuchs/Services/InvoiceService.cs b/Fuchs/Services/InvoiceService.cs index 7433436..d2901fa 100644 --- a/Fuchs/Services/InvoiceService.cs +++ b/Fuchs/Services/InvoiceService.cs @@ -1,6 +1,7 @@ using System.Data; using System.Diagnostics; using Fuchs.intranet; +using Fuchs.Notifications; using Fuchs.Observability; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; @@ -23,14 +24,16 @@ public class InvoiceService : IInvoiceService private readonly Fuchs_intranet _intranet; private readonly IPdfService _pdf; private readonly IBlobStorageService _blobStorage; + private readonly IEventService _events; private readonly ILogger _logger; public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage, - ILogger logger) + IEventService events, ILogger logger) { _intranet = intranet; _pdf = pdf; _blobStorage = blobStorage; + _events = events; _logger = logger; } @@ -150,6 +153,7 @@ public class InvoiceService : IInvoiceService string fileName = invoice.InvoiceRegistration?.getString("DocumentName") .ne($"Rechnung_{invoice.Id}.pdf") ?? $"Rechnung_{invoice.Id}.pdf"; await _blobStorage.UploadInvoicePdfAsync(invoice.Id, fileName, ba, invoice.InvoiceRegistration); + await _events.InvoiceFileCreatedAsync(invoice, fileName, userAccountId); return ba; } diff --git a/Fuchs/Services/ProcessWebComService.cs b/Fuchs/Services/ProcessWebComService.cs index 2f1f658..982a718 100644 --- a/Fuchs/Services/ProcessWebComService.cs +++ b/Fuchs/Services/ProcessWebComService.cs @@ -234,16 +234,22 @@ public class ProcessWebComService : IComService } } - private static string BuildSignature() + private string BuildSignature() { + string sigPath = Path.Combine(AppContext.BaseDirectory, + "email_signature", "sanitaerfuchs_email_signature.txt"); try { - string sigPath = Path.Combine(AppContext.BaseDirectory, - "email_signature", "sanitaerfuchs_email_signature.txt"); if (File.Exists(sigPath)) return SignatureIntro + File.ReadAllText(sigPath); } - catch { /* signature is optional */ } + catch (Exception ex) + { + // The signature is optional (emails still send without it), but a read failure here + // usually means a misconfigured deployment (permissions, locked file) that would + // otherwise go unnoticed indefinitely — every email would just quietly lack a signature. + _logger.LogWarning(ex, "Failed to read email signature file at {SignaturePath}", sigPath); + } return ""; } diff --git a/Fuchs/Services/ReminderService.cs b/Fuchs/Services/ReminderService.cs index 1d03409..f74d29b 100644 --- a/Fuchs/Services/ReminderService.cs +++ b/Fuchs/Services/ReminderService.cs @@ -1,6 +1,7 @@ using System.Data; using System.Diagnostics; using Fuchs.intranet; +using Fuchs.Notifications; using Fuchs.Observability; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; @@ -23,14 +24,16 @@ public class ReminderService : IReminderService private readonly Fuchs_intranet _intranet; private readonly IPdfService _pdf; private readonly IBlobStorageService _blobStorage; + private readonly IEventService _events; private readonly ILogger _logger; public ReminderService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage, - ILogger logger) + IEventService events, ILogger logger) { _intranet = intranet; _pdf = pdf; _blobStorage = blobStorage; + _events = events; _logger = logger; } @@ -153,6 +156,7 @@ public class ReminderService : IReminderService string fileName = reminder.ReminderRegistration?.getString("DocumentName") .ne($"Zahlungserinnerung_{reminder.Id}.pdf") ?? $"Zahlungserinnerung_{reminder.Id}.pdf"; await _blobStorage.UploadReminderPdfAsync(reminder.Id, fileName, ba, reminder.ReminderRegistration); + await _events.ReminderFileCreatedAsync(reminder, fileName, userAccountId); return ba; } diff --git a/Fuchs/Views/Shared/_Layout.cshtml b/Fuchs/Views/Shared/_Layout.cshtml index 05acf27..754296b 100644 --- a/Fuchs/Views/Shared/_Layout.cshtml +++ b/Fuchs/Views/Shared/_Layout.cshtml @@ -1,5 +1,8 @@ @using System.Security.Claims +@using Microsoft.Data.SqlClient @using Newtonsoft.Json +@inject IConfiguration Configuration +@inject Microsoft.AspNetCore.Hosting.IWebHostEnvironment HostEnvironment @{ bool isAuth = User.Identity?.IsAuthenticated ?? false; @@ -16,12 +19,26 @@ string appName = ViewData["AppName"] as string ?? "Fuchs Intranet"; string fullName = ViewData["FullName"] as string ?? ""; string pageTitle = ViewData["Title"] as string ?? "Intranet"; + string? debugDbTarget = null; + if (HostEnvironment.IsDevelopment()) + { + var connectionString = Configuration.GetConnectionString("fuchs_fds_ConnectionString"); + if (!string.IsNullOrWhiteSpace(connectionString)) + { + var builder = new SqlConnectionStringBuilder(connectionString); + debugDbTarget = $"{builder.DataSource} / {builder.InitialCatalog}"; + } + } } + @if (!string.IsNullOrWhiteSpace(debugDbTarget)) + { + + } @pageTitle @@ -92,6 +109,7 @@
+
@await RenderSectionAsync("BodyFooter", required: false)
} diff --git a/Fuchs/bdlconfig.json b/Fuchs/bdlconfig.json index 85ab091..1b40a86 100644 --- a/Fuchs/bdlconfig.json +++ b/Fuchs/bdlconfig.json @@ -40,6 +40,7 @@ "outputFileName": "wwwroot/web/fis.min.js", "inputFiles": [ "js/intranet/oci_texts_basic_de.js", + "node_modules/@microsoft/signalr/dist/browser/signalr.min.js", "js/intranet/oci_texts_gui_de.js", "js/intranet/oci_texts_val_de.js", "web/loadcss/loadCSS.js", diff --git a/Fuchs/code/FuchsIntranet.cs b/Fuchs/code/FuchsIntranet.cs index 8022a6b..f77a317 100644 --- a/Fuchs/code/FuchsIntranet.cs +++ b/Fuchs/code/FuchsIntranet.cs @@ -1,5 +1,6 @@ using System.Globalization; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; @@ -218,14 +219,21 @@ public class FuchsUserIdentity // --------------------------- SQL options ------------------------------------- /// -/// Fuchs-specific SQL options — adds debug logging on error. +/// Fuchs-specific SQL options — logs every SQL error both to the app's structured +/// /OpenTelemetry pipeline and to the fds__admin_logdebug SQL +/// table (via ). Handlers that don't separately inspect +/// the result's .Exception would otherwise return an empty/200 response on a failing +/// stored procedure with zero application-log signal — the DB table alone requires someone to +/// go looking for it. /// public class FIS_SQLOptions : sqloptions { - public FIS_SQLOptions(Dictionary? context = null) + public FIS_SQLOptions(Dictionary? context = null, ILogger? logger = null) { OnError = (procedure, ex, data) => { + logger?.LogError(ex, "SQL error in {Procedure}: {Message} — context={@Context}", + procedure, ex.Message, context); try { FuchsOcmsIntranet.Instance.debug_log($"SQL Error in {procedure}", ex, data: context); } catch { } }; diff --git a/Fuchs/css/intranet/fis_main.scss b/Fuchs/css/intranet/fis_main.scss index a6e4b08..98ffd6c 100644 --- a/Fuchs/css/intranet/fis_main.scss +++ b/Fuchs/css/intranet/fis_main.scss @@ -29,6 +29,61 @@ main nav ul > li a[role=button] { text-align: center; } +#notification_frame { + position: fixed; + bottom: 1rem; + right: 1rem; + z-index: 2000; + width: min(24rem, calc(100vw - 2rem)); + display: flex; + flex-direction: column; + gap: 0.5rem; + pointer-events: none; +} + +.notification_item { + position: relative; + background: #fff; + border-left: 0.35rem solid $fuchs_blau; + border-radius: 0.35rem; + box-shadow: 0 0.25rem 1rem rgba(30, 35, 45, 0.25); + color: #222; + padding: 0.75rem 2.2rem 0.75rem 0.85rem; + pointer-events: auto; + + &.warn { + border-left-color: #c78300; + } + + &.error { + border-left-color: #b92525; + } + + .notification_title { + font-weight: bold; + line-height: 1.25; + margin-bottom: 0.2rem; + } + + .notification_message { + font-size: 0.9rem; + line-height: 1.3; + } + + .notification_close { + position: absolute; + top: 0.35rem; + right: 0.45rem; + border: 0; + background: transparent; + color: #444; + cursor: pointer; + font-size: 1.2rem; + line-height: 1; + padding: 0.1rem 0.25rem; + } +} + .wdg_frame { background-color: #FFF; border: 1px solid #ccc; diff --git a/Fuchs/js/intranet/fis_main.js b/Fuchs/js/intranet/fis_main.js index b028006..4e8f603 100644 --- a/Fuchs/js/intranet/fis_main.js +++ b/Fuchs/js/intranet/fis_main.js @@ -228,4 +228,45 @@ $fis.ov = function () { }); }, loading: ovf }); -}; \ No newline at end of file +}; + +$fis.notifications = { + connection: null, + init: function () { + if (typeof signalR === 'undefined' || this.connection !== null || !$ocms.auth.useraccount_id) { + return; + } + this.ensureFrame(); + this.connection = new signalR.HubConnectionBuilder() + .withUrl('/notifications') + .withAutomaticReconnect() + .build(); + this.connection.on('notification', (notification) => { + this.push(notification); + }); + this.connection.start().catch(() => { + this.connection = null; + }); + }, + ensureFrame: function () { + if ($('#notification_frame').length < 1) { + $('
', { id: 'notification_frame' }).appendTo($('footer:first').length ? 'footer:first' : 'body'); + } + }, + push: function (notification) { + this.ensureFrame(); + notification = notification || {}; + let item = $('
', { class: 'notification_item' }) + .addClass((notification.severity || 'info').toLowerCase()) + .append($('
'),n=e.find(".form-body"),r=null;e.find("form").submit((function(t){t.preventDefault();var i=$(this).serializeObject(!0),o=null===r,a=o?"spwc":"spw";return $ocms.postXT.call(this,{url:$ocms.url(a),data:i,complete:function(){o?(n.append('
Ihnen wurde ein Code per SMS zugesandt.
Bitte tragen Sie den hier ein:
'),r=$('
').appendTo(n)):(alert($t.l13),e.remove())},error:()=>{}}),!1})),e.find(".modal-close").click((function(){e.remove()}));var i=[];$.each($t.l7a.split("\n"),((t,e)=>{Array.prototype.push.apply(i,[$("
"),$("").text(e)])})),e.find(".modal-note").append($('').text($t.alert)).append(i),e.appendTo("body"),setTimeout((function(){$(".modal").find('input[name="lastname"]').focus()}),600)}};var $$={s:function(t){return $("").text(t)},br:function(){return $("
")},sc:function(t,e){return $("").addClass(t).text(e)},td:function(t,e){var n=$("");return t instanceof jQuery==!0?n.appendTo(t):"object"==typeof t?n.attr(t):"string"==typeof t&&n.text(t),"object"==typeof e?n.attr(e):"string"==typeof e&&n.text(e),n},th:function(t,e){var n=$("");return t instanceof jQuery==!0?n.appendTo(t):"object"==typeof t?n.attr(t):"string"==typeof t&&n.text(t),"object"==typeof e?n.attr(e):"string"==typeof e&&n.text(e),n},tdc:function(t,e,n){return $$.td(e,n).addClass(t)},td2:function(t){var e=$('');return"string"===$.type(t)?e.text(t):t instanceof jQuery?e.append(t):"function"==typeof t?t.call(e):e.html(" "),e},td3:function(t){var e=$('');return"string"===$.type(t)?e.text(t):t instanceof jQuery?e.append(t):"function"==typeof t?t.call(e):e.html(" "),e},tdtr:function(t,e){var n=$$.tr().appendTo(e);return t instanceof jQuery==!0||"string"==typeof t?t.appendTo($$.td().appendTo(n)):!0===Array.isArray(t)&&$.each(t,(function(t,e){$(e).appendTo($$.td().appendTo(n))})),n},tr:function(t,e){var n=$("");return t instanceof jQuery==!0?n.appendTo(t):"object"==typeof t&&n.attr(t),"object"==typeof e&&n.attr(e),n},trc:function(t,e){var n=$("").addClass(t);return e instanceof jQuery==!0?n.appendTo(e):"object"==typeof e&&n.attr(e),n},d:function(t){return $("
").attr(t||{})},dc:function(t,e,n,r){var i=$("
").addClass(t);return e instanceof jQuery==!0?i.appendTo(e):"object"==typeof e?i.attr(e):"function"==typeof e?i.click(e):"string"==typeof e&&i.text(e),"string"==typeof n?i.text(n):"object"==typeof n?i.attr(n):"function"==typeof n&&i.click(n),"string"==typeof r?i.text(r):"object"==typeof r?i.attr(r):"function"==typeof r&&i.click(r),i},df:function(t){return $("
 
").attr(t||{})},opt:function(t,e,n){var r=$("");return"string"==typeof t?r.attr("value",t):"object"==typeof t&&r.attr(t),"string"==typeof e?r.text(e):"object"==typeof e&&r.attr(e),"object"==typeof n&&r.attr(n),r},eOpt:function(t){var e=$('');return t&&e.attr("selected","selected"),e},tbl:function(t){return $("
").attr(t||{})},tblc:function(t){return $("
").addClass(t)},thead:function(t){let e=$("");return t instanceof jQuery&&e.prependTo(t),e},tbody:function(t){let e=$("");return t instanceof jQuery&&e.appendTo(t),e},tblset:function(t,e){let n=$$.tbl(t||{});return e instanceof jQuery&&e.append(n),{tbl:n,hd:$$.thead().appendTo(n),bdy:$$.tbody().appendTo(n)}},i:function(t){return $("").attr(t||{})},img:function(t,e){return $("").attr("src",t).attr(e||{})},sel:function(t){return $("").attr(t||{})},btn:function(t){return $("").attr(t||{})},a:function(t){return $("").attr(t||{})},li:function(t){return $("
  • ").attr(t||{})},ul:function(t){return $("
      ").attr(t||{})},nav:function(t){return $("").attr(t||{})},lbl:function(t,e){var n=$("");return"string"==typeof t&&n.text(t),"object"==typeof t?n.attr(t):"object"==typeof e&&n.attr(e),n},txt:function(t){return $("").attr(t||{})},0:function(t,e){return $("<"+t+">").attr(e||{})},bbtn:function(t,e){return $$.btn({type:"button",class:"btn"}).addClass(e).text(t)},svg:t=>$(document.createElementNS("http://www.w3.org/2000/svg",t))};function getMonday(t){var e=(t=new Date(t)).getDay(),n=t.getDate()-e+(0==e?-6:1);return new Date(t.setDate(n))}function $lf(t){var e=void 0===t?null:"number"==typeof t&&1!==t||"boolean"==typeof cl&&!1===t;return $("#listframe").tC("hd",e).is(".hd")}function $nuf(t){if(t&&t.stopPropagation(),!$(this).is(".disabled")){var e=function(t){t.removeClass("vis").find("li.dropdown").removeClass("open").removeClass("vis").attr("aria-expanded","false")},n=$(this).parent("li.dropdown");if(n.length>0){n.tC("open"),navs=!0===n.is(".open")?"true":"false",n.attr("aria-expanded",navs);var r=n.closest("nav");r.find("li.dropdown").not(n.parentsUntil("nav")).not(n).removeClass("open").attr("aria-expanded","false"),!1===n.is(".open")&&n.find("li.dropdown").removeClass("open").attr("aria-expanded","false"),e($("nav").not(r))}else e($("nav"))}}function $tbr(){return $lf(0),$("#topbar").ocmsmenu([])}function $lfr(){return $("#sidebar").empty(),$("#listframe").removeClass("fix").addClass("hd").empty()}function $cfr(){return $tbr(),$("#contentframe").empty()}function jObj(t,e){let n={};if("{"===(t||"").substr(0,1))try{n=JSON.parse(t)}catch(t){n={}}return n[e]||""}function string(t,e){var n,r=t||"";return $.each(e||[],(function(t,e){n=new RegExp("\\{"+t.toString()+"\\}","ig"),r=r.replace(n,e)})),r}function init_tooltip(t){var e=!0===("boolean"==typeof t&&t)&&"mouse";$("[title]").qtip({position:{target:e,adjust:{x:2,y:2},viewport:!0},events:{hidden:$ocms.tooltip_hidden},effect:!1}),$("div.tooltiptext").each((function(){$(this).filter(":not(:empty)").parent().qtip({suppress:!1,content:{text:$(this).clone()},position:{target:e,adjust:{x:2,y:2},viewport:!0},events:{hidden:$ocms.tooltip_hidden}})}))}String.prototype.trim=function(){return this.replace(/^\s+|\s+$/g,"")},String.prototype.left=function(t){if("string"===$.type(t)){var e=this.indexOf(t);return e>0?this.slice(0,e):""}return this.substring(0,t)},String.prototype.right=function(t){if("string"===$.type(t)){var e=this.indexOf(t);return e>0?this.substring(this.length-e):""}return this.substring(this.length-t)},Array.prototype.move=function(t,e){if(e>=this.length)for(var n=e-this.length;1+n--;)this.push(void 0);return this.splice(e,0,this.splice(t,1)[0]),this},function(t){t.fn.appendToIf=function(e,n){var r=t(this),i="function"==typeof n?n(r):n;return!0===("boolean"!=typeof i||i)&&r.appendTo(e),r},t.fn.appendIf=function(e,n){var r=t(this),i="function"==typeof n?n(r):n;return!0===("boolean"!=typeof i||i)&&r.append(e),r},t.fn.rwText=function(e,n,r){var i=t(this).empty();r=t.extend({wrap:!0},r);var o=!0===Array.isArray(e)?e:(null==e?"":String(e)).split("\n");return t.each(o,(function(t,e){""!==(e||"")&&(t>0&&i.append($$.br()),i.append(!0===r.wrap?$$.s(e):e))})),n&&i.attr("title",n),i},t.fn.loadSel=function(e,n,r){if("SELECT"===t(this).prop("tagName").toUpperCase()){var i=t(this);$ocms.postXT.call(this,{url:e,data:n||{},success:function(e){t.each(e,(function(){i.append($$.opt(e.value,e.text))}))},complete:function(){i.ldng(0),"function"==typeof r&&r.call(i)}})}},t.fn.emptyWithEditors=function(e){var n=t(this);return n.find(":input.tinymce").each((function(e,n){try{var r=tinymce.get(t(n).attr("id"));r&&r.remove()}catch(e){t.noop()}})),n.empty()},t.fn.cssValue=function(t){if(this.length>0){var e=this.css(t)||"";if(""===e)return 0;var n=/(^[\d\.]*)(\D{1,3}$)/gi.exec(e);return null!==n?"rem"===n[2]?$ocms.rpx(parseFloat(n[1])):parseFloat(n[1]):!1===isNaN(e)?parseFloat(e):0}return 0},t.fn.veryInnerHeight=function(){let e=e=>t(this).cssValue(e);return t(this).innerHeight()-e("padding-top")-e("padding-bottom")},t.fn.veryInnerWidth=function(){let e=e=>t(this).cssValue(e);return t(this).innerWidth()-e("padding-left")-e("padding-right")},t.fn.marginWidth=function(){let e=e=>t(this).cssValue(e);return e("margin-left")+e("margin-right")},t.fn.marginHeight=function(){let e=e=>t(this).cssValue(e);return e("margin-top")+e("margin-bottom")},t.inArrayRegEx=function(e,n,r){var i="regexp"===t.type(e)?e:new RegExp(e);if(!n)return-1;for(var o=r=r||0;o7){r=e.split(","),i=(n||(t<0?"rgb(0,0,0)":"rgb(255,255,255)")).split(",");var l=s(r[0].slice(4)),c=s(r[1]),d=s(r[2]);return"rgb("+(a((s(i[0].slice(4))-l)*o)+l)+","+(a((s(i[1])-c)*o)+c)+","+(a((s(i[2])-d)*o)+d)+")"}var u=(r=s(e.slice(1),16))>>16,p=r>>8&255,f=255&r;return"#"+(16777216+65536*(a((((i=s((n||(t<0?"#000000":"#FFFFFF")).slice(1),16))>>16)-u)*o)+u)+256*(a(((i>>8&255)-p)*o)+p)+(a(((255&i)-f)*o)+f)).toString(16).slice(1)},t.fn.IN=function(e){return t(this).fadeIn(400,e),t(this)},t.fn.OUT=function(e){return t(this).fadeOut(400,e),t(this)},t.fn.tooltip=function(e,n){var r=!0===("boolean"==typeof e&&e)&&"mouse",i="boolean"==typeof n&&n,o=t(this);return o.each((function(){var e=i?t(this).find(".tooltiptext"):t(this).children(".tooltiptext");t(e).length>0?e.each((function(){var e=t(this);t(this).filter(":not(:empty)").parent().qtip({suppress:!1,content:{text:e.clone()},position:{target:r,adjust:{x:2,y:2},viewport:!0},events:{hidden:$ocms.tooltip_hidden},show:{effect:!1},hide:{effect:!1}}),e.remove()})):t(this).qtip({position:{target:r,adjust:{x:2,y:2},viewport:!0},events:{hidden:$ocms.tooltip_hidden},effect:!1})})),o},t.fn.rC=function(e){return t(this).removeClass(e)},t.fn.aC=function(e){return t(this).addClass(e)},t.fn.tC=function(e,n){return t(this).toggleClass(e,n)}}(jQuery),function(t){t.fn.ocmsmenu=function(e,n){var r=t(this);return $ocms.menu.call(r,e,n),r},t.fn.activatemenu=function(){var e=t(this).filter("nav");return e.find("a").not(".on").addClass("on").click($nuf),e.find(".nav-btn").not(".on").addClass("on").click((function(e){e.stopPropagation();var n=t(this);t(n.attr("data-target")).tC(n.attr("data-toggle"))})),e}}(jQuery);class ObjectArray extends Array{isEmpty(){return 0===this[0].length}static get[Symbol.species](){return Array}filter(t){return"function"==typeof t?new ObjectArray(this[0].filter(t)):this}remove(t){if("function"!=typeof t)return this;{let e=this[0].findIndex(t);for(;e>-1;)this[0].splice(e),e=this[0].findIndex(t)}}sortBy(t){return"function"==typeof t&&this[0].sort(t),this}sortString(t){return this[0].sort(((e,n)=>{let r=(e[t]||"").toString().toUpperCase(),i=(n[t]||"").toString().toUpperCase();return console.debug(r.localeCompare(i)),r.localeCompare(i)})),this}sortNum(t){return this[0].sort(((e,n)=>{let r=e[t],i=n[t];return!0===isNaN(i)&&!1===isNaN(r)||ri?1:0})),this}sum(t){return this[0].reduce(((e,n)=>e+(!0===isNaN(n[t])?0:n[t])),0)}groupBy(t){return this[0].reduce((function(e,n){let r=n[t];return e[r]||(e[r]=[]),e[r].push(n),e}),{})}each(t){if("function"==typeof t){let e=!1;this[0].forEach(((n,r,i)=>{if(!1===e){let o=t(n,r,i);"boolean"==typeof o&&!1===o&&(e=!0)}}))}}get toArray(){return this[0]}}class NumArray extends Array{sum(){return this.reduce(((t,e)=>t+e))}first(){return this[0]}last(){return this[this.length-1]}average(){return this.sum()/this.length}range(){let t=this.map((t=>t)).sort();return{min:t[0],max:t[this.length-1]}}static get[Symbol.species](){return Array}}$ocms.ocmsmenu=[{lbl:"",id:"m_home",ico:"glyphicon glyphicon-home",fnc:"init:home"},{fnc:"separator"}],function(t){t.multline=function(t){let e=t.split("\n"),n=$$.d();return $.each(e,((t,e)=>{n.append($$.s(e))})),n.html()},t.tooltip_hidden=function(t,e){$(this).remove(),e.rendered=!1},t.isJSONDateString=function(t){return"string"==typeof t&&/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/.test(t)},t.failure=function(e){11110===(e.internalCode||-1)?t.login.dlg():alert($t.f1+"\n"+(e.internalText||""))},t.getScript=function(e,n){var r=[],i=[],o=function(t){return"string"==typeof t&&""!==(t||"")},a=function(t,e){!0===bool(e.condition,!0)&&(""!==(e.script||"")&&i.push({url:e.script,module:e.module||""}),!0===o(e.css||"")?r.push(e.css):!0===Array.isArray(e.css)&&Array.prototype.push.apply(r,e.css.filter(o)))};!0===o(e||"")?i.push(e):!0===Array.isArray(e)?$.each(e,a):"object"==typeof e&&""!==(e.script||"")&&a(0,e);let s=[];$.each(r,(function(t,e){""!==(e||"")&&s.push(loadCSS(e))}));let l=i.map((function(e,n){let r=e.url,o=e.module||"";if(""===o){return new Promise((function(t,e){try{!async function(){$.ajax({url:r,dataType:"script",success:function(){t(i)},error:function(){e(i)},timeout:3e4})}()}catch(t){console.debug(t.message+"%o",t)}}))}return t.loadmodule(o,r,e.alias)}));Promise.all(l).then(n)},t.loadmodule=function(e,n,r){return new Promise((function(i,o){!async function(){try{let a=(n.startsWith("/")||n.startsWith(".")?"":"/")+n;import(a).then((n=>{t[e]=n[r||"default"],i(e)})).catch((t=>{console.debug(t.message+"%o",t),o(e)}))}catch(t){console.debug(t.message+"%o",t)}}()}))},t.ocms_auth=function(e,n,r,i){!1===$.isPlainObject(t.auth.modules)&&(t.auth.modules={});var o=0;t.auth.modules[e+(r||"")]?((o=t.auth.modules[e+(r||"")])<2&&(r||"")===auth.guid&&(o=2),o>=(n||0)&&i(false)):t.postXT({url:t.url("auth"),data:{module:e,person_guid:r||""},success:function(a){o=a[e],t.auth.modules[e+(r||"")]=o,o<2&&(r||"")===t.auth.person_guid&&(o=2),o>=(n||0)&&i(false)},error:function(e){t.failure.call(this,e)}})},t.auth.locale="de",t.ocms_prepauth=function(e,n,r){t.postXT({url:t.url("auth"),data:{fn:"csv",modules:e,person_guid:n||""},success:function(e){t.ocms_regauth(e)},error:function(e){t.failure.call(this,e)},complete:function(){r()}})},t.ocms_regauth=function(t){$.each(t||{},(function(t,e){auth.modules[t]=parseInt(e)}))},t.init=function(e){var n="string"==typeof e?e:(e.data||{}).fn||"";""!==n&&("home"===n?($cfr(),$lfr(),$("#topbar").ocmsmenu([],!0),$("#activemodule").text($t.ov),t.ov.call($("#contentframe"))):($cfr(),$lfr(),$("#topbar").ocmsmenu([]),t.postXT({url:t.url(n+"/auth"),success:function(e){void 0===t[n]&&(t[n]={}),t[n].auth=e,e.manage>0&&t.getScript({module:n,script:["web/imdl",n,t.auth.locale||"de","js"].join("."),css:["web/imdl",n,"css"].join("."),condition:"function"!=typeof t[n].init2},(function(){t[n].init2()}))},error:function(){$("#contentframe").empty()}})))},t.menuarray=function(t){this.array=[],this.sep=function(){this.length>0&&"separator"!==this.array[array.length-1].fnc&&this.push({fnc:"separator"})},this.push=function(t){return void 0===t?null:(!0===Array.isArray(t)?Array.prototype.push.apply(this.array,t):"object"==typeof t&&this.array.push(t),t)},this.unshift=function(t){return void 0===t?null:(!0===Array.isArray(t)?Array.prototype.unshift.apply(this.array,t):"object"==typeof t&&this.array.unshift(t),t)},this.push(t)},t.menu=function(e,n){e=e||[];var r=$(this).removeClass("vis");if(!0===bool(n,!0)&&!1===r.is("#mainmenu")&&r.empty(),!1===bool(n,!1)&&r.is("#sidebar,#topbar")&&(e.unshift({id:"sbctrl",glyph:"glyphicon-th-list",aclass:"fbtn",fnc:function(){$lf()}}),$lf(0)),0===(e||[]).length)r.empty().addClass("hd");else{r.removeClass("hd");var i=!0===r.is("nav")?r:r.children("nav");1!==i.length&&(i=$("").tC("nv",r.is("#sidebar")).tC("ctxt",r.is("#topbar")).appendTo(r));var o,a=$$.ul().appendTo(i),s=function(t,e){var n=$(this).addClass("dropdown submenu");t.append($$.sc("caret dd")).addClass("dds dropdown-toggle").attr({"aria-expanded":"false"}),""!==(e.ico||"")&&t.prepend($$.sc("ico "+e.ico));var r=$$.ul({class:"dropdown-menu",role:"menu"}).appendTo(n);$.each(e.itm||[],(function(t,e){o.call(r,e)}))},l=function(t){$(this).tC("disabled","boolean"==typeof t.disabled?t.disabled:"string"==typeof t.disabled&&"subs"===t.disabled&&0===(t.itm||[]).length)};o=function(e){var n,r=$$.li({id:e.id}).attr(e.attr||{}).addClass(e.lclass).appendTo($(this)),i="string"==typeof e.fnc&&""!==e.fnc?e.fnc.split(":")[0]:"";""!==i&&"init"!==i?r.attr("role",i).appendIf($$.s(e.lbl),""!==ne(e.lbl)):(n=$$.a({class:"on",role:"button"}).addClass(e.aclass).appendTo(r).append($$.s(e.lbl)),l.call(n,e),(e.itm||[]).length>0&&s.call(r,n,e),n.click($nuf),"function"==typeof e.fnc?n.click(e.data||{},e.fnc):"init"===i&&n.click($.extend({},e.data||{},{fn:e.fnc.split(":")[1]}),t.init))},$.each(e,(function(e,n){var r,i=$$.li({id:n.id}).attr(n.attr||{}).addClass(n.lclass),s="string"==typeof n.fnc&&""!==n.fnc?n.fnc.split(":")[0]:"";if(""!==s&&"init"!==s)i.attr("role",s).appendIf($$.s(n.lbl),""!==ne(n.lbl));else{if(r=$$.a({class:"on",role:"button"}).addClass(n.aclass).appendTo(i),l.call(r,n),""!==(n.lbl||"")&&r.append($$.s(n.lbl)),""!==(n.ico||"")&&r.prepend($$.sc("ico "+n.ico)),""!==(n.glyph||"")&&r.prepend($$.sc("glyphicon "+n.glyph)),(n.itm||[]).length>0){i.addClass("dropdown"),r.append($$.sc("caret dd")).addClass("dds dropdown-toggle").attr({"aria-expanded":"false"});var c=$$.ul({class:"dropdown-menu",role:"menu"}).appendTo(i);$.each(n.itm||[],(function(t,e){o.call(c,e)}))}(n.sel||[]).length>0||(r.click($nuf),"function"==typeof n.fnc?r.click(n.data||{},n.fnc):"init"===s&&r.click($.extend({},n.data||{},{fn:n.fnc.split(":")[1]}),t.init))}i.appendTo(a)})),i.activatemenu()}},t.easytbl=(t,e)=>{e=e||{};let n=$$.tbl().addClass(e.class).css("border-collapse","collapse"),r=($$.tbody(n),!0===bool(e.frame,!1)?{padding:"5px",border:"1px solid #727272"}:{});if(!0===Array.isArray(e.header)){let t=$$.thead(n);$.each(e.header,((n,i)=>$$.th(t).css(e.cellcss||r).rwText(i)))}else if(!0===bool(e.header,!1)&&(t||[]).length>0){let i=$$.thead(n);$.each(Object.keys(t[0]),((t,n)=>$$.th(i).css(e.cellcss||r).rwText(n)))}return $.each(t||[],((t,i)=>{let o=$$.tr();$.each(i,((t,n)=>{n=n||"";let i=$$.td(o).css(e.cellcss||r);n instanceof jQuery?i.append(n):"string"==typeof n&&("<"===n.substring(0,1)?i.append(n):i.text(n))})),n.append(o)})),n},t.dlgtbl=(e,n,r)=>{r=r||{};let i=t.easytbl(e,r);t.dlg(i,$.extend({title:n},r))},t.dlg=function(t,n){n=n||{};let r=$("body > .modal").length>0,i=t=>typeof n[t],o=t=>"function"===i(t);if(!0===bool(n.exclusive,!0)&&!0===r)return void alert($t.dbldlg||"Es ist bereits ein Dialog geöffnet");let a=$$.dc("modal",$("body")),s=$$.dc("modal-dialog",a);!1===isNaN(n.zindex)?a.css("zIndex",n.zindex):!0===r&&a.css("zIndex",parseInt($("body > .modal:last").cssValue("zIndex"))+200),!1===isNaN(n.zindex_min)&&a.cssValue("zIndex")').appendTo(u)),""!==ne(n.title)&&(l=$$.dc("modal-header",u),$("

      ").text(n.title).appendTo(l));let f=$$.dc("modal-body",u),m=$$.dc("modal-footer",u);t instanceof jQuery==!0&&f.append(t);let h=function(t){t&&"function"==typeof t.stopPropagation&&t.stopPropagation(),s.removeClass("in"),!0===o("closing")&&n.closing.call(u),f.hide().emptyWithEditors(),a.remove(),!0===o("close")&&n.close.call(u)};if(u.find(":input[required]").length>0&&($$.dc("note_required",m).append($$.sc("ind_required","*")).append($$.s($t.t1||"Eingabe erforderlich")),$$.dc("note_invalid",m).append($$.s($t.t2||"Bitte überprüfen Sie Ihre Eingaben im Formular."))),!0===o("cancel")){$$.bbtn(n.cancelbutton||"Abbrechen","cancel").attr({type:"button",role:"cancel"}).appendTo(m).click((function(t){n.cancel.call(u,t);t.stopPropagation(),h()}))}if(!0===o("confirm")){let t=$$.bbtn(n.button||"OK","confirm").attr({type:!0===bool(n.form,!1)?"submit":"button",role:"confirm"}).appendTo(m);!0===p?(u.submit((function(t){try{n.confirm.call(u,t)}finally{t.preventDefault()}return!1})),u.on("modal_submit",(function(){n.confirm.call(u,e)}))):(t.click((function(t){n.confirm.call(u,t);t.stopPropagation()})),u.on("modal_submit",(function(){t.click()})))}else!0===p&&u.submit((function(t){return t.preventDefault(),!1}));return u.on("modal_close",(function(){h()})),c.click(h),!0===o("opening")&&n.opening.call(u),s.addClass("in"),ne(n.mode).indexOf("maxbody")>-1&&f.css("min-height",(d.height()-l.outerHeight()-m.outerHeight()).toString()+"px"),!0===o("open")&&n.open.call(u),{hd:l,bdy:f,ft:m,ct:d,dlg:s,c:u}},t.mform=function(e){let n=$$.dc("form-body"),r=Array.isArray(e)?e:e instanceof fields_definition?e.fields:[];return $.each(r||[],(function(e,r){let i=r.type||"";if("ignore"===i)return!0;let o=$$.dc("form-group",n),a=r.id||"dlg_"+(r.name||"")+("html"===r.type?"_"+(65536*(1+Math.random())||0).toString(16).substr(9):""),s=$$.lbl(r.label||r.name,{for:a}).appendTo($$.dc("form-itm",o)),l=$$.dc("form-itm",o),c=$$.i({id:a,name:r.name,placeholder:r.placeholder,type:r.type});switch(i){case"email":r.pattern=ne(r.pattern,"[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$");break;case"url":r.pattern=ne(r.pattern,"https?://.+");break;case"number":r.pattern=ne(r.pattern,"[-+]?[0-9]*[.,]?[0-9]*"),c.attr("step",r.precision||"any"),c.attr("data-format","float");break;case"integer":case"int":r.pattern=ne(r.pattern,"[-+]?[0-9]*"),c.attr("type","number"),c.attr("data-format","integer");break;case"date":if(""!==ne(r.pattern,$t.datepattern)&&(r.pattern=ne(r.pattern,"("+$t.datepattern+")|([0-9]{4}.(0[1-9]|1[012]).(0[1-9]|1[0-9]|2[0-9]|3[01]))")),""!==ne(r.placeholder,$t.dateplaceholder)&&c.attr("placeholder",ne(r.placeholder,$t.dateplaceholder)),"string"==typeof r.value){var d=r.value.substr(0,10);r.value="date"!==c.prop("type")?fdt(d+"T00:00:00",ne(r.dateformat,$t.dateformat)):d}c.attr("data-format","date:"+ne(r.dateformat,$t.dateformat)+";yyyy-MM-dd");break;case"datetime":c.attr("type","datetime-local"),""!==ne(r.pattern,$t.datetimepattern)&&(r.pattern=ne(r.pattern,"("+$t.datetimepattern+")|([0-9]{4}-(0[1-9]|1[012])-(0[1-9]|1[0-9]|2[0-9]|3[01])\\s([0-5][0-9]):([0-5][0-9]))")),""!==ne(r.placeholder,$t.datetimeplaceholder)&&c.attr("placeholder",ne(r.placeholder,$t.datetimeplaceholder)),"string"==typeof r.value&&"T"===r.value.substr(10,1)&&(r.value="datetime"!==c.prop("type").substr(0,8)?fdt(r.value,ne(r.datetimeformat,$t.datetimeformat)):r.value),c.attr("data-format","datetime:"+ne(r.datetimeformat,$t.datetimeformat)+";yyyy-MM-dd HH:mm:ss");break;case"hidden":o.addClass("hd");break;case"html":case"text":c=$$.txt({id:a,name:r.name,placeholder:r.placeholder,type:r.type}),c.tC("tinymce","html"===r.type);break;case"bool":case"boolean":r.url=[{value:"true",label:($t||{}).true||"Yes"},{value:"false",label:($t||{}).false||"No"}],"boolean"==typeof r.value&&(r.value=r.value?"true":"false");case"select":c=$$.sel({id:a,name:r.name,type:r.type}),!1===bool(r.required,!1)&&$$.eOpt().appendTo(c);try{var u=function(t){!0===Array.isArray(t)&&$.each(t,(function(t,e){"string"==typeof e?$$.opt(e,e).appendTo(c):!0===Array.isArray(e)?$$.opt(e[0],e[1]).appendTo(c):"object"==typeof e&&$$.opt(e.value,e.label||e.text).appendTo(c)}))};!0===Array.isArray(r.url)?u(r.url):"function"==typeof r.url?r.url.call(c):"string"==typeof r.url&&t.postXT({url:r.url,success:u})}catch(t){$.noop()}break;default:""!==ne(r["max-length"])&&c.attr("max-length",r["max-length"])}""!==ne(r.pattern)&&c.attr("pattern",r.pattern),c.val(r.value).change(),c.change((function(){$(this)[0].setCustomValidity("")})),c.addClass("form-control").prop("required",bool(r.required,!1)).prop("readonly",bool(r.readonly,!1)).appendTo(l),!0===bool(r.required,!1)&&s.append($$.sc("ind_required","*")),"object"==typeof r.attr&&c.attr(r.attr),"object"==typeof r.prop&&c.prop(r.prop),"string"==typeof r.class&&c.addClass(r.class),"function"==typeof r.change&&(c.change(r.change),!0===bool(r.applychange,!1)&&void 0!==r.value&&c.change()),""!==(r.note||"")&&$$.dc("form-note",l).rwText(r.note),"function"==typeof r.complete&&r.complete.call(c)})),n},t.initMCE=function(t,e){t=$(t),e=e||{};try{let n={target:t[0],inline:!1,width:e.width||"100%",statusbar:!1,document_base_url:window.location.origin+"/",content_style:"ph:before {content: '«'; color: #BBB; font-style:italic; } ph:after {content: '»'; color: #BBB; font-style:italic; } ph { color: #AAA; font-style:italic; }",relative_urls:!1,remove_script_host:!1};!0===bool(e.hidemenu,!1)&&(n.menubar=!1,n.menu={}),!0===bool(e.hidetoolbar,!1)&&(n.toolbar=!1),$.extend(n,e||{}),tinymce.init(n)}catch(t){alert(t.message)}},t.dlgform=function(e,n){n=n||{};let r,i=$$.dc("frm").append(t.mform(e||[]).addClass("stacked"));n.addcontent instanceof jQuery&&i.append(n.addcontent),"function"==typeof n.submit?r=n.submit:"function"==typeof n.success&&(r=function(e){var r=$(this).ldng(1),i=$.extend({loginaccount:t.auth.account||""},r.serializeObject(bool(n.checkvalidity,!0),{typedvalues:bool(n.typedvalues,!1)}));""!==(n.url||"")?t.postXT({url:n.url,data:i,success:function(t){n.success.call(this,t),r.trigger("modal_close")},error:function(){alert($t.l17)},complete:function(){r.ldng(0)},timeout:6e4}):(n.success.call(this,i),r.trigger("modal_close"))});let o={form:!0,title:n.title||"",button:n.button||$t.submit,confirm:r,size:n.size||[500,600],open:function(){let e=$(this).find(".tinymce");e.length>0&&t.initMCE(e,n.tinymce||{})}};return t.dlg.call(this,i,o)},t.login.dlg=function(e){e=e||{};let n=[{name:"userinfo",label:$t.l1,type:"string",value:t.auth.login,change:t.login.uichange,required:!0},{name:"userlogin",type:"hidden",required:!0,value:t.auth.login},{name:"username",type:"string",label:$t.l4,required:!0,readonly:!0,placeholder:$t.l5,value:t.auth.fullname_rev},{name:"userpass",type:"password",label:$t.l3,required:!0,placeholder:$t.l3}];""===(t.auth.account||"")&&n.unshift({id:"dlg_loginaccount",name:"loginaccount",type:"string",required:!0,value:t.auth.account});let r=$$.dc("frm").append(t.mform(n).addClass("stacked")),i=t.dlg.call(this,r,{form:!0,title:$t.l0,button:$t.submit,confirm:function(n){var r=$(this).ldng(1),i=$.extend({loginaccount:t.auth.account||""},r.serializeObject());t.postXT({url:"/vt/login",data:i,success:function(n){""!==((n||{}).login||"")&&(r.trigger("modal_close"),t.auth=n,"object"==typeof e.ajo&&(e.ajo.islogin,$.ajax(e.ajo)))},error:function(){alert($t.l17)},complete:function(){r.ldng(0)},timeout:6e4})},size:[500,600]}),o=$$.dc("modal-content").css("height","auto").attr("novalidate","true").append($$.dc("modal-header").appendIf($("

      ").text(t.auth.accountname),""!==(t.auth.accountname||"")).append($("

      Vereinsmanager

      ")));i.dlg.prepend(o)},t.addNoEntryInfo=function(t){$(this).append($$.dc("noentryinfo").text(t||$t.t11))}}($ocms),Element.prototype.matches||(Element.prototype.matches=Element.prototype.msMatchesSelector||Element.prototype.webkitMatchesSelector),Element.prototype.closest||(Element.prototype.closest=function(t){var e=this;do{if(Element.prototype.matches.call(e,t))return e;e=e.parentElement||e.parentNode}while(null!==e&&1===e.nodeType);return null}),function(t,e){var n,r;"object"==typeof window&&(window[t]=(n=function(t){var e=window,n=document.body,r=document.documentElement,i=Math.max(0,e.pageXOffset||r.scrollLeft||n.scrollLeft||0)-(r.clientLeft||0),o=Math.max(0,e.pageYOffset||r.scrollTop||n.scrollTop||0)-(r.clientTop||0);return{x:t?Math.max(0,t.pageX||t.clientX||0)-i:0,y:t?Math.max(0,t.pageY||t.clientY||0)-o:0}},(r=function(t,e){t&&t instanceof Element&&(this._container=t,this._options=e||{},this._clickItem=null,this._dragItem=null,this._showDragItem="boolean"!=typeof this._options.dragItem||!1!==this._options.dragItem,this._hovItem=null,this._sortLists=[],this._click={},this._dragging=!1,this._dragHandleClass=this._options.dragHandleClass||"",this._parentident=this._options.parentident||"",this._swapdone="function"==typeof this._options.swapdone?this._options._swapdone:null,this._container.setAttribute("data-is-sortable",1),this._container.classList.add("sortable"),this._container.style.position="static",window.addEventListener("mousedown",this._onPress.bind(this),!0),window.addEventListener("touchstart",this._onPress.bind(this),!0),window.addEventListener("mouseup",this._onRelease.bind(this),!0),window.addEventListener("touchend",this._onRelease.bind(this),!0),window.addEventListener("mousemove",this._onMove.bind(this),!0),window.addEventListener("touchmove",this._onMove.bind(this),!0))}).prototype={constructor:r,toArray:function(t){t=t||"id";for(var e=[],n="",r=0;rr.left&&er.top&&n-1)&&e.className.indexOf("nosort")<0)&&(t.preventDefault(),this._dragging=!0,this._click=n(t),this._makeDragItem(e),this._onMove(t),!0)}t&&!1===e.call(this,t.target)&&""!==this._parentident&&t.target.closest(this._parentident)&&e.call(this,t.target.closest(this._parentident))},_onRelease:function(t){this._dragging=!1,this._trashDragItem()},_onMove:function(t){if(this._dragItem&&this._dragging){t.preventDefault();var e=n(t),r=this._container;!0===this._showDragItem&&this._moveItem(this._dragItem,e.x-this._click.x,e.y-this._click.y);for(var i=0;i0?s.mousedown(l).addClass("dctrl"):a.mousedown(l).addClass("dctrl"),t(this)}}(jQuery),$(document).ready((function(){$("html").click((function(t){$nuf()})),$("#listframe").click((function(t){t.stopPropagation(),$nuf()})),$("#mainmenu").ocmsmenu($ocms.ocmsmenu),$("#mainmenu").activatemenu()})),$.extend($t,{m_inv:"Rechnungen",m_req:"Aufträge",m_rep:"Berichte",m_todo:"ToDos",m_bcd:"BankBuchungen",rsp:"Passwort ändern",pnm:"Die Passwörter stimmen nicht überein",cps:"Das neue Passwort wurde gespeichert.",pwr:"Bitte wählen Sie ein starkes Passwort (min 8 Zeichen, davon jeweils min 2 Zahlen, kleine und große Buchstaben, Sonderzeichen sind optional).",smsc:"Sie beötigen für diese Funktion einen SMS-Code.\nSoll dieser nun versandt werden?",wdc:"Doppelt klicken, um die Box zu aktualisieren.",wdg:{}}),$t.rspf={sms:"Der SMS-Code konnte nicht bestätigt werden",valid:"Das alte Passwort ist nicht korrekt",requirements:"Das Passwort entspricht nicht den Anforderungen.\n"+$t.pwr},$fd={rsp:new fields_definition("","",[{name:"opw",label:"aktuelles Passwort",type:"password",required:!0,attr:{"auto-complete":"current-password"}},{name:"npw",label:"neues Passwort",type:"password",required:!0,pattern:"(.{6,})",attr:{"auto-complete":"new-password"}},{name:"npwc",label:"neues Passwort (Bestätigung)",type:"password",required:!0,attr:{"auto-complete":"new-password"},note:$t.pwr},{name:"code",label:"SMS-Code",type:"string",required:!0,attr:{"auto-complete":"one-time-code"}}])},$ocms.init=function(t){var e="string"==typeof t?t:(t.data||{}).fn||"";""!==e&&("home"===e?($cfr(),$lfr(),$("#topbar").ocmsmenu([],!0),$("#activemodule").text($t.ov),$fis.ov()):($cfr(),$lfr(),$("#topbar").ocmsmenu([]),$ocms.postXT({url:$ocms.url(e+"/auth"),success:function(t){void 0===$ocms[e]&&($ocms[e]={}),$ocms[e].auth=t,t.manage>0&&$ocms.getScript({module:e,script:["/web/fis",e,$ocms.auth.locale||"de","js"].join("."),css:["/web/fis",e,"css"].join("."),condition:"function"!=typeof $ocms[e].init2},(function(){$ocms[e].init2()}))},error:function(){$("#contentframe").empty()}})))};var $fis={auth:{},db:function(){$("#mainmenu_activemodule").text($t.ov);let t=$(this).empty(),e=$$.d({id:"dashboard_frame"}).appendTo(t);$ocms.postXT({url:$ocms.url("wdg/my"),success:function(t){$.each(t,(function(t,n){var r=$$.dc("wdg_frame",e,{"data-wdg":n}).ldng(1);$ocms.wdg.call(r,{wdg:n})}))},loading:e})},ValidateEmail:function(t){return!!/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/.test(t)},cf:t=>{let e=$("#contentframe");return!0===bool(t,!1)&&e.empty().rC("hd"),e},lf:t=>{let e=$("#listframe");return!0===bool(t,!1)&&e.empty().aC("hd").rC("fix"),e},frm_edit:function(t){let e=$fis.cf(!1),n=e.children(".cfrm"),r=e.children(".edit_frm");return n.length<1?n=$$.dc("cfrm hd").prependTo(e):!0===bool(t,!1)&&n.empty(),r.length<1&&(r=$$.dc("edit_frm").insertAfter(n)),r.empty()},frm_list:function(t,e){let n=$fis.cf(!1),r=n.children(".cfrm"),i=n.children(".list_frm");return r.length<1?r=$$.dc("cfrm hd").prependTo(n):!0===bool(t,!1)&&r.empty(),!0===bool(e,!1)&&n.children(".edit_frm").remove(),i.length<1&&(i=$$.dc("list_frm").appendTo(n)),i.empty()},lfm:()=>{let t=$fis.lf(!1),e=t.children(".lfrm");return e.length<1&&(e=$$.dc("lfrm").prependTo(t)),e},getAuth:(t,e)=>new Promise(((n,r)=>{$fis.auth[t]&&!1===bool(e,!1)?n($fis.auth[t]||-1):$ocms.postXT({url:$ocms.url("auth"),data:{module:t},success:e=>{$fis.auth[t]=e.auth||-1,n($fis.auth[t]||-1)},error:()=>{r()}})})),prepAuth:t=>new Promise(((e,n)=>{$ocms.postXT({url:$ocms.url("auth"),data:{module:t,array:1},success:t=>{$.extend($fis.auth,t||{})},complete:()=>{e()}})})),isAuth:(t,e)=>($fis.auth[t]||-1)>=(e||1),resetPass:function(t,e){confirm($t.smsc)&&($ocms.postXT({url:$ocms.url("account/sms"),data:{fn:"pwc"}}),$ocms.dlgform($fd.rsp.clone(),{title:$t.rsp||"",submit:function(t){var e=$(this).ldng(1),n=$.extend({loginaccount:$ocms.auth.account||""},e.serializeObject(!0,{typedvalues:!0}));(n.npw||"")!==(n.npwc||"")?e.find('input[name="npw"]:first')[0].setCustomValidity($t.pnm):$ocms.postXT({url:$ocms.url("account/changepassword"),data:n,success:function(t){alert($t.cps),e.trigger("modal_close")},error:function(t){alert($t.rspf[t.getResponseHeader("x-ocms-std")])},complete:function(){e.ldng(0)},timeout:6e4})}}))},wdg:function(t){let e=$(this).empty();$ocms.postXT({url:$ocms.url("wdg/one"),data:{short_name:t.wdg},timeout:9e4,success:function(n,r,i){let o=t.wdg,a=n[o];if(!a)return void e.ldng(0);let s=$.inArrayRegEx("dblwidth",a.rendering_options)>-1,l=$.inArrayRegEx("tiny",a.rendering_options)>-1;e.toggleClass("dbl",s&&!l).toggleClass("tny",l);$$.dc("wdg_hd",e,{title:ne(a.description,$t.wdc)}).toggleClass("dbl",s).text(ne(a.name,t.wdg)).dblclick((function(t){t.stopPropagation(),$fis.wdg.call(e,{wdg:o})}));let c=$$.dc("wdg_cnt",e).toggleClass("dbl",s).hide(),d=$.inArrayRegEx("bgcolor",a.rendering_options);switch(d>-1&&c.css("backgroundColor",a.rendering_options[d].toString().right(":")),a.type){case"table":var u=$$.tblset({},c),p=$$.tr().appendTo(u.hd),f=$t.wdg[o.indexOf("wdg_ev_")>=0?"wdg_ev_":o]||{};$.each(a.columns,(function(t,e){var n=f[e]?f[e].label:e;$$.th().text(n).appendTo(p)})),$.each(a.data,(function(t,e){var n=$$.tr().appendTo(u.bdy);$.each(a.columns,(function(t,r){var i=$$.td().appendTo(n);e[r]instanceof Date||!0===$ocms.isJSONDateString(e[r])?i.text(fdt(e[r],$t.dateformat)):i.rwText(e[r])}))})),$.inArray("firstrow_bold",a.rendering_options)>-1&&p.nextAll("tr:first").css("font-weight","bold");break;case"ind":$$.dc("ind",c).addClass("sts_"+(a.data.status||"")).append([$$.dc("ind").text(a.data.value),$$.lbl(a.data.label)]);break;case"image_url":c.css("background","url('"+a.url+"') no-repeat center center transparent");break;case"image_base64":c.css("background","url('data:image/png;base64,"+a.image+"') no-repeat center center transparent");break;case"html":if(c.html(a.html),$.inArray("reload_10min",a.rendering_options)>-1){var m=c.find("iframe");setTimeout((function(){m.attr("src",(function(t,e){return e}))}),6e5)}}$.inArray("reload_30min",a.rendering_options)>-1&&"html"!==a.type&&setTimeout((function(){$fis.wdg.call(e,{wdg:o})}),18e5),c.slideDown(150)},error:function(t){e.slideUp(150),$fis.failure.call(this,t)},complete:function(){e.ldng(0)}})},ov:function(){$fis.lf(!0);let t=$("#contentframe").empty(),e=$$.d({id:"dashboard_frame"}).appendTo(t);$ocms.postXT({url:$ocms.url("wdg/my"),success:function(t){$.each(t,(function(t,n){var r=$$.dc("wdg_frame",e,{"data-wdg":n}).ldng(1);$fis.wdg.call(r,{wdg:n})}))},loading:e})}};Array.prototype.push.apply($ocms.ocmsmenu,[{lbl:$t.m_inv,id:"m_inv",fnc:"init:inv",ico:"glyphicon glyphicon-list-alt"},{lbl:$t.m_req,id:"m_req",fnc:"init:req",ico:"glyphicon glyphicon-eur"},{lbl:$t.m_bcd,id:"m_bcd",fnc:"init:bam",ico:"glyphicon glyphicon-indent-right"},{fnc:"separator"},{lbl:$t.m_rep,id:"m_rep",fnc:"init:rep",ico:"glyphicon glyphicon-dashboard"},{fnc:"separator"},{lbl:$t.m_todo,id:"m_todo",fnc:()=>{$("#contentframe").empty().load($ocms.url("todos")),$("#listframe").rC("fix").aC("hd")},ico:"glyphicon glyphicon-sunglasses"}]),$(document).ready((function(){$fis.ov()})); \ No newline at end of file +function onloadCSS(t,e){e=e||{};let n=function(e){return new Promise(((n,o)=>{t.addEventListener?e.addEventListener("load",newcb):t.attachEvent&&e.attachEvent("onload",newcb),"isApplicationInstalled"in navigator&&"onloadcssdefined"in t&&e.onloadcssdefined(newcb)}))};if(Array.isArray(t)){let o=t.length;Promise.all(t.map(n)).then((function(t){var n=t.reduce(((t,e)=>t+(!0===e?1:0)));!async function(t){!0===t&&"function"==typeof e.success?e.success():!0===t&&"object"==typeof e.success&&e.success instanceof Promise&&await e.success(),e.complete()}(o===n)}))}else n(t)}!function(t){"use strict";var e=function(e,n,o,i){var r,s=t.document,a=s.createElement("link");if(n)r=n;else{var c=(s.body||s.getElementsByTagName("head")[0]).childNodes;r=c[c.length-1]}var l=s.styleSheets;if(i)for(var u in i)i.hasOwnProperty(u)&&a.setAttribute(u,i[u]);a.rel="stylesheet",a.href=e,a.media="only x",function t(e){if(s.body)return e();setTimeout((function(){t(e)}))}((function(){r.parentNode.insertBefore(a,n?r:r.nextSibling)}));var d=function(t){for(var e=a.href,n=l.length;n--;)if(l[n].href===e)return t();setTimeout((function(){d(t)}))};function h(){a.addEventListener&&a.removeEventListener("load",h),a.media=o||"all"}return a.addEventListener&&a.addEventListener("load",h),a.onloadcssdefined=d,d(h),a};"undefined"!=typeof exports?exports.loadCSS=e:t.loadCSS=e}("undefined"!=typeof global?global:this);const isIE=/MSIE\/|Trident/gi.test(window.navigator.userAgent)||void 0!==window.document.documentMode,isfileapi=!!(window.File&&window.FileReader&&window.FileList&&window.Blob);var $ocms={auth:{},no:function(t){t.stopPropagation()},vmin:function(t){var e=$(window).width*(t||1),n=$(window).height*(t||1);return e($ocms.baseurl+"/"+(t||"")).replace(/\/\//,"/"),cexi:null};function deepCopy(t){var e,n,o;if("object"!=typeof t||null===t)return t;for(o in e=Array.isArray(t)?[]:{},t)n=t[o],e[o]=deepCopy(n);return e}function fields_definition(t,e,n){this.label_sng=!0===Array.isArray(t)?"":t||"",this.label_pl=!0===Array.isArray(t)?"":e||"",this.fields=!0===Array.isArray(t)?t:n||[],this.itm=function(t){for(var e=0;e0)for(var n=0;nt||"")).filter(((t,e)=>""!==t)).join(e)}function parseDt(t,e,n){t=(t||"").substr(0,e.length);var o=e,i=t.length>0&&e.split(";").some((function(e){for(var n,i=/[^yMdhms0-9]/gi,r=!0;null!==(n=i.exec(e));)r=r&&e.substr(n.index,1)===t.substr(n.index,1);var s=t.length===e.length&&r;return!0===s&&(o=e),s}));if(!0===i){for(var r,s=[0,0,0,0,0,0,0],a=/(mm{1,2}|dd{1,2}|MM{1,2}|(yy){2,4}|ss{1,2}|hh{1,2})(?!\w)/g;null!==(r=a.exec(o));)s["yMdhms".indexOf(r[0].substr(0,1))]=parseInt(("yy"===r[0]?"20":"")+t.substr(r.index,r[0].length))-("M"===r[0].substr(0,1)?1:0);var c=new(Function.prototype.bind.apply(Date,[null].concat(s)));return"string"==typeof n?fdt(c,n):c}return!1}function bool(t,e){return"boolean"==typeof t?t:"boolean"==typeof e&&e}function booln(t,e){return"boolean"==typeof t?t:"number"==typeof t?1===t:"boolean"==typeof e&&e}Date.prototype.isValid=function(){return!isNaN(this)},Date.prototype.format=function(t){return fdt(this,t)},Date.prototype.addDays=function(t){return this.setDate(this.getDate()+t),this},Date.prototype.isBetween=function(t,e){return this>t&&this section");$(window).scroll((function(e){let n=$(window).scrollTop(),o=$("body");o.toggleClass("unfocus",n>vh()-1.2*t),o.toggleClass("btb",n>.5*vh()-t)}))},$ocms.cf_reset=function(){return $("#contentframe").empty()},function(t){t.fn.scrollTo=function(e){if(t(this).length>0){var n=t(this).offset().top||0;n>0&&t("html, body").animate({scrollTop:n-hh()},2e3)}},t.fn.ldng=function(e){var n=!0;return"boolean"==typeof e?n=e:"number"==typeof e&&(n=e>0),t(this).toggleClass("loading",n)},"function"!=typeof t.noop&&(t.noop=function(){}),t.fn.hasAttr=function(e){var n=t(this).attr(e);return void 0!==n&&!1!==n},t.fn.parseCssPx=function(e){try{return parseFloat(t(this).css(e).replace("px","")||0)}catch(t){return 0}},t.max=function(t,e){return isNaN(t)&&isNaN(e)?null:isNaN(t)&&!isNaN(e)?e:!isNaN(e)&&isNaN(e)||t>=e?t:e},t.min=function(t,e){return isNaN(t)&&isNaN(e)?null:isNaN(t)&&!isNaN(e)?e:!isNaN(e)&&isNaN(e)||t<=e?t:e},t.lim=function(t,e){return isNaN(t)?null:isNaN(e)?t:e<=t?e:t},t.fn.enterKey=function(e){return this.each((function(){t(this).keypress((function(t){"13"===(t.keyCode?t.keyCode:t.which).toString()&&e.call(this,t)}))}))}}(jQuery),$ocms.defaultTimeout=3e4,$ocms.AjaxEX=function(t){var e=this;e.responseText=e.responseText||"";var n=e.getResponseHeader("x-ocms-code")||"";e.internalCode=""!==n&&!1===isNaN(n)?parseInt(n):-1,e.isInternal=e.internalCode>-1,e.internalText=decodeURIComponent((e.getResponseHeader("x-ocms-desc")||"").replace(/\+/g,"%20")||"");var o=e.internalText||t,i=e.internalCode||e.status;e.logtext=o+" ("+i+")"},$ocms.postXTS=function(t){$ocms.postXT.call(this,$.extend(t,{sync:!0}))},$ocms.postXT=function(t){if((t=t||{}).trycount=t.trycount||0,""!==(t.url||"")){t.url=-1!==t.url.indexOf("&yy=")?t.url:t.url.indexOf("?")>-1?t.url+"&yy="+(new Date).getTime():t.url+"?yy="+(new Date).getTime();var e=t.context||this;switch(t.context=e,t.retryLimit=t.retryLimit||0,t.timeout=t.timeout||$ocms.defaultTimeout,t.timeout<100&&(t.timeout=1e3*t.timeout),t.data=t.data||{},t.contentType=t.contentType||"multipart/form-data; charset=UTF-8",t.islogin="boolean"==typeof t.islogin&&t.islogin,t.contentType){case"":case"json":t.contentType="application/json; charset=utf-8";break;case"form":t.contentType="application/x-www-form-urlencoded; charset=UTF-8";break;case"multi":t.contentType="multipart/form-data";break;case"text":t.contentType="text/plain; charset=UTF-8"}if(t.form instanceof jQuery?(t.data=t.form.serializeObject(),t.contentType="form-data"):t.lzw instanceof jQuery&&(t.data.lzw=$.ccLZW(t.lzw.serializeAnything(!0)).join(",")),"multipart/form-data"!==t.contentType.substr(0,19)&&"form-data"!==t.contentType.substr(0,9)||t.data instanceof FormData!=!1)t.data instanceof FormData&&(t.contentType=!1,t.processData=!1);else{t.contentType=!1;var n=new FormData;$.each(t.files||[],(function(t,e){n.append("upload_file",e)})),$.each(t.data||{},(function(t,e){n.append(t,e)})),t.data=n,t.processData=!1}var o={type:t.method||"post",url:t.url,data:t.data,processData:"boolean"!=typeof t.processData||t.processData,contentType:t.contentType,cache:t.cache||!1,timeout:t.timeout,beforeSend:function(n){$(t.loading).ldng(),$("body").addClass("ldng"),"function"==typeof t.beforesend&&t.beforesend.apply(e,[n])},success:function(n,o,i){"false"===n||"not authorized"===n?("function"==typeof t.error&&t.error.apply(e,[i,o,n]),"function"==typeof $.status&&$.status(o+" - "+n)):"function"==typeof t.success&&t.success.apply(e,[n,o,i])},error:function(n,o,i){if($ocms.AjaxEX.call(n,o),-1===t.url.indexOf("doc.ashx")||-1!==t.url.indexOf("ftest")){if(401===n.status&&111===n.internalCode&&!1===t.islogin&&"function"==typeof $ocms.login.dlg)$ocms.login.dlg({ajo:t});else if("timeout"===o||302===n.status)return t.tryCount++,t.tryCount<=t.retryLimit?void $ocms.postXT(t):void 0;"function"==typeof t.error?t.error.apply(e,[n,o,i]):"function"==typeof $ocms.failure?$ocms.failure.apply(e,[n]):"function"==typeof $.status&&$.status("Server error: "+o+" - "+i)}},dataType:t.datatype||"json",complete:function(n,o){"function"==typeof t.complete&&t.complete.apply(e,[n,o]),$(t.loading).ldng(0),$("body").removeClass("ldng");let i=$("body > .timer");if(i.length>0){let t=new Date(n.getResponseHeader("ocms_cec")||""),e=new Date(n.getResponseHeader("ocms_cex")||"");if(t.isValid()&&e.isValid()){let n=new Date,o=Math.abs(e-t);n.setMilliseconds(n.getMilliseconds()+o),i.data({cex:n,ctt:o}),$ocms.cex_timer()}}},context:e,async:!0};"boolean"==typeof t.sync&&(o.async=!1===t.sync),!0==("boolean"==typeof t.contentType&&!1===t.contentType)&&(o.contentType=!1),$.ajax(o)}},$ocms.cex_timer=function(){$ocms.cexi||($ocms.cexi=setInterval($ocms.cex_timer,15e3));let t=$("body > .timer"),e=t.data("cex"),n=t.data("ctt"),o=new Date;if(e instanceof Date&&e.isValid()&&"number"==typeof n&&n>0&&e>o){let i=Math.abs(o-e)/n*100;t.css("width",i.toString()+"%"),i<98&&(!$ocms.cex_lp||Math.abs(o-$ocms.cex_lp)>6e5)&&$ocms.postXT({url:$ocms.url("ping"),success:()=>{$ocms.cex_lp=o},timeout:5e3,error:()=>{}})}},$ocms.vbl_send=function(t){var e=t.data||{};if(""!==(e.url||"")){var n=$("#contentframe form:first"),o={url:e.url,data:new FormData,success:function(t){"function"==typeof e.success?e.success(t):"string"==typeof e.success&&alert(e.success)},error:function(t,n,o){"function"==typeof e.error?e.error(o):"string"==typeof e.error&&alert(e.error)},complete:function(){n.ldng(0)}},i=!0;n.find("input").each((function(){var t=$(this),e=t.nza("name"),n=t.val(),r=$(this).prop("required")||!1;if(""!==e){var s=""!==n||!1===r;i=i&&s,!0===s?(o.data.append(e,n),t[0].setCustomValidity("")):""!==$(this).nza("ocms-nvnote")&&t[0].setCustomValidity($(this).nza("ocms-nvnote"))}})),!0===i&&(n.ldng(1),$ocms.postXT.call(this,o))}},function(t){t.fn.nza=function(e,n){var o=t(this).attr(e);return void 0!==o&&!1!==o?o:n||""},t.fn.serializeObject=function(e,n){var o=/\r?\n/g,i=/^(?:submit|button|image|reset|file)$/i,r=/^(?:input|select|textarea|keygen)/i,s=/^(?:checkbox|radio)$/i,a=bool((n=n||{}).typedvalues,!1),c={},l=t(this),u=l.find(':input:not([nosend],[type="file"])').addBack(":input"),d=!0;return t.each(u.not(".tinymce").get(),(function(n,l){var u=t(this),h=this,p=(this.type||"").toLowerCase(),f=u.prop("required")||!1;if(!0===(h.name&&!u.is(":disabled")&&r.test(h.nodeName)&&!i.test(p))){var m=u.val(),g=h.name,y=u.nza("data-format").split(":"),b=u.nza("pattern")||".*";if(!0===s.test(p)&&(m=h.checked?""!==m?m:"true":""),"date"===y[0].substr(0,4)&&y.length>1)"boolean"==typeof(m=parseDt(m,y.slice(1).join(":")))&&(m=null),null===m&&"date"===u.prop("type").substr(0,4)&&!1===isNaN(new Date(u.val()))&&(m=new Date(u.val())),m instanceof Date==!0&&"function"==typeof m.getMonth?!1===a&&(m=fdt(m,"date"===y[0]?"dts":"iso")):m=null;else if("number"===p&&!0===a){let t;t="integer"===y[0]?parseInt(m):parseFloat(m),m=isNaN(t)?m:t}if(!0!==f||""!==(m||"")&&null!==m.match(b)?!0===bool(e,!1)&&h.setCustomValidity(""):(!0===bool(e,!1)&&h.setCustomValidity(u.nza("ocms-nvnote",$ocms.t.inv||"Invalid field")),m=null),null!=m&&"string"==typeof m){let t=c[g];null!=t?Array.isArray(t)?t.push(m.replace(o,"\r\n")):c[g]=[t,m.replace(o,"\r\n")]:c[g]=m.replace(o,"\r\n")}else if(null!=m){let t=c[g];null!=t?Array.isArray(t)?t.push(m):c[g]=[t,m]:c[g]=m}else d=!1}})),u.filter(".tinymce").each((function(e,n){var o=t(this),i=((this.type||"").toLowerCase(),o.prop("required")||!1);try{var r=tinymce.get(t(n).attr("id"));if(r){var s=t(n).attr("name"),a=r.getContent();!1===i||""!==(a||"")?c[s]=a:d=!1}}catch(e){t.noop()}})),l.toggleClass("invalid",!d),d?c:null},t.fn.sendForm=function(e,n,o){var i=t(this);o=o||{};var r={url:e,success:function(t){if(o.response=t,"function"==typeof n)n(t);i.closest("div.modal").remove()},error:function(t,e,n){"function"==typeof o.error?o.error.call(this,t):$ocms.failure.call(this,t)},complete:function(){i.ldng(0),"function"==typeof o.complete&&o.complete.call(this,jqXHR)}},s=i.find('input[type="file"]');r.data=new FormData,s.length>0&&t.each(s[0].files,(function(t,e){r.data.append(t,e),r.data.append("file_lastmodified",$ocms.isodt(e.lastModifiedDate))}));var a=i.serializeObject();t.each(a||{},(function(t,e){r.data.append(t,e)})),i.ldng(),$ocms.postXT.call(this,r)},t.fn.checkValidity=function(){var e=t(this),n=!0;return e.each((function(t,e){n=n&&e.checkValidity()})),n},t.fn.wrap=function(e,n){var o=t(this),i=$$.dc(e).attr(n||{}).insertAfter(o);return o.append(i),i}}(jQuery),$ocms.logout=function(){$ocms.postXT({url:$ocms.url("logout"),complete:function(){window.location.reload()}})},$ocms.login={send:function(t){t.preventDefault();var e=$(this);if(!0===e.find("#dbtn-confirm").hasClass("disabled"))return!1;var n=e.serializeObject();return n.loginaccount=ne(n.loginaccount,$ocms.auth.account||$ocms.auth.requestedaccount||""),n.loginaccount=ne(n.loginaccount,$ocms.auth.account||$ocms.auth.requestedaccount||""),""===ne(n.loginaccount)&&!0===bool($ocms.auth.accountrequired,!0)?(alert($t.l16),!1):($ocms.postXT({url:$ocms.url("login"),data:n,success:function(){window.location.reload()}}),!1)},uichange:function(){let t=$(this),e=t.closest("form"),n=bool($ocms.auth.accountrequired,!0),o=ne(e.find('[name="loginaccount"]').val(),$ocms.auth.account||$ocms.auth.requestedaccount||"");if(""!==o||!1===n){var i=e.find('[name="userlogin"]').empty().val(""),r=e.find('[name="username"]').empty().val(""),s=$("#dlg_userlogin_sel").empty().val(""),a=t.val()||"";if(!1===t.checkValidity()&&""===a)return;var c=t.closest("table").ldng();$ocms.postXT.call(this,{url:$ocms.url("auth"),data:{userinfo:a,account:o||""},success:function(t,e,n){if(1===t.length){var o=t[0];i.val(o.login).change().attr("required","").removeAttr("nosend"),r.val(o.name).change().attr("required","").show(),s.removeAttr("required").attr("nosend","").hide()}else t.length>0?(r.hide().removeAttr("required"),i.removeAttr("required").attr("nosend",""),0===s.length&&(s=$("").attr({name:"userlogin",size:t.length,id:"dlg_userlogin_sel",class:"form-control",required:""}).css({width:"100%","max-width":"100%",padding:"2px"}).insertAfter(r)),$.each(t,(function(t,e){var n=$("").attr({value:e.login,style:"padding-top: 2px; padding-bottom: 5px;","border-bottom":"1px solid #EEE;"}).text(e.name).appendTo(s);t%2==0&&n.css({"background-color":"#F9F9F9"})})),s.attr("required","").removeAttr("nosend")):(s.hide().attr("nosend",""),r.attr("required","").show(),i.attr("required","").removeAttr("nosend"),alert($t.l9))},error:function(t){$ocms.failure.call(this,t)},complete:function(){c.ldng(0)}})}else alert($t.l18)},sendpassword:function(t){var e=$(''),n=e.find(".form-body"),o=null;e.find("form").submit((function(t){t.preventDefault();var i=$(this).serializeObject(!0),r=null===o,s=r?"spwc":"spw";return $ocms.postXT.call(this,{url:$ocms.url(s),data:i,complete:function(){r?(n.append('
      Ihnen wurde ein Code per SMS zugesandt.
      Bitte tragen Sie den hier ein:
      '),o=$('
      ').appendTo(n)):(alert($t.l13),e.remove())},error:()=>{}}),!1})),e.find(".modal-close").click((function(){e.remove()}));var i=[];$.each($t.l7a.split("\n"),((t,e)=>{Array.prototype.push.apply(i,[$("
      "),$("").text(e)])})),e.find(".modal-note").append($('').text($t.alert)).append(i),e.appendTo("body"),setTimeout((function(){$(".modal").find('input[name="lastname"]').focus()}),600)}};var $$={s:function(t){return $("").text(t)},br:function(){return $("
      ")},sc:function(t,e){return $("").addClass(t).text(e)},td:function(t,e){var n=$("");return t instanceof jQuery==!0?n.appendTo(t):"object"==typeof t?n.attr(t):"string"==typeof t&&n.text(t),"object"==typeof e?n.attr(e):"string"==typeof e&&n.text(e),n},th:function(t,e){var n=$("");return t instanceof jQuery==!0?n.appendTo(t):"object"==typeof t?n.attr(t):"string"==typeof t&&n.text(t),"object"==typeof e?n.attr(e):"string"==typeof e&&n.text(e),n},tdc:function(t,e,n){return $$.td(e,n).addClass(t)},td2:function(t){var e=$('');return"string"===$.type(t)?e.text(t):t instanceof jQuery?e.append(t):"function"==typeof t?t.call(e):e.html(" "),e},td3:function(t){var e=$('');return"string"===$.type(t)?e.text(t):t instanceof jQuery?e.append(t):"function"==typeof t?t.call(e):e.html(" "),e},tdtr:function(t,e){var n=$$.tr().appendTo(e);return t instanceof jQuery==!0||"string"==typeof t?t.appendTo($$.td().appendTo(n)):!0===Array.isArray(t)&&$.each(t,(function(t,e){$(e).appendTo($$.td().appendTo(n))})),n},tr:function(t,e){var n=$("");return t instanceof jQuery==!0?n.appendTo(t):"object"==typeof t&&n.attr(t),"object"==typeof e&&n.attr(e),n},trc:function(t,e){var n=$("").addClass(t);return e instanceof jQuery==!0?n.appendTo(e):"object"==typeof e&&n.attr(e),n},d:function(t){return $("
      ").attr(t||{})},dc:function(t,e,n,o){var i=$("
      ").addClass(t);return e instanceof jQuery==!0?i.appendTo(e):"object"==typeof e?i.attr(e):"function"==typeof e?i.click(e):"string"==typeof e&&i.text(e),"string"==typeof n?i.text(n):"object"==typeof n?i.attr(n):"function"==typeof n&&i.click(n),"string"==typeof o?i.text(o):"object"==typeof o?i.attr(o):"function"==typeof o&&i.click(o),i},df:function(t){return $("
       
      ").attr(t||{})},opt:function(t,e,n){var o=$("");return"string"==typeof t?o.attr("value",t):"object"==typeof t&&o.attr(t),"string"==typeof e?o.text(e):"object"==typeof e&&o.attr(e),"object"==typeof n&&o.attr(n),o},eOpt:function(t){var e=$('');return t&&e.attr("selected","selected"),e},tbl:function(t){return $("
      ").attr(t||{})},tblc:function(t){return $("
      ").addClass(t)},thead:function(t){let e=$("");return t instanceof jQuery&&e.prependTo(t),e},tbody:function(t){let e=$("");return t instanceof jQuery&&e.appendTo(t),e},tblset:function(t,e){let n=$$.tbl(t||{});return e instanceof jQuery&&e.append(n),{tbl:n,hd:$$.thead().appendTo(n),bdy:$$.tbody().appendTo(n)}},i:function(t){return $("").attr(t||{})},img:function(t,e){return $("").attr("src",t).attr(e||{})},sel:function(t){return $("").attr(t||{})},btn:function(t){return $("").attr(t||{})},a:function(t){return $("").attr(t||{})},li:function(t){return $("
    • ").attr(t||{})},ul:function(t){return $("
        ").attr(t||{})},nav:function(t){return $("").attr(t||{})},lbl:function(t,e){var n=$("");return"string"==typeof t&&n.text(t),"object"==typeof t?n.attr(t):"object"==typeof e&&n.attr(e),n},txt:function(t){return $("").attr(t||{})},0:function(t,e){return $("<"+t+">").attr(e||{})},bbtn:function(t,e){return $$.btn({type:"button",class:"btn"}).addClass(e).text(t)},svg:t=>$(document.createElementNS("http://www.w3.org/2000/svg",t))};function getMonday(t){var e=(t=new Date(t)).getDay(),n=t.getDate()-e+(0==e?-6:1);return new Date(t.setDate(n))}function $lf(t){var e=void 0===t?null:"number"==typeof t&&1!==t||"boolean"==typeof cl&&!1===t;return $("#listframe").tC("hd",e).is(".hd")}function $nuf(t){if(t&&t.stopPropagation(),!$(this).is(".disabled")){var e=function(t){t.removeClass("vis").find("li.dropdown").removeClass("open").removeClass("vis").attr("aria-expanded","false")},n=$(this).parent("li.dropdown");if(n.length>0){n.tC("open"),navs=!0===n.is(".open")?"true":"false",n.attr("aria-expanded",navs);var o=n.closest("nav");o.find("li.dropdown").not(n.parentsUntil("nav")).not(n).removeClass("open").attr("aria-expanded","false"),!1===n.is(".open")&&n.find("li.dropdown").removeClass("open").attr("aria-expanded","false"),e($("nav").not(o))}else e($("nav"))}}function $tbr(){return $lf(0),$("#topbar").ocmsmenu([])}function $lfr(){return $("#sidebar").empty(),$("#listframe").removeClass("fix").addClass("hd").empty()}function $cfr(){return $tbr(),$("#contentframe").empty()}function jObj(t,e){let n={};if("{"===(t||"").substr(0,1))try{n=JSON.parse(t)}catch(t){n={}}return n[e]||""}function string(t,e){var n,o=t||"";return $.each(e||[],(function(t,e){n=new RegExp("\\{"+t.toString()+"\\}","ig"),o=o.replace(n,e)})),o}function init_tooltip(t){var e=!0===("boolean"==typeof t&&t)&&"mouse";$("[title]").qtip({position:{target:e,adjust:{x:2,y:2},viewport:!0},events:{hidden:$ocms.tooltip_hidden},effect:!1}),$("div.tooltiptext").each((function(){$(this).filter(":not(:empty)").parent().qtip({suppress:!1,content:{text:$(this).clone()},position:{target:e,adjust:{x:2,y:2},viewport:!0},events:{hidden:$ocms.tooltip_hidden}})}))}String.prototype.trim=function(){return this.replace(/^\s+|\s+$/g,"")},String.prototype.left=function(t){if("string"===$.type(t)){var e=this.indexOf(t);return e>0?this.slice(0,e):""}return this.substring(0,t)},String.prototype.right=function(t){if("string"===$.type(t)){var e=this.indexOf(t);return e>0?this.substring(this.length-e):""}return this.substring(this.length-t)},Array.prototype.move=function(t,e){if(e>=this.length)for(var n=e-this.length;1+n--;)this.push(void 0);return this.splice(e,0,this.splice(t,1)[0]),this},function(t){t.fn.appendToIf=function(e,n){var o=t(this),i="function"==typeof n?n(o):n;return!0===("boolean"!=typeof i||i)&&o.appendTo(e),o},t.fn.appendIf=function(e,n){var o=t(this),i="function"==typeof n?n(o):n;return!0===("boolean"!=typeof i||i)&&o.append(e),o},t.fn.rwText=function(e,n,o){var i=t(this).empty();o=t.extend({wrap:!0},o);var r=!0===Array.isArray(e)?e:(null==e?"":String(e)).split("\n");return t.each(r,(function(t,e){""!==(e||"")&&(t>0&&i.append($$.br()),i.append(!0===o.wrap?$$.s(e):e))})),n&&i.attr("title",n),i},t.fn.loadSel=function(e,n,o){if("SELECT"===t(this).prop("tagName").toUpperCase()){var i=t(this);$ocms.postXT.call(this,{url:e,data:n||{},success:function(e){t.each(e,(function(){i.append($$.opt(e.value,e.text))}))},complete:function(){i.ldng(0),"function"==typeof o&&o.call(i)}})}},t.fn.emptyWithEditors=function(e){var n=t(this);return n.find(":input.tinymce").each((function(e,n){try{var o=tinymce.get(t(n).attr("id"));o&&o.remove()}catch(e){t.noop()}})),n.empty()},t.fn.cssValue=function(t){if(this.length>0){var e=this.css(t)||"";if(""===e)return 0;var n=/(^[\d\.]*)(\D{1,3}$)/gi.exec(e);return null!==n?"rem"===n[2]?$ocms.rpx(parseFloat(n[1])):parseFloat(n[1]):!1===isNaN(e)?parseFloat(e):0}return 0},t.fn.veryInnerHeight=function(){let e=e=>t(this).cssValue(e);return t(this).innerHeight()-e("padding-top")-e("padding-bottom")},t.fn.veryInnerWidth=function(){let e=e=>t(this).cssValue(e);return t(this).innerWidth()-e("padding-left")-e("padding-right")},t.fn.marginWidth=function(){let e=e=>t(this).cssValue(e);return e("margin-left")+e("margin-right")},t.fn.marginHeight=function(){let e=e=>t(this).cssValue(e);return e("margin-top")+e("margin-bottom")},t.inArrayRegEx=function(e,n,o){var i="regexp"===t.type(e)?e:new RegExp(e);if(!n)return-1;for(var r=o=o||0;r7){o=e.split(","),i=(n||(t<0?"rgb(0,0,0)":"rgb(255,255,255)")).split(",");var c=a(o[0].slice(4)),l=a(o[1]),u=a(o[2]);return"rgb("+(s((a(i[0].slice(4))-c)*r)+c)+","+(s((a(i[1])-l)*r)+l)+","+(s((a(i[2])-u)*r)+u)+")"}var d=(o=a(e.slice(1),16))>>16,h=o>>8&255,p=255&o;return"#"+(16777216+65536*(s((((i=a((n||(t<0?"#000000":"#FFFFFF")).slice(1),16))>>16)-d)*r)+d)+256*(s(((i>>8&255)-h)*r)+h)+(s(((255&i)-p)*r)+p)).toString(16).slice(1)},t.fn.IN=function(e){return t(this).fadeIn(400,e),t(this)},t.fn.OUT=function(e){return t(this).fadeOut(400,e),t(this)},t.fn.tooltip=function(e,n){var o=!0===("boolean"==typeof e&&e)&&"mouse",i="boolean"==typeof n&&n,r=t(this);return r.each((function(){var e=i?t(this).find(".tooltiptext"):t(this).children(".tooltiptext");t(e).length>0?e.each((function(){var e=t(this);t(this).filter(":not(:empty)").parent().qtip({suppress:!1,content:{text:e.clone()},position:{target:o,adjust:{x:2,y:2},viewport:!0},events:{hidden:$ocms.tooltip_hidden},show:{effect:!1},hide:{effect:!1}}),e.remove()})):t(this).qtip({position:{target:o,adjust:{x:2,y:2},viewport:!0},events:{hidden:$ocms.tooltip_hidden},effect:!1})})),r},t.fn.rC=function(e){return t(this).removeClass(e)},t.fn.aC=function(e){return t(this).addClass(e)},t.fn.tC=function(e,n){return t(this).toggleClass(e,n)}}(jQuery),function(t){t.fn.ocmsmenu=function(e,n){var o=t(this);return $ocms.menu.call(o,e,n),o},t.fn.activatemenu=function(){var e=t(this).filter("nav");return e.find("a").not(".on").addClass("on").click($nuf),e.find(".nav-btn").not(".on").addClass("on").click((function(e){e.stopPropagation();var n=t(this);t(n.attr("data-target")).tC(n.attr("data-toggle"))})),e}}(jQuery);class ObjectArray extends Array{isEmpty(){return 0===this[0].length}static get[Symbol.species](){return Array}filter(t){return"function"==typeof t?new ObjectArray(this[0].filter(t)):this}remove(t){if("function"!=typeof t)return this;{let e=this[0].findIndex(t);for(;e>-1;)this[0].splice(e),e=this[0].findIndex(t)}}sortBy(t){return"function"==typeof t&&this[0].sort(t),this}sortString(t){return this[0].sort(((e,n)=>{let o=(e[t]||"").toString().toUpperCase(),i=(n[t]||"").toString().toUpperCase();return console.debug(o.localeCompare(i)),o.localeCompare(i)})),this}sortNum(t){return this[0].sort(((e,n)=>{let o=e[t],i=n[t];return!0===isNaN(i)&&!1===isNaN(o)||oi?1:0})),this}sum(t){return this[0].reduce(((e,n)=>e+(!0===isNaN(n[t])?0:n[t])),0)}groupBy(t){return this[0].reduce((function(e,n){let o=n[t];return e[o]||(e[o]=[]),e[o].push(n),e}),{})}each(t){if("function"==typeof t){let e=!1;this[0].forEach(((n,o,i)=>{if(!1===e){let r=t(n,o,i);"boolean"==typeof r&&!1===r&&(e=!0)}}))}}get toArray(){return this[0]}}class NumArray extends Array{sum(){return this.reduce(((t,e)=>t+e))}first(){return this[0]}last(){return this[this.length-1]}average(){return this.sum()/this.length}range(){let t=this.map((t=>t)).sort();return{min:t[0],max:t[this.length-1]}}static get[Symbol.species](){return Array}}$ocms.ocmsmenu=[{lbl:"",id:"m_home",ico:"glyphicon glyphicon-home",fnc:"init:home"},{fnc:"separator"}],function(t){t.multline=function(t){let e=t.split("\n"),n=$$.d();return $.each(e,((t,e)=>{n.append($$.s(e))})),n.html()},t.tooltip_hidden=function(t,e){$(this).remove(),e.rendered=!1},t.isJSONDateString=function(t){return"string"==typeof t&&/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/.test(t)},t.failure=function(e){11110===(e.internalCode||-1)?t.login.dlg():alert($t.f1+"\n"+(e.internalText||""))},t.getScript=function(e,n){var o=[],i=[],r=function(t){return"string"==typeof t&&""!==(t||"")},s=function(t,e){!0===bool(e.condition,!0)&&(""!==(e.script||"")&&i.push({url:e.script,module:e.module||""}),!0===r(e.css||"")?o.push(e.css):!0===Array.isArray(e.css)&&Array.prototype.push.apply(o,e.css.filter(r)))};!0===r(e||"")?i.push(e):!0===Array.isArray(e)?$.each(e,s):"object"==typeof e&&""!==(e.script||"")&&s(0,e);let a=[];$.each(o,(function(t,e){""!==(e||"")&&a.push(loadCSS(e))}));let c=i.map((function(e,n){let o=e.url,r=e.module||"";if(""===r){let t=new Promise((function(t,e){try{!async function(){$.ajax({url:o,dataType:"script",success:function(){t(i)},error:function(){e(i)},timeout:3e4})}()}catch(t){console.debug(t.message+"%o",t)}}));return t}return t.loadmodule(r,o,e.alias)}));Promise.all(c).then(n)},t.loadmodule=function(e,n,o){let i=new Promise((function(i,r){!async function(){try{let s=(n.startsWith("/")||n.startsWith(".")?"":"/")+n;import(s).then((n=>{t[e]=n[o||"default"],i(e)})).catch((t=>{console.debug(t.message+"%o",t),r(e)}))}catch(t){console.debug(t.message+"%o",t)}}()}));return i},t.ocms_auth=function(e,n,o,i){!1===$.isPlainObject(t.auth.modules)&&(t.auth.modules={});var r=0;t.auth.modules[e+(o||"")]?((r=t.auth.modules[e+(o||"")])<2&&(o||"")===auth.guid&&(r=2),r>=(n||0)&&i(false)):t.postXT({url:t.url("auth"),data:{module:e,person_guid:o||""},success:function(s){r=s[e],t.auth.modules[e+(o||"")]=r,r<2&&(o||"")===t.auth.person_guid&&(r=2),r>=(n||0)&&i(false)},error:function(e){t.failure.call(this,e)}})},t.auth.locale="de",t.ocms_prepauth=function(e,n,o){t.postXT({url:t.url("auth"),data:{fn:"csv",modules:e,person_guid:n||""},success:function(e){t.ocms_regauth(e)},error:function(e){t.failure.call(this,e)},complete:function(){o()}})},t.ocms_regauth=function(t){$.each(t||{},(function(t,e){auth.modules[t]=parseInt(e)}))},t.init=function(e){var n="string"==typeof e?e:(e.data||{}).fn||"";""!==n&&("home"===n?($cfr(),$lfr(),$("#topbar").ocmsmenu([],!0),$("#activemodule").text($t.ov),t.ov.call($("#contentframe"))):($cfr(),$lfr(),$("#topbar").ocmsmenu([]),t.postXT({url:t.url(n+"/auth"),success:function(e){void 0===t[n]&&(t[n]={}),t[n].auth=e,e.manage>0&&t.getScript({module:n,script:["web/imdl",n,t.auth.locale||"de","js"].join("."),css:["web/imdl",n,"css"].join("."),condition:"function"!=typeof t[n].init2},(function(){t[n].init2()}))},error:function(){$("#contentframe").empty()}})))},t.menuarray=function(t){this.array=[],this.sep=function(){this.length>0&&"separator"!==this.array[array.length-1].fnc&&this.push({fnc:"separator"})},this.push=function(t){return void 0===t?null:(!0===Array.isArray(t)?Array.prototype.push.apply(this.array,t):"object"==typeof t&&this.array.push(t),t)},this.unshift=function(t){return void 0===t?null:(!0===Array.isArray(t)?Array.prototype.unshift.apply(this.array,t):"object"==typeof t&&this.array.unshift(t),t)},this.push(t)},t.menu=function(e,n){e=e||[];var o=$(this).removeClass("vis");if(!0===bool(n,!0)&&!1===o.is("#mainmenu")&&o.empty(),!1===bool(n,!1)&&o.is("#sidebar,#topbar")&&(e.unshift({id:"sbctrl",glyph:"glyphicon-th-list",aclass:"fbtn",fnc:function(){$lf()}}),$lf(0)),0===(e||[]).length)o.empty().addClass("hd");else{o.removeClass("hd");var i=!0===o.is("nav")?o:o.children("nav");1!==i.length&&(i=$("").tC("nv",o.is("#sidebar")).tC("ctxt",o.is("#topbar")).appendTo(o));var r,s=$$.ul().appendTo(i),a=function(t,e){var n=$(this).addClass("dropdown submenu");t.append($$.sc("caret dd")).addClass("dds dropdown-toggle").attr({"aria-expanded":"false"}),""!==(e.ico||"")&&t.prepend($$.sc("ico "+e.ico));var o=$$.ul({class:"dropdown-menu",role:"menu"}).appendTo(n);$.each(e.itm||[],(function(t,e){r.call(o,e)}))},c=function(t){$(this).tC("disabled","boolean"==typeof t.disabled?t.disabled:"string"==typeof t.disabled&&"subs"===t.disabled&&0===(t.itm||[]).length)};r=function(e){var n,o=$$.li({id:e.id}).attr(e.attr||{}).addClass(e.lclass).appendTo($(this)),i="string"==typeof e.fnc&&""!==e.fnc?e.fnc.split(":")[0]:"";""!==i&&"init"!==i?o.attr("role",i).appendIf($$.s(e.lbl),""!==ne(e.lbl)):(n=$$.a({class:"on",role:"button"}).addClass(e.aclass).appendTo(o).append($$.s(e.lbl)),c.call(n,e),(e.itm||[]).length>0&&a.call(o,n,e),n.click($nuf),"function"==typeof e.fnc?n.click(e.data||{},e.fnc):"init"===i&&n.click($.extend({},e.data||{},{fn:e.fnc.split(":")[1]}),t.init))},$.each(e,(function(e,n){var o,i=$$.li({id:n.id}).attr(n.attr||{}).addClass(n.lclass),a="string"==typeof n.fnc&&""!==n.fnc?n.fnc.split(":")[0]:"";if(""!==a&&"init"!==a)i.attr("role",a).appendIf($$.s(n.lbl),""!==ne(n.lbl));else{if(o=$$.a({class:"on",role:"button"}).addClass(n.aclass).appendTo(i),c.call(o,n),""!==(n.lbl||"")&&o.append($$.s(n.lbl)),""!==(n.ico||"")&&o.prepend($$.sc("ico "+n.ico)),""!==(n.glyph||"")&&o.prepend($$.sc("glyphicon "+n.glyph)),(n.itm||[]).length>0){i.addClass("dropdown"),o.append($$.sc("caret dd")).addClass("dds dropdown-toggle").attr({"aria-expanded":"false"});var l=$$.ul({class:"dropdown-menu",role:"menu"}).appendTo(i);$.each(n.itm||[],(function(t,e){r.call(l,e)}))}(n.sel||[]).length>0||(o.click($nuf),"function"==typeof n.fnc?o.click(n.data||{},n.fnc):"init"===a&&o.click($.extend({},n.data||{},{fn:n.fnc.split(":")[1]}),t.init))}i.appendTo(s)})),i.activatemenu()}},t.easytbl=(t,e)=>{e=e||{};let n=$$.tbl().addClass(e.class).css("border-collapse","collapse"),o=($$.tbody(n),!0===bool(e.frame,!1)?{padding:"5px",border:"1px solid #727272"}:{});if(!0===Array.isArray(e.header)){let t=$$.thead(n);$.each(e.header,((n,i)=>$$.th(t).css(e.cellcss||o).rwText(i)))}else if(!0===bool(e.header,!1)&&(t||[]).length>0){let i=$$.thead(n);$.each(Object.keys(t[0]),((t,n)=>$$.th(i).css(e.cellcss||o).rwText(n)))}return $.each(t||[],((t,i)=>{let r=$$.tr();$.each(i,((t,n)=>{n=n||"";let i=$$.td(r).css(e.cellcss||o);n instanceof jQuery?i.append(n):"string"==typeof n&&("<"===n.substring(0,1)?i.append(n):i.text(n))})),n.append(r)})),n},t.dlgtbl=(e,n,o)=>{o=o||{};let i=t.easytbl(e,o);t.dlg(i,$.extend({title:n},o))},t.dlg=function(t,n){n=n||{};let o=$("body > .modal").length>0,i=t=>typeof n[t],r=t=>"function"===i(t);if(!0===bool(n.exclusive,!0)&&!0===o)return void alert($t.dbldlg||"Es ist bereits ein Dialog geöffnet");let s=$$.dc("modal",$("body")),a=$$.dc("modal-dialog",s);!1===isNaN(n.zindex)?s.css("zIndex",n.zindex):!0===o&&s.css("zIndex",parseInt($("body > .modal:last").cssValue("zIndex"))+200),!1===isNaN(n.zindex_min)&&s.cssValue("zIndex")').appendTo(d)),""!==ne(n.title)&&(c=$$.dc("modal-header",d),$("

        ").text(n.title).appendTo(c));let p=$$.dc("modal-body",d),f=$$.dc("modal-footer",d);t instanceof jQuery==!0&&p.append(t);let m=function(t){t&&"function"==typeof t.stopPropagation&&t.stopPropagation(),a.removeClass("in"),!0===r("closing")&&n.closing.call(d),p.hide().emptyWithEditors(),s.remove(),!0===r("close")&&n.close.call(d)};if(d.find(":input[required]").length>0&&($$.dc("note_required",f).append($$.sc("ind_required","*")).append($$.s($t.t1||"Eingabe erforderlich")),$$.dc("note_invalid",f).append($$.s($t.t2||"Bitte überprüfen Sie Ihre Eingaben im Formular."))),!0===r("cancel")){$$.bbtn(n.cancelbutton||"Abbrechen","cancel").attr({type:"button",role:"cancel"}).appendTo(f).click((function(t){n.cancel.call(d,t);t.stopPropagation(),m()}))}if(!0===r("confirm")){let t=$$.bbtn(n.button||"OK","confirm").attr({type:!0===bool(n.form,!1)?"submit":"button",role:"confirm"}).appendTo(f);!0===h?(d.submit((function(t){try{n.confirm.call(d,t)}finally{t.preventDefault()}return!1})),d.on("modal_submit",(function(){n.confirm.call(d,e)}))):(t.click((function(t){n.confirm.call(d,t);t.stopPropagation()})),d.on("modal_submit",(function(){t.click()})))}else!0===h&&d.submit((function(t){return t.preventDefault(),!1}));return d.on("modal_close",(function(){m()})),l.click(m),!0===r("opening")&&n.opening.call(d),a.addClass("in"),ne(n.mode).indexOf("maxbody")>-1&&p.css("min-height",(u.height()-c.outerHeight()-f.outerHeight()).toString()+"px"),!0===r("open")&&n.open.call(d),{hd:c,bdy:p,ft:f,ct:u,dlg:a,c:d}},t.mform=function(e){let n=$$.dc("form-body"),o=Array.isArray(e)?e:e instanceof fields_definition?e.fields:[];return $.each(o||[],(function(e,o){let i=o.type||"";if("ignore"===i)return!0;let r=$$.dc("form-group",n),s=o.id||"dlg_"+(o.name||"")+("html"===o.type?"_"+(65536*(1+Math.random())||0).toString(16).substr(9):""),a=$$.lbl(o.label||o.name,{for:s}).appendTo($$.dc("form-itm",r)),c=$$.dc("form-itm",r),l=$$.i({id:s,name:o.name,placeholder:o.placeholder,type:o.type});switch(i){case"email":o.pattern=ne(o.pattern,"[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$");break;case"url":o.pattern=ne(o.pattern,"https?://.+");break;case"number":o.pattern=ne(o.pattern,"[-+]?[0-9]*[.,]?[0-9]*"),l.attr("step",o.precision||"any"),l.attr("data-format","float");break;case"integer":case"int":o.pattern=ne(o.pattern,"[-+]?[0-9]*"),l.attr("type","number"),l.attr("data-format","integer");break;case"date":if(""!==ne(o.pattern,$t.datepattern)&&(o.pattern=ne(o.pattern,"("+$t.datepattern+")|([0-9]{4}.(0[1-9]|1[012]).(0[1-9]|1[0-9]|2[0-9]|3[01]))")),""!==ne(o.placeholder,$t.dateplaceholder)&&l.attr("placeholder",ne(o.placeholder,$t.dateplaceholder)),"string"==typeof o.value){var u=o.value.substr(0,10);o.value="date"!==l.prop("type")?fdt(u+"T00:00:00",ne(o.dateformat,$t.dateformat)):u}l.attr("data-format","date:"+ne(o.dateformat,$t.dateformat)+";yyyy-MM-dd");break;case"datetime":l.attr("type","datetime-local"),""!==ne(o.pattern,$t.datetimepattern)&&(o.pattern=ne(o.pattern,"("+$t.datetimepattern+")|([0-9]{4}-(0[1-9]|1[012])-(0[1-9]|1[0-9]|2[0-9]|3[01])\\s([0-5][0-9]):([0-5][0-9]))")),""!==ne(o.placeholder,$t.datetimeplaceholder)&&l.attr("placeholder",ne(o.placeholder,$t.datetimeplaceholder)),"string"==typeof o.value&&"T"===o.value.substr(10,1)&&(o.value="datetime"!==l.prop("type").substr(0,8)?fdt(o.value,ne(o.datetimeformat,$t.datetimeformat)):o.value),l.attr("data-format","datetime:"+ne(o.datetimeformat,$t.datetimeformat)+";yyyy-MM-dd HH:mm:ss");break;case"hidden":r.addClass("hd");break;case"html":case"text":l=$$.txt({id:s,name:o.name,placeholder:o.placeholder,type:o.type}),l.tC("tinymce","html"===o.type);break;case"bool":case"boolean":o.url=[{value:"true",label:($t||{}).true||"Yes"},{value:"false",label:($t||{}).false||"No"}],"boolean"==typeof o.value&&(o.value=o.value?"true":"false");case"select":l=$$.sel({id:s,name:o.name,type:o.type}),!1===bool(o.required,!1)&&$$.eOpt().appendTo(l);try{var d=function(t){!0===Array.isArray(t)&&$.each(t,(function(t,e){"string"==typeof e?$$.opt(e,e).appendTo(l):!0===Array.isArray(e)?$$.opt(e[0],e[1]).appendTo(l):"object"==typeof e&&$$.opt(e.value,e.label||e.text).appendTo(l)}))};!0===Array.isArray(o.url)?d(o.url):"function"==typeof o.url?o.url.call(l):"string"==typeof o.url&&t.postXT({url:o.url,success:d})}catch(t){$.noop()}break;default:""!==ne(o["max-length"])&&l.attr("max-length",o["max-length"])}""!==ne(o.pattern)&&l.attr("pattern",o.pattern),l.val(o.value).change(),l.change((function(){$(this)[0].setCustomValidity("")})),l.addClass("form-control").prop("required",bool(o.required,!1)).prop("readonly",bool(o.readonly,!1)).appendTo(c),!0===bool(o.required,!1)&&a.append($$.sc("ind_required","*")),"object"==typeof o.attr&&l.attr(o.attr),"object"==typeof o.prop&&l.prop(o.prop),"string"==typeof o.class&&l.addClass(o.class),"function"==typeof o.change&&(l.change(o.change),!0===bool(o.applychange,!1)&&void 0!==o.value&&l.change()),""!==(o.note||"")&&$$.dc("form-note",c).rwText(o.note),"function"==typeof o.complete&&o.complete.call(l)})),n},t.initMCE=function(t,e){t=$(t),e=e||{};try{let n={target:t[0],inline:!1,width:e.width||"100%",statusbar:!1,document_base_url:window.location.origin+"/",content_style:"ph:before {content: '«'; color: #BBB; font-style:italic; } ph:after {content: '»'; color: #BBB; font-style:italic; } ph { color: #AAA; font-style:italic; }",relative_urls:!1,remove_script_host:!1};!0===bool(e.hidemenu,!1)&&(n.menubar=!1,n.menu={}),!0===bool(e.hidetoolbar,!1)&&(n.toolbar=!1),$.extend(n,e||{}),tinymce.init(n)}catch(t){alert(t.message)}},t.dlgform=function(e,n){n=n||{};let o,i=$$.dc("frm").append(t.mform(e||[]).addClass("stacked"));n.addcontent instanceof jQuery&&i.append(n.addcontent),"function"==typeof n.submit?o=n.submit:"function"==typeof n.success&&(o=function(e){var o=$(this).ldng(1),i=$.extend({loginaccount:t.auth.account||""},o.serializeObject(bool(n.checkvalidity,!0),{typedvalues:bool(n.typedvalues,!1)}));""!==(n.url||"")?t.postXT({url:n.url,data:i,success:function(t){n.success.call(this,t),o.trigger("modal_close")},error:function(){alert($t.l17)},complete:function(){o.ldng(0)},timeout:6e4}):(n.success.call(this,i),o.trigger("modal_close"))});let r={form:!0,title:n.title||"",button:n.button||$t.submit,confirm:o,size:n.size||[500,600],open:function(){let e=$(this).find(".tinymce");e.length>0&&t.initMCE(e,n.tinymce||{})}};return t.dlg.call(this,i,r)},t.login.dlg=function(e){e=e||{};let n=[{name:"userinfo",label:$t.l1,type:"string",value:t.auth.login,change:t.login.uichange,required:!0},{name:"userlogin",type:"hidden",required:!0,value:t.auth.login},{name:"username",type:"string",label:$t.l4,required:!0,readonly:!0,placeholder:$t.l5,value:t.auth.fullname_rev},{name:"userpass",type:"password",label:$t.l3,required:!0,placeholder:$t.l3}];""===(t.auth.account||"")&&n.unshift({id:"dlg_loginaccount",name:"loginaccount",type:"string",required:!0,value:t.auth.account});let o=$$.dc("frm").append(t.mform(n).addClass("stacked")),i=t.dlg.call(this,o,{form:!0,title:$t.l0,button:$t.submit,confirm:function(n){var o=$(this).ldng(1),i=$.extend({loginaccount:t.auth.account||""},o.serializeObject());t.postXT({url:"/vt/login",data:i,success:function(n){""!==((n||{}).login||"")&&(o.trigger("modal_close"),t.auth=n,"object"==typeof e.ajo&&(e.ajo.islogin,$.ajax(e.ajo)))},error:function(){alert($t.l17)},complete:function(){o.ldng(0)},timeout:6e4})},size:[500,600]}),r=$$.dc("modal-content").css("height","auto").attr("novalidate","true").append($$.dc("modal-header").appendIf($("

        ").text(t.auth.accountname),""!==(t.auth.accountname||"")).append($("

        Vereinsmanager

        ")));i.dlg.prepend(r)},t.addNoEntryInfo=function(t){$(this).append($$.dc("noentryinfo").text(t||$t.t11))}}($ocms),Element.prototype.matches||(Element.prototype.matches=Element.prototype.msMatchesSelector||Element.prototype.webkitMatchesSelector),Element.prototype.closest||(Element.prototype.closest=function(t){var e=this;do{if(Element.prototype.matches.call(e,t))return e;e=e.parentElement||e.parentNode}while(null!==e&&1===e.nodeType);return null}),function(t,e){var n,o;"object"==typeof window&&(window[t]=(n=function(t){var e=window,n=document.body,o=document.documentElement,i=Math.max(0,e.pageXOffset||o.scrollLeft||n.scrollLeft||0)-(o.clientLeft||0),r=Math.max(0,e.pageYOffset||o.scrollTop||n.scrollTop||0)-(o.clientTop||0);return{x:t?Math.max(0,t.pageX||t.clientX||0)-i:0,y:t?Math.max(0,t.pageY||t.clientY||0)-r:0}},(o=function(t,e){t&&t instanceof Element&&(this._container=t,this._options=e||{},this._clickItem=null,this._dragItem=null,this._showDragItem="boolean"!=typeof this._options.dragItem||!1!==this._options.dragItem,this._hovItem=null,this._sortLists=[],this._click={},this._dragging=!1,this._dragHandleClass=this._options.dragHandleClass||"",this._parentident=this._options.parentident||"",this._swapdone="function"==typeof this._options.swapdone?this._options._swapdone:null,this._container.setAttribute("data-is-sortable",1),this._container.classList.add("sortable"),this._container.style.position="static",window.addEventListener("mousedown",this._onPress.bind(this),!0),window.addEventListener("touchstart",this._onPress.bind(this),!0),window.addEventListener("mouseup",this._onRelease.bind(this),!0),window.addEventListener("touchend",this._onRelease.bind(this),!0),window.addEventListener("mousemove",this._onMove.bind(this),!0),window.addEventListener("touchmove",this._onMove.bind(this),!0))}).prototype={constructor:o,toArray:function(t){t=t||"id";for(var e=[],n="",o=0;oo.left&&eo.top&&n-1)&&e.className.indexOf("nosort")<0)&&(t.preventDefault(),this._dragging=!0,this._click=n(t),this._makeDragItem(e),this._onMove(t),!0)}t&&!1===e.call(this,t.target)&&""!==this._parentident&&t.target.closest(this._parentident)&&e.call(this,t.target.closest(this._parentident))},_onRelease:function(t){this._dragging=!1,this._trashDragItem()},_onMove:function(t){if(this._dragItem&&this._dragging){t.preventDefault();var e=n(t),o=this._container;!0===this._showDragItem&&this._moveItem(this._dragItem,e.x-this._click.x,e.y-this._click.y);for(var i=0;i0?a.mousedown(c).addClass("dctrl"):s.mousedown(c).addClass("dctrl"),t(this)}}(jQuery),$(document).ready((function(){$("html").click((function(t){$nuf()})),$("#listframe").click((function(t){t.stopPropagation(),$nuf()})),$("#mainmenu").ocmsmenu($ocms.ocmsmenu),$("#mainmenu").activatemenu()})),$.extend($t,{m_inv:"Rechnungen",m_req:"Aufträge",m_rep:"Berichte",m_todo:"ToDos",m_bcd:"BankBuchungen",rsp:"Passwort ändern",pnm:"Die Passwörter stimmen nicht überein",cps:"Das neue Passwort wurde gespeichert.",pwr:"Bitte wählen Sie ein starkes Passwort (min 8 Zeichen, davon jeweils min 2 Zahlen, kleine und große Buchstaben, Sonderzeichen sind optional).",smsc:"Sie beötigen für diese Funktion einen SMS-Code.\nSoll dieser nun versandt werden?",wdc:"Doppelt klicken, um die Box zu aktualisieren.",wdg:{}}),$t.rspf={sms:"Der SMS-Code konnte nicht bestätigt werden",valid:"Das alte Passwort ist nicht korrekt",requirements:"Das Passwort entspricht nicht den Anforderungen.\n"+$t.pwr},$fd={rsp:new fields_definition("","",[{name:"opw",label:"aktuelles Passwort",type:"password",required:!0,attr:{"auto-complete":"current-password"}},{name:"npw",label:"neues Passwort",type:"password",required:!0,pattern:"(.{6,})",attr:{"auto-complete":"new-password"}},{name:"npwc",label:"neues Passwort (Bestätigung)",type:"password",required:!0,attr:{"auto-complete":"new-password"},note:$t.pwr},{name:"code",label:"SMS-Code",type:"string",required:!0,attr:{"auto-complete":"one-time-code"}}])},$ocms.init=function(t){var e="string"==typeof t?t:(t.data||{}).fn||"";""!==e&&("home"===e?($cfr(),$lfr(),$("#topbar").ocmsmenu([],!0),$("#activemodule").text($t.ov),$fis.ov()):($cfr(),$lfr(),$("#topbar").ocmsmenu([]),$ocms.postXT({url:$ocms.url(e+"/auth"),success:function(t){void 0===$ocms[e]&&($ocms[e]={}),$ocms[e].auth=t,t.manage>0&&$ocms.getScript({module:e,script:["/web/fis",e,$ocms.auth.locale||"de","js"].join("."),css:["/web/fis",e,"css"].join("."),condition:"function"!=typeof $ocms[e].init2},(function(){$ocms[e].init2()}))},error:function(){$("#contentframe").empty()}})))};var $fis={auth:{},db:function(){$("#mainmenu_activemodule").text($t.ov);let t=$(this).empty(),e=$$.d({id:"dashboard_frame"}).appendTo(t);$ocms.postXT({url:$ocms.url("wdg/my"),success:function(t){$.each(t,(function(t,n){var o=$$.dc("wdg_frame",e,{"data-wdg":n}).ldng(1);$ocms.wdg.call(o,{wdg:n})}))},loading:e})},ValidateEmail:function(t){return!!/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/.test(t)},cf:t=>{let e=$("#contentframe");return!0===bool(t,!1)&&e.empty().rC("hd"),e},lf:t=>{let e=$("#listframe");return!0===bool(t,!1)&&e.empty().aC("hd").rC("fix"),e},frm_edit:function(t){let e=$fis.cf(!1),n=e.children(".cfrm"),o=e.children(".edit_frm");return n.length<1?n=$$.dc("cfrm hd").prependTo(e):!0===bool(t,!1)&&n.empty(),o.length<1&&(o=$$.dc("edit_frm").insertAfter(n)),o.empty()},frm_list:function(t,e){let n=$fis.cf(!1),o=n.children(".cfrm"),i=n.children(".list_frm");return o.length<1?o=$$.dc("cfrm hd").prependTo(n):!0===bool(t,!1)&&o.empty(),!0===bool(e,!1)&&n.children(".edit_frm").remove(),i.length<1&&(i=$$.dc("list_frm").appendTo(n)),i.empty()},lfm:()=>{let t=$fis.lf(!1),e=t.children(".lfrm");return e.length<1&&(e=$$.dc("lfrm").prependTo(t)),e},getAuth:(t,e)=>new Promise(((n,o)=>{$fis.auth[t]&&!1===bool(e,!1)?n($fis.auth[t]||-1):$ocms.postXT({url:$ocms.url("auth"),data:{module:t},success:e=>{$fis.auth[t]=e.auth||-1,n($fis.auth[t]||-1)},error:()=>{o()}})})),prepAuth:t=>new Promise(((e,n)=>{$ocms.postXT({url:$ocms.url("auth"),data:{module:t,array:1},success:t=>{$.extend($fis.auth,t||{})},complete:()=>{e()}})})),isAuth:(t,e)=>($fis.auth[t]||-1)>=(e||1),resetPass:function(t,e){confirm($t.smsc)&&($ocms.postXT({url:$ocms.url("account/sms"),data:{fn:"pwc"}}),$ocms.dlgform($fd.rsp.clone(),{title:$t.rsp||"",submit:function(t){var e=$(this).ldng(1),n=$.extend({loginaccount:$ocms.auth.account||""},e.serializeObject(!0,{typedvalues:!0}));(n.npw||"")!==(n.npwc||"")?e.find('input[name="npw"]:first')[0].setCustomValidity($t.pnm):$ocms.postXT({url:$ocms.url("account/changepassword"),data:n,success:function(t){alert($t.cps),e.trigger("modal_close")},error:function(t){alert($t.rspf[t.getResponseHeader("x-ocms-std")])},complete:function(){e.ldng(0)},timeout:6e4})}}))},wdg:function(t){let e=$(this).empty();$ocms.postXT({url:$ocms.url("wdg/one"),data:{short_name:t.wdg},timeout:9e4,success:function(n,o,i){let r=t.wdg,s=n[r];if(!s)return void e.ldng(0);let a=$.inArrayRegEx("dblwidth",s.rendering_options)>-1,c=$.inArrayRegEx("tiny",s.rendering_options)>-1;e.toggleClass("dbl",a&&!c).toggleClass("tny",c);$$.dc("wdg_hd",e,{title:ne(s.description,$t.wdc)}).toggleClass("dbl",a).text(ne(s.name,t.wdg)).dblclick((function(t){t.stopPropagation(),$fis.wdg.call(e,{wdg:r})}));let l=$$.dc("wdg_cnt",e).toggleClass("dbl",a).hide(),u=$.inArrayRegEx("bgcolor",s.rendering_options);switch(u>-1&&l.css("backgroundColor",s.rendering_options[u].toString().right(":")),s.type){case"table":var d=$$.tblset({},l),h=$$.tr().appendTo(d.hd),p=$t.wdg[r.indexOf("wdg_ev_")>=0?"wdg_ev_":r]||{};$.each(s.columns,(function(t,e){var n=p[e]?p[e].label:e;$$.th().text(n).appendTo(h)})),$.each(s.data,(function(t,e){var n=$$.tr().appendTo(d.bdy);$.each(s.columns,(function(t,o){var i=$$.td().appendTo(n);e[o]instanceof Date||!0===$ocms.isJSONDateString(e[o])?i.text(fdt(e[o],$t.dateformat)):i.rwText(e[o])}))})),$.inArray("firstrow_bold",s.rendering_options)>-1&&h.nextAll("tr:first").css("font-weight","bold");break;case"ind":$$.dc("ind",l).addClass("sts_"+(s.data.status||"")).append([$$.dc("ind").text(s.data.value),$$.lbl(s.data.label)]);break;case"image_url":l.css("background","url('"+s.url+"') no-repeat center center transparent");break;case"image_base64":l.css("background","url('data:image/png;base64,"+s.image+"') no-repeat center center transparent");break;case"html":if(l.html(s.html),$.inArray("reload_10min",s.rendering_options)>-1){var f=l.find("iframe");setTimeout((function(){f.attr("src",(function(t,e){return e}))}),6e5)}}$.inArray("reload_30min",s.rendering_options)>-1&&"html"!==s.type&&setTimeout((function(){$fis.wdg.call(e,{wdg:r})}),18e5),l.slideDown(150)},error:function(t){e.slideUp(150),$fis.failure.call(this,t)},complete:function(){e.ldng(0)}})},ov:function(){$fis.lf(!0);let t=$("#contentframe").empty(),e=$$.d({id:"dashboard_frame"}).appendTo(t);$ocms.postXT({url:$ocms.url("wdg/my"),success:function(t){$.each(t,(function(t,n){var o=$$.dc("wdg_frame",e,{"data-wdg":n}).ldng(1);$fis.wdg.call(o,{wdg:n})}))},loading:e})}};$fis.notifications={connection:null,init:function(){"undefined"!=typeof signalR&&null===this.connection&&$ocms.auth.useraccount_id&&(this.ensureFrame(),this.connection=(new signalR.HubConnectionBuilder).withUrl("/notifications").withAutomaticReconnect().build(),this.connection.on("notification",(t=>{this.push(t)})),this.connection.start().catch((()=>{this.connection=null})))},ensureFrame:function(){$("#notification_frame").length<1&&$("
        ",{id:"notification_frame"}).appendTo($("footer:first").length?"footer:first":"body")},push:function(t){this.ensureFrame(),t=t||{};let e=$("
        ",{class:"notification_item"}).addClass((t.severity||"info").toLowerCase()).append($("