Skip to content

Reports

Genie offers three distinct kinds of report, for three different needs:

  1. Code-defined reports — a C# class that loads data, formats it, and returns a downloadable file (typically a PDF). Use these for pixel-formatted, printable documents (invoices, statements).
  2. SQL-view report grids — an ordinary read-only *.view.xml over a SQL view. Use these for interactive, filterable, on-screen reporting tables.
  3. Report pages — a *.report.xml dashboard of tiles, charts and tables over several datasets. Use these when one number or one grid is not the answer. Fully documented under Report pages (built by hand or in the designer).

A report is a class deriving from ReportBase (implementing IReportBuilder). It has full access to dependency injection (DbContext, session parameters, configuration) and owns its own data loading, formatting and styling. It implements BuildAsync() to return an IReport (content bytes + file name + content type + size).

The report key is the attribute’s positional argument; DisplayName, Description and Category are optional named properties (all three are inferred from the key when omitted).

[Report("sales.invoice", DisplayName = "Invoice", Category = "Sales")]
public class InvoiceReport(
ILogger logger,
ISessionParametersService sessionParams,
IServiceProvider services,
AppDbContext db) : ReportBase(logger, sessionParams, services)
{
public override async Task<IReport> BuildAsync()
{
var invoiceId = GetParameter<long>("invoiceId");
// load data, render a PDF, return an IReport …
return new Report { Content = bytes, ContentType = "application/pdf", FileName = "invoice.pdf" };
}
}

A report is resolved from the request scope, so it can inject anything the rest of the app injects — the request’s DbContext, unit of work and session services included.

The engine has no PDF dependency of its own: IReport is just content + content type + file name, so the choice of writer is yours. The Inventory sample uses QuestPDF (whose Community licence is free below a revenue threshold) — see sample/Inventory/api/Reports/StockSummaryReport.cs.

Reports are discovered by reflection over the [Report] attribute. Discovery registers both halves that a working report needs — the builder type in the service container (scoped) and the key → type entry in the IReportRegistry — so there is nothing else to wire:

services.AddGenie<AppDbContext>(genie => genie
.LoadFromConfiguration(configuration)
.AddReports(typeof(Program).Assembly));

AddReports(...) is optional: without it the engine scans the entry assembly plus Genie.Engine itself, which covers the normal single-host layout. Call it when the reports live in a separate class library, or to be explicit.

At request time ReportService looks the key up in the registry, resolves the builder from the current request scope, passes the parameters via WithParameters(...), and calls BuildAsync().

The report endpoints sit at the root /report path — a sibling of /files, not under the versioned /api/v1/genie base.

Method Route Returns
GET /report the registered reports: Key, DisplayName, Description, Category (metadata only)
GET /report/{reportName} the generated file (FileResult)

Every query-string value is passed through as a report parameter, except the reserved keys the endpoint and viewer own — reportName, report, title, inline, download. Read parameters in the builder with the typed helper GetParameter<T>("key").

GET /report/sales.invoice?invoiceId=42
GET /report/sales.invoice?invoiceId=42&inline=true

inline=true marks the response Content-Disposition: inline so a browser previews it instead of saving it; the default is an attachment (download). An unknown key is a 404 whose message deliberately does not enumerate the other registered reports.

A report is authenticated but not verb-gated per report: any signed-in user who knows a key can generate it. Scope the data inside the builder (the sample reads the tenant from the session, never from a parameter).

The endpoint is [Authorize], and the SPA holds its JWT in JavaScript — so a plain <a href> or window.open to /report/{key} cannot send the Authorization header and gets a 401. Link instead to the UI’s document viewer page, which fetches the bytes with the bearer token and renders them:

/report-view?report=sales.invoice&title=Invoice&invoiceId=42

report names the report, title sets the heading, and everything else is forwarded to the builder as a report parameter. The viewer renders a PDF as one scrolling column of every page, with a pager (its arrows jump to a page; its counter follows the scroll), zoom, Download and Print; an image renders inline; anything else downloads immediately.

Stored files have the same page at /file-view?path=Attachments/…&title=…, which previews images only — every other type (PDFs included) downloads.

Both are ordinary SPA routes, so they can be reached from anywhere a route can:

<!-- A grid/record row action: navigates to the viewer page -->
<Action Name="Invoice" Icon="fa fa-file-pdf"
Url="/report-view?report=sales.invoice&amp;title=Invoice&amp;invoiceId={Id}" />
<!-- Target="Modal" opens the same document in a dialog, without leaving the grid -->
<Action Name="Preview" Icon="fa fa-eye" Target="Modal"
Url="/report-view?report=sales.invoice&amp;title=Invoice&amp;invoiceId={Id}" />
<!-- A navbar entry (no row context, so no {Id}) -->
<Item Name="stock-summary" Label="Stock Summary" Icon="fa fa-file-pdf"
Route="/report-view?report=inventory.stock-summary&amp;title=Stock Summary" />

{Column} tokens in a row action’s Url are substituted from the row; tokens that land in the query string are percent-encoded, so a value containing & or # can’t corrupt the parameters after it.

Host apps can also open a document directly, with <GenieDocumentViewer> (a page) or <GenieDocumentModal> (a dialog), both exported from @orbyn-technologies/genie-engine-ui.

Many “reports” need no code at all — they are just a read-only grid over a database view. Author a normal object view whose <Sql> selects from a SQL view, and turn off CRUD:

<Table Name="LowStockReport" Slug="low-stock-report" Label="Low Stock"
DisableActions="true" DisableControls="true">
<ParentView Name="catalog" />
<Sql PrimaryKey="ProductId" SortBy="Shortfall" SortDirection="DESC" PageSize="25">
<![CDATA[
SELECT v.ProductId, v.Sku, v.Name, v.OnHand, v.ReorderLevel, v.Shortfall
FROM [Inventory].[vw_LowStock] v
WHERE v.CompanyId = @SessionCompanyId
]]>
</Sql>
<Columns>
<Column Name="Shortfall" Label="Shortfall"
StyleClassesExpression="If(Shortfall &gt; 10, 'badge bg-danger', 'badge bg-warning')" />
</Columns>
</Table>

DisableActions/DisableControls make it a pure report — no create/edit/delete. Rows can still be opened read-only; add ViewAction="Disabled" to drop that too. It still gets paging, sorting, per-column search, lookups and expression-driven styling from the standard object pipeline, and it is permission-filtered and tenant-scoped like any other view. The same SQL view can also back a navbar counts badge.

See sample/Inventory/models/views/LowStockReport.view.xml and CategoryStockReport.view.xml.

A report page is a whole dashboard in one document — <Filters>, <DataSets>, <Widgets> and <Layout> — served at /reports/{slug}. It can embed a report grid like the one above read-only, so a dashboard summarizes the grids you already have rather than restating their SQL.

Unlike a view, a report definition is managed rather than migrated: it lives in the database and is imported through the Reports definitions grid, exactly like a workflow or a wizard — or authored in the visual designer, where editing writes to a draft and a separate Publish is what visitors finally see.

See sample/Inventory/models/reports/InventoryOverview.report.xml.

Need Use
Printable/downloadable document, precise layout Code-defined report (ReportBase + PDF)
Interactive on-screen table, filter/sort/search SQL-view report grid (*.view.xml)
Several figures side by side — KPIs, charts, a top-N list Report page (*.report.xml)