Add superadmin moderation queues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-01 18:52:32 +01:00
parent 4fbd0815a4
commit 117250879b
22 changed files with 1324 additions and 5 deletions

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Pages;
use App\Filament\Clusters\DailyOps\Resources\Photos\PhotoResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePhoto extends CreateRecord
{
protected static string $resource = PhotoResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Pages;
use App\Filament\Clusters\DailyOps\Resources\Photos\PhotoResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
class EditPhoto extends EditRecord
{
protected static string $resource = PhotoResource::class;
protected function getHeaderActions(): array
{
return [
ViewAction::make(),
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Pages;
use App\Filament\Clusters\DailyOps\Resources\Photos\PhotoResource;
use Filament\Resources\Pages\ListRecords;
class ListPhotos extends ListRecords
{
protected static string $resource = PhotoResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Pages;
use App\Filament\Clusters\DailyOps\Resources\Photos\PhotoResource;
use Filament\Resources\Pages\ViewRecord;
class ViewPhoto extends ViewRecord
{
protected static string $resource = PhotoResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos;
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
use App\Filament\Clusters\DailyOps\Resources\Photos\Pages\ListPhotos;
use App\Filament\Clusters\DailyOps\Resources\Photos\Pages\ViewPhoto;
use App\Filament\Clusters\DailyOps\Resources\Photos\Schemas\PhotoForm;
use App\Filament\Clusters\DailyOps\Resources\Photos\Schemas\PhotoInfolist;
use App\Filament\Clusters\DailyOps\Resources\Photos\Tables\PhotosTable;
use App\Models\Photo;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class PhotoResource extends Resource
{
protected static ?string $model = Photo::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
protected static ?string $cluster = DailyOpsCluster::class;
protected static ?string $slug = 'moderation-queue';
protected static ?string $recordTitleAttribute = 'id';
protected static ?int $navigationSort = 20;
public static function canCreate(): bool
{
return false;
}
public static function form(Schema $schema): Schema
{
return PhotoForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return PhotoInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return PhotosTable::configure($table);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with(['event.tenant', 'moderator']);
}
public static function getNavigationLabel(): string
{
return __('admin.moderation.navigation.label');
}
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.curation');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListPhotos::route('/'),
'view' => ViewPhoto::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Schemas;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
class PhotoForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Select::make('event_id')
->relationship('event', 'name')
->required(),
Select::make('emotion_id')
->relationship('emotion', 'name'),
Select::make('task_id')
->relationship('task', 'title'),
TextInput::make('guest_name')
->required(),
TextInput::make('file_path')
->required(),
TextInput::make('thumbnail_path')
->required(),
TextInput::make('likes_count')
->required()
->numeric()
->default(0),
Toggle::make('is_featured')
->required(),
Textarea::make('metadata')
->columnSpanFull(),
TextInput::make('tenant_id')
->numeric(),
Select::make('media_asset_id')
->relationship('mediaAsset', 'id'),
TextInput::make('security_scan_status')
->required()
->default('pending'),
Textarea::make('security_scan_message')
->columnSpanFull(),
DateTimePicker::make('security_scanned_at'),
Textarea::make('security_meta')
->columnSpanFull(),
TextInput::make('ingest_source')
->required()
->default('guest_pwa'),
TextInput::make('filename'),
TextInput::make('original_name'),
TextInput::make('mime_type'),
TextInput::make('size')
->numeric(),
TextInput::make('width')
->numeric(),
TextInput::make('height')
->numeric(),
TextInput::make('status')
->required()
->default('pending'),
TextInput::make('uploader_id')
->numeric(),
TextInput::make('ip_address'),
Textarea::make('user_agent')
->columnSpanFull(),
TextInput::make('created_by_device_id'),
]);
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Schemas;
use App\Models\Photo;
use Filament\Infolists\Components\ImageEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class PhotoInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make(__('admin.moderation.sections.photo'))
->columns(3)
->schema([
ImageEntry::make('thumbnail_path')
->label(__('admin.moderation.fields.photo'))
->disk('public')
->visibility('public')
->getStateUsing(fn (Photo $record) => $record->thumbnail_path ?: $record->file_path)
->columnSpanFull(),
TextEntry::make('event.name')
->label(__('admin.moderation.fields.event'))
->placeholder('—'),
TextEntry::make('event.tenant.name')
->label(__('admin.moderation.fields.tenant'))
->placeholder('—'),
TextEntry::make('guest_name')
->label(__('admin.moderation.fields.uploader'))
->placeholder('—'),
TextEntry::make('created_at')
->label(__('admin.moderation.fields.uploaded_at'))
->since()
->placeholder('—'),
TextEntry::make('ingest_source')
->label(__('admin.moderation.fields.ingest_source'))
->formatStateUsing(fn (?string $state) => match ($state) {
Photo::SOURCE_GUEST_PWA => __('admin.moderation.ingest_sources.guest_pwa'),
Photo::SOURCE_TENANT_ADMIN => __('admin.moderation.ingest_sources.tenant_admin'),
Photo::SOURCE_PHOTOBOOTH => __('admin.moderation.ingest_sources.photobooth'),
Photo::SOURCE_SPARKBOOTH => __('admin.moderation.ingest_sources.sparkbooth'),
Photo::SOURCE_UNKNOWN => __('admin.moderation.ingest_sources.unknown'),
default => '—',
}),
]),
Section::make(__('admin.moderation.sections.moderation'))
->columns(2)
->schema([
TextEntry::make('status')
->label(__('admin.moderation.fields.status'))
->badge()
->color(fn (?string $state) => match ($state) {
'approved' => 'success',
'rejected' => 'danger',
'hidden' => 'gray',
default => 'warning',
})
->formatStateUsing(fn (?string $state) => match ($state) {
'pending' => __('admin.moderation.status.pending'),
'approved' => __('admin.moderation.status.approved'),
'rejected' => __('admin.moderation.status.rejected'),
'hidden' => __('admin.moderation.status.hidden'),
default => '—',
}),
TextEntry::make('moderator.name')
->label(__('admin.moderation.fields.moderated_by'))
->placeholder('—'),
TextEntry::make('moderated_at')
->label(__('admin.moderation.fields.moderated_at'))
->dateTime()
->placeholder('—'),
TextEntry::make('moderation_notes')
->label(__('admin.moderation.fields.moderation_notes'))
->placeholder('—')
->columnSpanFull(),
TextEntry::make('security_scan_status')
->label(__('admin.moderation.fields.security_scan_status'))
->badge()
->color(fn (?string $state) => match ($state) {
'clean', 'skipped', 'stripped' => 'success',
'infected' => 'danger',
'error' => 'warning',
default => 'gray',
})
->formatStateUsing(fn (?string $state) => match ($state) {
'pending' => __('admin.moderation.security_scan.pending'),
'clean' => __('admin.moderation.security_scan.clean'),
'infected' => __('admin.moderation.security_scan.infected'),
'skipped' => __('admin.moderation.security_scan.skipped'),
'stripped' => __('admin.moderation.security_scan.stripped'),
'error' => __('admin.moderation.security_scan.error'),
default => '—',
}),
TextEntry::make('security_scan_message')
->label(__('admin.moderation.fields.security_scan_message'))
->placeholder('—')
->columnSpanFull(),
TextEntry::make('security_scanned_at')
->label(__('admin.moderation.fields.security_scanned_at'))
->dateTime()
->placeholder('—'),
]),
]);
}
}

View File

@@ -0,0 +1,261 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Tables;
use App\Models\Event;
use App\Models\Photo;
use App\Models\Tenant;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\ViewAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Textarea;
use Filament\Support\Icons\Heroicon;
use Filament\Tables;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
class PhotosTable
{
public static function configure(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->columns([
ImageColumn::make('thumbnail_path')
->label(__('admin.moderation.table.photo'))
->disk('public')
->visibility('public')
->circular()
->getStateUsing(fn (Photo $record) => $record->thumbnail_path ?: $record->file_path),
Tables\Columns\TextColumn::make('event.name')
->label(__('admin.moderation.table.event'))
->searchable()
->limit(30),
Tables\Columns\TextColumn::make('event.tenant.name')
->label(__('admin.moderation.table.tenant'))
->limit(30),
Tables\Columns\TextColumn::make('guest_name')
->label(__('admin.moderation.table.uploader'))
->searchable()
->limit(20),
Tables\Columns\TextColumn::make('status')
->label(__('admin.moderation.table.status'))
->badge()
->color(fn (?string $state) => match ($state) {
'approved' => 'success',
'rejected' => 'danger',
'hidden' => 'gray',
default => 'warning',
})
->formatStateUsing(fn (?string $state) => self::statusLabels()[$state] ?? '—')
->sortable(),
Tables\Columns\TextColumn::make('security_scan_status')
->label(__('admin.moderation.table.security_scan'))
->badge()
->color(fn (?string $state) => match ($state) {
'clean', 'skipped', 'stripped' => 'success',
'infected' => 'danger',
'error' => 'warning',
default => 'gray',
})
->formatStateUsing(fn (?string $state) => self::securityScanLabels()[$state] ?? '—')
->toggleable(),
Tables\Columns\TextColumn::make('ingest_source')
->label(__('admin.moderation.table.ingest_source'))
->badge()
->color('gray')
->formatStateUsing(fn (?string $state) => self::ingestSourceLabels()[$state] ?? '—')
->toggleable(),
Tables\Columns\TextColumn::make('created_at')
->label(__('admin.moderation.table.uploaded_at'))
->since()
->sortable(),
Tables\Columns\TextColumn::make('moderator.name')
->label(__('admin.moderation.table.moderated_by'))
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('moderated_at')
->label(__('admin.moderation.table.moderated_at'))
->since()
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('status')
->label(__('admin.moderation.filters.status'))
->options(self::statusLabels())
->default('pending'),
SelectFilter::make('ingest_source')
->label(__('admin.moderation.filters.ingest_source'))
->options(self::ingestSourceLabels())
->default(Photo::SOURCE_GUEST_PWA),
SelectFilter::make('security_scan_status')
->label(__('admin.moderation.filters.security_scan_status'))
->options(self::securityScanLabels()),
SelectFilter::make('tenant_id')
->label(__('admin.common.tenant'))
->options(fn () => Tenant::query()->orderBy('name')->pluck('name', 'id')->toArray())
->searchable(),
SelectFilter::make('event_id')
->label(__('admin.common.event'))
->options(fn () => Event::query()->orderBy('name')->pluck('name', 'id')->toArray())
->searchable(),
Filter::make('created_at')
->label(__('admin.moderation.filters.uploaded_at'))
->form([
DatePicker::make('from')->label(__('admin.common.from')),
DatePicker::make('until')->label(__('admin.common.until')),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['from'] ?? null,
fn (Builder $query, string $date): Builder => $query->whereDate('created_at', '>=', $date)
)
->when(
$data['until'] ?? null,
fn (Builder $query, string $date): Builder => $query->whereDate('created_at', '<=', $date)
);
}),
])
->recordActions([
ViewAction::make(),
Action::make('approve')
->label(__('admin.moderation.actions.approve'))
->color('success')
->icon(Heroicon::OutlinedCheckCircle)
->visible(fn (Photo $record) => $record->status === 'pending')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (Photo $record, array $data) => self::applyModeration($record, 'approved', $data['moderation_notes'] ?? null)),
Action::make('reject')
->label(__('admin.moderation.actions.reject'))
->color('danger')
->icon(Heroicon::OutlinedXCircle)
->visible(fn (Photo $record) => $record->status === 'pending')
->form([
self::moderationNotesField(true),
])
->requiresConfirmation()
->action(fn (Photo $record, array $data) => self::applyModeration($record, 'rejected', $data['moderation_notes'] ?? null)),
Action::make('hide')
->label(__('admin.moderation.actions.hide'))
->color('gray')
->icon(Heroicon::OutlinedEyeSlash)
->visible(fn (Photo $record) => $record->status !== 'hidden')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (Photo $record, array $data) => self::applyModeration($record, 'hidden', $data['moderation_notes'] ?? null)),
])
->toolbarActions([
BulkActionGroup::make([
BulkAction::make('approve')
->label(__('admin.moderation.actions.approve_selected'))
->icon(Heroicon::OutlinedCheckCircle)
->color('success')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (Collection $records, array $data) => self::applyModerationToRecords($records, 'approved', $data['moderation_notes'] ?? null)),
BulkAction::make('reject')
->label(__('admin.moderation.actions.reject_selected'))
->icon(Heroicon::OutlinedXCircle)
->color('danger')
->form([
self::moderationNotesField(true),
])
->requiresConfirmation()
->action(fn (Collection $records, array $data) => self::applyModerationToRecords($records, 'rejected', $data['moderation_notes'] ?? null)),
BulkAction::make('hide')
->label(__('admin.moderation.actions.hide_selected'))
->icon(Heroicon::OutlinedEyeSlash)
->color('gray')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (Collection $records, array $data) => self::applyModerationToRecords($records, 'hidden', $data['moderation_notes'] ?? null)),
]),
]);
}
private static function moderationNotesField(bool $required): Textarea
{
return Textarea::make('moderation_notes')
->label(__('admin.moderation.fields.moderation_notes'))
->maxLength(1000)
->rows(3)
->required($required);
}
private static function applyModeration(Photo $record, string $status, ?string $notes): void
{
$record->update([
'status' => $status,
'moderation_notes' => $notes,
'moderated_at' => now(),
'moderated_by' => Filament::auth()->id(),
]);
}
private static function applyModerationToRecords(Collection $records, string $status, ?string $notes): int
{
$moderatedAt = now();
$moderatedBy = Filament::auth()->id();
return Photo::query()
->whereIn('id', $records->pluck('id'))
->where('status', 'pending')
->update([
'status' => $status,
'moderation_notes' => $notes,
'moderated_at' => $moderatedAt,
'moderated_by' => $moderatedBy,
]);
}
private static function statusLabels(): array
{
return [
'pending' => __('admin.moderation.status.pending'),
'approved' => __('admin.moderation.status.approved'),
'rejected' => __('admin.moderation.status.rejected'),
'hidden' => __('admin.moderation.status.hidden'),
];
}
private static function ingestSourceLabels(): array
{
return [
Photo::SOURCE_GUEST_PWA => __('admin.moderation.ingest_sources.guest_pwa'),
Photo::SOURCE_TENANT_ADMIN => __('admin.moderation.ingest_sources.tenant_admin'),
Photo::SOURCE_PHOTOBOOTH => __('admin.moderation.ingest_sources.photobooth'),
Photo::SOURCE_SPARKBOOTH => __('admin.moderation.ingest_sources.sparkbooth'),
Photo::SOURCE_UNKNOWN => __('admin.moderation.ingest_sources.unknown'),
];
}
private static function securityScanLabels(): array
{
return [
'pending' => __('admin.moderation.security_scan.pending'),
'clean' => __('admin.moderation.security_scan.clean'),
'infected' => __('admin.moderation.security_scan.infected'),
'skipped' => __('admin.moderation.security_scan.skipped'),
'stripped' => __('admin.moderation.security_scan.stripped'),
'error' => __('admin.moderation.security_scan.error'),
];
}
}

