Display-Only Grid — Client-Side Sorting, What-If Totals, Custom Cells & Trusted Exports
Both grids on this page are painted from in-memory rows: no
query(), no editable() — just
<x-laragrid :grid :rows>. This is the mode for computed report grids
whose rows do not exist as table rows: trial balances, ageings, roll-ups. They still get
the full keyboard model, the selection engine, the column chooser, resizable and
persisted widths, and server-computed footer totals. The city roll-up sits on a plain
Blade page with no Livewire component at all; the ageing grid below it is the same
display mode hosted by a small nested Livewire component — purely so it can offer
trusted downloads.
Try it:
click any header to sort — client-side, stable and type-aware, cycling
asc → desc → the original order ·
select a range and read Count / Sum / Average off the status bar ·
press F9 on a row to temporarily hide it and watch the
footer totals recompute over what is left (the what-if view), then
Shift+F9 to bring everything back ·
Ctrl+C copies the selection as TSV ·
▦ hides and restores columns — on the ageing grid the remaining columns
re-fit the box (fitColumns()) ·
⤓ on the ageing grid downloads CSV / XLSX / PDF rebuilt on the server.
City roll-up — a database aggregate, painted as rows, no Livewire
The Tier column is an application-defined column type
(App\Grid\Columns\RatingColumn) drawn by an application-registered painter,
and the money columns use an application-registered 'inr' format with
Indian digit grouping. Neither the package nor the renderer knows either of them exists.
Receivables ageing — hand-built rows, fixed height, fitted columns, trusted exports
Six literal rows in a PHP constant. height('280px') fixes the box,
fitColumns() keeps every visible column inside it (declared widths become
proportional weights, so there is never a horizontal scrollbar), and the footer still
totals authoritatively. Press F9 on the “Over 180 days” row to see the
provision total drop.
Why the Export control is here and not on the roll-up: LaraGrid never
exports what a page painted — Blade :rows, a Livewire property or the DOM
are not trusted sources. exportRows() is the separate, trusted half: a
closure on a Livewire host that rebuilds the rows on the server, after
authorize(), from only the normalized current view
{sort, dir, search, filters}. Rendering stays in-memory; only the download
gains a server source.
What this page demonstrates
- Display mode — rows passed to the tag, painted as-is, with or without a Livewire component behind them.
- Client-side sorting —
sortable()without a DB target, plusdefaultSort()applied at load. - What-if totals — F9 / Shift+F9 row hiding with live footer recomputation.
- Trusted exports of an in-memory report —
exportRows()+exportable()+authorize()on a nested Livewire component; the resolver honours the current sort so the file matches the screen, and a generator keeps the pipeline bounded-memory. - Fitted columns —
fitColumns()suppresses the horizontal scrollbar and rebalances the visible columns on resize, hide and reveal. - The extension seams — a custom column type, a custom cell painter, a custom display format and a custom parse kind, each with a PHP half and a JavaScript twin registered under the same name.
- Mode-appropriate refusals —
savedViews(),persistQuery(),call()actions and bulk actions all fail loudly at build time on a display grid, because none of them can be honoured without a server-side row source;exportable()is refused unlessexportRows()(orquery()) supplies one.
The source
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Grid\Columns\RatingColumn;
use App\Models\Resort;
use Illuminate\View\View;
use LaraGrid\Actions\Action;
use LaraGrid\Aggregate;
use LaraGrid\ColumnGroup;
use LaraGrid\Columns\ComputedColumn;
use LaraGrid\Columns\DecimalColumn;
use LaraGrid\Columns\IntegerColumn;
use LaraGrid\Columns\SerialColumn;
use LaraGrid\Columns\TextColumn;
use LaraGrid\Grid;
use LaraGrid\GridDensity;
/**
* The DISPLAY-MODE showcase — LaraGrid on a plain Blade page with no Livewire component
* behind the main grid.
*
* A display grid declares neither ->query() nor ->editable(): rows are handed to the tag
* (<x-laragrid :grid :rows>) and painted as-is, with the same keyboard model, selection
* engine, column chooser, resizing and footer totals as the other two modes. It is the mode
* for computed report grids — trial balances, ageings, roll-ups — that can never be
* query()-backed because their rows do not exist as table rows.
*
* Unique to this mode:
* · ->sortable() sorts CLIENT-SIDE — stable, type-aware, empties last, click cycling
* asc → desc → original order, with ->defaultSort() applied at load
* · F9 / Shift+F9 temporarily hide and restore rows, and the footer aggregates recompute
* over what is left — the what-if view an accountant actually wants
* · only url() row actions are allowed: an in-memory grid has no authoritative server-side
* row source, so call() and bulk actions are refused at build time
* · ->savedViews() and ->persistQuery() are refused too (their state IS a query pipeline's
* input), and ->exportable() needs a trusted server row source — either a ->query(), or
* ->exportRows() on a Livewire host. The second grid on this page, the nested
* App\Livewire\AgeingReport component, is exactly that.
*
* This page also carries the EXTENSION demo: a custom column type (RatingColumn), a custom
* painter, a custom display format ('inr') and a custom parse kind ('stars') — PHP halves in
* App\Grid\* and App\Providers\AppServiceProvider, JavaScript twins in
* resources/views/partials/laragrid-extensions.blade.php.
*/
class ReportsController extends Controller
{
public function index(): View
{
return view('reports.index', [
'cityGrid' => $this->cityGrid(),
'cityRows' => $this->cityRows(),
]);
}
/**
* The roll-up: one row per city, computed by the database but shaped here — exactly the
* kind of result set display mode exists for.
*
* @return list<array<string, mixed>>
*/
private function cityRows(): array
{
$rows = Resort::query()
->selectRaw('city')
->selectRaw('COUNT(*) as properties')
->selectRaw('COALESCE(SUM(rooms), 0) as rooms')
->selectRaw('COALESCE(AVG(comparison_tariff), 0) as avg_tariff')
->selectRaw('COALESCE(SUM(comparison_tariff), 0) as tariff_total')
->selectRaw('COALESCE(SUM(hits), 0) as hits')
->whereNotNull('city')
->groupBy('city')
->orderBy('city')
->get()
->map(fn ($row): array => [
'city' => (string) $row->city,
'properties' => (int) $row->properties,
'rooms' => (int) $row->rooms,
'avg_tariff' => number_format((float) $row->avg_tariff, 2, '.', ''),
'tariff_total' => (int) $row->tariff_total,
'hits' => (int) $row->hits,
// Feeds the custom RatingColumn: a 0–5 score off the average tariff.
'rating' => max(0, min(5, (int) round(((float) $row->avg_tariff) / 2500))),
])
->all();
$totalHits = max(1, array_sum(array_column($rows, 'hits')));
return array_map(fn (array $row): array => $row + [
'share' => round($row['hits'] * 100 / $totalHits, 2),
], $rows);
}
/**
* The main report grid. No ->query(), no ->editable() — display mode.
*/
private function cityGrid(): Grid
{
return Grid::make('city-report')
// No authorize() is required in display mode: there is no server data surface to
// gate — the host already decided what rows to hand over. (That is also why
// ->exportable() is refused here without ->exportRows(): a download needs a
// server-side row source the grid can re-authorize, which Blade rows are not.)
->defaultSort('city')
->columnGroups([
ColumnGroup::make('Inventory', ['properties', 'rooms']),
ColumnGroup::make('Tariff', ['avg_tariff', 'tariff_total', 'rating']),
ColumnGroup::make('Demand', ['hits', 'share']),
])
->columns([
SerialColumn::make(),
// Client-side sorting: no argument is allowed (a DB sort target would be
// meaningless here and fails loudly at build time).
TextColumn::make('city')->label('City')->sortable()->minWidth(150)->grow(),
IntegerColumn::make('properties')->label('Properties')->sortable()
->width(110)->align('right')->format('number'),
IntegerColumn::make('rooms')->label('Rooms')->sortable()
->width(100)->align('right')->format('number'),
// The app-registered 'inr' format — Indian digit grouping, PHP + JS twins.
DecimalColumn::make('avg_tariff')->label('Avg tariff')->scale(2)->sortable()
->width(130)->align('right')->format('inr', ['scale' => 2]),
IntegerColumn::make('tariff_total')->label('Tariff total')->sortable()
->width(140)->align('right')->format('inr'),
// The app-defined column type, drawn by the app-registered 'rating' painter.
RatingColumn::make('rating')->label('Tier')->sortable(),
IntegerColumn::make('hits')->label('Page views')->sortable()
->width(120)->align('right')->format('number'),
ComputedColumn::make('share')->label('Share')->html()->width(150)
->state(fn (array $row): string => sprintf(
'<span class="bar"><span class="bar-fill" style="width:%s%%"></span></span><span class="bar-num">%s%%</span>',
min(100, (float) ($row['share'] ?? 0) * 4),
number_format((float) ($row['share'] ?? 0), 2),
)),
])
->footer([
Aggregate::sum('properties')->format('number'),
Aggregate::sum('rooms')->format('number'),
Aggregate::sum('tariff_total')->format('inr'),
Aggregate::sum('hits')->format('number'),
])
// Display grids may declare url() row actions only: with no server-side row
// source there is nothing for a call() action to re-resolve and re-authorize.
->actions([
Action::make('browse')->label('Browse in the register')->icon('→')
->url(fn (): string => route('resorts.index')),
])
->stickyHeader()
->freezeColumns(2)
->striped()
->density(GridDensity::Normal)
->theme('zinc')
->statusBar()
->persistWidths()
->maxHeight('60vh')
->emptyState('No cities to report on.');
}
}
<?php
declare(strict_types=1);
namespace App\Livewire;
use Illuminate\Contracts\View\View;
use LaraGrid\Aggregate;
use LaraGrid\Columns\ComputedColumn;
use LaraGrid\Columns\IntegerColumn;
use LaraGrid\Columns\SerialColumn;
use LaraGrid\Columns\TextColumn;
use LaraGrid\Grid;
use LaraGrid\GridDensity;
use LaraGrid\Livewire\WithLaraGrid;
use LaraGrid\Support\CellHtml;
use Livewire\Component;
/**
* The TRUSTED-EXPORT showcase — a display-mode grid that still offers CSV / XLSX / PDF
* downloads, because it declares a server-side row resolver.
*
* Display mode paints whatever rows the host hands to the tag, and LaraGrid will never
* export those: Blade rows, a Livewire public property, the DOM, rows included in the
* export request — none of them is a trusted source. ->exportRows() is the separate,
* trusted half of the contract: a closure that rebuilds the rows ON THE SERVER, after
* ->authorize(), from data the host owns, with only the normalized current view
* {sort, dir, search, filters} as input. Rendering stays in-memory — isServerSide() is still
* false and there is no gridFetch; only the ⤓ Export control gains a server source.
*
* It is a Livewire component (unlike the city roll-up above it on the page) because exports
* are an RPC on the host — and a small NESTED component is all that takes; the page around
* it stays a plain Blade view.
*
* Also here: ->fitColumns() — every visible column is kept inside the box (declared widths
* become proportional weights, and hiding or revealing a column rebalances the rest), so a
* fixed-height report never grows a horizontal scrollbar.
*/
class AgeingReport extends Component
{
use WithLaraGrid;
/** A small in-memory table — rows that exist nowhere in the database. */
private const AGEING = [
['bucket' => 'Not due', 'invoices' => 128, 'amount' => 4128500, 'weight' => 0],
['bucket' => '1 – 30 days', 'invoices' => 74, 'amount' => 2260750, 'weight' => 15],
['bucket' => '31 – 60 days', 'invoices' => 41, 'amount' => 1189300, 'weight' => 35],
['bucket' => '61 – 90 days', 'invoices' => 22, 'amount' => 742900, 'weight' => 60],
['bucket' => '91 – 180 days', 'invoices' => 13, 'amount' => 415600, 'weight' => 80],
['bucket' => 'Over 180 days', 'invoices' => 7, 'amount' => 233450, 'weight' => 100],
];
/**
* @return array<string, Grid>
*/
protected function grids(): array
{
return [
'ageing' => Grid::make('ageing')
// No ->query(), no ->editable(): display mode. Rows are handed to the tag.
//
// Exports are a server surface, so the gate is mandatory and fail-closed — the
// build refuses exportRows() without authorize(). Demo app has no auth.
->authorize(fn (): bool => true)
// The trusted row source. It receives only {sort, dir, search, filters} — never
// the painted rows — and rebuilds the report from what the server knows.
->exportRows(fn (array $state): iterable => $this->exportRows($state))
->exportable(['csv', 'xlsx', 'pdf'], fileName: 'receivables-ageing')
->height('280px') // a fixed box rather than a content-sized one
->fitColumns() // and no horizontal scrollbar inside it, ever
->columns([
SerialColumn::make(),
TextColumn::make('bucket')->label('Ageing bucket')->sortable()->minWidth(150)->grow(),
IntegerColumn::make('invoices')->label('Invoices')->sortable()
->width(100)->align('right')->format('number'),
IntegerColumn::make('amount')->label('Outstanding')->sortable()
->width(150)->align('right')->format('inr'),
IntegerColumn::make('weight')->label('Provision %')->sortable()
->width(110)->align('right'),
IntegerColumn::make('provision')->label('Provision')->sortable()
->width(140)->align('right')->format('inr'),
ComputedColumn::make('health')->label('Health')->html()->width(100)->align('center')
// Painted chrome exports as plain text ("At risk"), never as markup.
->state(fn (array $row): string => match (true) {
($row['weight'] ?? 0) >= 60 => CellHtml::badge('red', 'At risk'),
($row['weight'] ?? 0) >= 15 => CellHtml::badge('amber', 'Watch'),
default => CellHtml::badge('green', 'Current'),
}),
])
->footer([
Aggregate::sum('invoices')->format('number'),
Aggregate::sum('amount')->format('inr'),
Aggregate::sum('provision')->format('inr'),
])
->density(GridDensity::Comfortable)
->theme('amber')
->striped()
->statusBar()
->emptyState('Nothing outstanding.'),
];
}
/**
* The rows the PAGE paints — the `:rows` half of the contract. Computed, not stored.
*
* @return list<array<string, mixed>>
*/
private function rows(): array
{
return array_map(fn (array $row): array => $row + [
'provision' => (int) round($row['amount'] * $row['weight'] / 100),
], self::AGEING);
}
/**
* The rows a DOWNLOAD contains — rebuilt here, on the server, from the same source the
* page was built from. $state is exactly {sort, dir, search, filters}: unknown client keys
* and invalid sort targets have already been removed. This report has no search or
* filters, so only the current sort is honoured — the file then matches the screen.
*
* A generator keeps the export pipeline bounded-memory on a report of any size.
*
* @param array{sort: string|null, dir: string, search: string, filters: array<string, string>} $state
* @return iterable<int, array<string, mixed>>
*/
private function exportRows(array $state): iterable
{
$rows = $this->rows();
if ($state['sort'] !== null) {
$column = $state['sort'];
$direction = $state['dir'] === 'desc' ? -1 : 1;
usort($rows, fn (array $a, array $b): int => $direction * (($a[$column] ?? null) <=> ($b[$column] ?? null)));
}
yield from $rows;
}
public function render(): View
{
return view('livewire.ageing-report', ['rows' => $this->rows()]);
}
}
{{-- The whole nested component: one tag. The :rows are the display dataset; the download
never trusts them — see exportRows() in App\Livewire\AgeingReport. --}}
<div>
<x-laragrid :grid="$this->gridDefinition('ageing')" :rows="$rows" />
</div>
<?php
declare(strict_types=1);
namespace App\Grid\Columns;
use LaraGrid\Columns\Column;
/**
* An app-defined column type — the whole extension seam in one small class.
*
* A custom column names three things and nothing else:
* · painterId() — which client paint routine draws its cells (registered in JS as 'rating')
* · editorId() — which floating editor opens on it, or null for display-only
* · parseSpec() — how typed text becomes the model value, as a {kind} tag whose PHP cast
* ('stars', registered on the CastRegistry) and JS twin must agree
*
* The renderer never learns the type: it asks the registry "which painter?" and calls it.
* See App\Providers\AppServiceProvider for the PHP registrations and
* resources/views/reports/index.blade.php for their JavaScript twins.
*/
final class RatingColumn extends Column
{
protected function configureDefaults(): void
{
$this->defaultAlign('center');
if ($this->width === null) {
$this->width(110);
}
}
public function painterId(): string
{
return 'rating';
}
/** Reuses the built-in number editor — a custom type need not ship a custom editor. */
public function editorId(): ?string
{
return 'number';
}
/**
* @return array<string, mixed>
*/
public function parseSpec(): array
{
return ['kind' => 'stars'];
}
/**
* Stars are numbers, so a selection of them gets a status-bar Sum / Average like any
* other numeric column.
*/
public function isSelectableNumeric(): bool
{
return true;
}
/**
* Server rules the TYPE contributes on top of whatever the author declared.
*
* @return list<mixed>
*/
public function implicitRules(): array
{
return ['integer', 'min:0', 'max:5'];
}
}
<?php
declare(strict_types=1);
namespace App\Grid\Formatting;
use LaraGrid\Formatting\Formatter;
/**
* An app-registered display format — Indian digit grouping (12,34,56,789) with a ₹ prefix.
*
* Formatting runs in BOTH runtimes: the client for instant paint, the server for authority
* (tests, pre-computed footer totals, PDF exports). Every PHP formatter therefore needs a
* behaviourally identical JS twin registered under the same name — ours lives in
* resources/views/reports/index.blade.php. The package pins its own pairs with shared
* vectors; an app should test its own the same way.
*
* Registered as 'inr' in App\Providers\AppServiceProvider; used as
* ->format('inr', ['scale' => 2]) on any column or footer aggregate.
*/
final class InrFormatter implements Formatter
{
/**
* @param array<string, scalar> $args Supports {scale: int (default 0), symbol: bool (default true)}.
*/
public function format(mixed $value, array $args = []): string
{
if ($value === null || $value === '') {
return '';
}
$scale = max(0, (int) ($args['scale'] ?? 0));
$symbol = (bool) ($args['symbol'] ?? true);
$number = (float) $value;
$negative = $number < 0;
// Ungrouped fixed-scale text first, then regroup — so rounding happens exactly once.
[$whole, $fraction] = array_pad(explode('.', number_format(abs($number), $scale, '.', '')), 2, '');
if (strlen($whole) > 3) {
// Indian grouping: the last three digits, then pairs all the way up.
$whole = preg_replace('/\B(?=(\d{2})+(?!\d))/', ',', substr($whole, 0, -3))
.','.substr($whole, -3);
}
return ($negative ? '-' : '')
.($symbol ? '₹' : '')
.$whole
.($fraction !== '' ? '.'.$fraction : '');
}
}
<?php
declare(strict_types=1);
namespace App\Grid\Casting;
use LaraGrid\Casting\Cast;
use LaraGrid\Columns\Column;
/**
* The parse "kind" behind App\Grid\Columns\RatingColumn — turns whatever the operator typed
* into an integer 0–5.
*
* Casting, like formatting, runs in both runtimes: the client casts optimistically so the
* cell paints instantly, the server casts authoritatively so the stored value is the truth.
* The two must agree by construction, which is why every registered cast needs a JS twin
* under the same kind name (ours is in resources/views/reports/index.blade.php).
*
* Registered as 'stars' in App\Providers\AppServiceProvider.
*/
final class StarsCast implements Cast
{
/**
* @param array<string, mixed> $spec The column's full parseSpec.
*/
public function cast(mixed $value, array $spec, Column $column): mixed
{
if ($value === null || $value === '') {
return null;
}
return max(0, min(5, (int) round((float) $value)));
}
}
<?php
namespace App\Providers;
use App\Grid\Casting\StarsCast;
use App\Grid\Formatting\InrFormatter;
use Illuminate\Support\ServiceProvider;
use LaraGrid\Casting\CastRegistry;
use LaraGrid\Formatting\FormatRegistry;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// LaraGrid's PHP-side extension seams. Both registries are singletons the package
// resolves whenever it needs to render or cast a value server-side; each entry here
// has a behaviourally identical JavaScript twin registered through
// window.LaraGrid.pending (see resources/views/reports/index.blade.php).
$this->app->make(FormatRegistry::class)->register('inr', new InrFormatter);
$this->app->make(CastRegistry::class)->register('stars', new StarsCast);
}
}
{{-- The JavaScript half of this app's LaraGrid extensions — the twins of the PHP registrations
in App\Providers\AppServiceProvider. Included once from the layout, so every page can use
the 'inr' format, the 'rating' painter and the 'stars' cast.
Registration goes through the ORDER-INDEPENDENT `pending` queue rather than a direct
LaraGrid.registerX() call: with auto-injection the grid bundle is a DEFERRED script at the
end of <head>, so this inline script runs first, when window.LaraGrid does not exist yet.
The bundle merges onto whatever is already on window, drains the queue, and only then runs
its first scan — so everything seeded here wins the FIRST PAINT rather than reconciling
after it. After boot the queue is replaced by a live sink whose push() runs callbacks
immediately, which is why this same idiom is correct from any script position. --}}
<script>
(window.LaraGrid = window.LaraGrid || {}).pending = [
(LG) => {
// A custom PAINTER, for App\Grid\Columns\RatingColumn::painterId() === 'rating'.
// Built with LG.el() — the same XSS-safe element factory the built-in renderers
// use — so a custom cell never touches innerHTML.
LG.registerPainter('rating', (cellEl, ctx) => {
const stars = Math.max(0, Math.min(5, Math.round(Number(ctx.value) || 0)));
cellEl.textContent = '';
cellEl.appendChild(
LG.el('span', 'demo-rating', '★'.repeat(stars) + '☆'.repeat(5 - stars))
);
cellEl.setAttribute('aria-label', stars + ' of 5');
});
// The twin of App\Grid\Formatting\InrFormatter: same name, same output, both
// runtimes. Indian digit grouping — the last three digits, then pairs.
LG.registerFormatter('inr', (value, args) => {
args = args || {};
if (value === null || value === undefined || value === '') return '';
const scale = Math.max(0, parseInt(args.scale || 0, 10) || 0);
const symbol = args.symbol === undefined ? true : Boolean(args.symbol);
const number = Number(value);
if (!Number.isFinite(number)) return '';
const parts = Math.abs(number).toFixed(scale).split('.');
const whole = parts[0];
const fraction = parts[1] || '';
const grouped = whole.length > 3
? whole.slice(0, -3).replace(/\B(?=(\d{2})+(?!\d))/g, ',') + ',' + whole.slice(-3)
: whole;
return (number < 0 ? '-' : '') + (symbol ? '₹' : '') + grouped
+ (fraction ? '.' + fraction : '');
});
// The twin of App\Grid\Casting\StarsCast — used when a RatingColumn sits on an
// EDITABLE grid: `parse` produces the model value the client paints optimistically,
// `editText` seeds the editor when it opens.
LG.registerCast('stars', {
parse: (text) => {
if (text === null || text === undefined || String(text).trim() === '') return null;
const n = Math.round(Number(text));
return Number.isFinite(n) ? Math.max(0, Math.min(5, n)) : null;
},
editText: (value) => (value === null || value === undefined ? '' : String(value)),
});
},
];
</script>
<style>
.demo-rating { letter-spacing: .08em; color: #d97706; }
html.dark .demo-rating { color: #fbbf24; }
</style>
<x-layouts.app :wide="true">
<h1>Display-Only Grid — Client-Side Sorting, What-If Totals, Custom Cells & Trusted Exports</h1>
<p class="lede">
Both grids on this page are painted from <strong>in-memory rows</strong>: no
<code>query()</code>, no <code>editable()</code> — just
<code><x-laragrid :grid :rows></code>. This is the mode for computed report grids
whose rows do not exist as table rows: trial balances, ageings, roll-ups. They still get
the full keyboard model, the selection engine, the column chooser, resizable and
persisted widths, and server-computed footer totals. The city roll-up sits on a plain
Blade page with no Livewire component at all; the ageing grid below it is the same
display mode hosted by a small nested Livewire component — purely so it can offer
<em>trusted</em> downloads.
</p>
<p class="keys">
<strong>Try it:</strong>
click any header to sort — client-side, stable and type-aware, cycling
asc → desc → the original order ·
select a range and read Count / Sum / Average off the status bar ·
press <kbd>F9</kbd> on a row to <strong>temporarily hide it</strong> and watch the
footer totals recompute over what is left (the what-if view), then
<kbd>Shift</kbd>+<kbd>F9</kbd> to bring everything back ·
<kbd>Ctrl</kbd>+<kbd>C</kbd> copies the selection as TSV ·
<kbd>▦</kbd> hides and restores columns — on the ageing grid the remaining columns
re-fit the box (<code>fitColumns()</code>) ·
<kbd>⤓</kbd> on the ageing grid downloads CSV / XLSX / PDF rebuilt on the server.
</p>
<h2>City roll-up <span class="muted">— a database aggregate, painted as rows, no Livewire</span></h2>
<x-laragrid :grid="$cityGrid" :rows="$cityRows" />
<p class="muted" style="margin-top:.6rem">
The <strong>Tier</strong> column is an application-defined column type
(<code>App\Grid\Columns\RatingColumn</code>) drawn by an application-registered painter,
and the money columns use an application-registered <code>'inr'</code> format with
Indian digit grouping. Neither the package nor the renderer knows either of them exists.
</p>
<h2>Receivables ageing <span class="muted">— hand-built rows, fixed height, fitted columns, trusted exports</span></h2>
<p class="lede">
Six literal rows in a PHP constant. <code>height('280px')</code> fixes the box,
<code>fitColumns()</code> keeps every visible column inside it (declared widths become
proportional weights, so there is never a horizontal scrollbar), and the footer still
totals authoritatively. Press <kbd>F9</kbd> on the “Over 180 days” row to see the
provision total drop.
</p>
<p class="lede">
<strong>Why the Export control is here and not on the roll-up:</strong> LaraGrid never
exports what a page painted — Blade <code>:rows</code>, a Livewire property or the DOM
are not trusted sources. <code>exportRows()</code> is the separate, trusted half: a
closure on a Livewire host that rebuilds the rows <em>on the server</em>, after
<code>authorize()</code>, from only the normalized current view
<code>{sort, dir, search, filters}</code>. Rendering stays in-memory; only the download
gains a server source.
</p>
<livewire:ageing-report />
<h2>What this page demonstrates</h2>
<ul class="lede">
<li><strong>Display mode</strong> — rows passed to the tag, painted as-is, with or
without a Livewire component behind them.</li>
<li><strong>Client-side sorting</strong> — <code>sortable()</code> without a DB target,
plus <code>defaultSort()</code> applied at load.</li>
<li><strong>What-if totals</strong> — <kbd>F9</kbd> / <kbd>Shift</kbd>+<kbd>F9</kbd> row
hiding with live footer recomputation.</li>
<li><strong>Trusted exports of an in-memory report</strong> —
<code>exportRows()</code> + <code>exportable()</code> + <code>authorize()</code> on a
nested Livewire component; the resolver honours the current sort so the file matches
the screen, and a generator keeps the pipeline bounded-memory.</li>
<li><strong>Fitted columns</strong> — <code>fitColumns()</code> suppresses the horizontal
scrollbar and rebalances the visible columns on resize, hide and reveal.</li>
<li><strong>The extension seams</strong> — a custom column type, a custom cell painter,
a custom display format and a custom parse kind, each with a PHP half and a
JavaScript twin registered under the same name.</li>
<li><strong>Mode-appropriate refusals</strong> — <code>savedViews()</code>,
<code>persistQuery()</code>, <code>call()</code> actions and bulk actions all fail
loudly at build time on a display grid, because none of them can be honoured
without a server-side row source; <code>exportable()</code> is refused unless
<code>exportRows()</code> (or <code>query()</code>) supplies one.</li>
</ul>
<h2 id="source">The source</h2>
<x-source-code title="Reports source" :files="[
'app/Http/Controllers/ReportsController.php',
'app/Livewire/AgeingReport.php',
'resources/views/livewire/ageing-report.blade.php',
'app/Grid/Columns/RatingColumn.php',
'app/Grid/Formatting/InrFormatter.php',
'app/Grid/Casting/StarsCast.php',
'app/Providers/AppServiceProvider.php',
'resources/views/partials/laragrid-extensions.blade.php',
'resources/views/reports/index.blade.php',
]" />
@push('styles')
<style>
.bar { display: inline-block; width: 60px; height: .5rem; margin-right: .4rem; border-radius: 9999px; background: color-mix(in oklab, currentColor 15%, transparent); overflow: hidden; vertical-align: middle; }
.bar-fill { display: block; height: 100%; background: currentColor; opacity: .55; }
.bar-num { font-variant-numeric: tabular-nums; }
</style>
@endpush
</x-layouts.app>