Double-Entry Voucher Grid — Balanced Completion, Dr/Cr Locking & Autofill
The accounting shape LaraGrid was extracted from. Entry ends by balancing: while Σ Debit ≠ Σ Credit the grid keeps auto-appending rows, and the instant the two sides agree, Enter past the last cell stops growing the grid and fires the completion signal instead — which carries focus to a Post button that was disabled until that very commit.
Try it:
type a few letters of an account (or its code) in Particulars ·
Enter skips straight over Group — it is a
FocusMode::Manual column — but click it and pick Expenses, and the
Particulars list on that row narrows to the 5xxx accounts
(optionsUsing(fn ($term, $row)) + optionsDependOn(['group']));
picking a ledger fills its group back in ·
type an amount in Debit and the Dr/Cr selector flips to Dr while Credit
blanks itself — and vice versa (whenFilled mirrors it instantly,
afterCellChange makes it authoritative) ·
the greyed cell on each row is a lockedWhen() mask: the editor refuses it
and serpentine Enter skips straight over it ·
leave the voucher out of balance, land on the empty amount cell of the deficit side, and
the balancing figure is pre-filled — accept it with Enter or
overtype it ·
once it balances, one more Enter lands on Post, and Enter again
posts the voucher.
commitAndSave().
What this page demonstrates
completeWhenBalanced('dr', 'cr')— the balancing guard, plus its autofill of the deficit side through the normal commit pipeline.- A
FocusMode::Manualcolumn — the ERP "semi-variable" field: serpentine navigation never stops on Group, a click still edits it. - Row-dependent picker options —
optionsUsing()receives the row and filters by its Group;optionsDependOn(['group'])keys the client's per-term option cache by it, so one row's list is never reused for a row on a different group, while the server still re-runs the closure authoritatively. - A mutually exclusive column pair —
whenFilled()client mirrors,lockedWhen()navigation masks, and anafterCellChange()hook that makes "typed side wins" authoritative. SyncPolicy::PerRow— ops batch until the cursor leaves the row instead of flushing per cell.- An async picker with no database —
optionsUsing()over a plain PHP array, with the account code painted as muted option meta. newRowUsing()as the grid-level row template, next to the booking grid's per-columndefault()s — both seed the same blank-row template.- The retrying focus target —
onCompleteFocus()lands on a Post button that only enables on the commit that completed the voucher, and the button posts throughgrid.commitAndSave('post').
The whole voucher, in one class
<?php
declare(strict_types=1);
namespace App\Livewire;
use Illuminate\Contracts\View\View;
use LaraGrid\Aggregate;
use LaraGrid\Columns\DecimalColumn;
use LaraGrid\Columns\FocusMode;
use LaraGrid\Columns\HiddenColumn;
use LaraGrid\Columns\SearchSelectColumn;
use LaraGrid\Columns\SelectColumn;
use LaraGrid\Columns\SerialColumn;
use LaraGrid\Columns\TextColumn;
use LaraGrid\Editing\RowContext;
use LaraGrid\Grid;
use LaraGrid\GridDensity;
use LaraGrid\Livewire\WithLaraGrid;
use LaraGrid\SyncPolicy;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The DOUBLE-ENTRY showcase — the accounting shape LaraGrid was extracted from.
*
* Where the booking grid ends entry with a picker exit, this one ends it by BALANCING:
* `->completeWhenBalanced('dr', 'cr')` keeps auto-appending while Σdr ≠ Σcr and, the moment
* the two sides agree (both above zero), turns Enter-past-the-last-cell into the completion
* signal instead of another row. With autofill on, landing on an empty amount cell of the
* deficit side pre-fills the balancing figure through the normal commit pipeline — the
* operator accepts it with Enter or overtypes it.
*
* Also covered here, and nowhere else in the demo:
* · a FocusMode::Manual column — the ERP "semi-variable" field: Enter and Tab skip it, a
* mouse click still edits it (the Group selector that narrows the ledger picker)
* · row-dependent picker options — optionsUsing(fn ($term, $row)) reads the row's Group,
* and optionsDependOn(['group']) keys the client's option cache by it, so one row's
* list is never reused for a row on a different group
* · SyncPolicy::PerRow — ops batch until the cursor leaves the row, instead of per cell
* · a mutually exclusive Dr / Cr column pair under a D/C selector, declared with
* whenFilled() mirrors + lockedWhen() masks and reconciled by an authoritative
* afterCellChange() hook (typed side wins)
* · optionsUsing() over a plain PHP array — an async picker needs no database
* · newRowUsing() as the grid-level row template (the booking grid uses per-column
* ->default() instead — both seed the same blank-row template)
* · a Post button that only enables on the very commit that completes the voucher, which
* onCompleteFocus() still focuses thanks to its retrying selector lookup, and which posts
* through grid.commitAndSave('post') so it can never race an open editor
*
* Nothing is written to the database — Post captures the cleaned rows and shows them.
*/
#[Layout('components.layouts.app', ['wide' => true])]
class JournalEntry extends Component
{
use WithLaraGrid;
/** A miniature chart of accounts — the picker's option source. The leading digit is the group. */
private const LEDGERS = [
'1001' => 'Cash in Hand',
'1002' => 'Bank — Current A/c',
'1101' => 'Trade Receivables',
'1201' => 'Prepaid Expenses',
'1301' => 'Furniture & Fixtures',
'2001' => 'Trade Payables',
'2101' => 'GST Payable',
'2201' => 'TDS Payable',
'3001' => 'Capital A/c',
'4001' => 'Room Revenue',
'4002' => 'Food & Beverage Revenue',
'5001' => 'Salaries & Wages',
'5002' => 'Housekeeping Supplies',
'5003' => 'Electricity & Water',
'5004' => 'Repairs & Maintenance',
'5005' => 'Commission — Travel Agents',
];
/** Ledger groups, keyed by the account code's leading digit. */
private const GROUPS = [
'1' => 'Assets',
'2' => 'Liabilities',
'3' => 'Capital',
'4' => 'Income',
'5' => 'Expenses',
];
private const SIDES = ['D' => 'Dr', 'C' => 'Cr'];
/** @var list<array<string, mixed>> The grid-bound rows (each carries a stable _k). */
public array $entries = [];
/** @var array<string, mixed> The last posted voucher (header + cleaned lines), for display. */
public array $posted = [];
public string $voucherDate = '';
public string $narration = '';
public function mount(): void
{
$this->entries = $this->gridMountRows('voucher');
$this->voucherDate = now()->toDateString();
}
/**
* @return array<string, Grid>
*/
protected function grids(): array
{
return [
'voucher' => Grid::make('voucher')
->editable()
->rowsFrom('entries')
->authorize(fn (): bool => true)
->defaultRows(4)
// The grid-level new-row template. Column ->default()s are applied first, then
// this overlay — and BOTH are part of the blank-row template, so a row that
// still reads 'D' here holds no operator data.
->newRowUsing(fn (): array => ['dc' => 'D'])
->minRows(2)
->autoAppend()
->padRows(2)
// PerRow: a row's ops queue until the cursor leaves it, then flush as one batch.
// Fewer round trips on a wide row; the trade-off is that validation feedback for
// a cell arrives when the row is left rather than when the cell is committed.
->sync(SyncPolicy::PerRow)
->keymap('entry')
->focusOnMount()
->focusOutTo('#narration')
// The Post button below is disabled until the voucher balances — i.e. until the
// very commit that fires this. The retrying lookup is what makes that work.
->onCompleteFocus('[data-post]')
// While Σdr ≠ Σcr the grid keeps growing; once they agree (both > 0) Enter past
// the last cell fires lgrid:complete instead. autofill: true (the default)
// pre-fills the balancing amount on the deficit side.
->completeWhenBalanced('dr', 'cr', autofill: true)
->refreshesHost(['dr', 'cr'])
->columns([
SerialColumn::make(),
// FocusMode::Manual — the "semi-variable" field of voucher entry: serpentine
// Enter and Tab never stop here, a mouse click still opens it. An operator
// who knows the ledger name flies past; one who doesn't clicks it to narrow
// the picker on the next cell. (FocusMode::Never would also lock it; the
// default, FocusMode::Always, is the ordinary stop-here field.)
SelectColumn::make('group')->label('Group')->options(self::GROUPS)
->focusMode(FocusMode::Manual)
->width(110),
// An async picker needs no database: optionsUsing() may return anything. It
// also receives the ROW, so the list can depend on a sibling cell — here the
// optional Group narrows the chart of accounts.
SearchSelectColumn::make('ledger')->label('Particulars')
->optionsUsing(fn (string $term, array $row): array => collect(self::LEDGERS)
->filter(fn (string $name, string $code): bool => ($row['group'] ?? null) === null
|| str_starts_with($code, (string) $row['group']))
->filter(fn (string $name, string $code): bool => $term === ''
|| str_contains(strtolower($name), strtolower($term))
|| str_starts_with($code, $term))
->map(fn (string $name, string $code): array => [
'value' => $code,
'label' => $name,
'meta' => $code,
])
->values()->all())
// Declares the dependency: the client caches options per typed term, and
// this keys that cache by the row's group too — so the list fetched for
// an Expenses row is never reused for an Income row. The server still
// re-runs the closure with the row, so the filtering stays authoritative.
->optionsDependOn(['group'])
->onSelect(function (RowContext $row, mixed $value): void {
// Enrichment: carry the account code alongside the picked id, so the
// posted payload needs no second lookup — and fill in the group the
// pick belongs to, so the Manual column reads right even if skipped.
$code = $value === null ? null : (string) $value;
$row->set('code', $code);
$row->set('group', $code === null ? null : substr($code, 0, 1));
})
->required()
->minChars(0)->debounce(200)->limit(20)
->minWidth(240)->grow(),
SelectColumn::make('dc')->label('Dr/Cr')->options(self::SIDES)
->width(90)->align('center')->required(),
// The mutually exclusive pair. whenFilled() is the CLIENT mirror (instant,
// no round trip); lockedWhen() masks the side the selector rules out; the
// afterCellChange() hook below is the authoritative implementation.
DecimalColumn::make('dr')->label('Debit')->scale(2)->width(140)
->align('right')->format('number', ['scale' => 2])
->rules(['numeric', 'min:0'])
->lockedWhen('dc', 'C')
->whenFilled(sets: ['dc' => 'D'], clears: ['cr']),
DecimalColumn::make('cr')->label('Credit')->scale(2)->width(140)
->align('right')->format('number', ['scale' => 2])
->rules(['numeric', 'min:0'])
->lockedWhen('dc', 'D')
->whenFilled(sets: ['dc' => 'C'], clears: ['dr']),
TextColumn::make('narration')->label('Line narration')->maxLength(120)
->minWidth(200)->grow(),
HiddenColumn::make('code')->writable(),
])
->footer([
Aggregate::sum('dr')->format('number', ['scale' => 2]),
Aggregate::sum('cr')->format('number', ['scale' => 2]),
])
// Typed side wins: whichever amount the operator actually typed sets the
// selector and blanks the opposite cell. Flipping the selector by hand clears
// the amount the new side no longer owns. Changing the group drops a ledger
// that no longer belongs to it.
->afterCellChange(function (RowContext $row, string $column): void {
if ($column === 'group') {
$ledger = (string) ($row->get('ledger') ?? '');
$group = $row->get('group');
if ($ledger !== '' && $group !== null && ! str_starts_with($ledger, (string) $group)) {
$row->set('ledger', null)->set('code', null)->setLabel('ledger', '');
}
return;
}
if ($column === 'dr' && $row->get('dr') !== null && $row->get('dr') !== '') {
$row->set('dc', 'D')->set('cr', null);
return;
}
if ($column === 'cr' && $row->get('cr') !== null && $row->get('cr') !== '') {
$row->set('dc', 'C')->set('dr', null);
return;
}
if ($column === 'dc') {
$row->get('dc') === 'D'
? $row->set('cr', null)
: $row->set('dr', null);
}
})
->afterRowRemove(fn () => null) // hook point: recompute host chrome after a delete
->stickyHeader()
->freezeColumns(2)
->density(GridDensity::Compact)
->theme('violet')
->statusBar()
->persistWidths()
->rowClass(fn (array $row): ?string => match ($row['dc'] ?? null) {
'D' => 'row-dr',
'C' => 'row-cr',
default => null,
})
->maxHeight('50vh')
->emptyState('No voucher lines yet.'),
];
}
/** Σdr − Σcr over the bound rows, kept live by ->refreshesHost(['dr', 'cr']). */
public function getDifferenceProperty(): float
{
$sum = fn (string $column): float => array_sum(
array_map(fn (array $row): float => (float) ($row[$column] ?? 0), $this->entries)
);
return round($sum('dr') - $sum('cr'), 2);
}
public function getIsBalancedProperty(): bool
{
$debits = array_sum(array_map(fn (array $row): float => (float) ($row['dr'] ?? 0), $this->entries));
return $debits > 0 && abs($this->difference) < 0.005;
}
/**
* Invoked by grid.commitAndSave('post') — i.e. only after the open editor has committed and
* every queued op is acknowledged — never by a bare wire:click racing the grid.
*/
public function post(): void
{
if (! $this->isBalanced) {
return;
}
$this->posted = [
'date' => $this->voucherDate,
'narration' => $this->narration,
'lines' => $this->gridRows('voucher'),
];
$this->entries = $this->gridMountRows('voucher');
$this->narration = '';
$this->reseedGrid('voucher');
}
public function render(): View
{
return view('livewire.journal-entry');
}
}
<div>
<h1>Double-Entry Voucher Grid — Balanced Completion, Dr/Cr Locking & Autofill</h1>
<p class="lede">
The accounting shape LaraGrid was extracted from. Entry ends by <em>balancing</em>:
while Σ Debit ≠ Σ Credit the grid keeps auto-appending rows, and the instant
the two sides agree, <kbd>Enter</kbd> past the last cell stops growing the grid and
fires the completion signal instead — which carries focus to a Post button that was
disabled until that very commit.
</p>
<p class="keys">
<strong>Try it:</strong>
type a few letters of an account (or its code) in <strong>Particulars</strong> ·
<kbd>Enter</kbd> skips straight over <strong>Group</strong> — it is a
<code>FocusMode::Manual</code> column — but click it and pick <em>Expenses</em>, and the
Particulars list on that row narrows to the 5xxx accounts
(<code>optionsUsing(fn ($term, $row))</code> + <code>optionsDependOn(['group'])</code>);
picking a ledger fills its group back in ·
type an amount in <strong>Debit</strong> and the Dr/Cr selector flips to Dr while Credit
blanks itself — and vice versa (<code>whenFilled</code> mirrors it instantly,
<code>afterCellChange</code> makes it authoritative) ·
the greyed cell on each row is a <code>lockedWhen()</code> mask: the editor refuses it
and serpentine <kbd>Enter</kbd> skips straight over it ·
leave the voucher out of balance, land on the empty amount cell of the deficit side, and
the balancing figure is <strong>pre-filled</strong> — accept it with <kbd>Enter</kbd> or
overtype it ·
once it balances, one more <kbd>Enter</kbd> lands on Post, and <kbd>Enter</kbd> again
posts the voucher.
</p>
<div class="voucher-head">
<label for="voucher-date">Date</label>
<input id="voucher-date" type="date" wire:model.blur="voucherDate">
<span class="balance @if($this->isBalanced) ok @endif">
@if ($this->isBalanced)
✓ Balanced
@else
Difference <strong>{{ number_format(abs($this->difference), 2) }}</strong>
{{ $this->difference > 0 ? 'Cr short' : 'Dr short' }}
@endif
</span>
</div>
<x-laragrid :grid="$this->gridDefinition('voucher')" :rows="$entries" />
<div class="entry-foot">
<label for="narration" class="muted">Narration (Tab off the last cell lands here)</label>
<input id="narration" type="text" wire:model.blur="narration" placeholder="Being…">
{{-- Disabled server-side until the voucher balances (re-rendered by refreshesHost);
the click runs grid.commitAndSave('post') — see the script below. --}}
<button type="button" data-post class="btn-save"
@disabled(! $this->isBalanced)>Post Voucher</button>
<span class="muted">Enabled only while the voucher balances; posts through <code>commitAndSave()</code>.</span>
</div>
<h2>What this page demonstrates</h2>
<ul class="lede">
<li><strong><code>completeWhenBalanced('dr', 'cr')</code></strong> — the balancing guard,
plus its autofill of the deficit side through the normal commit pipeline.</li>
<li><strong>A <code>FocusMode::Manual</code> column</strong> — the ERP "semi-variable"
field: serpentine navigation never stops on Group, a click still edits it.</li>
<li><strong>Row-dependent picker options</strong> — <code>optionsUsing()</code> receives
the row and filters by its Group; <code>optionsDependOn(['group'])</code> keys the
client's per-term option cache by it, so one row's list is never reused for a row on
a different group, while the server still re-runs the closure authoritatively.</li>
<li><strong>A mutually exclusive column pair</strong> — <code>whenFilled()</code> client
mirrors, <code>lockedWhen()</code> navigation masks, and an
<code>afterCellChange()</code> hook that makes "typed side wins" authoritative.</li>
<li><strong><code>SyncPolicy::PerRow</code></strong> — ops batch until the cursor leaves
the row instead of flushing per cell.</li>
<li><strong>An async picker with no database</strong> — <code>optionsUsing()</code> over
a plain PHP array, with the account code painted as muted option meta.</li>
<li><strong><code>newRowUsing()</code></strong> as the grid-level row template, next to
the booking grid's per-column <code>default()</code>s — both seed the same blank-row
template.</li>
<li><strong>The retrying focus target</strong> — <code>onCompleteFocus()</code> lands on
a Post button that only enables on the commit that completed the voucher, and the
button posts through <code>grid.commitAndSave('post')</code>.</li>
</ul>
<h2 id="source">The whole voucher, in one class</h2>
<x-source-code title="Journal Entry source" panel="Posted payload" :files="[
'app/Livewire/JournalEntry.php',
'resources/views/livewire/journal-entry.blade.php',
]">
@if ($posted !== [])
<pre>{{ json_encode($posted, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
@else
<p>Balance a voucher and hit Post — the cleaned gridRows() output lands here.</p>
@endif
</x-source-code>
@push('styles')
<style>
.voucher-head { display: flex; flex-wrap: wrap; gap: .5rem .9rem; align-items: center; margin: 0 0 .6rem; font-size: .85rem; }
.voucher-head input { font: inherit; font-size: .85rem; padding: .3rem .5rem; border: 1px solid #d4d4d8; border-radius: .375rem; background: #fff; color: inherit; }
html.dark .voucher-head input { background: #18181b; border-color: #3f3f46; }
.balance { margin-left: auto; padding: .25rem .75rem; border-radius: 9999px; background: #fef3c7; color: #92400e; font-weight: 600; }
.balance.ok { background: #d1fae5; color: #065f46; }
html.dark .balance { background: #422006; color: #fbbf24; }
html.dark .balance.ok { background: #052e16; color: #34d399; }
.entry-foot { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; margin-top: 1rem; }
.entry-foot input#narration { font: inherit; font-size: .85rem; padding: .4rem .6rem; border: 1px solid #d4d4d8; border-radius: .375rem; background: #fff; color: inherit; min-width: 18rem; }
html.dark .entry-foot input#narration { background: #18181b; border-color: #3f3f46; }
.btn-save { font: inherit; padding: .45rem 1.4rem; border: 1px solid #18181b; border-radius: .375rem; background: #18181b; color: #fff; cursor: pointer; }
.btn-save:disabled { opacity: .45; cursor: default; }
html.dark .btn-save { background: #e4e4e7; border-color: #e4e4e7; color: #18181b; }
/* ->rowClass() targets: a faint side stripe per Dr / Cr row. */
.row-dr .lgrid-cell:first-child { box-shadow: inset 3px 0 0 #7c3aed; }
.row-cr .lgrid-cell:first-child { box-shadow: inset 3px 0 0 #0ea5e9; }
</style>
@endpush
@script
<script>
// Post goes through the grid, not around it: commitAndSave() commits any open
// editor, flushes the PerRow batch, waits for every op to be acknowledged, and only
// then invokes $wire.post(). It refuses while a validation error remains.
$wire.$el.querySelector('[data-post]').addEventListener('click', async () => {
const grid = window.LaraGrid && window.LaraGrid.find($wire.$el.querySelector('[data-lgrid]'));
if (!grid) return;
try {
await grid.commitAndSave('post');
} catch (error) {
console.warn('[demo] post refused:', error.message);
}
});
</script>
@endscript
</div>
Balance a voucher and hit Post — the cleaned gridRows() output lands here.