Fundamental Collect redesign: unified creation, payment clarity, widget embed
THE CORE PROBLEM:
Users didn't understand the appeal→link hierarchy.
Payment method was hidden inside appeal creation.
The widget was a separate, undiscoverable concept.
External platforms (JustGiving, LaunchGood) felt disconnected.
THE FIX:
1. ONE CREATION FLOW for everything:
Step 1: 'What are you raising for?' → creates the appeal
Step 2: 'How will donors pay?' → 3 big clear cards:
- Bank transfer (most popular, free)
- External platform (JustGiving, LaunchGood, etc.)
- Card payment (Stripe)
Step 3: 'Name your link' → shows summary, creates both
2. PAYMENT METHOD VISIBLE ON EVERY LINK:
Each link card shows a badge: 'Bank' or 'JustGiving' etc.
External links show 'After pledging, donors are sent to...'
No confusion about how money flows.
3. WIDGET IS A SHARING TAB, NOT A SEPARATE CONCEPT:
Every link card expands to show 3 tabs:
- Link (copy URL, WhatsApp, email, share)
- QR Code (download PNG for printing)
- Website Widget (iframe embed code with copy button)
The widget is just another way to share the same link.
4. FLAT LINK LIST (not appeal→link hierarchy):
All links shown in one flat list
Appeal name shown as subtitle when multiple appeals exist
'New link' adds to existing appeal
'New appeal' uses the full 3-step wizard
5. EDUCATIONAL RIGHT COLUMN:
'How it works' 5-step guide
'Which payment method should I choose?' comparison
'Can I mix payment methods?' FAQ
'What's an appeal?' explanation (demystifies the concept)
Leaderboard when 3+ links have pledges
This commit is contained in:
93
temp_files/fix2/AdminPanelProvider.php
Normal file
93
temp_files/fix2/AdminPanelProvider.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use App\Helpers;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Navigation\MenuItem;
|
||||
use Filament\Navigation\NavigationGroup;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\AuthenticateSession;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
class AdminPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
return $panel
|
||||
->default()
|
||||
->id('admin')
|
||||
->path('admin')
|
||||
->login()
|
||||
->colors(['primary' => config('branding.colours')])
|
||||
->viteTheme('resources/css/filament/admin/theme.css')
|
||||
->sidebarCollapsibleOnDesktop()
|
||||
->sidebarWidth('16rem')
|
||||
->globalSearch(true)
|
||||
->globalSearchKeyBindings(['command+k', 'ctrl+k'])
|
||||
->globalSearchDebounce('300ms')
|
||||
->navigationGroups([
|
||||
// ── Daily Work (always visible, top of sidebar) ──
|
||||
NavigationGroup::make('Daily')
|
||||
->collapsible(false),
|
||||
|
||||
// ── Giving (30 Nights, 10 Days, Night of Power) ──
|
||||
NavigationGroup::make('Giving')
|
||||
->icon('heroicon-o-calendar-days')
|
||||
->collapsible(),
|
||||
|
||||
// ── Fundraising (appeals, review queue) ──
|
||||
NavigationGroup::make('Fundraising')
|
||||
->icon('heroicon-o-megaphone')
|
||||
->collapsible(),
|
||||
|
||||
// ── Setup (rarely touched config) ──
|
||||
NavigationGroup::make('Setup')
|
||||
->icon('heroicon-o-cog-6-tooth')
|
||||
->collapsible()
|
||||
->collapsed(),
|
||||
])
|
||||
->brandLogo(Helpers::getCurrentLogo(true))
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
|
||||
->pages([\Filament\Pages\Dashboard::class])
|
||||
->userMenuItems([
|
||||
'profile' => MenuItem::make()
|
||||
->label('Edit profile')
|
||||
->url(url('user/profile')),
|
||||
|
||||
'back2site' => MenuItem::make()
|
||||
->label('Return to site')
|
||||
->icon('heroicon-o-home')
|
||||
->url(url('/')),
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
StartSession::class,
|
||||
AuthenticateSession::class,
|
||||
ShareErrorsFromSession::class,
|
||||
VerifyCsrfToken::class,
|
||||
SubstituteBindings::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
])
|
||||
->login(null)
|
||||
->registration(null)
|
||||
->darkMode(false)
|
||||
->databaseNotifications();
|
||||
}
|
||||
}
|
||||
164
temp_files/fix2/ListScheduledGivingDonations.php
Normal file
164
temp_files/fix2/ListScheduledGivingDonations.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ScheduledGivingDonationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ScheduledGivingDonationResource;
|
||||
use App\Models\ScheduledGivingCampaign;
|
||||
use App\Models\ScheduledGivingDonation;
|
||||
use Filament\Resources\Components\Tab;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ListScheduledGivingDonations extends ListRecords
|
||||
{
|
||||
protected static string $resource = ScheduledGivingDonationResource::class;
|
||||
|
||||
public function getHeading(): string
|
||||
{
|
||||
return 'Scheduled Giving';
|
||||
}
|
||||
|
||||
public function getSubheading(): string
|
||||
{
|
||||
$current = $this->currentSeasonScope()->count();
|
||||
return "{$current} subscribers this season.";
|
||||
}
|
||||
|
||||
/** Real subscriber: has customer, has payments, amount > 0, not soft-deleted */
|
||||
private function realScope(): Builder
|
||||
{
|
||||
return ScheduledGivingDonation::query()
|
||||
->whereNotNull('customer_id')
|
||||
->where('total_amount', '>', 0)
|
||||
->whereNull('scheduled_giving_donations.deleted_at')
|
||||
->whereHas('payments', fn ($q) => $q->whereNull('deleted_at'));
|
||||
}
|
||||
|
||||
/** Current season: real + has at least one future payment */
|
||||
private function currentSeasonScope(): Builder
|
||||
{
|
||||
return $this->realScope()
|
||||
->whereHas('payments', fn ($q) => $q
|
||||
->whereNull('deleted_at')
|
||||
->where('expected_at', '>', now()));
|
||||
}
|
||||
|
||||
/** Applies real + current season filters to the query */
|
||||
private function applyCurrentSeason(Builder $q): Builder
|
||||
{
|
||||
return $q
|
||||
->whereNotNull('customer_id')
|
||||
->where('total_amount', '>', 0)
|
||||
->whereNull('scheduled_giving_donations.deleted_at')
|
||||
->whereHas('payments', fn ($sub) => $sub->whereNull('deleted_at'))
|
||||
->whereHas('payments', fn ($sub) => $sub
|
||||
->whereNull('deleted_at')
|
||||
->where('expected_at', '>', now()));
|
||||
}
|
||||
|
||||
/** Applies real + expired (no future payments) filters */
|
||||
private function applyExpired(Builder $q): Builder
|
||||
{
|
||||
return $q
|
||||
->whereNotNull('customer_id')
|
||||
->where('total_amount', '>', 0)
|
||||
->whereNull('scheduled_giving_donations.deleted_at')
|
||||
->whereHas('payments', fn ($sub) => $sub->whereNull('deleted_at'))
|
||||
->whereDoesntHave('payments', fn ($sub) => $sub
|
||||
->whereNull('deleted_at')
|
||||
->where('expected_at', '>', now()));
|
||||
}
|
||||
|
||||
/** Applies real subscriber filters */
|
||||
private function applyReal(Builder $q): Builder
|
||||
{
|
||||
return $q
|
||||
->whereNotNull('customer_id')
|
||||
->where('total_amount', '>', 0)
|
||||
->whereNull('scheduled_giving_donations.deleted_at')
|
||||
->whereHas('payments', fn ($sub) => $sub->whereNull('deleted_at'));
|
||||
}
|
||||
|
||||
public function getTabs(): array
|
||||
{
|
||||
$campaigns = ScheduledGivingCampaign::all();
|
||||
|
||||
$currentCount = $this->currentSeasonScope()->count();
|
||||
|
||||
$tabs = [];
|
||||
|
||||
// Current season — the primary tab
|
||||
$tabs['current'] = Tab::make('This Season')
|
||||
->icon('heroicon-o-sun')
|
||||
->badge($currentCount)
|
||||
->badgeColor('success')
|
||||
->modifyQueryUsing(fn (Builder $q) => $this->applyCurrentSeason($q));
|
||||
|
||||
// Per-campaign tabs for current season
|
||||
foreach ($campaigns as $c) {
|
||||
$slug = str($c->title)->slug()->toString();
|
||||
$count = $this->currentSeasonScope()
|
||||
->where('scheduled_giving_campaign_id', $c->id)
|
||||
->count();
|
||||
|
||||
if ($count === 0) continue; // Skip campaigns with no current subscribers
|
||||
|
||||
$tabs[$slug] = Tab::make($c->title)
|
||||
->icon('heroicon-o-calendar')
|
||||
->badge($count)
|
||||
->badgeColor('primary')
|
||||
->modifyQueryUsing(fn (Builder $q) => $this->applyCurrentSeason($q)
|
||||
->where('scheduled_giving_campaign_id', $c->id));
|
||||
}
|
||||
|
||||
// Failed (current season only)
|
||||
$failedCount = $this->currentSeasonScope()
|
||||
->whereHas('payments', fn ($q) => $q
|
||||
->where('is_paid', false)
|
||||
->where('attempts', '>', 0)
|
||||
->whereNull('deleted_at'))
|
||||
->count();
|
||||
|
||||
if ($failedCount > 0) {
|
||||
$tabs['failed'] = Tab::make('Failed')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->badge($failedCount)
|
||||
->badgeColor('danger')
|
||||
->modifyQueryUsing(fn (Builder $q) => $this->applyCurrentSeason($q)
|
||||
->whereHas('payments', fn ($sub) => $sub
|
||||
->where('is_paid', false)
|
||||
->where('attempts', '>', 0)
|
||||
->whereNull('deleted_at')));
|
||||
}
|
||||
|
||||
// Past seasons
|
||||
$expiredCount = $this->realScope()
|
||||
->whereDoesntHave('payments', fn ($q) => $q
|
||||
->whereNull('deleted_at')
|
||||
->where('expected_at', '>', now()))
|
||||
->count();
|
||||
|
||||
$tabs['past'] = Tab::make('Past Seasons')
|
||||
->icon('heroicon-o-archive-box')
|
||||
->badge($expiredCount > 0 ? $expiredCount : null)
|
||||
->badgeColor('gray')
|
||||
->modifyQueryUsing(fn (Builder $q) => $this->applyExpired($q));
|
||||
|
||||
// All real
|
||||
$tabs['all'] = Tab::make('All')
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
->modifyQueryUsing(fn (Builder $q) => $this->applyReal($q));
|
||||
|
||||
return $tabs;
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getDefaultActiveTab(): string|int|null
|
||||
{
|
||||
return 'current';
|
||||
}
|
||||
}
|
||||
365
temp_files/fix2/ScheduledGivingCampaignResource.php
Normal file
365
temp_files/fix2/ScheduledGivingCampaignResource.php
Normal file
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Definitions\DefaultCalendarSchedule;
|
||||
use App\Definitions\HijriCalendar;
|
||||
use App\Definitions\ScheduledGivingSplitTypeFlag;
|
||||
use App\Definitions\ScheduledGivingTime;
|
||||
use App\Filament\Resources\ScheduledGivingCampaignResource\Pages\CreateScheduledGivingCampaign;
|
||||
use App\Filament\Resources\ScheduledGivingCampaignResource\Pages\EditScheduledGivingCampaign;
|
||||
use App\Filament\Resources\ScheduledGivingCampaignResource\Pages\ListScheduledGivingCampaigns;
|
||||
use App\Filament\Resources\ScheduledGivingCampaignResource\RelationManagers\ScheduledGivingDonationsRelationManager;
|
||||
use App\Models\DonationCountry;
|
||||
use App\Models\DonationType;
|
||||
use App\Models\ScheduledGivingCampaign;
|
||||
use App\Services\ScheduleGeneratorService;
|
||||
use Filament\Forms\Components\ColorPicker;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Grid;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\TimePicker;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ScheduledGivingCampaignResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ScheduledGivingCampaign::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-calendar';
|
||||
|
||||
protected static ?string $navigationGroup = 'Giving';
|
||||
|
||||
protected static ?string $navigationLabel = 'Campaign Config';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Toggle::make('active'),
|
||||
Section::make('Info')->schema([
|
||||
TextInput::make('title')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->live()
|
||||
->debounce(1000)
|
||||
->afterStateUpdated(function (\Filament\Forms\Get $get, \Filament\Forms\Set $set) {
|
||||
$set('slug', Str::slug($get('title')));
|
||||
})
|
||||
->placeholder('30 Nights of Giving'),
|
||||
|
||||
TextInput::make('slug')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->placeholder('30-nights-of-giving'),
|
||||
|
||||
FileUpload::make('logo_image')
|
||||
->image()
|
||||
->helperText('Leave blank to use Charity Right logo.'),
|
||||
|
||||
Grid::make()->schema([
|
||||
ColorPicker::make('primary_colour')
|
||||
->label('Primary Colour')
|
||||
->hex()
|
||||
->placeholder('#E42281'),
|
||||
|
||||
ColorPicker::make('text_colour')
|
||||
->label('Text Colour')
|
||||
->hex()
|
||||
->placeholder('#000000'),
|
||||
])->columns(1)->columnSpan(1),
|
||||
|
||||
TextInput::make('minimum_donation')
|
||||
->required()
|
||||
->label('Minimum Donation Amount')
|
||||
->prefix('£')
|
||||
->numeric()
|
||||
->default(30.0),
|
||||
|
||||
Toggle::make('catch_up_payments')
|
||||
->required()
|
||||
->label('Should we attempt to take out missed payments if a subscription is made during the dates?'),
|
||||
|
||||
Toggle::make('accepts_appeals')
|
||||
->required()
|
||||
->label('Can donors set up subscriptions against appeals via this campaign?'),
|
||||
|
||||
Select::make('split_types')
|
||||
->label('Use-able Split Options')
|
||||
->required()
|
||||
->multiple()
|
||||
->options([
|
||||
ScheduledGivingSplitTypeFlag::EQUAL => 'Equal',
|
||||
ScheduledGivingSplitTypeFlag::DOUBLE_ON_ODD => 'Double on odd',
|
||||
ScheduledGivingSplitTypeFlag::DOUBLE_ON_EVEN => 'Double on even',
|
||||
ScheduledGivingSplitTypeFlag::DOUBLE_ON_FRIDAYS => 'Double on Fridays',
|
||||
ScheduledGivingSplitTypeFlag::DOUBLE_ON_LAST_10_NIGHTS => 'Double on last 10',
|
||||
ScheduledGivingSplitTypeFlag::DOUBLE_ON27TH_NIGHT => 'Double on 27th',
|
||||
ScheduledGivingSplitTypeFlag::DOUBLE_ON_DAY_OF_ARAFAH => 'Double on 9th',
|
||||
]),
|
||||
])
|
||||
->columns(),
|
||||
|
||||
Section::make('Date Configuration')->collapsible()->schema([
|
||||
Select::make('date_config_type')
|
||||
->options([
|
||||
-1 => 'Custom Configuration',
|
||||
DefaultCalendarSchedule::RAMADAN_DAYS => 'Ramadan Days',
|
||||
DefaultCalendarSchedule::RAMADAN_NIGHTS => 'Ramadan Nights',
|
||||
DefaultCalendarSchedule::DHUL_HIJJAH_DAYS => 'Dhul Hijjah Days',
|
||||
DefaultCalendarSchedule::DHUL_HIJJAH_NIGHTS => 'Dhul Hijjah Nights',
|
||||
DefaultCalendarSchedule::RAMADAN_NIGHTS_LAST_10 => 'Ramadan Nights (Last 10)',
|
||||
DefaultCalendarSchedule::DHUL_HIJJAH_DAYS_FIRST_10 => 'Dhul Hijjah Days (First 10)',
|
||||
])
|
||||
->label('Builtin Date Configuration')
|
||||
->helperText('Select the built-in date configuration - based on previous campaigns - or choose \'Custom Configuration\' to set up the timings manually. Please note it may take some time to generate the dates when selecting anything other than Custom.')
|
||||
->live()
|
||||
->afterStateUpdated(function ($old, \Filament\Forms\Get $get, \Filament\Forms\Set $set) {
|
||||
$configType = (int) $get('date_config_type');
|
||||
|
||||
if ($configType === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = app(ScheduleGeneratorService::class)->generate($configType);
|
||||
$set('dates', $data); // This becomes VERY heavy when we are doing it reactively.
|
||||
// I don't know why, but it seems to fry the server when it passes the data into the repeater.
|
||||
// Let's just use it when creating the resource and indicate to the user to wait.
|
||||
})
|
||||
->visibleOn('create'),
|
||||
|
||||
Repeater::make('dates')
|
||||
->label('Dates')
|
||||
->schema([
|
||||
Select::make('month')
|
||||
->options(self::formatHijriMonths())
|
||||
->live(),
|
||||
|
||||
Select::make('day')
|
||||
->visible(fn (\Filament\Forms\Get $get) => $get('month'))
|
||||
->live()
|
||||
->options(fn (\Filament\Forms\Get $get) => static::formatHijriDays($get('month'))),
|
||||
|
||||
Select::make('timing')
|
||||
->options([
|
||||
ScheduledGivingTime::DAYTIME => 'Daytime (GMT) / 10:00am',
|
||||
ScheduledGivingTime::NIGHTTIME => 'Nighttime (GMT) / 10:00pm',
|
||||
ScheduledGivingTime::CUSTOM => 'Custom',
|
||||
])
|
||||
->required()
|
||||
->live()
|
||||
->visible(fn (\Filament\Forms\Get $get) => $get('day'))
|
||||
->required(fn (\Filament\Forms\Get $get) => $get('day')),
|
||||
|
||||
TimePicker::make('timing_custom')
|
||||
->visible(fn (\Filament\Forms\Get $get) => $get('timing') == 2)
|
||||
->required(fn (\Filament\Forms\Get $get) => $get('timing') == 2)
|
||||
->live()
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->grid(2)
|
||||
->columns(2)
|
||||
->columnSpanFull()
|
||||
->visible(fn (\Filament\Forms\Get $get) => (bool) $get('date_config_type'))
|
||||
->live()
|
||||
->helperText('Select the dates to use for this campaign. Ordering does not matter as it will be chronologically ordered once you save this page.'),
|
||||
])
|
||||
->columns(1),
|
||||
|
||||
Section::make('Allocations')->collapsible()->schema([
|
||||
Repeater::make('allocation_types')->schema([
|
||||
Select::make('donation_type_id')
|
||||
->label('Donation Item')
|
||||
->options(function (\Filament\Forms\Get $get) {
|
||||
$donationTypes = [];
|
||||
$firstDonationCountryId = static::donationCountries()->first()->id;
|
||||
|
||||
if (($countryTypeId = $get('country_type_id')) && ($countryTypeId != $firstDonationCountryId)) {
|
||||
$donationTypes = DonationCountry::find($countryTypeId)
|
||||
?->donationTypes()
|
||||
->where('is_scheduling', true)
|
||||
->get() ?? collect([]);
|
||||
} else {
|
||||
$donationTypes = static::donationTypes();
|
||||
}
|
||||
|
||||
return $donationTypes->mapWithKeys(
|
||||
fn ($record) => [$record->id => $record->display_name]
|
||||
)->toArray();
|
||||
})
|
||||
->required()
|
||||
->live(),
|
||||
|
||||
Select::make('country_type_id')
|
||||
->label('Country')
|
||||
->options(function (\Filament\Forms\Get $get) {
|
||||
$donationCountries = [];
|
||||
$firstDonationTypeId = static::donationTypes()->first()->id;
|
||||
|
||||
if (($donationTypeId = $get('donation_type_id')) && ($donationTypeId != $firstDonationTypeId)) {
|
||||
$donationCountries = DonationType::find($donationTypeId)
|
||||
?->donationCountries ?? collect([]);
|
||||
} else {
|
||||
$donationCountries = DonationCountry::all();
|
||||
}
|
||||
|
||||
return $donationCountries->mapWithKeys(
|
||||
fn ($record) => [$record->id => $record->name]
|
||||
)->toArray();
|
||||
})
|
||||
->required()
|
||||
->live(),
|
||||
|
||||
TextInput::make('title')
|
||||
->placeholder('Hunger After Eid')
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('description')
|
||||
->placeholder('Make a contribution towards causes with the greatest need, including emergencies.')
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns()
|
||||
->helperText('Set up the possible allocation types for this donation.'),
|
||||
]),
|
||||
|
||||
Section::make('Page Metadata')->schema([
|
||||
TextInput::make('meta_title')
|
||||
->label('Title')
|
||||
->maxLength(512)
|
||||
->placeholder(fn (\Filament\Forms\Get $get) => $get('title')),
|
||||
|
||||
Textarea::make('meta_description')
|
||||
->label('Description')
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('meta_keywords')
|
||||
->label('Keywords')
|
||||
->maxLength(512),
|
||||
])
|
||||
->collapsible(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
ToggleColumn::make('active'),
|
||||
|
||||
TextColumn::make('user.name')
|
||||
->label('Creator')
|
||||
->numeric()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: false),
|
||||
|
||||
TextColumn::make('title')
|
||||
->description(fn ($record) => $record->slug)
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('split_types_formatted')
|
||||
->label('Split Types'),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make()
|
||||
->visible(fn () => Auth::user()?->hasRole('Superadmin')), // On Asim's request, this is only available to top-level administrators.
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
ScheduledGivingDonationsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListScheduledGivingCampaigns::route('/'),
|
||||
'create' => CreateScheduledGivingCampaign::route('/create'),
|
||||
'edit' => EditScheduledGivingCampaign::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
protected static function formatHijriMonths(): array
|
||||
{
|
||||
$c = new HijriCalendar;
|
||||
$islamicMonths = config('calendar.hijri.months');
|
||||
$monthOffset = $c->getCurrentIslamicMonth();
|
||||
$hijriYear = $c->getCurrentIslamicYear();
|
||||
|
||||
$rotatedMonths = array_merge(
|
||||
array_slice($islamicMonths, $monthOffset - 1, null, true), // From current month to the end
|
||||
array_slice($islamicMonths, 0, $monthOffset - 1, true) // From start to just before current month
|
||||
);
|
||||
|
||||
$output = [];
|
||||
foreach ($rotatedMonths as $index => $monthInfo) {
|
||||
$englishName = $monthInfo['en'];
|
||||
$arabicName = $monthInfo['ar'];
|
||||
$output[$monthInfo['number']] = "{$arabicName} ({$englishName}) {$hijriYear}";
|
||||
|
||||
if ($monthInfo['number'] == 12) {
|
||||
$hijriYear++;
|
||||
}
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Will always return 30 days. This formats the days of the week to show.
|
||||
*/
|
||||
protected static function formatHijriDays(int $monthIndex): array
|
||||
{
|
||||
$c = new HijriCalendar;
|
||||
|
||||
$monthIndex = Str::padLeft($monthIndex, 2, '0');
|
||||
$currentYear = $c->getCurrentIslamicYear();
|
||||
$currentDate = "01-{$monthIndex}-{$currentYear}";
|
||||
|
||||
$dates = [];
|
||||
for ($i = 1; $i <= 30; $i++) {
|
||||
$dates[$i] = $currentDate;
|
||||
$currentDate = $c->adjustHijriDate($currentDate, 1);
|
||||
}
|
||||
|
||||
return $dates;
|
||||
}
|
||||
|
||||
private static function donationTypes(): Collection
|
||||
{
|
||||
return DonationType::where('is_scheduling', true)->orderBy('display_name')->get();
|
||||
}
|
||||
|
||||
private static function donationCountries(): Collection
|
||||
{
|
||||
return DonationCountry::orderBy('name')->get();
|
||||
}
|
||||
}
|
||||
288
temp_files/fix2/ScheduledGivingDashboard.php
Normal file
288
temp_files/fix2/ScheduledGivingDashboard.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\ScheduledGivingCampaign;
|
||||
use App\Models\ScheduledGivingDonation;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Scheduled Giving Command Centre.
|
||||
*
|
||||
* Campaigns are seasonal (Ramadan). Each subscription belongs to a specific year.
|
||||
* "Current season" = has future payments. "Expired" = all payments in the past.
|
||||
* Only shows REAL subscribers (has customer + payments + amount > 0 + not soft-deleted).
|
||||
*/
|
||||
class ScheduledGivingDashboard extends Page
|
||||
{
|
||||
protected static ?string $navigationIcon = 'heroicon-o-calendar-days';
|
||||
|
||||
protected static ?string $navigationGroup = 'Giving';
|
||||
|
||||
protected static ?int $navigationSort = 0;
|
||||
|
||||
protected static ?string $navigationLabel = 'Dashboard';
|
||||
|
||||
protected static ?string $title = 'Scheduled Giving';
|
||||
|
||||
protected static string $view = 'filament.pages.scheduled-giving-dashboard';
|
||||
|
||||
/** Real subscriber IDs (has customer + payments + amount > 0 + not soft-deleted) */
|
||||
private function realSubscriberIds(?int $campaignId = null)
|
||||
{
|
||||
$q = DB::table('scheduled_giving_donations as d')
|
||||
->whereNotNull('d.customer_id')
|
||||
->where('d.total_amount', '>', 0)
|
||||
->whereNull('d.deleted_at')
|
||||
->whereExists(function ($sub) {
|
||||
$sub->select(DB::raw(1))
|
||||
->from('scheduled_giving_payments as p')
|
||||
->whereColumn('p.scheduled_giving_donation_id', 'd.id')
|
||||
->whereNull('p.deleted_at');
|
||||
});
|
||||
|
||||
if ($campaignId) {
|
||||
$q->where('d.scheduled_giving_campaign_id', $campaignId);
|
||||
}
|
||||
|
||||
return $q->pluck('d.id');
|
||||
}
|
||||
|
||||
/** IDs with at least one future payment = current season */
|
||||
private function currentSeasonIds(?int $campaignId = null)
|
||||
{
|
||||
$realIds = $this->realSubscriberIds($campaignId);
|
||||
if ($realIds->isEmpty()) return collect();
|
||||
|
||||
return DB::table('scheduled_giving_donations as d')
|
||||
->whereIn('d.id', $realIds)
|
||||
->whereExists(function ($sub) {
|
||||
$sub->select(DB::raw(1))
|
||||
->from('scheduled_giving_payments as p')
|
||||
->whereColumn('p.scheduled_giving_donation_id', 'd.id')
|
||||
->whereNull('p.deleted_at')
|
||||
->whereRaw('p.expected_at > NOW()');
|
||||
})
|
||||
->pluck('d.id');
|
||||
}
|
||||
|
||||
public function getCampaignData(): array
|
||||
{
|
||||
$campaigns = ScheduledGivingCampaign::all();
|
||||
$result = [];
|
||||
|
||||
foreach ($campaigns as $c) {
|
||||
$realIds = $this->realSubscriberIds($c->id);
|
||||
if ($realIds->isEmpty()) {
|
||||
$result[] = $this->emptyCampaign($c);
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentIds = $this->currentSeasonIds($c->id);
|
||||
$expiredIds = $realIds->diff($currentIds);
|
||||
|
||||
// Current season payment stats
|
||||
$currentPayments = null;
|
||||
if ($currentIds->isNotEmpty()) {
|
||||
$currentPayments = DB::table('scheduled_giving_payments')
|
||||
->whereIn('scheduled_giving_donation_id', $currentIds)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw("
|
||||
COUNT(*) as total,
|
||||
SUM(is_paid = 1) as paid,
|
||||
SUM(is_paid = 0) as pending,
|
||||
SUM(is_paid = 0 AND attempts > 0) as failed,
|
||||
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) as collected,
|
||||
SUM(CASE WHEN is_paid = 0 THEN amount ELSE 0 END) as pending_amount,
|
||||
AVG(CASE WHEN is_paid = 1 THEN amount ELSE NULL END) as avg_amount,
|
||||
MIN(CASE WHEN is_paid = 0 AND expected_at > NOW() THEN expected_at ELSE NULL END) as next_payment
|
||||
")
|
||||
->first();
|
||||
}
|
||||
|
||||
// All-time payment stats (for totals)
|
||||
$allPayments = DB::table('scheduled_giving_payments')
|
||||
->whereIn('scheduled_giving_donation_id', $realIds)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw("
|
||||
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) as collected,
|
||||
SUM(CASE WHEN is_paid = 1 THEN 1 ELSE 0 END) as paid,
|
||||
COUNT(*) as total
|
||||
")
|
||||
->first();
|
||||
|
||||
// Completion for current season
|
||||
$totalNights = count($c->dates ?? []);
|
||||
$fullyPaid = 0;
|
||||
if ($totalNights > 0 && $currentIds->isNotEmpty()) {
|
||||
$ids = $currentIds->implode(',');
|
||||
$row = DB::selectOne("SELECT COUNT(*) as cnt FROM (SELECT scheduled_giving_donation_id FROM scheduled_giving_payments WHERE scheduled_giving_donation_id IN ({$ids}) AND deleted_at IS NULL GROUP BY scheduled_giving_donation_id HAVING SUM(is_paid) >= {$totalNights}) sub");
|
||||
$fullyPaid = $row->cnt ?? 0;
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'campaign' => $c,
|
||||
'all_time_subscribers' => $realIds->count(),
|
||||
'all_time_collected' => ($allPayments->collected ?? 0) / 100,
|
||||
'all_time_payments' => (int) ($allPayments->total ?? 0),
|
||||
'all_time_paid' => (int) ($allPayments->paid ?? 0),
|
||||
// Current season
|
||||
'current_subscribers' => $currentIds->count(),
|
||||
'expired_subscribers' => $expiredIds->count(),
|
||||
'current_payments' => (int) ($currentPayments->total ?? 0),
|
||||
'current_paid' => (int) ($currentPayments->paid ?? 0),
|
||||
'current_pending' => (int) ($currentPayments->pending ?? 0),
|
||||
'current_failed' => (int) ($currentPayments->failed ?? 0),
|
||||
'current_collected' => ($currentPayments->collected ?? 0) / 100,
|
||||
'current_pending_amount' => ($currentPayments->pending_amount ?? 0) / 100,
|
||||
'avg_per_night' => ($currentPayments->avg_amount ?? 0) / 100,
|
||||
'fully_completed' => $fullyPaid,
|
||||
'dates' => $c->dates ?? [],
|
||||
'total_nights' => $totalNights,
|
||||
'next_payment' => $currentPayments->next_payment ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getGlobalStats(): array
|
||||
{
|
||||
$realIds = $this->realSubscriberIds();
|
||||
$currentIds = $this->currentSeasonIds();
|
||||
|
||||
$allTime = DB::table('scheduled_giving_payments')
|
||||
->whereIn('scheduled_giving_donation_id', $realIds)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw("
|
||||
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) / 100 as collected,
|
||||
SUM(CASE WHEN is_paid = 1 THEN 1 ELSE 0 END) as paid,
|
||||
COUNT(*) as total
|
||||
")
|
||||
->first();
|
||||
|
||||
$currentStats = null;
|
||||
if ($currentIds->isNotEmpty()) {
|
||||
$currentStats = DB::table('scheduled_giving_payments')
|
||||
->whereIn('scheduled_giving_donation_id', $currentIds)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw("
|
||||
SUM(is_paid = 1) as paid,
|
||||
SUM(is_paid = 0 AND attempts > 0) as failed,
|
||||
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) / 100 as collected,
|
||||
SUM(CASE WHEN is_paid = 0 THEN amount ELSE 0 END) / 100 as pending,
|
||||
COUNT(*) as total
|
||||
")
|
||||
->first();
|
||||
}
|
||||
|
||||
return [
|
||||
'total_subscribers' => $realIds->count(),
|
||||
'current_subscribers' => $currentIds->count(),
|
||||
'expired_subscribers' => $realIds->count() - $currentIds->count(),
|
||||
'all_time_collected' => (float) ($allTime->collected ?? 0),
|
||||
'current_collected' => (float) ($currentStats->collected ?? 0),
|
||||
'current_pending' => (float) ($currentStats->pending ?? 0),
|
||||
'current_failed' => (int) ($currentStats->failed ?? 0),
|
||||
'collection_rate' => ($currentStats->total ?? 0) > 0
|
||||
? round($currentStats->paid / $currentStats->total * 100, 1)
|
||||
: 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function getFailedPayments(): array
|
||||
{
|
||||
$currentIds = $this->currentSeasonIds();
|
||||
if ($currentIds->isEmpty()) return [];
|
||||
|
||||
return DB::table('scheduled_giving_payments as p')
|
||||
->join('scheduled_giving_donations as d', 'd.id', '=', 'p.scheduled_giving_donation_id')
|
||||
->join('scheduled_giving_campaigns as c', 'c.id', '=', 'd.scheduled_giving_campaign_id')
|
||||
->leftJoin('customers as cu', 'cu.id', '=', 'd.customer_id')
|
||||
->whereIn('d.id', $currentIds)
|
||||
->where('p.is_paid', false)
|
||||
->where('p.attempts', '>', 0)
|
||||
->whereNull('p.deleted_at')
|
||||
->orderByDesc('p.updated_at')
|
||||
->limit(15)
|
||||
->get([
|
||||
'p.id as payment_id',
|
||||
'p.amount',
|
||||
'p.expected_at',
|
||||
'p.attempts',
|
||||
'd.id as donation_id',
|
||||
'c.title as campaign',
|
||||
DB::raw("CONCAT(cu.first_name, ' ', cu.last_name) as donor_name"),
|
||||
'cu.email as donor_email',
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getUpcomingPayments(): array
|
||||
{
|
||||
$currentIds = $this->currentSeasonIds();
|
||||
if ($currentIds->isEmpty()) return [];
|
||||
|
||||
return DB::table('scheduled_giving_payments as p')
|
||||
->join('scheduled_giving_donations as d', 'd.id', '=', 'p.scheduled_giving_donation_id')
|
||||
->join('scheduled_giving_campaigns as c', 'c.id', '=', 'd.scheduled_giving_campaign_id')
|
||||
->whereIn('d.id', $currentIds)
|
||||
->where('p.is_paid', false)
|
||||
->where('p.attempts', 0)
|
||||
->where('d.is_active', true)
|
||||
->whereNull('p.deleted_at')
|
||||
->whereBetween('p.expected_at', [now(), now()->addHours(48)])
|
||||
->selectRaw("
|
||||
c.title as campaign,
|
||||
COUNT(*) as payment_count,
|
||||
SUM(p.amount) / 100 as total_amount,
|
||||
MIN(p.expected_at) as earliest
|
||||
")
|
||||
->groupBy('c.title')
|
||||
->orderBy('earliest')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getDataQuality(): array
|
||||
{
|
||||
$total = DB::table('scheduled_giving_donations')->count();
|
||||
$softDeleted = DB::table('scheduled_giving_donations')->whereNotNull('deleted_at')->count();
|
||||
$noCustomer = DB::table('scheduled_giving_donations')->whereNull('customer_id')->whereNull('deleted_at')->count();
|
||||
$noPayments = DB::table('scheduled_giving_donations as d')
|
||||
->whereNull('d.deleted_at')
|
||||
->whereNotNull('d.customer_id')
|
||||
->whereNotExists(function ($q) {
|
||||
$q->select(DB::raw(1))
|
||||
->from('scheduled_giving_payments as p')
|
||||
->whereColumn('p.scheduled_giving_donation_id', 'd.id')
|
||||
->whereNull('p.deleted_at');
|
||||
})->count();
|
||||
$zeroAmount = DB::table('scheduled_giving_donations')
|
||||
->whereNull('deleted_at')
|
||||
->where('total_amount', '<=', 0)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total_records' => $total,
|
||||
'soft_deleted' => $softDeleted,
|
||||
'no_customer' => $noCustomer,
|
||||
'no_payments' => $noPayments,
|
||||
'zero_amount' => $zeroAmount,
|
||||
];
|
||||
}
|
||||
|
||||
private function emptyCampaign($c): array
|
||||
{
|
||||
return [
|
||||
'campaign' => $c,
|
||||
'all_time_subscribers' => 0, 'all_time_collected' => 0, 'all_time_payments' => 0, 'all_time_paid' => 0,
|
||||
'current_subscribers' => 0, 'expired_subscribers' => 0,
|
||||
'current_payments' => 0, 'current_paid' => 0, 'current_pending' => 0, 'current_failed' => 0,
|
||||
'current_collected' => 0, 'current_pending_amount' => 0, 'avg_per_night' => 0,
|
||||
'fully_completed' => 0, 'dates' => $c->dates ?? [], 'total_nights' => count($c->dates ?? []),
|
||||
'next_payment' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
312
temp_files/fix2/ScheduledGivingDonationResource.php
Normal file
312
temp_files/fix2/ScheduledGivingDonationResource.php
Normal file
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\ScheduledGivingDonationResource\Pages\EditScheduledGivingDonation;
|
||||
use App\Filament\Resources\ScheduledGivingDonationResource\Pages\ListScheduledGivingDonations;
|
||||
use App\Filament\Resources\ScheduledGivingDonationResource\RelationManagers\ScheduledGivingDonationPayments;
|
||||
use App\Filament\RelationManagers\InternalNotesRelationManager;
|
||||
use App\Helpers;
|
||||
use App\Jobs\Zapier\Data\SendCustomer;
|
||||
use App\Models\DonationType;
|
||||
use App\Models\ScheduledGivingCampaign;
|
||||
use App\Models\ScheduledGivingDonation;
|
||||
use Filament\Forms\Components\Fieldset;
|
||||
use Filament\Forms\Components\Grid;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
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\ViewAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class ScheduledGivingDonationResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ScheduledGivingDonation::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-user-group';
|
||||
|
||||
protected static ?string $navigationGroup = 'Giving';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
protected static ?string $modelLabel = 'Scheduled Giving';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Scheduled Giving';
|
||||
|
||||
protected static ?string $navigationLabel = 'Subscribers';
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Section::make('Donation Details')
|
||||
->schema([
|
||||
Grid::make(5)->schema([
|
||||
Placeholder::make('status')
|
||||
->label('Status')
|
||||
->content(function (ScheduledGivingDonation $scheduledGivingDonation) {
|
||||
if ($scheduledGivingDonation->is_active) {
|
||||
return new HtmlString('<span class="bg-green-100 text-green-800 text-sm font-medium px-2 py-1 rounded-lg flex-1">Enabled</span>');
|
||||
} else {
|
||||
return new HtmlString('<span class="bg-yellow-100 text-yellow-800 text-sm font-medium px-2 py-1 rounded-lg flex-1">Disabled</span>');
|
||||
}
|
||||
}),
|
||||
|
||||
Placeholder::make('scheduledGivingCampaign.title')
|
||||
->label('Campaign')
|
||||
->content(fn (ScheduledGivingDonation $record): HtmlString => new HtmlString('<b>' . $record->scheduledGivingCampaign->title . '</b>')),
|
||||
|
||||
Placeholder::make('Amount')
|
||||
->content(fn (ScheduledGivingDonation $record): HtmlString => new Htmlstring('<b>' . Helpers::formatMoneyGlobal($record->total_amount) . '</b>')),
|
||||
|
||||
Placeholder::make('admin_contribution')
|
||||
->label('Admin Contribution')
|
||||
->content(fn (ScheduledGivingDonation $record): HtmlString => new Htmlstring('<b>' . Helpers::formatMoneyGlobal($record->amount_admin) . '</b>')),
|
||||
|
||||
Placeholder::make('is_zakat')
|
||||
->label('Zakat?')
|
||||
->content(fn (ScheduledGivingDonation $record): HtmlString => new Htmlstring('<b>' . ($record->is_zakat ? 'Yes' : 'No') . '</b>')),
|
||||
|
||||
Placeholder::make('is_gift_aid')
|
||||
->label('Gift Aid?')
|
||||
->content(fn (ScheduledGivingDonation $record): HtmlString => new Htmlstring('<b>' . ($record->is_gift_aid ? 'Yes' : 'No') . '</b>')),
|
||||
]),
|
||||
|
||||
Fieldset::make('Customer Details')
|
||||
->columns(3)
|
||||
->schema([
|
||||
Placeholder::make('name')
|
||||
->label('Name')
|
||||
->content(fn (ScheduledGivingDonation $donation) => $donation->customer->name),
|
||||
|
||||
Placeholder::make('email')
|
||||
->label('Email')
|
||||
->content(fn (ScheduledGivingDonation $donation) => $donation->customer->email),
|
||||
|
||||
Placeholder::make('phone')
|
||||
->label('Phone')
|
||||
->content(fn (ScheduledGivingDonation $donation) => strlen(trim($phone = $donation->customer->phone)) > 0 ? $phone : new HtmlString('<i>(Not given)</i>')),
|
||||
])
|
||||
->visible(fn (ScheduledGivingDonation $donation) => (bool) $donation->customer),
|
||||
|
||||
Fieldset::make('Address')
|
||||
->columns(3)
|
||||
->schema([
|
||||
Placeholder::make('house')
|
||||
->label('House')
|
||||
->content(fn (ScheduledGivingDonation $donation) => strlen(trim($v = $donation->address->house)) ? $v : new HtmlString('<i>(Not given)</i>')),
|
||||
|
||||
Placeholder::make('street')
|
||||
->label('Street')
|
||||
->content(fn (ScheduledGivingDonation $donation) => strlen(trim($v = $donation->address->street)) ? $v : new HtmlString('<i>(Not given)</i>')),
|
||||
|
||||
Placeholder::make('town')
|
||||
->label('Town')
|
||||
->content(fn (ScheduledGivingDonation $donation) => strlen(trim($v = $donation->address->town)) ? $v : new HtmlString('<i>(Not given)</i>')),
|
||||
|
||||
Placeholder::make('state')
|
||||
->label('State')
|
||||
->content(fn (ScheduledGivingDonation $donation) => strlen(trim($v = $donation->address->state)) ? $v : new HtmlString('<i>(Not given)</i>')),
|
||||
|
||||
Placeholder::make('postcode')
|
||||
->label('Postcode')
|
||||
->content(fn (ScheduledGivingDonation $donation) => strlen(trim($v = $donation->address->postcode)) ? $v : new HtmlString('<i>(Not given)</i>')),
|
||||
|
||||
Placeholder::make('country_code')
|
||||
->label('Country')
|
||||
->content(fn (ScheduledGivingDonation $donation) => strlen(trim($v = $donation->address->country_code)) ? $v : new HtmlString('<i>(Not given)</i>')),
|
||||
])
|
||||
->visible(fn (ScheduledGivingDonation $donation) => (bool) $donation->address),
|
||||
|
||||
Fieldset::make('Appeal')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Placeholder::make('appeal.name')
|
||||
->label('Name')
|
||||
->content(fn (ScheduledGivingDonation $donation) => $donation->appeal->name),
|
||||
|
||||
Placeholder::make('appeal.url')
|
||||
->label('URL')
|
||||
->content(fn (ScheduledGivingDonation $donation): HtmlString => new HtmlString('<a href="' . $donation->appeal->url() . '">' . $donation->appeal->url() . '</a>')),
|
||||
])
|
||||
->visible(fn (ScheduledGivingDonation $donation) => (bool) $donation->appeal),
|
||||
|
||||
Repeater::make('allocations')
|
||||
->schema([
|
||||
TextInput::make('type')
|
||||
->label('Donation Type')
|
||||
->required()
|
||||
->disabled(),
|
||||
|
||||
TextInput::make('amount')
|
||||
->label('Amount')
|
||||
->numeric()
|
||||
->required()
|
||||
->disabled(),
|
||||
])
|
||||
->columns()
|
||||
->formatStateUsing(function ($state) {
|
||||
return collect($state)
|
||||
->map(fn ($amount, $type) => ['type' => DonationType::find($type)?->display_name, 'amount' => $amount])
|
||||
->values()
|
||||
->toArray();
|
||||
})
|
||||
->addable(false)
|
||||
->deletable(false),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()
|
||||
->withCount(['payments as paid_payments_count' => fn ($q) => $q->where('is_paid', true)->whereNull('deleted_at')]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
IconColumn::make('is_active')
|
||||
->label('')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-check-circle')
|
||||
->falseIcon('heroicon-o-x-circle')
|
||||
->trueColor('success')
|
||||
->falseColor('danger')
|
||||
->tooltip(fn (ScheduledGivingDonation $r) => $r->is_active ? 'Active' : 'Cancelled'),
|
||||
|
||||
TextColumn::make('customer.name')
|
||||
->label('Donor')
|
||||
->description(fn (ScheduledGivingDonation $d) => $d->customer?->email)
|
||||
->sortable()
|
||||
->searchable(query: function (Builder $query, string $search) {
|
||||
$query->whereHas('customer', fn (Builder $q) => $q
|
||||
->where('first_name', 'like', "%{$search}%")
|
||||
->orWhere('last_name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%"));
|
||||
}),
|
||||
|
||||
TextColumn::make('scheduledGivingCampaign.title')
|
||||
->label('Campaign')
|
||||
->badge()
|
||||
->color(fn ($state) => match ($state) {
|
||||
'30 Nights of Giving' => 'primary',
|
||||
'10 Days of Giving' => 'success',
|
||||
'Night of Power' => 'warning',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
TextColumn::make('total_amount')
|
||||
->label('Per Night')
|
||||
->money('gbp', divideBy: 100)
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('payments_count')
|
||||
->label('Progress')
|
||||
->counts('payments')
|
||||
->formatStateUsing(function ($state, ScheduledGivingDonation $record) {
|
||||
$total = (int) $state;
|
||||
if ($total === 0) return '—';
|
||||
$paid = (int) ($record->paid_payments_count ?? 0);
|
||||
$pct = round($paid / $total * 100);
|
||||
return "{$paid}/{$total} ({$pct}%)";
|
||||
})
|
||||
->color(function ($state, ScheduledGivingDonation $record) {
|
||||
$total = (int) $state;
|
||||
if ($total === 0) return 'gray';
|
||||
$paid = (int) ($record->paid_payments_count ?? 0);
|
||||
$pct = $paid / $total * 100;
|
||||
return $pct >= 80 ? 'success' : ($pct >= 40 ? 'warning' : 'danger');
|
||||
})
|
||||
->badge(),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label('Signed Up')
|
||||
->date('d M Y')
|
||||
->description(fn (ScheduledGivingDonation $d) => $d->created_at?->diffForHumans())
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('reference_code')
|
||||
->label('Ref')
|
||||
->searchable()
|
||||
->fontFamily('mono')
|
||||
->copyable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->searchable()
|
||||
->bulkActions(static::getBulkActions())
|
||||
->actions(static::getTableRowActions())
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
Filter::make('confirmed')
|
||||
->query(fn (Builder $query): Builder => $query->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))),
|
||||
|
||||
SelectFilter::make('scheduled_giving_campaign_id')
|
||||
->options(ScheduledGivingCampaign::get()->pluck('title', 'id')),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
ScheduledGivingDonationPayments::class,
|
||||
InternalNotesRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListScheduledGivingDonations::route('/'),
|
||||
'edit' => EditScheduledGivingDonation::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
private static function getTableRowActions(): array
|
||||
{
|
||||
return [
|
||||
ViewAction::make(),
|
||||
ActionGroup::make([
|
||||
Action::make('view_customer')
|
||||
->label('View Customer')
|
||||
->icon('heroicon-s-eye')
|
||||
->visible(fn () => (Auth::user()?->hasPermissionTo('view-customer') || Auth::user()?->hasRole('Superadmin')) || Auth::user()?->hasRole('Superadmin')),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
private static function getBulkActions()
|
||||
{
|
||||
return BulkActionGroup::make([
|
||||
BulkAction::make('send_to_zapier')
|
||||
->label('Send to Zapier')
|
||||
->icon('heroicon-s-envelope')
|
||||
->visible(fn (ScheduledGivingDonation $donation) => (Auth::user()?->hasPermissionTo('view-donation') || Auth::user()?->hasRole('Superadmin')))
|
||||
->action(function ($records) {
|
||||
foreach ($records as $donation) {
|
||||
dispatch(new SendCustomer($donation->customer));
|
||||
Notification::make()
|
||||
->success()
|
||||
->title($donation->reference_code . ' pushed to Zapier.')
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
244
temp_files/fix2/scheduled-giving-dashboard.blade.php
Normal file
244
temp_files/fix2/scheduled-giving-dashboard.blade.php
Normal file
@@ -0,0 +1,244 @@
|
||||
<x-filament-panels::page>
|
||||
@php
|
||||
$global = $this->getGlobalStats();
|
||||
$campaigns = $this->getCampaignData();
|
||||
$failed = $this->getFailedPayments();
|
||||
$upcoming = $this->getUpcomingPayments();
|
||||
$quality = $this->getDataQuality();
|
||||
@endphp
|
||||
|
||||
{{-- ── Current Season Overview ─────────────────────────────── --}}
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-heroicon-o-sun class="w-5 h-5 text-warning-500" />
|
||||
Ramadan {{ now()->year }} — Current Season
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-primary-600">{{ number_format($global['current_subscribers']) }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Active This Season</div>
|
||||
<div class="text-xs text-gray-400">{{ number_format($global['expired_subscribers']) }} from past seasons</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-success-600">£{{ number_format($global['current_collected'], 0) }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Collected This Season</div>
|
||||
<div class="text-xs text-gray-400">£{{ number_format($global['all_time_collected'], 0) }} all-time</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-warning-600">£{{ number_format($global['current_pending'], 0) }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Pending</div>
|
||||
@if ($global['current_failed'] > 0)
|
||||
<div class="text-xs text-danger-500">{{ $global['current_failed'] }} failed</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold {{ $global['collection_rate'] >= 80 ? 'text-success-600' : ($global['collection_rate'] >= 60 ? 'text-warning-600' : 'text-danger-600') }}">
|
||||
{{ $global['collection_rate'] }}%
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Collection Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
{{-- ── Campaign Cards ──────────────────────────────────────── --}}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6 mb-6">
|
||||
@foreach ($campaigns as $data)
|
||||
@php
|
||||
$c = $data['campaign'];
|
||||
$hasCurrent = $data['current_subscribers'] > 0;
|
||||
$progressPct = $data['current_payments'] > 0
|
||||
? round($data['current_paid'] / $data['current_payments'] * 100)
|
||||
: 0;
|
||||
@endphp
|
||||
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>{{ $c->title }}</span>
|
||||
@if ($hasCurrent)
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">● Active</span>
|
||||
@else
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">○ No current season</span>
|
||||
@endif
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
@if ($hasCurrent)
|
||||
{{-- Current Season --}}
|
||||
<div class="text-xs font-medium text-primary-600 uppercase tracking-wide mb-2">This Season</div>
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Subscribers</div>
|
||||
<div class="text-lg font-semibold">{{ $data['current_subscribers'] }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Avg / Night</div>
|
||||
<div class="text-lg font-semibold">£{{ number_format($data['avg_per_night'], 2) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Collected</div>
|
||||
<div class="text-lg font-semibold text-success-600">£{{ number_format($data['current_collected'], 0) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Pending</div>
|
||||
<div class="text-lg font-semibold text-warning-600">£{{ number_format($data['current_pending_amount'], 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Payment progress bar --}}
|
||||
<div class="mb-3">
|
||||
<div class="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>{{ number_format($data['current_paid']) }} / {{ number_format($data['current_payments']) }} payments</span>
|
||||
<span>{{ $progressPct }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
|
||||
<div class="h-2.5 rounded-full {{ $progressPct >= 80 ? 'bg-success-500' : ($progressPct >= 50 ? 'bg-warning-500' : 'bg-primary-500') }}"
|
||||
style="width: {{ $progressPct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2 text-center border-t pt-3 dark:border-gray-700">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Nights</div>
|
||||
<div class="font-semibold">{{ $data['total_nights'] }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Completed</div>
|
||||
<div class="font-semibold text-success-600">{{ $data['fully_completed'] }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Failed</div>
|
||||
<div class="font-semibold {{ $data['current_failed'] > 0 ? 'text-danger-600' : 'text-gray-400' }}">{{ $data['current_failed'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- All-time summary --}}
|
||||
<div class="{{ $hasCurrent ? 'mt-3 pt-3 border-t dark:border-gray-700' : '' }}">
|
||||
<div class="text-xs font-medium text-gray-400 uppercase tracking-wide mb-1">All Time</div>
|
||||
<div class="flex justify-between text-sm text-gray-500">
|
||||
<span>{{ $data['all_time_subscribers'] }} subscribers ({{ $data['expired_subscribers'] }} expired)</span>
|
||||
<span class="font-semibold">£{{ number_format($data['all_time_collected'], 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-3 pt-3 border-t dark:border-gray-700">
|
||||
<a href="{{ url('/admin/scheduled-giving-donations?activeTab=' . Str::slug($c->title)) }}"
|
||||
class="text-xs text-primary-600 hover:underline">Subscribers →</a>
|
||||
<a href="{{ url('/admin/scheduled-giving-campaigns/' . $c->id . '/edit') }}"
|
||||
class="text-xs text-gray-500 hover:underline ml-auto">Config →</a>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- ── Upcoming Payments ───────────────────────────────────── --}}
|
||||
@if (count($upcoming) > 0)
|
||||
<x-filament::section class="mb-6">
|
||||
<x-slot name="heading">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-heroicon-o-clock class="w-5 h-5 text-primary-500" />
|
||||
Upcoming Payments (Next 48h)
|
||||
</div>
|
||||
</x-slot>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
@foreach ($upcoming as $u)
|
||||
<div class="flex items-center justify-between p-3 bg-primary-50 dark:bg-primary-950 rounded-lg">
|
||||
<div>
|
||||
<div class="font-semibold">{{ $u->campaign }}</div>
|
||||
<div class="text-sm text-gray-500">{{ $u->payment_count }} payments</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="font-bold text-primary-600">£{{ number_format($u->total_amount, 2) }}</div>
|
||||
<div class="text-xs text-gray-500">{{ \Carbon\Carbon::parse($u->earliest)->diffForHumans() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
{{-- ── Failed Payments ─────────────────────────────────────── --}}
|
||||
@if (count($failed) > 0)
|
||||
<x-filament::section class="mb-6">
|
||||
<x-slot name="heading">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-heroicon-o-exclamation-triangle class="w-5 h-5 text-danger-500" />
|
||||
Failed Payments — This Season ({{ count($failed) }})
|
||||
</div>
|
||||
</x-slot>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-gray-500 uppercase border-b dark:border-gray-700">
|
||||
<th class="pb-2">Donor</th>
|
||||
<th class="pb-2">Campaign</th>
|
||||
<th class="pb-2">Amount</th>
|
||||
<th class="pb-2">Expected</th>
|
||||
<th class="pb-2">Attempts</th>
|
||||
<th class="pb-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($failed as $f)
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900">
|
||||
<td class="py-2">
|
||||
<div class="font-medium">{{ $f->donor_name }}</div>
|
||||
<div class="text-xs text-gray-400">{{ $f->donor_email }}</div>
|
||||
</td>
|
||||
<td class="py-2">{{ $f->campaign }}</td>
|
||||
<td class="py-2 font-semibold">£{{ number_format($f->amount / 100, 2) }}</td>
|
||||
<td class="py-2 text-gray-500">{{ \Carbon\Carbon::parse($f->expected_at)->format('d M') }}</td>
|
||||
<td class="py-2">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-danger-100 text-danger-800 dark:bg-danger-900 dark:text-danger-300">
|
||||
{{ $f->attempts }}×
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2">
|
||||
<a href="{{ url('/admin/scheduled-giving-donations/' . $f->donation_id . '/edit') }}"
|
||||
class="text-xs text-primary-600 hover:underline">View →</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
{{-- ── Data Quality ────────────────────────────────────────── --}}
|
||||
<x-filament::section collapsible collapsed>
|
||||
<x-slot name="heading">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-heroicon-o-shield-exclamation class="w-5 h-5 text-gray-400" />
|
||||
Data Quality
|
||||
</div>
|
||||
</x-slot>
|
||||
<p class="text-sm text-gray-500 mb-3">
|
||||
{{ number_format($quality['total_records']) }} total records in database.
|
||||
Only {{ number_format($global['total_subscribers']) }} are real subscribers with payments.
|
||||
The rest are incomplete sign-ups, test data, or soft-deleted.
|
||||
</p>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-center text-sm">
|
||||
<div class="p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<div class="font-bold">{{ number_format($quality['soft_deleted']) }}</div>
|
||||
<div class="text-xs text-gray-500">Soft-deleted</div>
|
||||
</div>
|
||||
<div class="p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<div class="font-bold">{{ number_format($quality['no_customer']) }}</div>
|
||||
<div class="text-xs text-gray-500">No customer</div>
|
||||
</div>
|
||||
<div class="p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<div class="font-bold">{{ number_format($quality['no_payments']) }}</div>
|
||||
<div class="text-xs text-gray-500">No payments</div>
|
||||
</div>
|
||||
<div class="p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<div class="font-bold">{{ number_format($quality['zero_amount']) }}</div>
|
||||
<div class="text-xs text-gray-500">Zero amount</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-panels::page>
|
||||
Reference in New Issue
Block a user