## 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
430 lines
18 KiB
PHP
430 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources;
|
|
|
|
use App\Filament\Resources\AppealResource\Pages\EditAppeal;
|
|
use App\Filament\Resources\AppealResource\Pages\ListAppeals;
|
|
use App\Filament\Resources\AppealResource\RelationManagers\AppealChildrenRelationManager;
|
|
use App\Filament\Resources\AppealResource\RelationManagers\AppealDonationsRelationManager;
|
|
use App\Jobs\N3O\Data\SendAppeal;
|
|
use App\Jobs\WordPress\SyncAppeal;
|
|
use App\Models\Appeal;
|
|
use App\Models\DonationType;
|
|
use Filament\Forms\Components\Fieldset;
|
|
use Filament\Forms\Components\FileUpload;
|
|
use Filament\Forms\Components\RichEditor;
|
|
use Filament\Forms\Components\Section;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Forms\Components\Toggle;
|
|
use Filament\Forms\Form;
|
|
use Filament\GlobalSearch\Actions\Action as GlobalSearchAction;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Resource;
|
|
use Filament\Support\Enums\MaxWidth;
|
|
use Filament\Tables\Actions\Action;
|
|
use Filament\Tables\Actions\ActionGroup;
|
|
use Filament\Tables\Actions\BulkAction;
|
|
use Filament\Tables\Actions\EditAction;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Enums\FiltersLayout;
|
|
use Filament\Tables\Filters\Filter;
|
|
use Filament\Tables\Filters\SelectFilter;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\HtmlString;
|
|
|
|
class AppealResource extends Resource
|
|
{
|
|
protected static ?string $model = Appeal::class;
|
|
|
|
protected static ?string $navigationIcon = 'heroicon-o-hand-raised';
|
|
|
|
protected static ?string $navigationGroup = 'Campaigns';
|
|
|
|
protected static ?int $navigationSort = 1;
|
|
|
|
protected static ?string $modelLabel = 'Fundraiser';
|
|
|
|
protected static ?string $pluralModelLabel = 'Fundraisers';
|
|
|
|
protected static ?string $navigationLabel = 'Fundraisers';
|
|
|
|
protected static ?string $recordTitleAttribute = 'name';
|
|
|
|
// ─── Global Search ────────────────────────────────────────────
|
|
|
|
public static function getGloballySearchableAttributes(): array
|
|
{
|
|
return ['name', 'slug', 'description'];
|
|
}
|
|
|
|
public static function getGlobalSearchResultTitle(Model $record): string|HtmlString
|
|
{
|
|
return $record->name;
|
|
}
|
|
|
|
public static function getGlobalSearchResultDetails(Model $record): array
|
|
{
|
|
$pct = $record->amount_to_raise > 0
|
|
? round($record->amount_raised / $record->amount_to_raise * 100)
|
|
: 0;
|
|
|
|
return [
|
|
'Progress' => '£' . number_format($record->amount_raised / 100, 0) . ' / £' . number_format($record->amount_to_raise / 100, 0) . " ({$pct}%)",
|
|
'Status' => $record->is_accepting_donations ? 'Live' : 'Closed',
|
|
'Owner' => $record->user?->name ?? '—',
|
|
];
|
|
}
|
|
|
|
public static function getGlobalSearchResultActions(Model $record): array
|
|
{
|
|
return [
|
|
GlobalSearchAction::make('edit')
|
|
->label('Open Fundraiser')
|
|
->url(static::getUrl('edit', ['record' => $record])),
|
|
];
|
|
}
|
|
|
|
public static function getGlobalSearchEloquentQuery(): Builder
|
|
{
|
|
return parent::getGlobalSearchEloquentQuery()
|
|
->with('user')
|
|
->latest('created_at');
|
|
}
|
|
|
|
// ─── Form ────────────────────────────────────────────────────
|
|
|
|
public static function form(Form $form): Form
|
|
{
|
|
return $form
|
|
->schema([
|
|
Fieldset::make('Connections')->schema([
|
|
Select::make('parent_appeal_id')
|
|
->label('Parent Fundraiser')
|
|
->relationship('parent', 'name', modifyQueryUsing: fn ($query) => $query->whereNull('parent_appeal_id'))
|
|
->required(false)
|
|
->searchable(),
|
|
|
|
Select::make('user_id')
|
|
->label('Page Owner')
|
|
->searchable()
|
|
->relationship('user', 'name')
|
|
->required(),
|
|
]),
|
|
|
|
Section::make('General Info')->schema([
|
|
Fieldset::make('Info')->schema([
|
|
TextInput::make('name')
|
|
->label('Fundraiser Name')
|
|
->required()
|
|
->maxLength(255),
|
|
|
|
TextInput::make('slug')
|
|
->label('URL Slug')
|
|
->required()
|
|
->maxLength(255)
|
|
->unique(
|
|
table: 'appeals',
|
|
column: 'slug',
|
|
ignorable: $form->getRecord(),
|
|
modifyRuleUsing: fn ($rule) => $rule->whereNull('deleted_at')
|
|
)
|
|
->disabled(),
|
|
|
|
TextInput::make('description')
|
|
->label('Short Description')
|
|
->required()
|
|
->maxLength(512),
|
|
]),
|
|
|
|
Fieldset::make('Target & Allocation')->schema([
|
|
TextInput::make('amount_to_raise')
|
|
->label('Fundraising Target')
|
|
->numeric()
|
|
->minValue(150)
|
|
->maxValue(999_999_999)
|
|
->prefix('£')
|
|
->required(),
|
|
|
|
TextInput::make('amount_raised')
|
|
->label('Amount Raised So Far')
|
|
->numeric()
|
|
->prefix('£')
|
|
->disabled(),
|
|
|
|
Select::make('donation_type_id')
|
|
->label('Cause')
|
|
->relationship('donationType', 'display_name')
|
|
->searchable()
|
|
->preload()
|
|
->live()
|
|
->required(),
|
|
|
|
Select::make('donation_country_id')
|
|
->label('Country')
|
|
->required()
|
|
->visible(function (\Filament\Forms\Get $get) {
|
|
$donationTypeId = $get('donation_type_id');
|
|
if (!($donationType = DonationType::find($donationTypeId))) {
|
|
return false;
|
|
}
|
|
return $donationType->donationCountries()->count() > 1;
|
|
})
|
|
->options(function (\Filament\Forms\Get $get) {
|
|
$donationTypeId = $get('donation_type_id');
|
|
if (!($donationType = DonationType::find($donationTypeId))) {
|
|
return [];
|
|
}
|
|
return $donationType->donationCountries->pluck('name', 'id')->toArray();
|
|
})
|
|
->live(),
|
|
]),
|
|
|
|
Fieldset::make('Settings')->schema([
|
|
Toggle::make('is_visible')
|
|
->label('Visible on website'),
|
|
|
|
Toggle::make('is_in_memory')
|
|
->label('In memory of someone')
|
|
->live(),
|
|
|
|
TextInput::make('in_memory_name')
|
|
->label('In memory of')
|
|
->required(fn (\Filament\Forms\Get $get) => $get('is_in_memory'))
|
|
->visible(fn (\Filament\Forms\Get $get) => $get('is_in_memory')),
|
|
|
|
Toggle::make('is_accepting_donations')
|
|
->label('Accepting donations'),
|
|
|
|
Toggle::make('is_team_campaign')
|
|
->label('Team fundraiser'),
|
|
|
|
Toggle::make('is_accepting_members')
|
|
->label('Accepting team members')
|
|
->live()
|
|
->visible(fn (\Filament\Forms\Get $get) => $get('is_team_campaign')),
|
|
]),
|
|
])
|
|
->collapsible()
|
|
->collapsed(),
|
|
|
|
Section::make('Content')->schema([
|
|
FileUpload::make('picture')
|
|
->label('Cover Image')
|
|
->required()
|
|
->columnSpanFull(),
|
|
|
|
RichEditor::make('story')
|
|
->label('Fundraiser Story')
|
|
->required()
|
|
->minLength(150)
|
|
->columnSpanFull(),
|
|
])
|
|
->collapsible()
|
|
->collapsed(),
|
|
]);
|
|
}
|
|
|
|
// ─── Table ────────────────────────────────────────────────────
|
|
// Designed for the "Fundraiser Nurture" journey:
|
|
// Jasmine opens this page and immediately sees which fundraisers
|
|
// need attention, which are succeeding, which are stale.
|
|
|
|
public static function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->columns([
|
|
TextColumn::make('name')
|
|
->label('Fundraiser')
|
|
->searchable()
|
|
->sortable()
|
|
->weight('bold')
|
|
->limit(40)
|
|
->description(fn (Appeal $a) => $a->user?->name ? 'by ' . $a->user->name : null)
|
|
->tooltip(fn (Appeal $a) => $a->name),
|
|
|
|
TextColumn::make('status_label')
|
|
->label('Status')
|
|
->getStateUsing(function (Appeal $a) {
|
|
if ($a->status === 'pending') return 'Pending Review';
|
|
if (!$a->is_accepting_donations) return 'Closed';
|
|
return 'Live';
|
|
})
|
|
->badge()
|
|
->color(fn ($state) => match ($state) {
|
|
'Live' => 'success',
|
|
'Pending Review' => 'warning',
|
|
'Closed' => 'gray',
|
|
default => 'gray',
|
|
}),
|
|
|
|
TextColumn::make('progress')
|
|
->label('Progress')
|
|
->getStateUsing(function (Appeal $a) {
|
|
$raised = $a->amount_raised / 100;
|
|
$target = $a->amount_to_raise / 100;
|
|
$pct = $target > 0 ? round($raised / $target * 100) : 0;
|
|
return '£' . number_format($raised, 0) . ' / £' . number_format($target, 0) . " ({$pct}%)";
|
|
})
|
|
->color(function (Appeal $a) {
|
|
$pct = $a->amount_to_raise > 0 ? $a->amount_raised / $a->amount_to_raise : 0;
|
|
if ($pct >= 1) return 'success';
|
|
if ($pct >= 0.5) return 'info';
|
|
if ($pct > 0) return 'warning';
|
|
return 'danger';
|
|
})
|
|
->weight('bold'),
|
|
|
|
TextColumn::make('donationType.display_name')
|
|
->label('Cause')
|
|
->badge()
|
|
->color('info')
|
|
->toggleable(),
|
|
|
|
TextColumn::make('nurture_status')
|
|
->label('Needs Attention?')
|
|
->getStateUsing(function (Appeal $a) {
|
|
if ($a->status !== 'confirmed') return null;
|
|
if (!$a->is_accepting_donations) return null;
|
|
|
|
$raised = $a->amount_raised;
|
|
$target = $a->amount_to_raise;
|
|
$age = $a->created_at?->diffInDays(now()) ?? 0;
|
|
|
|
if ($raised == 0 && $age > 7) return '🔴 No donations yet';
|
|
if ($raised > 0 && $raised >= $target * 0.8 && $raised < $target) return '🟡 Almost there!';
|
|
if ($raised >= $target) return '🟢 Target reached!';
|
|
if ($raised > 0 && $age > 30) return '🟠 Slowing down';
|
|
|
|
return null;
|
|
})
|
|
->placeholder('—')
|
|
->wrap(),
|
|
|
|
TextColumn::make('created_at')
|
|
->label('Created')
|
|
->since()
|
|
->sortable()
|
|
->description(fn (Appeal $a) => $a->created_at?->format('d M Y')),
|
|
])
|
|
->filters([
|
|
SelectFilter::make('nurture_segment')
|
|
->label('Nurture Segment')
|
|
->options([
|
|
'needs_outreach' => '🔴 Needs Outreach (£0 raised, 7+ days)',
|
|
'almost_there' => '🟡 Almost There (80%+ of target)',
|
|
'target_reached' => '🟢 Target Reached',
|
|
'slowing' => '🟠 Slowing Down (raised something, 30+ days)',
|
|
'new_this_week' => '🆕 New This Week',
|
|
])
|
|
->query(function (Builder $query, array $data) {
|
|
if (!$data['value']) return;
|
|
$query->where('status', 'confirmed')->where('is_accepting_donations', true);
|
|
|
|
match ($data['value']) {
|
|
'needs_outreach' => $query->where('amount_raised', 0)->where('created_at', '<', now()->subDays(7)),
|
|
'almost_there' => $query->where('amount_raised', '>', 0)->whereRaw('amount_raised >= amount_to_raise * 0.8')->whereRaw('amount_raised < amount_to_raise'),
|
|
'target_reached' => $query->whereRaw('amount_raised >= amount_to_raise')->where('amount_raised', '>', 0),
|
|
'slowing' => $query->where('amount_raised', '>', 0)->whereRaw('amount_raised < amount_to_raise * 0.8')->where('created_at', '<', now()->subDays(30)),
|
|
'new_this_week' => $query->where('created_at', '>=', now()->subDays(7)),
|
|
default => null,
|
|
};
|
|
}),
|
|
|
|
SelectFilter::make('status')
|
|
->options([
|
|
'confirmed' => 'Live',
|
|
'pending' => 'Pending Review',
|
|
]),
|
|
|
|
Filter::make('accepting_donations')
|
|
->label('Currently accepting donations')
|
|
->toggle()
|
|
->query(fn (Builder $q) => $q->where('is_accepting_donations', true))
|
|
->default(),
|
|
|
|
Filter::make('has_raised')
|
|
->label('Has raised money')
|
|
->toggle()
|
|
->query(fn (Builder $q) => $q->where('amount_raised', '>', 0)),
|
|
], layout: FiltersLayout::AboveContentCollapsible)
|
|
->actions([
|
|
Action::make('view_page')
|
|
->label('View Page')
|
|
->icon('heroicon-o-eye')
|
|
->color('gray')
|
|
->url(fn (Appeal $a) => 'https://www.charityright.org.uk/fundraiser/' . $a->slug)
|
|
->openUrlInNewTab(),
|
|
|
|
ActionGroup::make([
|
|
EditAction::make(),
|
|
|
|
Action::make('email_owner')
|
|
->label('Email Owner')
|
|
->icon('heroicon-o-envelope')
|
|
->url(fn (Appeal $a) => $a->user?->email ? 'mailto:' . $a->user->email : null)
|
|
->visible(fn (Appeal $a) => (bool) $a->user?->email)
|
|
->openUrlInNewTab(),
|
|
|
|
Action::make('send_to_engage')
|
|
->label('Send to Engage')
|
|
->icon('heroicon-o-arrow-up-on-square')
|
|
->action(function (Appeal $appeal) {
|
|
dispatch(new SendAppeal($appeal));
|
|
Notification::make()->title('Sent to Engage')->success()->send();
|
|
}),
|
|
|
|
Action::make('appeal_owner')
|
|
->label('Page Owner Details')
|
|
->icon('heroicon-o-user')
|
|
->modalContent(fn (Appeal $appeal): View => view('filament.fields.appeal-owner', ['appeal' => $appeal]))
|
|
->modalWidth(MaxWidth::Large)
|
|
->modalSubmitAction(false)
|
|
->modalCancelAction(false),
|
|
]),
|
|
])
|
|
->bulkActions([
|
|
BulkAction::make('send_to_engage')
|
|
->label('Send to Engage')
|
|
->icon('heroicon-o-arrow-up-on-square')
|
|
->action(function ($records) {
|
|
foreach ($records as $appeal) {
|
|
dispatch(new SendAppeal($appeal));
|
|
}
|
|
Notification::make()->title(count($records) . ' sent to Engage')->success()->send();
|
|
}),
|
|
|
|
BulkAction::make('send_to_wp')
|
|
->label('Sync to WordPress')
|
|
->icon('heroicon-o-globe-alt')
|
|
->action(function ($records) {
|
|
foreach ($records as $appeal) {
|
|
dispatch(new SyncAppeal($appeal));
|
|
}
|
|
Notification::make()->title(count($records) . ' synced to WordPress')->success()->send();
|
|
}),
|
|
])
|
|
->defaultSort('created_at', 'desc')
|
|
->searchPlaceholder('Search by fundraiser name...');
|
|
}
|
|
|
|
public static function getRelations(): array
|
|
{
|
|
return [
|
|
AppealDonationsRelationManager::class,
|
|
AppealChildrenRelationManager::class,
|
|
];
|
|
}
|
|
|
|
public static function getPages(): array
|
|
{
|
|
return [
|
|
'index' => ListAppeals::route('/'),
|
|
'edit' => EditAppeal::route('/{record}/edit'),
|
|
];
|
|
}
|
|
}
|