View File

@@ -14,6 +14,7 @@ use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class TenantFeedbackResource extends Resource
@@ -26,7 +27,7 @@ class TenantFeedbackResource extends Resource
protected static UnitEnum|string|null $navigationGroup = null;
protected static ?int $navigationSort = 120;
protected static ?int $navigationSort = 30;
public static function canCreate(): bool
{
@@ -48,11 +49,22 @@ class TenantFeedbackResource extends Resource
return TenantFeedbackTable::configure($table);
}
public static function getNavigationLabel(): string
{
return __('admin.feedback.navigation.label');
}
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.feedback_support');
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with(['tenant', 'event', 'moderator']);
}
public static function getRelations(): array
{
return [];

View File

@@ -3,8 +3,8 @@
namespace App\Filament\Resources\TenantFeedbackResource\Schemas;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Str;
@@ -57,6 +57,37 @@ class TenantFeedbackInfolist
->label(__('Metadata'))
->columnSpanFull(),
]),
Section::make(__('admin.feedback.sections.moderation'))
->columns(2)
->schema([
TextEntry::make('status')
->label(__('admin.feedback.fields.status'))
->badge()
->color(fn (?string $state) => match ($state) {
'resolved' => 'success',
'hidden' => 'gray',
'deleted' => 'danger',
default => 'warning',
})
->formatStateUsing(fn (?string $state) => match ($state) {
'pending' => __('admin.feedback.status.pending'),
'resolved' => __('admin.feedback.status.resolved'),
'hidden' => __('admin.feedback.status.hidden'),
'deleted' => __('admin.feedback.status.deleted'),
default => '—',
}),
TextEntry::make('moderator.name')
->label(__('admin.feedback.fields.moderated_by'))
->placeholder('—'),
TextEntry::make('moderated_at')
->label(__('admin.feedback.fields.moderated_at'))
->dateTime()
->placeholder('—'),
TextEntry::make('moderation_notes')
->label(__('admin.feedback.fields.moderation_notes'))
->placeholder('—')
->columnSpanFull(),
]),
]);
}
}

