Restore legacy parity gaps lost in the VB.NET → C# migration

Close functional regressions found by comparing the legacy applications
(Intranet_Legacy/) against their C# counterparts:

- ProcessWebComService: send attachments inline (base64) with the
  push_com POST so invoice/reminder PDFs are attached again.
- FuchsPdf: wire GetPaycode into ApplyInvoice/ApplyReminder to restore
  the SEPA giro-code payment QR, and restore the full standard invoice
  text block (§35a labor-cost note, Akonto text, §14/§48 notes, AGB,
  Steuernummer, Verrechnungssätze, etc.).
- IntranetController: restore changepassword validation (password
  strength, confirmation match, current-password verification) and the
  mfr empty-id OData $metadata response.
- Reports: port the ocms_visualization engine to C# (FuchsVisualization)
  and wire FuchsReports.ProcessFdsRequest to render generic/
  generic_content/chart reports instead of returning an empty OK stub.

Adds smoke tests for the giro QR generator and report page builder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 16:41:46 +02:00
parent c8a4d18f1a
commit c81619fa53
6 changed files with 942 additions and 21 deletions
+52
View File
@@ -0,0 +1,52 @@
using Fuchs.intranet;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Smoke tests for the non-DB parts of the restored report engine and the
/// giro-code QR generator. The SQL-driven report rendering itself requires a
/// live report catalog and is validated separately against the database.
/// </summary>
public class FuchsReportRenderTests
{
[Fact]
public void GetPaycode_ProducesNonEmptyBitmap()
{
using var bmp = FuchsPdf.GetPaycode(
iban: "DE52301502000002091478", bic: "WELADED1KSD",
name: "Sebastian Fuchs Bad und Heizung", amount: 123.45m, purpose: "Rechnung 4711");
Assert.NotNull(bmp);
Assert.True(bmp.Width > 0);
Assert.True(bmp.Height > 0);
}
[Fact]
public void HtmlPage_Web_FallbackShell_EmbedsContentTitleAndReload()
{
var page = new FuchsHtmlPage("My Report", templatePath: "", queryDuration: 0)
{
ReloadSeconds = 60
};
page.Add("<div id=\"frm\">hello</div>");
string html = page.ToHtml(FdsDestination.web);
Assert.Contains("hello", html);
Assert.Contains("My Report", html); // title injected
Assert.Contains("http-equiv=\"refresh\"", html); // reload meta injected
Assert.Contains("content=\"60\"", html);
}
[Fact]
public void HtmlPage_Content_OmitsReloadAndTitleChrome()
{
var page = new FuchsHtmlPage("Ignored", templatePath: "");
page.Add("<span>fragment</span>");
string html = page.ToHtml(FdsDestination.content);
Assert.Contains("fragment", html);
Assert.DoesNotContain("http-equiv=\"refresh\"", html);
}
}
+36 -1
View File
@@ -317,11 +317,38 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
_logger.LogWarning("HandleAccount changepassword: missing required fields for user={User}", UserAccountID); _logger.LogWarning("HandleAccount changepassword: missing required fields for user={User}", UserAccountID);
return BadRequest400(); return BadRequest400();
} }
if (npw != npwc)
{
_logger.LogWarning("HandleAccount changepassword: password confirmation mismatch for user={User}", UserAccountID);
return StatusCode(409, new { error = "match" });
}
if (!npw.ValidatePassword(minLength: 6, numSpecial: 0))
{
_logger.LogWarning("HandleAccount changepassword: new password does not meet requirements for user={User}", UserAccountID);
return StatusCode(409, new { error = "requirements" });
}
if (!OCORE.security.TFA.validateTotp_3h(_intranet.Intranet__TOTPsharedsecret_base + "3MDR", totpCode).isVerifiedInTime) if (!OCORE.security.TFA.validateTotp_3h(_intranet.Intranet__TOTPsharedsecret_base + "3MDR", totpCode).isVerifiedInTime)
{ {
_logger.LogWarning("HandleAccount changepassword: TOTP verification failed for user={User}", UserAccountID); _logger.LogWarning("HandleAccount changepassword: TOTP verification failed for user={User}", UserAccountID);
return StatusCode(409, new { error = "sms" }); return StatusCode(409, new { error = "sms" });
} }
// Verify the supplied current password actually belongs to this user before changing it
string oldPw = Request.Form["opw"].ToString();
var authDt = await getSQLDatatable_async(
"SELECT TOP(1) * FROM [dbo].[fis_admin_authenticate_byID](@useraccount_id, @password);",
_intranet.Intranet__SQLConnectionString,
new List<SqlParameter>
{
SQL_VarChar("@useraccount_id", UserAccountID),
SQL_VarChar("@password", oldPw)
},
Security: DbSec, options: SqlOpt(fn, id, code));
var authRow = authDt.FirstRow.toObjectDictionary();
if (!string.IsNullOrEmpty(UserAccountID) && authRow.nz("useraccount_id") != UserAccountID)
{
_logger.LogWarning("HandleAccount changepassword: current password verification failed for user={User}", UserAccountID);
return StatusCode(409, new { error = "valid" });
}
_logger.LogInformation("Changing password for user={User}", UserAccountID); _logger.LogInformation("Changing password for user={User}", UserAccountID);
await setSQLValue_async( await setSQLValue_async(
"EXECUTE [dbo].[fis_admin_setNewPassword] @useraccount_id, @oldpassword, @newpassword, @enc_key;", "EXECUTE [dbo].[fis_admin_setNewPassword] @useraccount_id, @oldpassword, @newpassword, @enc_key;",
@@ -329,7 +356,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
new List<SqlParameter> new List<SqlParameter>
{ {
SQL_VarChar("@useraccount_id", UserAccountID), SQL_VarChar("@useraccount_id", UserAccountID),
SQL_VarChar("@oldpassword", Request.Form["opw"].ToString()), SQL_VarChar("@oldpassword", oldPw),
SQL_VarChar("@newpassword", npw) SQL_VarChar("@newpassword", npw)
}, },
Security: DbSec, options: SqlOpt(fn, id, code)); Security: DbSec, options: SqlOpt(fn, id, code));
@@ -344,6 +371,14 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
{ {
_logger.LogDebug("HandleMfr id={Id} code={Code} user={User} auth={Auth}", _logger.LogDebug("HandleMfr id={Id} code={Code} user={User} auth={Auth}",
id, code, UserAccountID, UserIdent.Authorization); id, code, UserAccountID, UserIdent.Authorization);
if (string.IsNullOrEmpty(id))
{
// Empty id → return the OData EDMX schema ($metadata), matching legacy fds.getSchema()
using var mfrSchema = new fds.FdsMfrClient();
string schema = await mfrSchema.ReadAnything(
mfrSchema.ClientConfig.BaseUrl + "$metadata", throwErrorIfNotOk: false);
return Content(schema, "text/xml", System.Text.Encoding.UTF8);
}
if (!string.IsNullOrEmpty(UserAccountID) && UserIdent.Authorization > 3) if (!string.IsNullOrEmpty(UserAccountID) && UserIdent.Authorization > 3)
{ {
string path = id + (!string.IsNullOrEmpty(code) ? "/" + code : HttpUtility.UrlDecode(Request.QueryString.Value ?? "")); string path = id + (!string.IsNullOrEmpty(code) ? "/" + code : HttpUtility.UrlDecode(Request.QueryString.Value ?? ""));
+37 -3
View File
@@ -66,14 +66,30 @@ public class ProcessWebComService : IComService
try try
{ {
// Attachments are transmitted inline as base64 in the same push_com POST.
// Each entry: { filename, mimeType, contentBase64 }.
var attachmentPayload = (attachments ?? new Dictionary<string, byte[]>())
.Where(kv => kv.Value is { Length: > 0 })
.Select(kv => new
{
filename = kv.Key,
mimeType = GuessMimeType(kv.Key),
contentBase64 = Convert.ToBase64String(kv.Value)
})
.ToArray();
var payload = new var payload = new
{ {
comType = "email", comType = "email",
recipient = email, recipient = email,
subject, subject,
body body,
attachments = attachmentPayload
}; };
_logger.LogDebug("SendEmailAsync ref={Reference} to={Email} attachments={Count}",
reference, email, attachmentPayload.Length);
var (ok, responseBody) = await PostToApiAsync("push_com", payload); var (ok, responseBody) = await PostToApiAsync("push_com", payload);
if (ok) if (ok)
{ {
@@ -190,6 +206,24 @@ public class ProcessWebComService : IComService
return ""; return "";
} }
private static string GuessMimeType(string filename) =>
Path.GetExtension(filename).ToLowerInvariant() switch
{
".pdf" => "application/pdf",
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".txt" => "text/plain",
".csv" => "text/csv",
".xml" => "application/xml",
".zip" => "application/zip",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls" => "application/vnd.ms-excel",
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
_ => "application/octet-stream"
};
private static bool IsValidEmail(string email) private static bool IsValidEmail(string email)
{ {
try try
+148 -8
View File
@@ -523,16 +523,95 @@ public static class FuchsPdf
ParseDec(inv.InvoiceRegistration?.getItem("InvoiceBalance"), out decimal gross); ParseDec(inv.InvoiceRegistration?.getItem("InvoiceBalance"), out decimal gross);
TotalRow("Rechnungsbetrag:", Currency(gross), "TblCell_TSum"); TotalRow("Rechnungsbetrag:", Currency(gross), "TblCell_TSum");
// Payment terms note
string terms = TranslatePaymentTerm(inv.PaymentTerms); // ── Standard invoice notes (ported from legacy fuchs_fds_pdf.vb) ──────────
string ibanLine = p13b var reg = inv.InvoiceRegistration;
? "Die Steuerschuldnerschaft geht auf den Leistungsempf\u00e4nger \u00fcber (§ 13b UStG)." void Note(string text, string style = "InvoiceNotes")
: $"Bitte \u00fcberweisen Sie den Rechnungsbetrag innerhalb von {terms} auf unser Konto:\n" +
"IBAN: DE76\u00a03005\u00a00110\u00a00045\u00a00148\u00a000, BIC DUSSSDEDDXXX (Stadtsparkasse D\u00fcsseldorf)";
{ {
var p = sec.AddParagraph(); p.Style = "InvoiceNotes"; if (string.IsNullOrEmpty(text)) return;
p.AddText(ibanLine); var np = sec.AddParagraph(); np.Style = style; np.AddText(text);
} }
ParseDec(reg?.getItem("InvoiceService"), out decimal invService);
string serviceGross = Currency(reg?.getItem("InvoiceService"));
if (p13b)
{
string ustString = ParseDec(reg?.getItem("InvoiceVAT_1"), out decimal ust)
? $" mit einem Steuersatz von {ust.ToString("0.##", DeCulture)}%" : "";
Note("Gem. §13b Umsatzsteuergesetz unterliegen Sie der Steuerschuldnerschaft des " +
$"Leistungsempfängers zur Umsatzsteuer aus dieser Rechnung{ustString}.");
}
if (inv.InvoiceType == "i")
{
Note("Für bereits erbrachte Arbeiten, Dienstleistungen, Materiallieferungen und getätigte " +
"Bestellvorgänge zum oben genannten Bauvorhaben, die sich aus dem mit Ihnen geschlossenen " +
"Vertrag ergeben, stellen wir Ihnen vertragsgemäß unsere Akontozahlung in Rechnung. " +
"Eine Endabrechnung erhalten Sie als Schlussrechnung nach Abschluss des gesamten Bauvorhabens. " +
"Das Ausführungsdatum entnehmen Sie bitte dem Schlusstext dieser Rechnung. Wir danken Ihnen " +
"herzlich für das entgegengebrachte Vertrauen und bitten Sie um kurzfristigen Ausgleich der Akontorechnung.");
}
else if (invService > 0 && !p13b && serviceGross != "?")
{
Note($"Im Bruttobetrag sind {serviceGross} Lohnkosten enthalten " +
$"(netto {Currency(reg?.getItem("InvoiceService_net"))}). " +
$"Die darin enthaltene Mehrwertsteuer beträgt {Currency(reg?.getItem("InvoiceService_VAT"))}.");
}
Note("Bitte beachten Sie, nach §14 Abs. 1 Umsatzsteuergesetz ist diese Rechnung ein Zahlungsbeleg " +
"oder eine andere beweiskräftige Unterlage für 2 Jahre nach Ablauf des Kalenderjahres der " +
"Ausstellung dieser Rechnung aufzubewahren, soweit nicht aufgrund anderer gesetzlicher Regelungen " +
"andere ggf. längere Aufbewahrungsfristen gelten.");
if (inv.InvoiceType != "i" && invService > 0 && !p13b)
{
decimal refundRate = ParseDec(reg?.getItem("tax_servicerefund"), out decimal rr) ? rr : 0.2m;
Note(($"Privathaushalten erstattet das Finanzamt bis zu {Currency(invService * refundRate)} " +
"des Arbeitslohns mit der nächsten Steuererklärung.").ToUpper(), "InvoiceNotes_ucb");
}
Note("Unsere Allgemeinen und ihnen bekannten Geschäftsbedingungen gelten für alle unsere Angebote. " +
"Wir liefern oder leisten ausschließlich zu diesen Bedingungen. Andere Bedingungen werden nicht " +
"Vertragsinhalt, auch wenn wir diesen nicht ausdrücklich widersprochen haben. Ergänzend zu diesen " +
"Bedingungen gelten unsere Zusatzbedingungen für allgemeine Dienstverträge, Handwerksleitungen und " +
"Wartungsverträge. Spätestens mit der Entgegennahme der entsprechenden Lieferung und/oder Leistung " +
"gelten unsere Bedingungen als angenommen. Sie gelten auch für künftige Geschäftsbeziehungen, auch " +
"wenn sie nicht nochmals ausdrücklich vereinbart werden. Insbesondere auch was die Datenverarbeitung " +
"nach Datenschutz-Grundverordnung (DSGVO) Artikel 5 anbelangt.");
Note("Steuernummer: 106/5849/2962");
string paymentTermPhrase = (reg?.nz("PaymentTermPhrase") ?? "")
.ne($"Zahlbar innerhalb von {TranslatePaymentTerm(reg?.nz("PaymentTerm") ?? "")}.");
Note("Freistellungsbescheinigung zum Steuerabzug bei Bauleistungen gemäß § 48 Abs. 1 Satz1 des EStG " +
"liegt vor. Es gelten unsere derzeit gültigen allgemeinen Liefer- und Zahlungsbedingungen. " +
$"{paymentTermPhrase} Danach erfolgt Verzugseintritt ohne Mahnung (§ 286 Absatz II BGB).");
Note("Hinweis zu unseren Verrechnungssätzen: In den ausgewiesenen Arbeitswerten sind die Dienstleistungen " +
"als Arbeitslohn auf Basis der benötigten Zeit enthalten, inklusive der Fahrtzeit, Rüstzeit, " +
"Auftragsvorbereitung und Werkzeugen (ausgenommen Spezialwerkzeuge wie Pressen Stemmhammer, etc.) " +
"und die Verfügbarkeit von gängigen Ersatzteilen im Kundendienstfahrzeug. Alle Reparatureinsätze " +
"(ggf. nur oder einschließlich Störungsdiagnoseeinsätze) des Kundendienst werden grundsätzlich mit " +
"einem Verrechnungssatz nach Aufwand (1 Arbeitswert/Stück Zeiteinheit = 10 Minuten) abgerechnet. " +
"Der Verrechnungssatz „Servicepauschale/ Notdienst” ausschließlich ausserhalb unserer Öffnungszeiten " +
"am Samstag und Sonntag sowie feiertags.");
Note("Weitere Informationen erhalten Sie unter www.sanitaerfuchs.de");
Note("\"Ach übrigens, wenn Sie mit uns zufrieden waren, dann sagen Sie es doch bitte den anderen. " +
"Und falls Sie mal nicht so zufrieden sind, dann sagen Sie es bitte gleich uns.\" Denn schließlich " +
"ist die Zufriedenheit unserer Kunden unser wichtigstes Ziel - und ihre Weiterempfehlung unsere " +
"Beste Visitenkarte.");
Note("PLANT-MY-TREE für jedes gebaute Badezimmer und für jede gebaute Heizung spenden wir einen Baum. " +
"PLANT MY TREE führt als Unternehmen eigene Erstaufforstungsprojekte auf eigenen Flächen in " +
"Deutschland durch, die zuvor anderweitig genutzt wurden. Im Vorfeld arbeiten wir dabei eng mit den " +
"lokalen Forstbehörden zusammen. Unser Ziel ist die langfristige CO2-Kompensierung und damit der " +
"nachhaltige Umwelt- und Klimaschutz. Nach der Vermeidung des CO2-Ausstoß bzw. der Reduzierung ist " +
"die Aufforstung nicht nur unserer Meinung nach der beste und nachhaltigste Weg, das Klima und damit " +
"die Umwelt zu schützen. Deshalb konzentrieren wir uns auf die Aufforstung von Flächen.");
Note("Wir bedanken uns herzlich für Ihren Auftrag.");
// GiroCode payment QR (only on finalized invoices with a positive balance)
ParseDec(reg?.getItem("InvoiceBalance"), out decimal payAmount);
if (!inv.IsDraft && payAmount > 0 && !string.IsNullOrWhiteSpace(inv.InvoiceId))
AddGirocode(sec, payAmount, $"{inv.InvoiceTitle.ne("Rechnung")} {inv.InvoiceId}");
} }
// ── ApplyReminder ───────────────────────────────────────────────────────── // ── ApplyReminder ─────────────────────────────────────────────────────────
@@ -602,6 +681,10 @@ public static class FuchsPdf
$"Bitte \u00fcberweisen Sie den offenen Betrag von {Currency(openTotal)} innerhalb von {terms} auf unser Konto:\n" + $"Bitte \u00fcberweisen Sie den offenen Betrag von {Currency(openTotal)} innerhalb von {terms} auf unser Konto:\n" +
"IBAN: DE76\u00a03005\u00a00110\u00a00045\u00a00148\u00a000, BIC DUSSSDEDDXXX (Stadtsparkasse D\u00fcsseldorf)"); "IBAN: DE76\u00a03005\u00a00110\u00a00045\u00a00148\u00a000, BIC DUSSSDEDDXXX (Stadtsparkasse D\u00fcsseldorf)");
// GiroCode payment QR (only on finalized reminders with a positive open amount)
if (!rem.IsDraft && openTotal > 0 && !string.IsNullOrWhiteSpace(rem.InvoiceId))
AddGirocode(sec, openTotal, $"Rechnung {rem.InvoiceId}");
// Greeting // Greeting
var greet = sec.AddParagraph(); greet.Style = "BodyText"; var greet = sec.AddParagraph(); greet.Style = "BodyText";
greet.Format.SpaceBefore = cm(0.8); greet.Format.SpaceBefore = cm(0.8);
@@ -711,6 +794,63 @@ public static class FuchsPdf
} }
} }
/// <summary>
/// Renders the SEPA "GiroCode" payment QR (EPC069-12) into a two-column box,
/// matching the legacy fuchs_fds_pdf.vb invoice/reminder layout. No-op on failure.
/// </summary>
private static void AddGirocode(Section sec, decimal amount, string purpose)
{
byte[]? girocodeImg = null;
try
{
using var girocode = GetPaycode(
iban: "DE52301502000002091478", bic: "WELADED1KSD",
name: "Sebastian Fuchs Bad und Heizung", amount: amount, purpose: purpose);
girocodeImg = ImageToByteArray(girocode);
}
catch { girocodeImg = null; }
if (girocodeImg == null) return;
var spacer = sec.AddParagraph();
spacer.Format.SpaceBefore = cm(2);
spacer.AddText("");
var girotbl = sec.AddTable();
girotbl.AddColumn(cm(17 - 3 - 0.6));
girotbl.AddColumn(cm(3 + 0.6));
girotbl.Borders.Color = Colors.Black;
girotbl.Borders.Width = 0.5;
girotbl.Borders.Style = BorderStyle.Single;
girotbl.Borders.Distance = cm(1);
var rw = girotbl.AddRow();
rw.Cells[0].Borders.Right.Visible = false;
rw.Cells[0].Format.LeftIndent = cm(0.6);
var p = rw.Cells[0].AddParagraph();
p.Format.SpaceBefore = cm(0.3);
p.Format.SpaceAfter = cm(0.2);
p.AddText("Zahlen mit Girocode. Mit dem GiroCode bezahlen Sie Ihre Rechnungen schnell, " +
"sicher und vor allem fehlerfrei. Ihre Banking App liest aus dem Code alle " +
"relevanten Daten für Ihre Überweisung.");
p = rw.Cells[0].AddParagraph();
p.AddText("Weitere Infos finden Sie unter ");
p.AddHyperlink("http://www.girocode.de", HyperlinkType.Web).AddText("http://www.girocode.de");
p.AddText(".");
rw.Cells[1].Borders.Left.Visible = false;
var imgPara = rw.Cells[1].AddParagraph();
var img = imgPara.AddImage(MigraDocFilenameFromByteArray(girocodeImg));
img.Resolution = 300;
img.WrapFormat.Style = WrapStyle.TopBottom;
img.RelativeHorizontal = RelativeHorizontal.Column;
img.RelativeVertical = RelativeVertical.Line;
img.Width = cm(3);
img.LockAspectRatio = true;
img.Left = ShapePosition.Right;
img.Top = ShapePosition.Top;
}
private static string MigraDocFilenameFromByteArray(byte[] image) => private static string MigraDocFilenameFromByteArray(byte[] image) =>
"base64:" + Convert.ToBase64String(image); "base64:" + Convert.ToBase64String(image);
+113 -9
View File
@@ -1,20 +1,124 @@
using Fuchs.Controllers; using Fuchs.Controllers;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static OCORE.web.mvc_helper_async; using Microsoft.Data.SqlClient;
using OCORE.SQL;
using static OCORE.commons;
using static OCORE.OCORE_dictionaries;
using static OCORE.SQL.sql;
namespace Fuchs.intranet; namespace Fuchs.intranet;
/// <summary> /// <summary>
/// Report processing helpers for the Fuchs intranet. /// Report processing for the Fuchs intranet — SQL-driven reports from the
/// Delegates to the SQL-driven report catalog. /// fds__ report catalog, rendered as HTML pages, HTML fragments, or PNG charts.
/// Ported from the legacy fuchs_reports.vb (process_fdsrequest) + ocms_visualization.
/// </summary> /// </summary>
public static class FuchsReports public static class FuchsReports
{ {
public static async Task<IActionResult> ProcessFdsRequest( private const int DefaultReloadSeconds = 60 * 10;
IntranetController ctrl, string action, string id)
/// <param name="ctrl">Current controller (DB, security, request, user).</param>
/// <param name="fnc">Target function (e.g. "generic", "generic_content"/"gct", "chart").</param>
/// <param name="id">Report name/id (the URL <c>code</c> segment).</param>
public static async Task<IActionResult> ProcessFdsRequest(IntranetController ctrl, string fnc, string id)
{ {
// Specific report actions are dispatched here. // Merge query string + form into a single parameter map (form wins); force @authuser.
// Extend with additional cases from the VB fuchs_reports.vb as needed. var prms = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
return new OkResult(); foreach (var kv in ctrl.Request.Query) prms[kv.Key] = kv.Value.ToString();
if (ctrl.Request.HasFormContentType)
foreach (var kv in ctrl.Request.Form) prms[kv.Key] = kv.Value.ToString();
prms["@authuser"] = ctrl.UserAccountID;
string tgt = (string.IsNullOrEmpty(fnc)
? (prms.TryGetValue("fnc", out var f) ? f : fnc)
: fnc).Replace("gct", "generic_content");
string report = prms.TryGetValue("report", out var r) && !string.IsNullOrEmpty(r)
? r
: (!string.IsNullOrEmpty(id) ? id : "");
string templatePath = Path.Combine(AppContext.BaseDirectory, "Content", "FDS_Template.html");
// Report configuration (refresh interval + cache flag) from the catalog.
int ciRefresh = -2;
bool ciCache = false;
try
{
var catalog = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__admin_getReportCatalog] @report_name, @authuser;",
ctrl._intranet.Intranet__SQLConnectionString,
new List<SqlParameter> { SQL_VarChar("@report_name", report), SQL_VarChar("@authuser", ctrl.UserAccountID) },
Security: ctrl.DbSec, options: new FIS_SQLOptions());
var cfg = catalog.FirstRow.toObjectDictionary();
if (cfg.TryGetValue("refresh", out var rf) && rf is not null && rf is not DBNull &&
int.TryParse(rf.ToString(), out var rfi)) ciRefresh = rfi;
if (cfg.TryGetValue("functions", out var fn) && fn is not null)
ciCache = (fn.ToString() ?? "").Split(',').Contains("cache");
}
catch (Exception cex)
{
ctrl._intranet.debug_log("FuchsReports.ProcessFdsRequest - catalog", ex: cex);
}
bool ciForce = prms.TryGetValue("cache", out var ca) && ca.ToLower() == "0";
try
{
switch (tgt)
{
case "generic_content":
if (string.IsNullOrEmpty(report)) return new StatusCodeResult(300);
string content = await FuchsVisualization.RenderContentAsync(
ctrl, report, FdsQueryType.generic, prms);
return new ContentResult { Content = content, ContentType = "text/html" };
case "generic":
{
if (string.IsNullOrEmpty(report)) return new StatusCodeResult(300);
var page = await FuchsVisualization.RenderPageAsync(
ctrl, report, report, FdsQueryType.generic, prms,
FdsDestination.web, templatePath, allowcache: ciCache, forceReload: ciForce);
ApplyReload(page, prms, ciRefresh);
return new ContentResult { Content = page.ToHtml(FdsDestination.web), ContentType = "text/html" };
}
case "chart":
byte[]? png = await FuchsVisualization.RenderQueryAsChartAsync(
ctrl, report, FdsQueryType.generic, prms);
if (png is null) return new StatusCodeResult(500);
return new FileContentResult(png, "image/png")
{
FileDownloadName = $"{report.Replace(" ", "_")}_{DateTime.Now:yyyyMMdd_HHmm}.png"
};
case "xls":
return new StatusCodeResult(501); // not implemented (matches legacy NotImplementedException)
default:
if (Enum.TryParse<FdsQueryType>(fnc, ignoreCase: true, out var qt))
{
if (string.IsNullOrEmpty(report)) return new StatusCodeResult(300);
var page = await FuchsVisualization.RenderPageAsync(
ctrl, report, report, qt, prms, FdsDestination.web, templatePath);
ApplyReload(page, prms, -2);
return new ContentResult { Content = page.ToHtml(FdsDestination.web), ContentType = "text/html" };
}
return new StatusCodeResult(300);
}
}
catch (Exception ex)
{
ctrl._intranet.debug_log("FuchsReports.ProcessFdsRequest",
ex: ex, data: new { fnc, id, report, tgt });
return new StatusCodeResult(500);
}
}
private static void ApplyReload(FuchsHtmlPage page, IDictionary<string, string> prms, int ciRefresh)
{
if (prms.TryGetValue("reload", out var rl) && int.TryParse(rl, out var rs)) page.ReloadSeconds = rs;
else if (ciRefresh > -2) page.ReloadSeconds = ciRefresh;
else if (DefaultReloadSeconds > 0) page.ReloadSeconds = DefaultReloadSeconds;
if (page.QueryDuration > 180 && page.ReloadSeconds is > 0 and < 3600) page.ReloadSeconds = 1200;
else if (page.QueryDuration > 60 && page.ReloadSeconds is > 0 and < 1200) page.ReloadSeconds = 1200;
} }
} }
+556
View File
@@ -0,0 +1,556 @@
using System.Data;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using Fuchs.Controllers;
using HtmlAgilityPack;
using Microsoft.Data.SqlClient;
using Newtonsoft.Json;
using OCORE.GenericCharts;
using OCORE.SQL;
using static OCORE.commons;
using static OCORE.SQL.sql;
namespace Fuchs.intranet;
// Ported from OCORE_web/imported/commons/ocms_visualization.vb.
// Renders SQL-driven reports (from the fds__ report catalog) as HTML pages,
// HTML fragments, or PNG charts. Reuses the ported OCORE chart engine
// (OCORE.GenericCharts.Chart) for chart visualizations.
public enum FdsQueryType { udf = 1, udp = 2, generic = 3, dashboard = 4 }
public enum FdsDestination { web, email, content }
/// <summary>
/// HTML page builder for reports. Optionally injects content into an HTML
/// template (FDS_Template.html); falls back to a minimal document otherwise.
/// </summary>
public class FuchsHtmlPage
{
public string Title { get; set; } = "Reporting";
public string Style { get; set; } = "body { font-family: Arial, sans-serif; padding: 3rem; margin: 0; } ";
public string Script { get; set; } = "";
public int ReloadSeconds { get; set; } = -1;
public int QueryDuration { get; set; }
public readonly List<string> Links = new();
private readonly StringBuilder _content = new();
private readonly string _templatePath;
public FuchsHtmlPage(string title, string templatePath, int queryDuration = 0)
{
Title = string.IsNullOrEmpty(title) ? "Report" : title;
_templatePath = templatePath;
QueryDuration = queryDuration;
}
public void Add(string html) => _content.Append(html);
private bool TemplateExists()
{
try { return !string.IsNullOrEmpty(_templatePath) && File.Exists(_templatePath); }
catch { return false; }
}
public string ToHtml(FdsDestination destination)
{
string htmlcode;
if (destination == FdsDestination.web && TemplateExists())
{
try { htmlcode = File.ReadAllText(_templatePath); }
catch { htmlcode = BasicShell(); }
}
else htmlcode = BasicShell();
try
{
var doc = new HtmlDocument();
doc.LoadHtml(htmlcode);
var headNode = doc.DocumentNode.SelectSingleNode("//head");
var bodyNode = doc.DocumentNode.SelectSingleNode("//body");
var mainNode = doc.DocumentNode.SelectSingleNode("//body/main");
if (headNode != null)
{
if (destination == FdsDestination.web && ReloadSeconds > 9)
{
var meta = HtmlNode.CreateNode($"<meta http-equiv=\"refresh\" content=\"{ReloadSeconds}\" />");
var firstMeta = headNode.SelectSingleNode("//meta");
if (firstMeta != null) headNode.InsertAfter(meta, firstMeta);
else headNode.ChildNodes.Add(meta);
}
if (destination == FdsDestination.web)
{
var titleNode = headNode.SelectSingleNode("//title");
if (titleNode != null) titleNode.InnerHtml = WebUtility.HtmlEncode(Title);
else headNode.AppendChild(HtmlNode.CreateNode($"<title>{WebUtility.HtmlEncode(Title)}</title>"));
foreach (var lnk in Links) headNode.AppendChild(HtmlNode.CreateNode(lnk));
}
if (!string.IsNullOrEmpty(Style))
headNode.AppendChild(HtmlNode.CreateNode($"<style type=\"text/css\">{Style}</style>"));
if (destination == FdsDestination.web && !string.IsNullOrEmpty(Script))
headNode.AppendChild(HtmlNode.CreateNode($"<script type=\"text/javascript\">{Script}</script>"));
}
if (mainNode != null) mainNode.InnerHtml = _content.ToString();
else if (bodyNode != null) bodyNode.InnerHtml = _content.ToString();
return doc.DocumentNode.OuterHtml;
}
catch
{
var sb = new StringBuilder();
if (destination == FdsDestination.web) sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\"><head><meta charset=\"utf-8\" />");
if (ReloadSeconds > 9) sb.AppendLine($"<meta http-equiv=\"refresh\" content=\"{ReloadSeconds}\" />");
if (destination == FdsDestination.web)
{
sb.AppendLine($"<title>{WebUtility.HtmlEncode(Title)}</title>");
foreach (var lnk in Links) sb.AppendLine(lnk);
}
if (!string.IsNullOrEmpty(Style)) sb.AppendLine($"<style type=\"text/css\">{Style}</style>");
if (destination == FdsDestination.web && !string.IsNullOrEmpty(Script))
sb.AppendLine($"<script type=\"text/javascript\">{Script}</script>");
sb.AppendLine("</head><body>");
sb.Append(_content);
sb.AppendLine("</body></html>");
return sb.ToString();
}
}
private static string BasicShell() =>
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\" /><title></title></head><body></body></html>";
}
/// <summary>Simple file-based report cache (tmp/*.cache), TTL 72h.</summary>
internal sealed class ManagedCache
{
private readonly DirectoryInfo? _tmp;
private readonly string _file;
private const string Ext = "cache";
public bool IsValid { get; }
public ManagedCache(string filename)
{
_file = string.IsNullOrEmpty(filename) ? "" : (filename.EndsWith(Ext) ? filename : filename + "." + Ext);
try
{
var baseDir = ApplicationBase();
if (baseDir != null)
{
_tmp = baseDir.GetDirectories("tmp").FirstOrDefault()
?? baseDir.CreateSubdirectory("tmp");
IsValid = _tmp.Exists;
if (IsValid)
foreach (var f in _tmp.GetFiles("*." + Ext))
try { if (DateTime.UtcNow.Subtract(f.CreationTimeUtc.Date).TotalHours > 72) f.Delete(); }
catch { /* ignore */ }
}
}
catch { IsValid = false; }
}
private string FullPath => Path.Combine(_tmp!.FullName, _file);
public bool Exists => IsValid && (File.Exists(FullPath) || File.Exists(FullPath.Replace(" ", "+")));
public string Read()
{
try { return Exists ? File.ReadAllText(File.Exists(FullPath) ? FullPath : FullPath.Replace(" ", "+")) : ""; }
catch { return ""; }
}
public void Write(string content)
{
try { if (IsValid && !string.IsNullOrEmpty(content)) File.WriteAllText(FullPath, content, Encoding.Unicode); }
catch { /* ignore */ }
}
public void Delete()
{
try { if (Exists) File.Delete(File.Exists(FullPath) ? FullPath : FullPath.Replace(" ", "+")); }
catch { /* ignore */ }
}
}
public static class FuchsVisualization
{
// ── Query execution ─────────────────────────────────────────────────────
private static string ConnStr(IntranetController ctrl) => ctrl._intranet.Intranet__SQLConnectionString;
/// <summary>Runs a report (fds__r_ / fds__xls_) and returns its admin table (ADT) + data tables.</summary>
public static async Task<(DataTable? adt, List<DataTable> dt)> GetQuery(
IntranetController ctrl, string query, IDictionary<string, string> prms)
{
var dtList = new List<DataTable>();
DataTable? adt = null;
bool isReport = query.StartsWith("fds__r_");
bool isXls = query.StartsWith("fds__xls_");
if (!isReport && !isXls) return (adt, dtList);
var reportinfo = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__admin_getReportCatalog] @report_name, @authuser;",
ConnStr(ctrl),
new List<SqlParameter> { SQL_VarChar("@report_name", query), SQL_VarChar("@authuser", ctrl.UserAccountID) },
tablenames: new[] { "procedures", "parameter", "categories", "tags" },
Security: ctrl.DbSec, options: new FIS_SQLOptions());
if (!reportinfo.Contains("procedures") || reportinfo.Tables("procedures").Rows.Count == 0)
return (adt, dtList);
var qparams = GetQParams(reportinfo.Tables("parameter"), prms);
var procRow = reportinfo.Tables("procedures").Rows[0];
string sql = $"EXECUTE [dbo].[{procRow["name"]}] {procRow["parameter"]};";
var dset = await getSQLDataSet_async(sql, ConnStr(ctrl), qparams,
Security: ctrl.DbSec, options: new FIS_SQLOptions());
if (isXls)
{
for (int i = 0; i < dset.Count; i++) dtList.Add(dset.Tables(i));
}
else if (dset.Count >= 1)
{
if (dset.Count > 1)
{
for (int i = 1; i < dset.Count; i++) dtList.Add(dset.Tables(i));
adt = dset.Tables(0);
}
else dtList.Add(dset.Tables(0));
}
return (adt, dtList);
}
private static List<SqlParameter> GetQParams(DataTable paramTbl, IDictionary<string, string> prms)
{
var list = new List<SqlParameter>();
foreach (DataRow prw in paramTbl.Rows)
{
string name = prw["name"]?.ToString() ?? "";
string type = (prw.Table.Columns.Contains("Type") ? prw["Type"]?.ToString() : "")?.ToLower() ?? "";
string val = ParamValue(prms, name);
switch (type)
{
case "int": list.Add(SQL_Int(name, stringvalue: val)); break;
case "bigint": list.Add(SQL_BigInt(name, stringvalue: val)); break;
default: list.Add(new SqlParameter(name, (object?)(string.IsNullOrEmpty(val) ? null : val) ?? DBNull.Value)); break;
}
}
return list;
}
private static string ParamValue(IDictionary<string, string> prms, string name)
{
if (prms.TryGetValue(name, out var v) && v != null) return v;
string trimmed = name.TrimStart('@');
if (prms.TryGetValue(trimmed, out var v2) && v2 != null) return v2;
return "";
}
// ── Chart settings ──────────────────────────────────────────────────────
private static ChartSettingsDic GetChartSettings(DataRow adtRow, DataTable dataTbl)
{
var cs = new ChartSettingsDic();
if (!adtRow.Table.Columns.Contains("settings") ||
adtRow["settings"] is DBNull ||
string.IsNullOrEmpty(adtRow["settings"]?.ToString()))
return cs;
var imported = JsonConvert.DeserializeObject<Dictionary<string, object?>>(adtRow["settings"]!.ToString()!);
if (imported != null) cs.Import(imported, true, true);
cs["title"] = ColValue(adtRow, "title");
cs["label"] = ColValue(adtRow, "label");
if (!cs.ContainsKey("x1_column") && dataTbl.Columns.Count == 2) cs["x1_column"] = dataTbl.Columns[0].ColumnName;
if (!cs.ContainsKey("y1_column") && dataTbl.Columns.Count == 2) cs["y1_column"] = dataTbl.Columns[1].ColumnName;
return cs;
}
private static async Task<string?> ChartDataUriAsync(DataTable data, ChartSettingsDic cs)
{
if (cs.StringIf("y1_column") == "" || cs.StringIf("x1_column") == "") return null;
try
{
var chart = new Chart(data, DateTime.Now);
chart.Init(cs, null);
string b64 = await chart.ToBase64string(System.Drawing.Imaging.ImageFormat.Png);
return "data:image/png;base64," + b64;
}
catch { return null; }
}
// ── HTML form builder (tabpages with table / chart / html visualizations) ─
private static async Task<string> BuildFormHtmlAsync(
DataTable? adt, List<DataTable> dt, FdsQueryType qtype, FdsDestination dest,
IDictionary<string, string> prms, FuchsHtmlPage page, string query)
{
var sb = new StringBuilder();
sb.Append("<div class=\"frm\" id=\"frm\">");
if (qtype != FdsQueryType.dashboard)
{
int selectedtab = (prms.TryGetValue("tab", out var tv) && int.TryParse(tv, out var ti)) ? ti : -1;
int from = selectedtab > 0 ? selectedtab - 1 : 0;
int to = selectedtab > 0 ? selectedtab - 1 : dt.Count - 1;
for (int dti = from; dti <= to && dti < dt.Count; dti++)
{
bool hasAdmin = adt != null && adt.Rows.Count > dti;
DataRow? aRow = hasAdmin ? adt!.Rows[dti] : null;
string label = AdtStr(aRow, "Label", query);
string css = AdtStr(aRow, "class", "");
string style = AdtStr(aRow, "style", "");
string vtype = AdtStr(aRow, "typ", "table");
sb.Append($"<div class=\"tabpage {WebUtility.HtmlEncode(css)}\" id=\"tab_{dti}\" data-title=\"{WebUtility.HtmlEncode(label)}\">");
if (dt[dti].Rows.Count > 0)
{
sb.Append("<div class=\"headline\" style=\"margin-bottom: 2rem; line-height: 1.3;\">");
sb.Append($"<h1>{WebUtility.HtmlEncode(label)}</h1>");
sb.Append($"<h3>{WebUtility.HtmlEncode(AdtStr(aRow, "SubLabel", ""))}</h3>");
sb.Append("</div>");
switch (vtype)
{
case "html":
sb.Append("<div>");
if (dt[dti].Rows.Count == 1 && dt[dti].Columns.Count == 1 &&
(dt[dti].Rows[0][0]?.ToString() ?? "").StartsWith("<"))
sb.Append(dt[dti].Rows[0][0]);
sb.Append("</div>");
break;
case "chart":
sb.Append("<div class=\"chartframe\">");
if (aRow != null)
{
var cs = GetChartSettings(aRow, dt[dti]);
string? src = await ChartDataUriAsync(dt[dti], cs);
if (src != null) sb.Append($"<img src=\"{src}\" />");
}
sb.Append("</div>");
break;
default: // "table"
sb.Append(BuildTableHtml(dt[dti]));
break;
}
if (!string.IsNullOrEmpty(style)) page.Style += " " + style;
string legend = AdtStr(aRow, "legend", "");
if (!string.IsNullOrEmpty(legend))
sb.Append($"<div class=\"legend\">{legend}</div>");
}
else
{
sb.Append("<div style=\"font-size:14px; color: red;\">No Data Found</div>");
}
sb.Append("</div>");
}
}
else if (adt != null && adt.Rows.Count == 1)
{
// Dashboards: data via JSON (script) + layout via id-based css/js
page.Script = JsonConvert.SerializeObject(dt);
string id = AdtStr(adt.Rows[0], "id", "");
if (!string.IsNullOrEmpty(id))
{
page.Links.Add($"<link rel=\"stylesheet\" href=\"/Content/{id}.css\" type=\"text/css\" />");
page.Links.Add($"<script src=\"/Scripts/{id}.js\" type=\"text/javascript\"></script>");
}
}
else
{
sb.Append("<div style=\"font-size:14px; color: red;\">Nothing Found</div>");
}
sb.Append("</div>");
return sb.ToString();
}
private static string BuildTableHtml(DataTable tbl)
{
var sb = new StringBuilder();
sb.Append("<table style=\"border-collapse: collapse;border: 1px solid #000;\"><thead><tr>");
for (int ci = 0; ci < tbl.Columns.Count; ci++)
{
var c = tbl.Columns[ci];
if (c.ColumnName == "order" || c.ColumnName.StartsWith("css") || c.ColumnName.StartsWith("style")) continue;
sb.Append($"<th class=\"trh {ToCssClass(c.ColumnName, ci + 1, 0)}\" " +
"style=\"white-space:nowrap; padding: 0.5rem 1rem; font-weight: 600;border:1px solid #727272;border-bottom: 3px double #727272;\">" +
$"{WebUtility.HtmlEncode(c.ColumnName)}</th>");
}
sb.Append("</tr></thead><tbody>");
var sorted = tbl.Select("", tbl.Columns.Contains("order") ? "order" : "");
for (int rwi = 0; rwi < sorted.Length; rwi++)
{
var rw = sorted[rwi];
string trStyle = "font-weight: 400";
if (tbl.Columns.Contains("css") && rw["css"] is not DBNull && !string.IsNullOrEmpty(rw["css"]?.ToString()))
trStyle = rw["css"]!.ToString()!;
sb.Append($"<tr class=\"{(rwi == sorted.Length - 1 ? "last" : "")}\" style=\"{WebUtility.HtmlEncode(trStyle)}\">");
for (int ci = 0; ci < tbl.Columns.Count; ci++)
{
var c = tbl.Columns[ci];
if (c.ColumnName == "order") continue;
if (c.ColumnName.StartsWith("css:") || c.ColumnName.StartsWith("style:")) continue;
string tdStyle = "padding: 0.5rem 1rem; border:1px solid #727272;";
string extraCls = "";
if (tbl.Columns.Contains("css:" + c.ColumnName) && rw["css:" + c.ColumnName] is not DBNull &&
!string.IsNullOrEmpty(rw["css:" + c.ColumnName]?.ToString()))
extraCls = " " + rw["css:" + c.ColumnName];
if (tbl.Columns.Contains("style:" + c.ColumnName) && rw["style:" + c.ColumnName] is not DBNull &&
!string.IsNullOrEmpty(rw["style:" + c.ColumnName]?.ToString()))
tdStyle = (tdStyle + ";" + rw["style:" + c.ColumnName]).Replace(";;", ";");
sb.Append($"<td class=\"{ToCssClass(c.ColumnName, ci + 1, rwi + 1)}{extraCls}\" style=\"{tdStyle}\">");
if (rw[c] is not DBNull)
{
string val = rw[c]?.ToString() ?? "";
bool largeText = c.DataType == typeof(string) && (c.MaxLength == -1 || c.MaxLength > 100);
if (largeText)
{
sb.Append("<div>");
if (val.StartsWith("<") && val.EndsWith(">"))
sb.Append(WebUtility.HtmlEncode(val)); // matches legacy SOC .text() (InnerText) behavior
else
sb.Append(string.Join("<br />",
val.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n').Select(WebUtility.HtmlEncode)));
sb.Append("</div>");
}
else
{
sb.Append(WebUtility.HtmlEncode(val));
}
}
sb.Append("</td>");
}
sb.Append("</tr>");
}
sb.Append("</tbody></table>");
return sb.ToString();
}
private static string ToCssClass(string columnName, int c, int r) =>
$"c{c} r{r} _{columnName.Replace(" ", "_").ToLower()}";
private static string AdtStr(DataRow? row, string col, string fallback)
{
if (row == null || !row.Table.Columns.Contains(col) || row[col] is DBNull) return fallback;
string v = row[col]?.ToString() ?? "";
return string.IsNullOrEmpty(v) ? fallback : v;
}
private static string ColValue(DataRow row, string col) =>
row.Table.Columns.Contains(col) && row[col] is not DBNull ? row[col]?.ToString() ?? "" : "";
// ── Public render entry points ──────────────────────────────────────────
/// <summary>Renders a report as a bare HTML fragment (destination = content).</summary>
public static async Task<string> RenderContentAsync(
IntranetController ctrl, string query, FdsQueryType qtype, IDictionary<string, string> prms)
{
var (adt, dt) = await GetQuery(ctrl, query, prms);
var page = new FuchsHtmlPage("", "");
return await BuildFormHtmlAsync(adt, dt, qtype, FdsDestination.content, prms, page, query);
}
/// <summary>Renders a report as a full HTML page (destination = web / email), with optional caching.</summary>
public static async Task<FuchsHtmlPage> RenderPageAsync(
IntranetController ctrl, string uniquename, string query, FdsQueryType qtype,
IDictionary<string, string> prms, FdsDestination dest, string templatePath,
bool allowcache = false, bool forceReload = false, string title = "")
{
ManagedCache? cache = null;
string cached = "", cachedTitle = "";
if (allowcache)
{
try
{
var illegal = new Regex($"[{Regex.Escape(new string(Path.GetInvalidFileNameChars()))}]");
var keys = prms.Keys.OrderBy(k => k, StringComparer.Ordinal);
string paramStr = string.Join("&", keys.Select(k => $"{WebUtility.UrlEncode(k)}={WebUtility.UrlEncode(prms[k])}"));
string cacheName = illegal.Replace($"{DateTime.Now:yyyyMMdd}_{uniquename}$${paramStr}.cache", "_");
if (cacheName.Length <= 255)
{
cache = new ManagedCache(cacheName);
if (forceReload) cache.Delete();
else cached = cache.Read();
}
}
catch { /* caching is best-effort */ }
}
var page = new FuchsHtmlPage(string.IsNullOrEmpty(title) ? "Report" : title, templatePath);
string formHtml;
if (string.IsNullOrEmpty(cached))
{
var start = DateTime.Now;
var (adt, dt) = await GetQuery(ctrl, query, prms);
page.QueryDuration = (int)DateTime.Now.Subtract(start).TotalSeconds;
// Title from the admin table when available
if (adt != null && adt.Columns.Contains("title") && adt.Rows.Count > 0 && adt.Rows[0]["title"] is not DBNull)
page.Title = adt.Rows[0]["title"]?.ToString() ?? "FDS Berichte";
else
page.Title = "FDS Berichte";
formHtml = await BuildFormHtmlAsync(adt, dt, qtype, dest, prms, page, query);
if (allowcache && cache is { IsValid: true })
try
{
cache.Write(JsonConvert.SerializeObject(new Dictionary<string, string>
{ ["title"] = page.Title, ["frm"] = formHtml }));
}
catch { /* ignore */ }
}
else
{
var dic = JsonConvert.DeserializeObject<Dictionary<string, string>>(cached);
cachedTitle = dic != null && dic.TryGetValue("title", out var t) ? t : "";
formHtml = dic != null && dic.TryGetValue("frm", out var f) ? f : "";
if (!string.IsNullOrEmpty(cachedTitle)) page.Title = cachedTitle;
}
if (dest == FdsDestination.web)
{
page.Style = ".tabpage.inactive { display: none; } " + page.Style;
page.Links.Insert(0, "<script src=\"/Scripts/jquery.min.js\" type=\"text/javascript\"></script>");
if (qtype != FdsQueryType.dashboard)
{
page.Links.Add("<link rel=\"stylesheet\" href=\"/Web/ctm.css\" type=\"text/css\" />");
page.Links.Add("<script src=\"/Web/ctm.js\" type=\"text/javascript\"></script>");
}
}
page.Add(formHtml);
return page;
}
/// <summary>Renders a report query directly as a PNG chart.</summary>
public static async Task<byte[]?> RenderQueryAsChartAsync(
IntranetController ctrl, string query, FdsQueryType qtype, IDictionary<string, string> prms)
{
var (adt, dt) = await GetQuery(ctrl, query, prms);
if (adt == null || dt.Count == 0) return null;
var cs = GetChartSettings(adt.Rows[0], dt[0]);
if (cs.StringIf("y1_column") == "" || cs.StringIf("x1_column") == "") return null;
try
{
var chart = new Chart(dt[0], DateTime.Now);
chart.Init(cs, null);
return await chart.ToByteArray(System.Drawing.Imaging.ImageFormat.Png);
}
catch { return null; }
}
}