Files
calvana/temp_files/v3/DonationResource.php
Omair Saleh b771858280 Settings + Admin redesign + Community Leader role
## New: Community Leader role
Who: Imam Yusuf, Sister Mariam, Uncle Tariq — the person who rallies
their mosque, WhatsApp group, neighbourhood to pledge.

Not an admin. Not a volunteer. A logged-in coordinator who needs
more than a live feed but less than full admin access.

/dashboard/community — their scoped dashboard:
- 'How are WE doing?' — their stats vs the whole appeal (dark hero section)
- Contribution percentage bar
- Their links with full share buttons (Copy/WhatsApp/Email/QR)
- Create new links (auto-tagged with their name)
- Leaderboard: 'How communities compare' with 'You' badge
- Read-only pledge list (no status changes, no bank details)

Navigation changes for community_leader role:
- Sees: My Community → Share Links → Reports (3 items)
- Does NOT see: Home, Money, Settings, New Appeal button
- Does NOT see: Bank details, WhatsApp config, reconciliation

## New: Team management API + UI
GET/POST/PATCH/DELETE /api/team — CRUD for team members
- Only org_admin/super_admin can invite
- Temp password generated on invite (shown once)
- Copy credentials or send via WhatsApp button
- Role selector with descriptions (Admin, Community Leader, Staff, Volunteer)
- Role change via dropdown, remove with trash icon
- Can't change own role or remove self

## Settings page redesign
Reordered by Aaisha's thinking:
1. WhatsApp (unchanged — most important)
2. Team (NEW — 'who has access? invite community leaders')
3. Bank account
4. Charity details
5. Direct Debit (collapsed in <details>)

Team section shows:
- All members with role icons (Crown/Users/Eye)
- Inline role change dropdown
- Remove button
- Invite form with role cards and descriptions
- Credentials shown once with copy + WhatsApp share buttons

## Admin page redesign
Brand-consistent: no more shadcn Card/Badge/Table
- Dark hero section with 7 platform stats
- Pipeline status breakdown (gap-px grid)
- Pill tab switcher (not shadcn Tabs)
- Grid tables matching the rest of the dashboard
- Role badges color-coded (blue super, green admin, amber leader)

6 files changed, 4 new routes/pages
2026-03-04 21:48:10 +08:00

