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 ·
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 posts the voucher.
What this page demonstrates
completeWhenBalanced('dr', 'cr')— the balancing guard, plus its autofill of the deficit side through the normal commit pipeline.- 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. - The retrying focus target —
onCompleteFocus()lands on a Post button that only enables on the commit that completed the voucher.
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\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:
* · 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
* · a Post button that only enables on the very commit that completes the voucher, which
* onCompleteFocus() still focuses thanks to its retrying selector lookup
*
* 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. */
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',
];
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)
->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(),
// An async picker needs no database: optionsUsing() may return anything.
SearchSelectColumn::make('ledger')->label('Particulars')
->optionsUsing(fn (string $term): array => collect(self::LEDGERS)
->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())
->onSelect(function (RowContext $row, mixed $value): void {
// Enrichment: carry the account code alongside the picked id, so the
// posted payload needs no second lookup.
$row->set('code', $value === null ? null : (string) $value);
})
->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.
->afterCellChange(function (RowContext $row, string $column): void {
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;
}
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> ·
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> 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…">
<button type="button" data-post wire:click="post" class="btn-save"
@disabled(! $this->isBalanced)>Post Voucher</button>
<span class="muted">Enabled only while the voucher balances.</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 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>The retrying focus target</strong> — <code>onCompleteFocus()</code> lands on
a Post button that only enables on the commit that completed the voucher.</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
</div>
Balance a voucher and hit Post — the cleaned gridRows() output lands here.