@extends('layouts.app') @section('title', 'M-Pesa Deposits') @section('content') {{-- ═══ HEADER ═══ --}}
{{-- Compact title block — matches the Daily Cash Collections style: bold heading on top, single-line date below. No breadcrumb, no long descriptive subtitle. Shows a single date when from==to, otherwise the range. --}}

M-Pesa Deposits

@if($from->format('Y-m-d') === $to->format('Y-m-d')) {{ $from->format('d M Y') }} @else {{ $from->format('d M Y') }} — {{ $to->format('d M Y') }} @endif

to {{-- Carry the current verify filter through the GET form so applying a date range doesn't reset the workload view. --}} @if($verifyFilter !== 'all') @endif @if(request('from') || request('to')) {{-- "Today" reset keeps the verify filter intact — only date is reset. --}} Today @endif
@if($isAdmin) ⚠ Find Duplicates @endif @include('reports._export-buttons', ['exportType' => 'mpesa-channel'])
{{-- ═══ CHANNEL TABS ═══ --}} {{-- Date filters survive when switching tabs so staff can compare the same window across both rails without re-picking dates. --}} @php // Shared param bag: date window AND verify-filter state. Used by // every link on this page so switching tabs or date-range presets // preserves the user's current filter selection. $linkParams = array_filter([ 'from' => request('from'), 'to' => request('to'), 'verified' => $verifyFilter !== 'all' ? $verifyFilter : null, ]); // Older code references $dateParams for the channel-tab links. // Keep that variable name so the pattern is consistent. $dateParams = $linkParams; @endphp
All Channels @if($channel === 'all'){{ number_format($summary['count']) }}@endif KCB Paybill @if($channel === 'kcb'){{ number_format($summary['count']) }}@endif Bingwa Sacco @if($channel === 'bingwa_sacco'){{ number_format($summary['count']) }}@endif {{-- OTC tab — separate cleanup view for OTC M-Pesa rows. Shows the active count when on tab; otherwise shows the UNASSIGNED count from $byChannel so admin can see how many rows still need a rail picked even when on a different tab. The unassigned badge is amber so it stands out as work-to-do. --}} OTC M-Pesa @if($channel === 'otc') {{ number_format($summary['count']) }} @elseif(($byChannel['otc_unassigned']['count'] ?? 0) > 0) {{ number_format($byChannel['otc_unassigned']['count']) }} ⚠ @endif
{{-- ═══ QUICK PRESETS (shortcut date ranges) + VERIFY FILTER ═══ The verify-state pills (All / Unverified / Verified) live on the same row to save vertical space — they're a natural extension of "narrow the view" actions. Every date-range link below carries the current $verifyFilter via $vp so toggling a date preset doesn't reset the workload view. --}} @php // verify-filter param survives the date-range links. Empty when // 'all' so we don't pollute URLs with redundant query string. $vp = $verifyFilter !== 'all' ? ['verified' => $verifyFilter] : []; @endphp
Quick range: Today Yesterday Last 7 days This month Last month All time (since HMIS) {{-- Visual separator before the verify-state pills --}} | Show: {{-- ═══ VERIFY-STATE FILTER PILLS ═══ Three mutually-exclusive pills. The active one wears the CRH purple-pink palette; the others are quiet grey. Each link carries the current date window via $dateParams so switching the filter doesn't reset your date range. The pending count and verified count from $summary live on the pills as small badges so you see your workload size at a glance without having to click. --}} @php $filterLinks = [ 'all' => ['label' => 'All', 'count' => $summary['count']], 'pending' => ['label' => 'Unverified', 'count' => $summary['pending_count']], 'verified' => ['label' => 'Verified', 'count' => $summary['verified_count']], ]; @endphp @foreach($filterLinks as $key => $info) @php $isActive = $verifyFilter === $key; // 'all' is the default state — omit the verified param // entirely when picking 'all' so the URL stays clean. $params = array_merge(['channel' => $channel], $dateParams); unset($params['verified']); if ($key !== 'all') { $params['verified'] = $key; } // Active-state palette per filter so the pill colour // hints at what state you're looking at. if ($isActive) { if ($key === 'pending') { $pillStyle = 'background:#fef3c7;color:#b45309;border:1px solid #f59e0b'; } elseif ($key === 'verified') { $pillStyle = 'background:#dcfce7;color:#15803d;border:1px solid #16a34a'; } else { $pillStyle = 'background:#fae8ff;color:#86198f;border:1px solid #c026d3'; } } else { $pillStyle = 'background:#f3f4f6;color:#6b7280;border:1px solid transparent'; } @endphp {{ $info['label'] }} {{ number_format($info['count']) }} @endforeach
{{-- ═══ SUMMARY CARDS ═══ Stats bar restored on the All Channels tab only (May 19 2026). Four cards in a single row: (1) Total Bank Deposits — sum of KCB + Bingwa allocated rows. OTC is treated as transient: it's a cleanup bucket, and once admin assigns each OTC row to its real rail it lands in KCB or Bingwa, so unassigned OTC is excluded here on purpose (it's not yet 'bank' money in any meaningful sense). (2) KCB Deposits — KCB-only total. (3) Bingwa Deposits — Bingwa-only total. (4) Unverified Deposits — sum of all rows where bank_verified_at IS NULL across both rails. The admin workload at a glance. Cards reflect PERIOD totals from $allPayments — they don't shift when the verify-filter pill changes (admin asked for a stable snapshot of the date window). Cards only show on the All tab; KCB / Bingwa / OTC stay table-only as previously agreed. --}} @if($channel === 'all') @php $totalBankDeposits = $byChannel['kcb']['amount'] + $byChannel['bingwa_sacco']['amount']; @endphp

Total Bank Deposits

KES {{ number_format($totalBankDeposits) }}

KCB Deposits

KES {{ number_format($byChannel['kcb']['amount']) }}

Bingwa Deposits

KES {{ number_format($byChannel['bingwa_sacco']['amount']) }}

Unverified Deposits

KES {{ number_format($summary['pending_amount']) }}

@else {{-- KCB / Bingwa / OTC tabs stay table-only — small spacer keeps the filter row off the table. --}}
@endif {{-- ═══ TRANSACTION DETAIL ═══ --}}

{{ $channelLabel }} — Transaction Detail @if($verifyFilter === 'pending') (unverified only) @elseif($verifyFilter === 'verified') (verified only) @endif

@if($payments->count()) {{ $payments->count() }} payment(s) @if($verifyFilter !== 'all') of {{ $summary['count'] }} @endif @endif
@if($channel === 'all' || $channel === 'otc') @endif {{-- MRN + Visit hidden on OTC tab since most OTC sales are walk-ins with no MRN and never link to a visit. Kept on All / KCB / Bingwa for cross-context. --}} @if($channel !== 'otc') @endif {{-- Reference column removed everywhere May 19 2026 — the field was almost always empty so it just added horizontal width. The mpesa_code column is the M-Pesa identifier admins actually use. --}} @forelse($payments as $p) {{-- Per-row channel detection — same string match the controller's query uses, so what's shown here is always consistent with what counts in the summary. OTC lineage is read from the underlying invoice's notes so it survives a description rewrite. --}} @php $rowIsKcb = stripos((string) $p->description, 'KCB') !== false; $rowIsBingwa = stripos((string) $p->description, 'Bingwa') !== false; $rowIsOtc = $p->invoice && stripos((string) $p->invoice->notes, 'OTC sale') !== false; // Unassigned OTC: an OTC row that still carries the // placeholder description. These need an admin to // pick a rail before the verify pipeline applies. $rowIsUnassignedOtc = $rowIsOtc && $p->description === 'OTC sale'; @endphp @if($channel === 'all' || $channel === 'otc') {{-- Channel pill — blue for KCB, purple for Bingwa, amber for unassigned OTC. Inline styles for portability. The OTC pill always shows on the OTC tab so admin can see at a glance which rows still need work (amber) versus already assigned (blue/purple with small OTC marker). --}} @endif @if($channel !== 'otc') @endif {{-- Reference column removed (May 19 2026). --}} {{-- ═══ BANK VERIFY ICON + ACTIONS ═══ Four mutually exclusive states share one column: (1) Verified — solid green tick alone, tooltip shows who/when. No buttons (locked). (2) Pending + admin + UNASSIGNED OTC — two assign buttons [KCB|Bingwa]. No verify until row is assigned, since you can't bank-verify a row that hasn't been allocated to a statement. (3) Pending + admin + assigned — channel-move arrow (left) + clickable amber clock. (4) Pending + non-admin — amber clock alone, not clickable. The cell content swaps in-place after a successful verify so the page keeps its date filter and channel tab. A successful channel move or assign reloads the page (the row may no longer belong on this tab). --}} @empty {{-- Colspans per tab — keep in sync with the head row above: OTC: Date + Channel + Patient + Code + Amount + Receiver + Bank = 7 All: Date + Channel + Patient + MRN + Visit + Code + Amount + Receiver + Bank = 9 KCB/Bingwa: Date + Patient + MRN + Visit + Code + Amount + Receiver + Bank = 8 --}} @endforelse @if($payments->count()) {{-- Footer label colspan — the count of columns BEFORE Amount. OTC: Date + Channel + Patient + Code = 4 All: Date + Channel + Patient + MRN + Visit + Code = 6 KCB/Bingwa: Date + Patient + MRN + Visit + Code = 5 --}} @endif
Date / TimeChannelPatientMRN VisitM-Pesa CodeAmount Received By {{-- Icon-only column to save horizontal space. Hover the icon for the full "Pending" / "Verified by …" tooltip. Admin pending rows also get a move-arrow on the left. Unassigned OTC rows get [KCB|Bingwa] assign buttons. --}} Bank
{{ $p->created_at->format('d M Y') }} {{ $p->created_at->format('H:i') }} @if($rowIsKcb) KCB @if($rowIsOtc) OTC @endif @elseif($rowIsBingwa) Bingwa @if($rowIsOtc) OTC @endif @elseif($rowIsUnassignedOtc) OTC ⚠ @else @endif {{ $p->invoice?->patient?->full_name ?? $p->patient?->full_name ?? '—' }}{{ $p->invoice?->patient?->mrn ?? $p->patient?->mrn ?? '—' }} {{ $p->invoice?->visit?->visit_number ?? '—' }}{{ $p->mpesa_code ?? '—' }}KES {{ number_format($p->amount) }} {{ $p->receiver->name ?? '—' }} @if($p->isBankVerified()) @elseif($isAdmin && $rowIsUnassignedOtc) {{-- Unassigned OTC: two-button assign UI. Reload after success since the row will disappear from the OTC unassigned set. --}} @elseif($isAdmin) @php // Move-arrow target depends on the ROW's own channel, // not the tab. On KCB/Bingwa tabs every row is the // same channel so this collapses to the simple flip. // On the 'all' / 'otc' tabs each row could be either // rail, so we read the row's own description. if (in_array($channel, ['all', 'otc'])) { $otherChannel = $rowIsKcb ? 'bingwa_sacco' : 'kcb'; $otherLabel = $rowIsKcb ? 'Bingwa Sacco' : 'KCB Paybill'; } else { $otherChannel = $channel === 'kcb' ? 'bingwa_sacco' : 'kcb'; $otherLabel = $channel === 'kcb' ? 'Bingwa Sacco' : 'KCB Paybill'; } @endphp {{-- Move arrow — pending + admin only --}} {{-- Verify clock --}} @else {{-- Non-admin pending — different tooltip for unassigned OTC so receptionists understand the row is waiting on admin cleanup, not on a bank-verify decision. --}} @if($rowIsUnassignedOtc) @else @endif @endif
@if($verifyFilter === 'pending') All {{ $channelLabel }} transactions in this date range are already verified. 🎉 @elseif($verifyFilter === 'verified') No verified {{ $channelLabel }} transactions in this date range yet. @elseif($channel === 'otc') No OTC M-Pesa transactions recorded for this period. @else No {{ $channelLabel }} payments recorded for this period. @endif
@if($verifyFilter === 'pending') Unverified Total @elseif($verifyFilter === 'verified') Verified Total @else Total @endif KES {{ number_format($payments->sum('amount')) }} @if($verifyFilter !== 'all')
of {{ number_format($summary['total_amount']) }} total
@endif

Cross-check this list against your {{ $channelLabel }} statement. Any deposit on the statement that's missing here was either captured under the wrong channel or not yet recorded in HMIS.

@if($isAdmin) {{-- ═══ ADMIN VERIFY + CHANNEL-MOVE + OTC-ASSIGN HANDLERS ═══ Plain fetch() — no jQuery dep. The verify handler swaps the clicked pending icon for the verified icon in-place. The move handler triggers a full page reload after success because the moved row no longer belongs on the current channel tab. The assign handler (for unassigned OTC) also reloads since the newly-routed row joins KCB or Bingwa and may leave the current view. All three routes are CSRF-protected via the meta token and re-check the admin gate server-side, so inspect-element bypass can't slip through. --}} @endif @endsection