VSA Monolith Framework

Reports

One screen lists every report a user may open, builds its filter form and opens it. Adding a report never adds a screen — it is two declarations and one class.

The three pieces

PieceWhereWhat it says
the catalogue entryconfig/apps/<app>.php → 'reports'its name, the module that gates it, its category, which filters it takes
the filter dictionaryconfig/apps/<app>.php → 'report_filters'what each filter code is: control, label, option list
the handlerApp\Reports\<StudlyCode>where the rows come from

The code is the link, and the only one: report item_list is handled by App\Reports\ItemList. There is no register to add it to — deploy the class and the code works. The code is also permanent: the console matches on it, so a new code is a new report.

config/apps/main.php
'reports' => [
    'item_list' => [
        'name'          => 'Item List',
        'module'        => 'RPC',                          // the XAuth module that gates it
        'category'      => 'Examples',                     // must be in 'report_categories'
        'params'        => 'item_id|start_date!|end_date', // ! required, =value default
        'actions'       => ['print', 'export_excel', 'export_csv'],
        'display_order' => 1,
        'live_filters'  => true,
    ],
],

'report_filters' => [
    'item_id'    => ['type' => 'select', 'label' => 'Item', 'width' => 'full',
                     'options' => 'items', 'value' => 'id', 'text' => 'name'],
    'start_date' => ['type' => 'date', 'label' => 'From'],
    'end_date'   => ['type' => 'date', 'label' => 'To'],
],

'report_categories' => ['Examples'],   // in display order

A dictionary entry may also name depends — the parent filters whose values scope its option list. A dependent select is filled only once every parent has a value, refilled when one changes, and emptied with its own children when one is cleared, so a stale child can never be submitted. lazy keeps a long list out of the first form payload and fetches it when the control is opened.

vsa check holds all three together: a category that is not declared, a filter the dictionary does not define, or a report without a module is an error — and the console refuses to save a report naming a filter it has never been told about, so the check catches it before the push does.

The commands

CommandWhat it does
make:report <app> <code>The catalogue entry and a handler stub. Takes --name=, --module=, --category=, --params=a|b, --permission=MOD.NAME or --open-access, --actions=print,export, --hidden, --no-handler
reports [app]The reports the code declares
rename:report <app> <code> "<New name>"The display name. The code never changes
report:permission <app> <code> <MOD.NAME | --own | --open-access>Gate it by an existing permission, by its own report.<code>, or not at all
xauth:syncPushes the catalogue and the dictionary to the console. Nothing is live until this runs
xauth:pullTakes console-side presentation edits back into config/apps

The handler writes a query, not a screen

View, Print, Excel and CSV are the base class's and all four render the same rows, so an export can never disagree with what was on screen. A filter map applies only the filters the user actually gave, so one query serves every combination without an if-chain; casts format cells and make money and integer columns summable; the layout declares grouping, subtotals, totals, signatures and orientation. Only a report none of that can express gets its own blade.

The ticket

A report is not opened by a link carrying its filters. The browser POSTs to /api/report-center/open; the server checks access now — this user, this report, these values — and answers with a signed, short-lived URL. GET /report/{ticket}, optionally /print, /xlsx, /csv or /pdf, then renders with no cookie, so a mobile app or a PDF service opens exactly the URL it was handed.

The ticket carries the decision, not a credential: an access token embedded in one went stale inside the ticket's own lifetime, so a link a minute old answered 401. Because access was decided when the ticket was issued, its TTL is the revocation window — keep it to minutes.

Who owns what, once it is live

vsa xauth:sync pushes the catalogue and the dictionary to the XAuth console, and from then on there are two owners:

  • the console owns presentation — name, description, icon, category, order, hidden, which filters and which are required, which actions a role may be granted. An engineer changes a live report there without a deploy.
  • the code owns data — the report code, the handler class, the filter dictionary. None of it can be typed into a console.

The sync is a three-way merge, not an overwrite: it records what the code last pushed, so a field only the console changed is kept, a field only the code changed is updated, and a field both changed is reported with the console's value kept. vsa xauth:pull writes those console edits back into config/apps so the two agree again. A catalogue row may exist before its class does — the report shows as "no handler" to engineers only, and lights up when the class deploys.

The actions a report offers beyond View are inherited from the application's Report Actions unless the entry names its own; both are set in the console under Catalog → Reports → Report actions.

The screen

ReportCenterComponent comes from vsd/report-center, lives in public/js/pages/common/ and is never edited. It is an ordinary page in the app file, gated by its own module, so it appears in the menu like any other.