Reconciliation embedded in Money — no more separate page

The reconcile feature was hidden behind a text link at the bottom of
the Money page. That's backwards. Reconciliation IS the Money page.

Aaisha's actual thought process:
1. People pledged
2. Some say they paid
3. 'Did the money actually arrive?' → opens bank website, downloads CSV
4. 'Let me match it' → THIS SHOULD BE RIGHT HERE, not behind a link
5. '8 out of 10 matched' → pledges auto-move to 'received'

Changes:
- Full bank statement upload area is NOW embedded directly in /dashboard/money
  With icon, description, drop zone — always visible, not a link
- When CSV is selected: file name + detected bank format shown inline
  Column mapping is collapsed by default (auto-detected) but expandable
- 'Match payments' button is blue, full-width, prominent
- Results appear INLINE below the upload area:
  - Summary stats (gap-px grid): rows, incoming, matched, possible, auto-confirmed
  - Green success banner when pledges are auto-confirmed
  - Full match results table with confidence icons
  - 'Upload another' button to reset
- After matching: dashboard data auto-refreshes to show updated pledge statuses
- 'Said they paid' section now says 'Upload a bank statement above to confirm'
  instead of linking to a separate page
- /dashboard/reconcile now redirects to /dashboard/money (backward compat)
- Contextual sections (confirm/nudge) hide when match results are showing
  to avoid visual clutter

Architecture is now:
Stats → MATCH PAYMENTS → Confirm these → Need a nudge → All pledges table

Not: Stats → table → small link at bottom → navigate away → separate page
This commit is contained in:
2026-03-04 21:35:23 +08:00
parent c43404694e
commit 9c7990e05c
4 changed files with 843 additions and 317 deletions

View File

@@ -0,0 +1,177 @@
<?php
namespace App\Filament\Resources\CustomerResource\Pages;
use App\Filament\Resources\CustomerResource;
use App\Filament\Resources\DonationResource;
use App\Models\Customer;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\HtmlString;
class EditCustomer extends EditRecord
{
protected static string $resource = CustomerResource::class;
// ─── Heading: Show who this person IS, not just a name ───────
// When Sahibah opens a donor, she needs to immediately understand:
// "Is this person important? Are they a monthly giver? Gift Aid?"
// No clicking, no scrolling — right in the heading.
public function getHeading(): string|HtmlString
{
$customer = $this->record;
$total = $customer->donations()
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->sum('amount') / 100;
$giftAid = $customer->donations()
->whereHas('donationPreferences', fn ($q) => $q->where('is_gift_aid', true))
->exists();
$badges = '';
if ($total >= 1000) {
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-2">⭐ Major Donor</span>';
}
if ($giftAid) {
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 ml-2">Gift Aid</span>';
}
$sg = $customer->scheduledGivingDonations()->where('is_active', true)->first();
if ($sg) {
$amt = '£' . number_format($sg->total_amount, 0);
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 ml-2">💙 ' . $amt . '/month</span>';
}
// Check for incomplete (problem) donations in last 7 days
$incompleteRecent = $customer->donations()
->whereDoesntHave('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->where('created_at', '>=', now()->subDays(7))
->count();
if ($incompleteRecent > 0) {
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 ml-2">⚠ ' . $incompleteRecent . ' incomplete</span>';
}
return new HtmlString($customer->name . $badges);
}
// ─── Subheading: The one-line story of this donor ────────────
public function getSubheading(): ?string
{
$customer = $this->record;
$confirmed = $customer->donations()
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'));
$total = $confirmed->sum('amount') / 100;
$count = $confirmed->count();
$first = $customer->donations()->oldest()->first();
$since = $first ? $first->created_at->format('M Y') : null;
$parts = [];
if ($total > 0) {
$parts[] = '£' . number_format($total, 2) . ' donated across ' . $count . ' donations';
} else {
$parts[] = 'No confirmed donations yet';
}
if ($since) {
$parts[] = 'Supporter since ' . $since;
}
return implode(' · ', $parts);
}
// ─── Header Actions: What staff DO when they find a donor ────
// These are the actual tasks that take 4+ clicks today:
// "Add a note", "Resend a receipt", "Look them up in Stripe"
protected function getHeaderActions(): array
{
$customer = $this->record;
return [
// Quick note — the #1 thing support staff do
Action::make('add_note')
->label('Add Note')
->icon('heroicon-o-chat-bubble-left-ellipsis')
->color('gray')
->form([
Textarea::make('body')
->label('Note')
->placeholder("e.g. Called on " . now()->format('d M') . " — wants to update their address")
->required()
->rows(3),
])
->action(function (array $data) use ($customer) {
$customer->internalNotes()->create([
'user_id' => auth()->id(),
'body' => $data['body'],
]);
Notification::make()->title('Note added')->success()->send();
}),
// Resend receipt — second most common request
Action::make('resend_receipt')
->label('Resend Receipt')
->icon('heroicon-o-envelope')
->color('info')
->form([
Select::make('donation_id')
->label('Which donation?')
->options(function () use ($customer) {
return $customer->donations()
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->latest()
->take(10)
->get()
->mapWithKeys(function ($d) {
$label = '£' . number_format($d->amount / 100, 2)
. ' on ' . $d->created_at->format('d M Y')
. ' — ' . ($d->donationType?->display_name ?? 'Unknown');
return [$d->id => $label];
});
})
->required()
->helperText('Select the donation to resend the receipt for'),
])
->visible(fn () => $customer->donations()
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->exists())
->action(function (array $data) use ($customer) {
$donation = $customer->donations()->find($data['donation_id']);
if ($donation) {
try {
Mail::to($customer->email)
->send(new \App\Mail\DonationConfirmed($donation));
Notification::make()
->title('Receipt sent to ' . $customer->email)
->body('For donation of £' . number_format($donation->amount / 100, 2) . ' on ' . $donation->created_at->format('d M Y'))
->success()
->send();
} catch (\Throwable $e) {
Notification::make()->title('Failed to send receipt')->body($e->getMessage())->danger()->send();
}
}
}),
// View in Stripe — for investigating payment issues
Action::make('view_in_stripe')
->label('Stripe')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url('https://dashboard.stripe.com/search?query=' . urlencode($customer->email))
->openUrlInNewTab(),
// Email the donor directly
Action::make('email_donor')
->label('Email')
->icon('heroicon-o-at-symbol')
->color('gray')
->url('mailto:' . $customer->email)
->openUrlInNewTab(),
];
}
}