Show payments and paid bills on billing dashboards.
Deploy Ladill Care / deploy (push) Successful in 1m21s

Cashiers and other bills.view roles get today's collections breakdown and a recent paid-invoice list under the existing metric cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 23:40:04 +00:00
co-authored by Cursor
parent e69c7da0d6
commit 4ad3a8a901
4 changed files with 247 additions and 1 deletions
@@ -80,6 +80,14 @@ class DashboardController extends Controller
? $this->patientQueueForMember($request, $organization->id, $owner, $member, $branchScope) ? $this->patientQueueForMember($request, $organization->id, $owner, $member, $branchScope)
: [collect(), collect(), null]; : [collect(), collect(), null];
$showBillingPanel = $canBills;
$paymentStats = $showBillingPanel
? $this->reports->todayPaymentStats($owner, $organization->id, $branchScope)
: null;
$recentPaidBills = $showBillingPanel
? $this->reports->recentPaidBills($owner, $organization->id, $branchScope)
: collect();
return view('care.dashboard', [ return view('care.dashboard', [
'organization' => $organization, 'organization' => $organization,
'member' => $member, 'member' => $member,
@@ -98,6 +106,9 @@ class DashboardController extends Controller
'inConsultation' => $inConsultation, 'inConsultation' => $inConsultation,
'practitionerId' => $practitionerId, 'practitionerId' => $practitionerId,
'specialtyModules' => $this->specialtyModules->enabledModulesForMember($organization, $member), 'specialtyModules' => $this->specialtyModules->enabledModulesForMember($organization, $member),
'showBillingPanel' => $showBillingPanel,
'paymentStats' => $paymentStats,
'recentPaidBills' => $recentPaidBills,
]); ]);
} }
+64
View File
@@ -78,6 +78,70 @@ class ReportService
]; ];
} }
/**
* Today's payment collections for billing dashboards (cashiers / bills.view).
*
* @return array{
* collected_minor: int,
* payment_count: int,
* cash_minor: int,
* other_minor: int
* }
*/
public function todayPaymentStats(
string $ownerRef,
int $organizationId,
?int $branchId = null,
): array {
$today = now()->startOfDay();
$base = Payment::owned($ownerRef)
->where('status', Payment::STATUS_PAID)
->whereHas('bill', function (Builder $q) use ($organizationId, $branchId) {
$q->where('organization_id', $organizationId);
if ($branchId) {
$q->where('branch_id', $branchId);
}
})
->whereDate('paid_at', $today);
$collected = (int) (clone $base)->sum('amount_minor');
$count = (clone $base)->count();
$cash = (int) (clone $base)->where('method', Payment::METHOD_CASH)->sum('amount_minor');
return [
'collected_minor' => $collected,
'payment_count' => $count,
'cash_minor' => $cash,
'other_minor' => max(0, $collected - $cash),
];
}
/**
* Recently paid invoices for billing dashboards.
*
* @return Collection<int, Bill>
*/
public function recentPaidBills(
string $ownerRef,
int $organizationId,
?int $branchId = null,
int $limit = 10,
): Collection {
return Bill::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->where('status', Bill::STATUS_PAID)
->with('patient')
->withMax([
'payments as last_paid_at' => fn ($q) => $q->where('status', Payment::STATUS_PAID),
], 'paid_at')
->orderByDesc('last_paid_at')
->orderByDesc('id')
->limit($limit)
->get();
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
+81
View File
@@ -276,4 +276,85 @@
@endforelse @endforelse
</section> </section>
@endif @endif
@if (! empty($showBillingPanel) && $paymentStats)
@php
$money = fn (int $minor) => $currency.' '.number_format($minor / 100, 2);
@endphp
<section class="mt-6">
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 class="text-sm font-semibold text-slate-900">Payments today</h2>
<p class="mt-0.5 text-xs text-slate-500">Collections at your branch</p>
</div>
<a href="{{ route('care.bills.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">Open billing</a>
</div>
<div class="mt-3 grid grid-cols-2 gap-4 lg:grid-cols-4">
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Collected</p>
<p class="mt-3 text-2xl font-semibold text-emerald-700">{{ $money($paymentStats['collected_minor']) }}</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Payments</p>
<p class="mt-3 text-2xl font-semibold text-slate-900">{{ number_format($paymentStats['payment_count']) }}</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Cash</p>
<p class="mt-3 text-2xl font-semibold text-slate-900">{{ $money($paymentStats['cash_minor']) }}</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Other methods</p>
<p class="mt-3 text-2xl font-semibold text-slate-900">{{ $money($paymentStats['other_minor']) }}</p>
</div>
</div>
</section>
<section class="mt-6 rounded-2xl border border-slate-200 bg-white">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-100 px-5 py-4">
<div>
<h2 class="text-sm font-semibold text-slate-900">Recently paid bills</h2>
<p class="mt-0.5 text-xs text-slate-500">Latest settled invoices</p>
</div>
<a href="{{ route('care.bills.index', ['status' => \App\Models\Bill::STATUS_PAID]) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">View paid</a>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
<tr>
<th class="px-5 py-3">Invoice</th>
<th class="px-5 py-3">Patient</th>
<th class="px-5 py-3">Total</th>
<th class="px-5 py-3">Paid at</th>
<th class="px-5 py-3 text-right"> </th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
@forelse ($recentPaidBills as $bill)
@php
$billUrl = route('care.bills.show', $bill);
$paidAt = $bill->last_paid_at
? \Illuminate\Support\Carbon::parse($bill->last_paid_at)->format('d M Y, H:i')
: '—';
@endphp
<tr class="hover:bg-slate-50">
<td class="px-5 py-3 font-mono text-xs">
<a href="{{ $billUrl }}" class="font-medium text-sky-600 hover:text-sky-700">{{ $bill->invoice_number }}</a>
</td>
<td class="px-5 py-3">{{ $bill->patient?->fullName() ?? 'Patient' }}</td>
<td class="px-5 py-3 tabular-nums">{{ $money((int) $bill->total_minor) }}</td>
<td class="px-5 py-3 text-slate-500">{{ $paidAt }}</td>
<td class="px-5 py-3 text-right">
<a href="{{ $billUrl }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">View</a>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-5 py-8 text-center text-sm text-slate-500">No paid bills yet.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</section>
@endif
</x-app-layout> </x-app-layout>
+91 -1
View File
@@ -83,7 +83,9 @@ class CareWebTest extends TestCase
->assertOk() ->assertOk()
->assertSee('Test Clinic') ->assertSee('Test Clinic')
->assertSee('Revenue today') ->assertSee('Revenue today')
->assertSee('Open bills'); ->assertSee('Open bills')
->assertSee('Payments today')
->assertSee('Recently paid bills');
} }
public function test_doctor_dashboard_hides_finance_metrics(): void public function test_doctor_dashboard_hides_finance_metrics(): void
@@ -99,10 +101,98 @@ class CareWebTest extends TestCase
->assertSee('No patients waiting right now.') ->assertSee('No patients waiting right now.')
->assertDontSee('Revenue today') ->assertDontSee('Revenue today')
->assertDontSee('Open bills') ->assertDontSee('Open bills')
->assertDontSee('Payments today')
->assertDontSee('Recently paid bills')
->assertDontSee('Team members') ->assertDontSee('Team members')
->assertDontSee('Active branches'); ->assertDontSee('Active branches');
} }
public function test_cashier_dashboard_shows_payments_and_paid_bills(): void
{
Member::where('user_ref', $this->user->public_id)->update([
'role' => 'cashier',
'branch_id' => Branch::firstOrFail()->id,
]);
$branch = Branch::firstOrFail();
$patient = \App\Models\Patient::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $branch->id,
'patient_number' => 'LC-2026-00099',
'first_name' => 'Ama',
'last_name' => 'Mensah',
]);
$visit = \App\Models\Visit::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $branch->id,
'patient_id' => $patient->id,
'status' => \App\Models\Visit::STATUS_COMPLETED,
'checked_in_at' => now()->subHour(),
'completed_at' => now(),
]);
$bill = \App\Models\Bill::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $branch->id,
'visit_id' => $visit->id,
'patient_id' => $patient->id,
'invoice_number' => 'INV-DASH-001',
'status' => \App\Models\Bill::STATUS_PAID,
'subtotal_minor' => 4500,
'total_minor' => 4500,
'amount_paid_minor' => 4500,
'balance_minor' => 0,
]);
\App\Models\Payment::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'owner_ref' => $this->user->public_id,
'bill_id' => $bill->id,
'amount_minor' => 3000,
'method' => \App\Models\Payment::METHOD_CASH,
'status' => \App\Models\Payment::STATUS_PAID,
'paid_at' => now(),
'recorded_by' => $this->user->public_id,
]);
\App\Models\Payment::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'owner_ref' => $this->user->public_id,
'bill_id' => $bill->id,
'amount_minor' => 1500,
'method' => \App\Models\Payment::METHOD_MOMO,
'status' => \App\Models\Payment::STATUS_PAID,
'paid_at' => now(),
'recorded_by' => $this->user->public_id,
]);
$this->actingAs($this->user)
->get(route('care.dashboard'))
->assertOk()
->assertSee('Patients today')
->assertSee('Open bills')
->assertSee('Payments today')
->assertSee('Collected')
->assertSee('GHS 45.00')
->assertSee('Cash')
->assertSee('GHS 30.00')
->assertSee('Other methods')
->assertSee('GHS 15.00')
->assertSee('Recently paid bills')
->assertSee('INV-DASH-001')
->assertSee('Ama Mensah')
->assertSee(route('care.bills.show', $bill), false)
->assertDontSee('Revenue today')
->assertDontSee('Patients in queue');
}
public function test_onboarding_creates_organization(): void public function test_onboarding_creates_organization(): void
{ {
Organization::query()->delete(); Organization::query()->delete();