494 lines
23 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Definitions\DonationOccurrence;
use App\Filament\Exports\DonationExporter;
use App\Filament\Resources\DonationResource\Pages\EditDonation;
use App\Filament\Resources\DonationResource\Pages\ListDonations;
use App\Filament\Resources\DonationResource\RelationManagers\EventLogsRelationManager;
use App\Filament\RelationManagers\InternalNotesRelationManager;
use App\Helpers;
use App\Jobs\N3O\Data\SendDonation;
use App\Jobs\Zapier\Data\SendCustomer;
use App\Mail\DonationConfirmed;
use App\Models\Donation;
use App\Models\DonationCountry;
use App\Models\DonationType;
use App\Models\EverGiveDonation;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\GlobalSearch\Actions\Action as GlobalSearchAction;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ActionGroup;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\ExportAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Enums\FiltersLayout;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\HtmlString;
use Throwable;
class DonationResource extends Resource
{
protected static ?string $model = Donation::class;
protected static ?string $navigationIcon = 'heroicon-o-banknotes';
protected static ?string $navigationGroup = 'Donations';
protected static ?string $navigationLabel = 'All Donations';
protected static ?string $modelLabel = 'Donation';
protected static ?string $pluralModelLabel = 'Donations';
protected static ?int $navigationSort = 1;
// ─── Global Search ────────────────────────────────────────────
// Staff search by donor name, email, or payment reference.
// "Someone called about payment ref pi_3xxx" → finds it instantly.
public static function getGloballySearchableAttributes(): array
{
return ['reference_code', 'provider_reference'];
}
public static function getGlobalSearchResultTitle(Model $record): string|HtmlString
{
$status = $record->isConfirmed() ? '✓' : '✗';
return $status . ' £' . number_format($record->amount / 100, 2) . ' — ' . ($record->customer?->name ?? 'Unknown donor');
}
public static function getGlobalSearchResultDetails(Model $record): array
{
return [
'Cause' => $record->donationType?->display_name ?? '—',
'Date' => $record->created_at?->format('d M Y H:i'),
'Ref' => $record->reference_code ?? '—',
'Status' => $record->isConfirmed() ? 'Confirmed' : 'Incomplete',
];
}
public static function getGlobalSearchResultActions(Model $record): array
{
return [
GlobalSearchAction::make('edit')
->label('View Donation')
->url(static::getUrl('edit', ['record' => $record])),
];
}
public static function getGlobalSearchEloquentQuery(): Builder
{
return parent::getGlobalSearchEloquentQuery()
->with(['customer', 'donationType', 'donationConfirmation'])
->latest('created_at');
}
public static function getGlobalSearchResultsLimit(): int
{
return 8;
}
// ─── Form (Edit/View screen) ─────────────────────────────────
public static function form(Form $form): Form
{
return $form
->schema([
Section::make('Donation Details')
->collapsible()
->schema([
Grid::make(5)->schema([
Placeholder::make('Confirmed?')
->content(fn (Donation $record) => new HtmlString(
$record->isConfirmed()
? '<span class="text-green-600 font-bold">✓ Confirmed</span>'
: '<span class="text-red-600 font-bold">✗ Incomplete</span>'
)),
Placeholder::make('Amount')
->content(fn (Donation $record) => new HtmlString('<b>' . Helpers::formatMoneyGlobal($record->donationTotal()) . '</b>')),
Placeholder::make('Admin Contribution')
->content(fn (Donation $record) => new HtmlString('<b>' . Helpers::formatMoneyGlobal($record->donationAdminAmount()) . '</b>')),
Placeholder::make('Gift Aid?')
->content(fn (Donation $record) => new HtmlString(
$record->isGiftAid()
? '<span class="text-green-600 font-bold">✓ Yes</span>'
: '<span class="text-gray-400">No</span>'
)),
Placeholder::make('Zakat?')
->content(fn (Donation $record) => new HtmlString(
$record->isZakat()
? '<span class="text-green-600 font-bold">✓ Yes</span>'
: '<span class="text-gray-400">No</span>'
)),
]),
Fieldset::make('Donor')
->columns(3)
->schema([
Placeholder::make('name')
->content(fn (Donation $donation) => $donation->customer?->name ?? '—'),
Placeholder::make('email')
->content(fn (Donation $donation) => $donation->customer?->email ?? '—'),
Placeholder::make('phone')
->content(fn (Donation $donation) => strlen(trim($phone = $donation->customer?->phone ?? '')) > 0 ? $phone : new HtmlString('<i class="text-gray-400">Not provided</i>')),
])
->visible(fn (Donation $donation) => (bool) $donation->customer),
Fieldset::make('Address')
->columns(3)
->schema([
Placeholder::make('house')
->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->house ?? '')) ? $v : new HtmlString('<i class="text-gray-400">—</i>')),
Placeholder::make('street')
->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->street ?? '')) ? $v : new HtmlString('<i class="text-gray-400">—</i>')),
Placeholder::make('town')
->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->town ?? '')) ? $v : new HtmlString('<i class="text-gray-400">—</i>')),
Placeholder::make('state')
->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->state ?? '')) ? $v : new HtmlString('<i class="text-gray-400">—</i>')),
Placeholder::make('postcode')
->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->postcode ?? '')) ? $v : new HtmlString('<i class="text-gray-400">—</i>')),
Placeholder::make('country_code')
->content(fn (Donation $donation) => strlen(trim($v = $donation->address?->country_code ?? '')) ? $v : new HtmlString('<i class="text-gray-400">—</i>')),
])
->visible(fn (Donation $donation) => (bool) $donation->address),
Fieldset::make('Allocation')
->schema([
Select::make('donation_type_id')
->label('Cause')
->relationship('donationType', 'display_name')
->required()
->disabled(),
Select::make('donation_country_id')
->label('Country')
->relationship('donationCountry', 'name')
->required()
->disabled(),
Select::make('appeal_id')
->label('Fundraiser')
->relationship('appeal', 'name')
->searchable()
->live()
->disabled()
->visible(fn (Donation $donation) => $donation->appeal_id !== null),
Placeholder::make('ever_give_donation')
->content(fn (Donation $donation) =>
'£' . number_format(
EverGiveDonation::where('donation_id', $donation->id)->value('amount') ?? 0,
2
)
)->visible(function (Donation $donation) {
return EverGiveDonation::where('donation_id', $donation->id)->where('status', '!=', 'failed_to_send_to_ever_give_api')->first();
}),
]),
]),
]);
}
// ─── Table ────────────────────────────────────────────────────
// Designed for two workflows:
// 1. "Find a specific donation" (search by donor name/email/ref)
// 2. "What happened today?" (default sort, quick status scan)
public static function table(Table $table): Table
{
return $table
->columns([
IconColumn::make('is_confirmed')
->label('')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('danger')
->tooltip(fn (Donation $d) => $d->isConfirmed() ? 'Payment confirmed' : 'Payment incomplete — may need follow-up'),
TextColumn::make('created_at')
->label('Date')
->dateTime('d M Y H:i')
->sortable()
->description(fn (Donation $d) => $d->created_at?->diffForHumans()),
TextColumn::make('customer.name')
->label('Donor')
->description(fn (Donation $d) => $d->customer?->email)
->searchable(query: function (Builder $query, string $search) {
$query->whereHas('customer', fn ($q) => $q
->where('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
);
})
->sortable(),
TextColumn::make('amount')
->label('Amount')
->money('gbp', divideBy: 100)
->sortable()
->weight('bold'),
TextColumn::make('donationType.display_name')
->label('Cause')
->badge()
->color('info')
->toggleable(),
TextColumn::make('reoccurrence')
->label('Type')
->formatStateUsing(fn ($state) => match ((int) $state) {
-1 => 'One-off',
2 => 'Monthly',
default => DonationOccurrence::translate($state),
})
->badge()
->color(fn ($state) => match ((int) $state) {
-1 => 'gray',
2 => 'success',
default => 'warning',
}),
TextColumn::make('appeal.name')
->label('Fundraiser')
->toggleable()
->limit(25)
->placeholder('Direct'),
TextColumn::make('reference_code')
->label('Ref')
->searchable()
->fontFamily('mono')
->copyable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('provider_reference')
->label('Payment Ref')
->searchable()
->fontFamily('mono')
->copyable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters(static::getTableFilters(), layout: FiltersLayout::AboveContentCollapsible)
->actions(static::getTableRowActions())
->bulkActions(static::getBulkActions())
->headerActions([
ExportAction::make('donations')
->visible(fn () => Auth::user()?->hasRole('Superadmin') || Auth::user()?->hasPermissionTo('view-donation'))
->label('Export')
->icon('heroicon-o-arrow-down-on-square')
->exporter(DonationExporter::class),
])
->defaultSort('created_at', 'desc')
->searchPlaceholder('Search by donor name, email, or reference...')
->poll('30s');
}
public static function getRelations(): array
{
return [
EventLogsRelationManager::class,
InternalNotesRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListDonations::route('/'),
'edit' => EditDonation::route('/{record}/edit'),
];
}
// ─── Table Actions ───────────────────────────────────────────
// Quick actions that solve problems without navigating away.
// "See a failed donation? → Resend receipt. See unknown donor? → Open profile."
private static function getTableRowActions(): array
{
return [
Action::make('view_donor')
->label('Donor')
->icon('heroicon-o-user')
->color('gray')
->url(fn (Donation $d) => $d->customer_id
? CustomerResource::getUrl('edit', ['record' => $d->customer_id])
: null)
->visible(fn (Donation $d) => (bool) $d->customer_id)
->openUrlInNewTab(),
ActionGroup::make([
ViewAction::make(),
Action::make('send_receipt')
->label('Send Receipt')
->icon('heroicon-o-envelope')
->requiresConfirmation()
->modalDescription(fn (Donation $d) => 'Send receipt to ' . ($d->customer?->email ?? '?'))
->visible(fn (Donation $d) => $d->isConfirmed() && (Auth::user()?->hasRole('Superadmin') || Auth::user()?->hasPermissionTo('view-donation')))
->action(function (Donation $donation) {
try {
Mail::to($donation->customer->email)->send(new DonationConfirmed($donation));
Notification::make()->title('Receipt sent to ' . $donation->customer->email)->success()->send();
} catch (Throwable $e) {
Log::error($e);
Notification::make()->title('Failed to send receipt')->body($e->getMessage())->danger()->send();
}
}),
Action::make('view_in_stripe')
->label('View in Stripe')
->icon('heroicon-o-arrow-top-right-on-square')
->visible(fn (Donation $d) => (bool) $d->provider_reference)
->url(fn (Donation $d) => 'https://dashboard.stripe.com/search?query=' . urlencode($d->provider_reference))
->openUrlInNewTab(),
Action::make('send_to_engage')
->label('Send to Engage')
->icon('heroicon-o-arrow-up-on-square')
->visible(fn (Donation $d) => $d->isConfirmed() && (Auth::user()?->hasRole('Superadmin') || Auth::user()?->hasPermissionTo('view-donation')))
->action(function (Donation $donation) {
dispatch(new SendDonation($donation));
Notification::make()->title('Sent to Engage')->success()->send();
}),
Action::make('send_to_zapier')
->label('Send to Zapier')
->icon('heroicon-o-arrow-up-on-square')
->visible(fn (Donation $d) => $d->isConfirmed() && (Auth::user()?->hasRole('Superadmin') || Auth::user()?->hasPermissionTo('view-donation')))
->action(function (Donation $donation) {
dispatch(new SendCustomer($donation->customer));
Notification::make()->title('Sent to Zapier')->success()->send();
}),
]),
];
}
private static function getBulkActions()
{
return BulkActionGroup::make([
BulkAction::make('send_receipt')
->label('Send Receipts')
->icon('heroicon-o-envelope')
->requiresConfirmation()
->visible(fn () => Auth::user()?->hasRole('Superadmin') || Auth::user()?->hasPermissionTo('view-donation'))
->action(function ($records) {
$sent = 0;
foreach ($records as $donation) {
try {
Mail::to($donation->customer->email)->send(new DonationConfirmed($donation));
$sent++;
} catch (Throwable $e) {
Log::error($e);
}
}
Notification::make()->title($sent . ' receipts sent')->success()->send();
}),
BulkAction::make('send_to_engage')
->label('Send to Engage')
->icon('heroicon-o-arrow-up-on-square')
->visible(fn () => Auth::user()?->hasRole('Superadmin') || Auth::user()?->hasPermissionTo('view-donation'))
->action(function ($records) {
foreach ($records as $donation) {
dispatch(new SendDonation($donation));
}
Notification::make()->title(count($records) . ' sent to Engage')->success()->send();
}),
]);
}
// ─── Filters ─────────────────────────────────────────────────
// Designed around real questions:
// "Show me today's incomplete donations" (investigating failures)
// "Show me Zakat donations this month" (reporting)
// "Show me donations to a specific cause" (allocation check)
private static function getTableFilters(): array
{
return [
TernaryFilter::make('confirmed')
->label('Payment Status')
->placeholder('All')
->trueLabel('Confirmed only')
->falseLabel('Incomplete only')
->queries(
true: fn (Builder $q) => $q->whereHas('donationConfirmation', fn ($q2) => $q2->whereNotNull('confirmed_at')),
false: fn (Builder $q) => $q->whereDoesntHave('donationConfirmation', fn ($q2) => $q2->whereNotNull('confirmed_at')),
blank: fn (Builder $q) => $q,
)
->default(true),
SelectFilter::make('donation_type_id')
->label('Cause')
->options(fn () => DonationType::orderBy('display_name')->pluck('display_name', 'id'))
->searchable(),
SelectFilter::make('donation_country_id')
->label('Country')
->options(fn () => DonationCountry::orderBy('name')->pluck('name', 'id'))
->searchable(),
Filter::make('date_range')
->form([
DatePicker::make('from')->label('From'),
DatePicker::make('to')->label('To'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when($data['from'], fn (Builder $q) => $q->whereDate('created_at', '>=', $data['from']))
->when($data['to'], fn (Builder $q) => $q->whereDate('created_at', '<=', $data['to']));
})
->columns(2),
Filter::make('is_zakat')
->label('Zakat')
->toggle()
->query(fn (Builder $q) => $q->whereHas('donationPreferences', fn ($q2) => $q2->where('is_zakat', true))),
Filter::make('is_gift_aid')
->label('Gift Aid')
->toggle()
->query(fn (Builder $q) => $q->whereHas('donationPreferences', fn ($q2) => $q2->where('is_gift_aid', true))),
Filter::make('has_fundraiser')
->label('Via Fundraiser')
->toggle()
->query(fn (Builder $q) => $q->whereNotNull('appeal_id')),
];
}
}