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:
320
temp_files/v3/CustomerResource.php
Normal file
320
temp_files/v3/CustomerResource.php
Normal file
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Definitions\DonationOccurrence;
|
||||
use App\Filament\Resources\CustomerResource\Pages\EditCustomer;
|
||||
use App\Filament\Resources\CustomerResource\Pages\ListCustomers;
|
||||
use App\Filament\Resources\CustomerResource\RelationManagers\AddressesRelationManager;
|
||||
use App\Filament\RelationManagers\InternalNotesRelationManager;
|
||||
use App\Filament\Resources\CustomerResource\RelationManagers\DonationsRelationManager;
|
||||
use App\Filament\Resources\CustomerResource\RelationManagers\ScheduledGivingDonationsRelationManager;
|
||||
use App\Models\Customer;
|
||||
use Filament\Forms\Components\Grid;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\GlobalSearch\Actions\Action as GlobalSearchAction;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class CustomerResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Customer::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-user-circle';
|
||||
|
||||
protected static ?string $navigationGroup = 'Supporter Care';
|
||||
|
||||
protected static ?string $navigationLabel = 'Donors';
|
||||
|
||||
protected static ?string $modelLabel = 'Donor';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Donors';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'email';
|
||||
|
||||
// ─── Global Search (Cmd+K) ────────────────────────────────────
|
||||
// This is how Sahibah finds a donor when they call or email.
|
||||
// She might type an email, a name, or a phone number.
|
||||
|
||||
public static function getGloballySearchableAttributes(): array
|
||||
{
|
||||
return ['first_name', 'last_name', 'email', 'phone'];
|
||||
}
|
||||
|
||||
public static function getGlobalSearchResultTitle(Model $record): string|HtmlString
|
||||
{
|
||||
return $record->name;
|
||||
}
|
||||
|
||||
public static function getGlobalSearchResultDetails(Model $record): array
|
||||
{
|
||||
$donationCount = $record->donations()->count();
|
||||
$totalDonated = $record->donations()
|
||||
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||
->sum('amount') / 100;
|
||||
|
||||
$activeSG = $record->scheduledGivingDonations()->where('is_active', true)->count();
|
||||
|
||||
$details = [
|
||||
'Email' => $record->email,
|
||||
];
|
||||
|
||||
if ($record->phone) {
|
||||
$details['Phone'] = $record->phone;
|
||||
}
|
||||
|
||||
if ($donationCount > 0) {
|
||||
$details['Donations'] = $donationCount . ' (£' . number_format($totalDonated, 0) . ' total)';
|
||||
}
|
||||
|
||||
if ($activeSG > 0) {
|
||||
$details['Monthly Giving'] = $activeSG . ' active';
|
||||
}
|
||||
|
||||
return $details;
|
||||
}
|
||||
|
||||
public static function getGlobalSearchResultActions(Model $record): array
|
||||
{
|
||||
return [
|
||||
GlobalSearchAction::make('edit')
|
||||
->label('Open Donor Profile')
|
||||
->url(static::getUrl('edit', ['record' => $record])),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getGlobalSearchEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getGlobalSearchEloquentQuery()->latest('created_at');
|
||||
}
|
||||
|
||||
public static function getGlobalSearchResultsLimit(): int
|
||||
{
|
||||
return 10;
|
||||
}
|
||||
|
||||
// ─── Form (Edit screen) ──────────────────────────────────────
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Select::make('user_id')
|
||||
->relationship('user', 'email')
|
||||
->searchable()
|
||||
->live()
|
||||
->helperText('The website login account linked to this donor (if any).')
|
||||
->disabled(),
|
||||
|
||||
Grid::make()->schema([
|
||||
Section::make('Personal Details')->schema([
|
||||
Select::make('title')
|
||||
->required()
|
||||
->options(config('donate.titles'))
|
||||
->columnSpan(1),
|
||||
|
||||
TextInput::make('first_name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->columnSpan(2),
|
||||
|
||||
TextInput::make('last_name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->columnSpan(2),
|
||||
])
|
||||
->columns(5)
|
||||
->columnSpan(1),
|
||||
|
||||
Section::make('Contact Information')->schema([
|
||||
TextInput::make('email')
|
||||
->email()
|
||||
->required()
|
||||
->disabled(fn (\Filament\Forms\Get $get) => (bool) $get('user_id'))
|
||||
->live()
|
||||
->maxLength(255)
|
||||
->copyable(),
|
||||
|
||||
TextInput::make('phone')
|
||||
->tel()
|
||||
->maxLength(32)
|
||||
->copyable(),
|
||||
])
|
||||
->columns()
|
||||
->columnSpan(1),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Table (List screen) ─────────────────────────────────────
|
||||
// This is the "Donor Lookup" — staff search by any field and
|
||||
// immediately see key context: are they a monthly giver? how many
|
||||
// donations? This helps them triage before even clicking in.
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Donor')
|
||||
->searchable(['first_name', 'last_name'])
|
||||
->sortable(['first_name'])
|
||||
->weight('bold')
|
||||
->description(fn (Customer $record): ?string => $record->email),
|
||||
|
||||
TextColumn::make('phone')
|
||||
->label('Phone')
|
||||
->searchable()
|
||||
->copyable()
|
||||
->placeholder('—'),
|
||||
|
||||
TextColumn::make('confirmed_total')
|
||||
->label('Total Donated')
|
||||
->getStateUsing(function (Customer $record) {
|
||||
return $record->donations()
|
||||
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||
->sum('amount') / 100;
|
||||
})
|
||||
->money('gbp')
|
||||
->sortable(query: function (Builder $query, string $direction) {
|
||||
$query->withSum([
|
||||
'donations as confirmed_total' => fn ($q) => $q->whereHas('donationConfirmation', fn ($q2) => $q2->whereNotNull('confirmed_at'))
|
||||
], 'amount')->orderBy('confirmed_total', $direction);
|
||||
})
|
||||
->color(fn ($state) => $state >= 1000 ? 'success' : null)
|
||||
->weight(fn ($state) => $state >= 1000 ? 'bold' : null),
|
||||
|
||||
TextColumn::make('donations_count')
|
||||
->label('Donations')
|
||||
->counts('donations')
|
||||
->sortable()
|
||||
->badge()
|
||||
->color(fn ($state) => match(true) {
|
||||
$state >= 50 => 'success',
|
||||
$state >= 10 => 'info',
|
||||
$state > 0 => 'gray',
|
||||
default => 'danger',
|
||||
}),
|
||||
|
||||
TextColumn::make('monthly_giving')
|
||||
->label('Monthly')
|
||||
->getStateUsing(function (Customer $record) {
|
||||
$active = $record->scheduledGivingDonations()->where('is_active', true)->first();
|
||||
if (!$active) return null;
|
||||
return '£' . number_format($active->total_amount, 0) . '/mo';
|
||||
})
|
||||
->badge()
|
||||
->color('success')
|
||||
->placeholder('—'),
|
||||
|
||||
TextColumn::make('gift_aid')
|
||||
->label('Gift Aid')
|
||||
->getStateUsing(function (Customer $record) {
|
||||
return $record->donations()
|
||||
->whereHas('donationPreferences', fn ($q) => $q->where('is_gift_aid', true))
|
||||
->exists() ? 'Yes' : null;
|
||||
})
|
||||
->badge()
|
||||
->color('success')
|
||||
->placeholder('—'),
|
||||
|
||||
TextColumn::make('last_donation')
|
||||
->label('Last Donation')
|
||||
->getStateUsing(function (Customer $record) {
|
||||
$last = $record->donations()
|
||||
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||
->latest()
|
||||
->first();
|
||||
return $last?->created_at;
|
||||
})
|
||||
->since()
|
||||
->placeholder('Never'),
|
||||
])
|
||||
->filters([
|
||||
Filter::make('has_donations')
|
||||
->label('Has donated')
|
||||
->toggle()
|
||||
->query(fn (Builder $q) => $q->has('donations')),
|
||||
|
||||
Filter::make('monthly_supporter')
|
||||
->label('Monthly supporter')
|
||||
->toggle()
|
||||
->query(fn (Builder $q) => $q->whereHas(
|
||||
'scheduledGivingDonations',
|
||||
fn ($q2) => $q2->where('is_active', true)
|
||||
)),
|
||||
|
||||
Filter::make('gift_aid')
|
||||
->label('Gift Aid donors')
|
||||
->toggle()
|
||||
->query(fn (Builder $q) => $q->whereHas(
|
||||
'donations',
|
||||
fn ($q2) => $q2->whereHas('donationPreferences', fn ($q3) => $q3->where('is_gift_aid', true))
|
||||
)),
|
||||
|
||||
Filter::make('major_donor')
|
||||
->label('Major donors (£1000+)')
|
||||
->toggle()
|
||||
->query(function (Builder $q) {
|
||||
$q->whereHas('donations', function ($q2) {
|
||||
$q2->whereHas('donationConfirmation', fn ($q3) => $q3->whereNotNull('confirmed_at'));
|
||||
}, '>=', 1)
|
||||
->withSum([
|
||||
'donations as total_confirmed' => fn ($q2) => $q2->whereHas('donationConfirmation', fn ($q3) => $q3->whereNotNull('confirmed_at'))
|
||||
], 'amount')
|
||||
->having('total_confirmed', '>=', 100000);
|
||||
}),
|
||||
|
||||
Filter::make('incomplete_donations')
|
||||
->label('Has incomplete donations')
|
||||
->toggle()
|
||||
->query(fn (Builder $q) => $q->whereHas(
|
||||
'donations',
|
||||
fn ($q2) => $q2->whereDoesntHave('donationConfirmation', fn ($q3) => $q3->whereNotNull('confirmed_at'))
|
||||
->where('created_at', '>=', now()->subDays(30))
|
||||
)),
|
||||
|
||||
Filter::make('recent')
|
||||
->label('Joined last 30 days')
|
||||
->toggle()
|
||||
->query(fn (Builder $q) => $q->where('created_at', '>=', now()->subDays(30))),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make()
|
||||
->label('Open')
|
||||
->icon('heroicon-o-arrow-right'),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->searchPlaceholder('Search by name, email, or phone...');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
DonationsRelationManager::class,
|
||||
ScheduledGivingDonationsRelationManager::class,
|
||||
AddressesRelationManager::class,
|
||||
InternalNotesRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListCustomers::route('/'),
|
||||
'edit' => EditCustomer::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
177
temp_files/v3/EditCustomer.php
Normal file
177
temp_files/v3/EditCustomer.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user