View File

@@ -3,10 +3,16 @@
namespace App\Filament\Resources\TenantFeedbackResource\Tables;
use App\Models\TenantFeedback;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\ViewAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\Textarea;
use Filament\Tables;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Str;
class TenantFeedbackTable
@@ -20,6 +26,17 @@ class TenantFeedbackTable
->label(__('Eingegangen'))
->since()
->sortable(),
Tables\Columns\TextColumn::make('status')
->label(__('admin.feedback.table.status'))
->badge()
->color(fn (?string $state) => match ($state) {
'resolved' => 'success',
'hidden' => 'gray',
'deleted' => 'danger',
default => 'warning',
})
->formatStateUsing(fn (?string $state) => self::statusLabels()[$state] ?? '—')
->sortable(),
Tables\Columns\TextColumn::make('tenant.name')
->label(__('Tenant'))
->searchable()
@@ -52,8 +69,21 @@ class TenantFeedbackTable
->label(__('Nachricht'))
->limit(60)
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('moderator.name')
->label(__('admin.feedback.table.moderated_by'))
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('moderated_at')
->label(__('admin.feedback.table.moderated_at'))
->since()
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('status')
->label(__('admin.feedback.filters.status'))
->options(self::statusLabels())
->default('pending'),
SelectFilter::make('sentiment')
->label(__('Stimmung'))
->options([
@@ -71,7 +101,108 @@ class TenantFeedbackTable
])
->recordActions([
ViewAction::make(),
Action::make('resolve')
->label(__('admin.feedback.actions.resolve'))
->color('success')
->icon('heroicon-o-check-circle')
->visible(fn (TenantFeedback $record) => $record->status === 'pending')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (TenantFeedback $record, array $data) => self::applyModeration($record, 'resolved', $data['moderation_notes'] ?? null)),
Action::make('hide')
->label(__('admin.feedback.actions.hide'))
->color('gray')
->icon('heroicon-o-eye-slash')
->visible(fn (TenantFeedback $record) => $record->status !== 'hidden' && $record->status !== 'deleted')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (TenantFeedback $record, array $data) => self::applyModeration($record, 'hidden', $data['moderation_notes'] ?? null)),
Action::make('delete')
->label(__('admin.feedback.actions.delete'))
->color('danger')
->icon('heroicon-o-trash')
->visible(fn (TenantFeedback $record) => $record->status !== 'deleted')
->form([
self::moderationNotesField(true),
])
->requiresConfirmation()
->action(fn (TenantFeedback $record, array $data) => self::applyModeration($record, 'deleted', $data['moderation_notes'] ?? null)),
])
->bulkActions([]);
->toolbarActions([
BulkActionGroup::make([
BulkAction::make('resolve')
->label(__('admin.feedback.actions.resolve_selected'))
->icon('heroicon-o-check-circle')
->color('success')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (Collection $records, array $data) => self::applyModerationToRecords($records, 'resolved', $data['moderation_notes'] ?? null)),
BulkAction::make('hide')
->label(__('admin.feedback.actions.hide_selected'))
->icon('heroicon-o-eye-slash')
->color('gray')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (Collection $records, array $data) => self::applyModerationToRecords($records, 'hidden', $data['moderation_notes'] ?? null)),
BulkAction::make('delete')
->label(__('admin.feedback.actions.delete_selected'))
->icon('heroicon-o-trash')
->color('danger')
->form([
self::moderationNotesField(true),
])
->requiresConfirmation()
->action(fn (Collection $records, array $data) => self::applyModerationToRecords($records, 'deleted', $data['moderation_notes'] ?? null)),
]),
]);
}
private static function moderationNotesField(bool $required): Textarea
{
return Textarea::make('moderation_notes')
->label(__('admin.feedback.fields.moderation_notes'))
->maxLength(1000)
->rows(3)
->required($required);
}
private static function applyModeration(TenantFeedback $record, string $status, ?string $notes): void
{
$record->update([
'status' => $status,
'moderation_notes' => $notes,
'moderated_at' => now(),
'moderated_by' => Filament::auth()->id(),
]);
}
private static function applyModerationToRecords(Collection $records, string $status, ?string $notes): int
{
return TenantFeedback::query()
->whereIn('id', $records->pluck('id'))
->update([
'status' => $status,
'moderation_notes' => $notes,
'moderated_at' => now(),
'moderated_by' => Filament::auth()->id(),
]);
}
private static function statusLabels(): array
{
return [
'pending' => __('admin.feedback.status.pending'),
'resolved' => __('admin.feedback.status.resolved'),
'hidden' => __('admin.feedback.status.hidden'),
'deleted' => __('admin.feedback.status.deleted'),
];
}
}