Deep UX: 2-column automations, visible appeal cards, platform education, strip model refs

Automations:
- 2-column layout: WhatsApp phone LEFT, education RIGHT
- Right column: 'How it works' (5 numbered steps), performance stats, timing controls, reply commands, tips
- Hero spans full width with photo+dark panel
- Improvement CTA is a prominent card, not floating text
- No misalignment — phone fills left column naturally

Collect:
- Appeals shown as visible gap-px grid cards (not hidden dropdown)
- Each card shows name, platform, amount raised, pledge count, collection rate
- Active appeal has border-l-2 blue indicator
- Platform integration clarity: shows 'Donors redirected to JustGiving' etc
- Educational section: 'Where to share your link' + 'How payment works'
- Explains bank transfer vs JustGiving vs card payment inline

AI model: Stripped all model name comments from code (no user-facing references existed)
This commit is contained in:
2026-03-05 03:20:20 +08:00
parent 3c3336383e
commit 8366054bd7
11 changed files with 2058 additions and 368 deletions

View File

@@ -0,0 +1,300 @@
<?php
namespace App\Filament\Resources\ScheduledGivingDonationResource\RelationManagers;
use App\Jobs\ProcessScheduleGivingDonationPayment;
use App\Models\ScheduledGivingPayment;
use App\Services\AppealScheduledDonationService;
use App\Services\StripeRefundService;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
/**
* Payment schedule with per-row charge / refund / skip actions.
*
* Visual status:
* ✓ green = paid via Stripe
* ✓ gray = paid (skipped / no PI)
* ! red = overdue
* ○ gray = upcoming
*/
class ScheduledGivingDonationPayments extends RelationManager
{
protected static string $relationship = 'payments';
protected static ?string $title = 'Payment Schedule';
public function form(Form $form): Form
{
return $form;
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('amount')
->columns([
TextColumn::make('expected_at')
->label('Date')
->date('d M Y')
->description(fn ($record) => $record->expected_at?->diffForHumans())
->sortable(),
TextColumn::make('amount')
->label('Amount')
->money('gbp', divideBy: 100)
->sortable(),
IconColumn::make('is_paid')
->label('Status')
->icon(fn ($state, $record) => match (true) {
(bool) $state && $record->stripe_payment_intent_id === 'SKIPPED' => 'heroicon-o-forward',
(bool) $state && (bool) $record->stripe_payment_intent_id => 'heroicon-o-check-circle',
(bool) $state => 'heroicon-o-check',
$record->expected_at?->isPast() ?? false => 'heroicon-o-exclamation-circle',
default => 'heroicon-o-clock',
})
->color(fn ($state, $record) => match (true) {
(bool) $state && $record->stripe_payment_intent_id === 'SKIPPED' => 'warning',
(bool) $state => 'success',
$record->expected_at?->isPast() ?? false => 'danger',
default => 'gray',
})
->tooltip(fn ($state, $record) => match (true) {
(bool) $state && $record->stripe_payment_intent_id === 'SKIPPED' => 'Skipped by admin',
(bool) $state && (bool) $record->stripe_payment_intent_id => 'Paid — ' . $record->stripe_payment_intent_id,
(bool) $state => 'Marked as paid (no Stripe ref)',
$record->expected_at?->isPast() ?? false => 'Overdue — not yet charged',
default => 'Upcoming',
}),
TextColumn::make('attempts')
->label('Tries')
->badge()
->color(fn ($state) => match (true) {
$state === 0 || $state === null => 'gray',
$state === 1 => 'success',
default => 'warning',
}),
TextColumn::make('stripe_payment_intent_id')
->label('Stripe')
->url(fn ($state) => $state && $state !== 'SKIPPED'
? "https://dashboard.stripe.com/payments/{$state}"
: null)
->openUrlInNewTab()
->placeholder('—')
->limit(18)
->fontFamily('mono')
->copyable(),
])
->defaultSort('expected_at', 'asc')
// ── Row Actions ─────────────────────────────────────
->actions([
// Charge now
Action::make('take_payment')
->label('Charge')
->icon('heroicon-o-banknotes')
->color('success')
->size('sm')
->visible(fn ($record) => ! $record->is_paid && ! $record->stripe_payment_intent_id)
->requiresConfirmation()
->modalHeading('Charge Payment')
->modalDescription(fn ($record) => 'Charge £' . number_format($record->amount / 100, 2)
. ' (expected ' . $record->expected_at?->format('d M Y') . ')')
->action(function (ScheduledGivingPayment $record) {
try {
(new ProcessScheduleGivingDonationPayment($record))->handle();
(new AppealScheduledDonationService())->syncProcessedPayments(collect([$record]));
Notification::make()
->title('Payment charged')
->body('£' . number_format($record->amount / 100, 2) . ' collected')
->success()
->send();
} catch (\Throwable $e) {
Notification::make()
->title('Payment failed')
->body($e->getMessage())
->danger()
->send();
}
}),
// Refund
Action::make('refund')
->label('Refund')
->icon('heroicon-o-arrow-uturn-left')
->color('danger')
->size('sm')
->visible(fn ($record) => $record->is_paid
&& $record->stripe_payment_intent_id
&& $record->stripe_payment_intent_id !== 'SKIPPED')
->requiresConfirmation()
->modalHeading('Refund Payment')
->modalDescription(fn ($record) => 'Refund £' . number_format($record->amount / 100, 2)
. ' for ' . $record->expected_at?->format('d M Y')
. ' back to donor\'s card via Stripe.')
->form([
TextInput::make('refund_amount')
->label('Refund amount (£)')
->numeric()
->default(fn ($record) => number_format($record->amount / 100, 2, '.', ''))
->required()
->minValue(0.01)
->maxValue(fn ($record) => $record->amount / 100)
->step(0.01)
->helperText('Full amount for complete refund, or reduce for partial.'),
])
->action(function (ScheduledGivingPayment $record, array $data) {
$amountPence = (int) round($data['refund_amount'] * 100);
$isPartial = $amountPence < $record->amount;
$service = app(StripeRefundService::class);
$result = $service->refundPaymentIntent(
$record->stripe_payment_intent_id,
$isPartial ? $amountPence : null,
'Admin refund: scheduled payment #' . $record->id
);
if ($result['success']) {
if (! $isPartial) {
$record->update(['is_paid' => false]);
}
Notification::make()
->title('Payment refunded')
->body('£' . number_format($result['amount'] / 100, 2) . ' refunded. ID: ' . $result['refund_id'])
->success()
->send();
} else {
Notification::make()
->title('Refund failed')
->body($result['error'])
->danger()
->send();
}
}),
// Skip
Action::make('skip')
->label('Skip')
->icon('heroicon-o-forward')
->color('warning')
->size('sm')
->visible(fn ($record) => ! $record->is_paid && ! $record->stripe_payment_intent_id)
->requiresConfirmation()
->modalHeading('Skip Payment')
->modalDescription(fn ($record) => 'Mark the £' . number_format($record->amount / 100, 2)
. ' payment for ' . $record->expected_at?->format('d M Y')
. ' as skipped. No charge will be made.')
->action(function (ScheduledGivingPayment $record) {
$record->update([
'is_paid' => true,
'stripe_payment_intent_id' => 'SKIPPED',
]);
Notification::make()->title('Payment skipped')->warning()->send();
}),
])
// ── Header Actions ──────────────────────────────────
->headerActions([
Action::make('generate_payments')
->label('Generate Payments')
->icon('heroicon-o-calculator')
->visible(function () {
return $this->ownerRecord->payments()->count() == 0
&& $this->ownerRecord->isConfirmed();
})
->requiresConfirmation()
->action(fn () => $this->ownerRecord->generatePayments()),
])
// ── Bulk Actions ────────────────────────────────────
->bulkActions([
BulkActionGroup::make([
BulkAction::make('refund_selected')
->label('Refund Selected')
->icon('heroicon-o-arrow-uturn-left')
->color('danger')
->requiresConfirmation()
->modalHeading('Refund Selected Payments')
->modalDescription('Refund all selected paid payments via Stripe. This cannot be undone.')
->action(function (Collection $records) {
$service = app(StripeRefundService::class);
$refunded = 0;
$failed = 0;
foreach ($records as $record) {
if (! $record->is_paid
|| ! $record->stripe_payment_intent_id
|| $record->stripe_payment_intent_id === 'SKIPPED') {
continue;
}
$result = $service->refundPaymentIntent(
$record->stripe_payment_intent_id,
null,
'Bulk refund: payment #' . $record->id
);
if ($result['success']) {
$record->update(['is_paid' => false]);
$refunded++;
} else {
$failed++;
}
}
Notification::make()
->title($refunded . ' payments refunded' . ($failed ? ", {$failed} failed" : ''))
->success()
->send();
}),
BulkAction::make('charge_selected')
->label('Charge Selected')
->icon('heroicon-o-banknotes')
->color('success')
->requiresConfirmation()
->modalHeading('Charge Selected Payments')
->modalDescription('Process all selected unpaid payments now.')
->action(function (Collection $records) {
$charged = 0;
$failed = 0;
foreach ($records as $record) {
if ($record->is_paid || $record->stripe_payment_intent_id) {
continue;
}
try {
(new ProcessScheduleGivingDonationPayment($record))->handle();
(new AppealScheduledDonationService())->syncProcessedPayments(collect([$record]));
$charged++;
} catch (\Throwable $e) {
$failed++;
}
}
Notification::make()
->title($charged . ' payments charged' . ($failed ? ", {$failed} failed" : ''))
->success()
->send();
}),
]),
]);
}
}