Show payments and paid bills on billing dashboards.
Deploy Ladill Care / deploy (push) Successful in 1m21s
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:
@@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user