Skip to content

[Reports]: Report Builder — bands + bricks #130

Description

@nielsdrost7

Story

As a company admin, I want a Report Builder where I design document templates by dropping Bricks (Company, Client, Invoice Meta, Items, Totals, …) into five bandsHeader, Group Header, Details, Group Footer, Footer — so that invoice and quote PDFs render exactly the layout my company needs, without touching code.

We deliver default bricks and default templates; the user clones and customizes. The PDF generators consume these templates — wanting multiple invoice templates (#411) is the same deliver-and-clone mechanism.

This reframes the original v1 PHP-block spec (kept at the bottom for history): v2 composes code-shipped mason bricks with data-only config files, never reorders raw PHP.

Part of epic #506 (Track A). Sub-tasks: #521, #528 (Phase 1), #523, #519, #525 (Phase 2).

Architecture (decided 2026-07-04)

Storage — pure files, no DB tables

resources/report-templates/                  # shipped with the app, version-controlled
  invoice/default/manifest.json              #   { name, slug, type: invoice, version }
  invoice/default/bands.json                 #   { header: […], group_header: […], details: […], group_footer: […], footer: […] }
  quote/default/{manifest,bands}.json
storage/app/report_templates/                # runtime disk 'report_templates'
  system/…                                   # synced from resources/ via `php artisan reports:sync-system`
  {company_id}/{slug}/{manifest,bands}.json  # user clones — clone = copy folder + rewrite manifest (name, slug, cloned_from)
  • No report_templates or report_blocks tables. Filament lists templates by scanning the disk; a thin ReportTemplateStorage service handles list/load/save/clone/delete.
  • bands.json entries are data only: { "brick": "header_company", "width": "half", "config": { "show_vat_id": true } }.

Bricks — code only

Classes in app/Mason/Bricks/ extending Awcodes\Mason\Brick, discovered via ReportBricksCollection (no DB registry). Each declares id/label/icon, allowedBands(), a config slide-over form, and toHtml()/toPreviewHtml() backed by escaped blade views. Details in #521. Seventeen bricks + views are harvested from feature/145-report-builder.

Builder UI

Admin-panel custom Filament pages (no Eloquent resource): a template list (system + current company) and a builder page with five stacked Mason canvases, one per band, each offering only the bricks allowed in that band. Cross-band moves use a "move to band…" action — no cross-canvas dragging (that is what stalled the previous attempt). Actions: Clone (system → company), Rename, Delete (company templates only; system read-only), Preview with dummy data. Details in #523, #519, #525.

Rendering

ReportRenderer: manifest + bands.json + entity → HTML (details band iterates line items; group bands repeat per group) → PDF through the domPDF driver from PR #575. Template resolution chain: document's pdf_template slug → company default setting → system/<type>/default, with the blade template as fallback until parity.

Security

  • No user-authored PHP/Blade, ever. Users compose shipped bricks; configs are pure data. (This deliberately supersedes the raw-source-editor approach of [Core]: Online PDF invoice template editor #401 — editing Blade in the browser is template-injection/RCE territory.)
  • bands.json validated on load: unknown brick ids skipped, config filtered against the brick's schema, widths/bands validated against enums.
  • Slugs sanitized ([a-z0-9-], no ..); storage paths derived from the tenant, never from user input; brick views escape everything with {{ }}; dompdf remote fetch disabled.

Acceptance criteria (Phase 3 exit)

  • php artisan reports:sync-system publishes shipped templates to storage/app/report_templates/system/ (idempotent).
  • Builder page shows five band canvases; each offers only bricks whose allowedBands() include that band.
  • Saving writes bands.json under the company folder; zero DB rows involved.
  • Clone copies a system template into the company folder; the clone is editable, the system original is not.
  • ReportRenderer renders invoice/default for a real invoice and produces non-empty PDF bytes via the domPDF driver.
  • Toggling a brick config off (e.g. show_vat_id: false) removes that field from the rendered output.
  • Band "keep together" emits page-break-inside: avoid; a PageBreak brick forces a page break ([Core]: PDF templates — page-break support #95).
  • Tenant isolation: company A cannot list or load company B's templates; ../ and invalid slugs are rejected.

Test scenarios (PHPUnit, class-based, #[Test] — no Pest)

#[Test]
public function it_round_trips_a_template_through_storage(): void
{
    $storage = app(ReportTemplateStorage::class);
    $storage->save($this->company->id, 'my-invoice', $manifest, $bands);
    $this->assertSame($bands, $storage->loadBands($this->company->id, 'my-invoice'));
}

#[Test]
public function it_rejects_path_traversal_slugs(): void
{
    $this->expectException(InvalidArgumentException::class);
    app(ReportTemplateStorage::class)->loadBands($this->company->id, '../22/secret');
}

#[Test]
public function it_renders_invoice_pdf_from_default_template(): void
{
    $invoice = Invoice::factory()->for($this->company)->create();
    $html = app(ReportRenderer::class)->renderHtml('system/invoice/default', $invoice);
    $this->assertStringContainsString($invoice->invoice_number, $html);
}

Dependencies & harvest source

  • PR [IP-542]: invoice edit page #575 (domPDF driver, Modules/Core/Support/PDF/Drivers/domPDF.php) must merge before Phase 3. Phases 1–2 are unblocked.
  • Branch feature/145-report-builder (PR [IP-130]: report builder #532, closed as superseded) is the harvest source: 17 bricks + blade views, ReportBand/ReportBlockWidth enums, DTOs, storage services, ~20 test files. Fresh work happens on feature/130-report-builder.

Original v1 spec (historical — superseded by the bands + bricks architecture above)

Core Functionality

  • Drag-and-Drop (DND) Interface: The user must be able to click and drag predefined content blocks (which represent the PHP code chunks) and drop them into designated sorting areas.
  • Encapsulation of PHP: Each draggable element must represent a complete, functional block of PHP/HTML from the original template (e.g., client_address_1) { ... } ?>). When the block is dragged, the entire underlying PHP string moves with it.
  • Sorting Zones: The tool must clearly define zones where dragging is allowed, such as:
  • Client Information Block (#client)
  • Company Information Block (#company)
  • Invoice Terms Block (.invoice-terms)
  • Cross-Zone Movement: Elements should be movable between the Client and Company zones (e.g., moving the client's phone number to the company information block).
  • Persistence: A Save function must collect the PHP code strings in their new order and write them back into the corresponding sections of the server-side template file (e.g., default.php).

Template Blocks (Examples)

The system should recognize and encapsulate the following as individual draggable elements:

Content Block Original PHP Code (Concept)
Client Name <b><?php _htmlsc(format_client($invoice)); ?></b>
Client VAT ID <?php if ($invoice->client_vat_id) { echo '<div>'. trans('vat_id_short') . ': ' . htmlsc($invoice->client_vat_id) . '</div>'; } ?>
Client Address 1 <?php if ($invoice->client_address_1) { echo '<div>' . htmlsc($invoice->client_address_1) . '</div>'; } ?>
Client Phone <?php if ($invoice->client_phone) { echo '<div>' . trans('phone_abbr') . ': ' . htmlsc($invoice->client_phone) . '</div>'; } ?>

Use Case Scenarios (AAA)

The following scenarios detail how the tool should function to meet the user's need to swap Address 1 and VAT ID in the Client information section.

Scenario 1: Swapping Address and VAT ID
Goal: Ensure the user can rearrange the order of information displayed for the client.

AAA Phase Description
Arrange The template editor is loaded. The current order in the Client Info block is: 1. Client Name, 2. VAT ID, 3. Address 1.
Act The user clicks and drags the Address 1 element and drops it immediately above the VAT ID element.
Assert Visual: The elements in the editor now display in the order: 1. Client Name, 2. Address 1, 3. VAT ID. Functionality: The user clicks Save. The server-side template file is updated so the PHP code block for client_address_1 precedes the PHP code block for client_vat_id.
--- ---

Scenario 2: Moving a Block between Client and Company
Goal: Ensure fields can be moved between the two main address columns.

AAA Phase Description
Arrange The template editor is loaded. The Client Phone number is currently listed at the bottom of the Client Info block.
Act The user clicks and drags the Client Phone element and drops it at the bottom of the Company Info block.
Assert Visual: The Client Phone element is removed from the Client block and appears in the Company block. Functionality: The user clicks Save. The PHP code for $invoice->client_phone is completely cut from the Client section of the template file and inserted into the Company section of the template file.
--- ---

Implementation Note for Developer

The most challenging aspect is the persistence layer (the Save function). The backend must be able to:

  • Read the current PHP template file (e.g., default.php).
  • Identify and isolate the specific PHP code blocks within the defined zones (#client, #company).
  • Receive the newly ordered list of PHP strings from the front end.
  • Replace the old, ordered set of PHP code blocks in the file with the new, reordered set.

This will likely require careful use of regular expressions or a robust file manipulation utility on the server to avoid corrupting the non-movable surrounding PHP/HTML code

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions